Skip to content

feat(zero-client): add query lifecycle hooks to ZeroOptions#5493

Open
Karavil wants to merge 2 commits intorocicorp:mainfrom
Karavil:feat/query-lifecycle-hooks
Open

feat(zero-client): add query lifecycle hooks to ZeroOptions#5493
Karavil wants to merge 2 commits intorocicorp:mainfrom
Karavil:feat/query-lifecycle-hooks

Conversation

@Karavil
Copy link
Contributor

@Karavil Karavil commented Jan 30, 2026

Query Lifecycle Hooks

Adds opt-in hooks to ZeroOptions for observing query materialization events on the client. We use these to create OpenTelemetry spans for every Zero query materialization, sent to BetterStack for monitoring.

We've been running this as a bun patch on @rocicorp/zero@0.25.11 in production for a few weeks. Upstreaming removes our patch maintenance burden and makes this available to anyone who wants client-side query telemetry.

How It Works

Zero constructor
│
├─ options.hooks ─────────────────────────────► QueryManager.#hooks
│
│
QueryManager.addMetric()
│
├─ metric: "query-materialization-client"
│   └─► hooks.onQueryMaterializeClient({ id })
│       Client IVM pipeline built. Measures local materialization time.
│
├─ metric: "query-materialization-end-to-end"
│   └─► hooks.onQueryMaterialize({ id, ast, durationMs, name })
│       Full round trip complete (server + network + client).
│
└─ other metrics: no hooks fired

Both hooks are wrapped in try/catch internally, so consumer errors never break query execution. Zero overhead when no hooks are provided (optional chaining short-circuits).

Usage

import {Zero} from '@rocicorp/zero';
import {trace} from '@opentelemetry/api';

const z = new Zero({
  // ...
  hooks: {
    onQueryMaterialize({id, ast, durationMs, name}) {
      const tracer = trace.getTracer('zero-client');
      const endTime = Date.now();
      const startTime = endTime - durationMs;
      const related = ast.related?.map(r => r.subquery.table).join(', ');

      tracer.startSpan('zero.query.materialize', {
        startTime,
        attributes: {
          'zero.query.id': id,
          'zero.query.table': ast.table,
          ...(name && {'zero.query.name': name}),
          ...(related && {'zero.query.related': related}),
          ...(ast.limit !== undefined && {'zero.query.limit': ast.limit}),
          'zero.query.duration_ms': durationMs,
        },
      }).end(endTime);
    },
  },
});

This gives us spans like:

zero.query.materialize
├─ zero.query.table: "assignment"
├─ zero.query.related: "student, problem"
├─ zero.query.duration_ms: 42
└─ zero.query.name: "assignmentDetail"

Changes

packages/zero-client/src/client/
├── options.ts            New types: QueryMaterializeClientEvent,
│                         QueryMaterializeEvent, ZeroQueryHooks.
│                         Added hooks property to ZeroOptions.
│
├── query-manager.ts      Added #hooks field, constructor parameter,
│                         hook calls in addMetric() with try/catch.
│
├── zero.ts               Pass options.hooks to new QueryManager().
│
├── query-manager.test.ts 7 new tests: both hooks fire correctly,
│                         error isolation, optional hooks, selectivity,
│                         unrelated metrics ignored.
│
└── ../mod.ts             Export new types from public API.

Tests

Test Results
───────────────────────────────────
query-manager.test.ts  40/40  ✓
  └── lifecycle hooks   7 new
───────────────────────────────────

Covers:

  • onQueryMaterializeClient fires for client materialization metric with correct {id}
  • onQueryMaterialize fires for end-to-end metric with correct {id, ast, durationMs, name}
  • Named queries include the query name in the event
  • Hook errors are swallowed (throw inside hook, query still works)
  • No hooks provided does not crash
  • Only the relevant hook fires per metric type
  • Unrelated metrics (query-update-client) don't fire any hook

Adds opt-in hooks for observing query materialization events,
enabling client-side telemetry (e.g. OpenTelemetry spans).

* Add ZeroQueryHooks type with onQueryMaterializeClient and
  onQueryMaterialize callbacks
* Add hooks option to ZeroOptions interface
* Wire hooks through Zero constructor to QueryManager
* Call hooks in QueryManager.addMetric() with try/catch
* Export new types from public API
* Add tests for hook invocation, error isolation, and selectivity
@vercel
Copy link

vercel bot commented Jan 30, 2026

Someone is attempting to deploy a commit to the Rocicorp Team on Vercel.

A member of the Team first needs to authorize it.

…rialize

The client-side materialization metric fires after the IVM pipeline
is already built, making it redundant. The end-to-end hook covers
the useful case (full round trip timing with AST details).

Also removes unnecessary ast guard: ast is always present when
metric is query-materialization-end-to-end per ClientMetricMap.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant