Skip to content

Commit a2d382b

Browse files
authored
feat(webapp): add emission fan-out metrics to the native realtime feed (#4341)
## Summary Adds two counters to the native realtime backend so we can see how much duplicate row serialization the change router does per batch. When a run changes it can match several held feeds at once (a run subscription plus one or more tag/list feeds), and today each matching feed serializes that run's wire value independently. These counters quantify that fan-out so we can decide whether a shared serialization step is worth it. ## What they measure - `realtime_native.emission_run_serializations`: total wire-value serializations performed across feeds per batch (what the current path does). - `realtime_native.emission_distinct_serializations`: distinct (columns, run) rows those serializations cover (what a serialize-once-per-batch step would do). Average feeds-per-run is `run_serializations / distinct_serializations`, and `1 - distinct / run_serializations` is the serialization work a shared step could save. Wired through a new optional `onEmissionFanout` callback on the router. No behavior change.
1 parent aafc333 commit a2d382b

3 files changed

Lines changed: 41 additions & 0 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Add metrics to the realtime backend that measure how often a single changed run is served to multiple subscriptions in one batch.

apps/webapp/app/services/realtime/envChangeRouter.server.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ export type EnvChangeRouterOptions = {
5151
/** Observability: a buffered record was evicted. `cap` evictions mean the env churns more
5252
* runs inside the window than the buffer holds (the replay guarantee is degrading). */
5353
onReplayEviction?: (reason: "cap" | "window") => void;
54+
/** Observability: per-batch emission fan-out. `deliveries` = total (feed,run) rows matched and
55+
* resolved to feeds this batch. It is an upper bound on the per-feed wire serializations, since
56+
* the client working-set diff drops already-seen rows before encoding. `distinctRuns` = distinct
57+
* (columnSig,runId) among them (the serialize-once-per-batch floor). `deliveries / distinctRuns`
58+
* is the average number of feeds a changed run is delivered to; a shared-serialization step would
59+
* save at most `deliveries - distinctRuns` encodings. */
60+
onEmissionFanout?: (stats: { distinctRuns: number; deliveries: number; feeds: number }) => void;
5461
/** Read-your-writes gate over the replica: delays wake-path hydrates until the replica
5562
* should have applied the change (record.updatedAtMs + lag + margin), and re-hydrates
5663
* rows the tripwire still finds stale. Omit to hydrate immediately (legacy behavior). */
@@ -609,6 +616,8 @@ export class EnvChangeRouter {
609616

610617
// 4. Assemble each feed's matched rows (post-filtering tag feeds against the
611618
// authoritative hydrated row) and resolve its pending wait.
619+
let deliveries = 0;
620+
const distinctRunKeys = new Set<string>();
612621
for (const [feed, runIds] of matchedRunIdsByFeed) {
613622
if (!feed.resolve) {
614623
continue; // stopped waiting while we hydrated; its next poll/backstop covers it
@@ -631,10 +640,22 @@ export class EnvChangeRouter {
631640

632641
if (rows.length > 0) {
633642
feed.resolve({ reason: "notify", rows });
643+
deliveries += rows.length;
644+
for (const matched of rows) {
645+
distinctRunKeys.add(`${feed.columnSig}${matched.row.id}`);
646+
}
634647
}
635648
// No surviving rows (e.g. a partial-record candidate that didn't actually match):
636649
// leave the feed waiting; nothing relevant changed for it.
637650
}
651+
652+
if (deliveries > 0) {
653+
this.options.onEmissionFanout?.({
654+
distinctRuns: distinctRunKeys.size,
655+
deliveries,
656+
feeds: matchedRunIdsByFeed.size,
657+
});
658+
}
638659
}
639660

640661
/** Runs whose hydrated row is provably behind its record's watermark (stale content),

apps/webapp/app/services/realtime/nativeRealtimeClientInstance.server.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,16 @@ function initializeNativeRealtimeClient(): NativeRealtimeClient {
7171
unit: "rows",
7272
});
7373

74+
const emissionFeedDeliveries = meter.createCounter("realtime_native.emission_feed_deliveries", {
75+
description:
76+
"Matched (feed,run) rows resolved to feeds per batch, summed. Upper bound on per-feed wire serializations, since the client working-set diff drops already-seen rows before encoding. Divide by realtime_native.emission_distinct_runs for average feeds-per-run fan-out.",
77+
});
78+
79+
const emissionDistinctRuns = meter.createCounter("realtime_native.emission_distinct_runs", {
80+
description:
81+
"Distinct (columnSig,run) among the deliveries per batch, summed. The serialize-once-per-batch floor: a shared-serialization step would encode at most this many rows, saving at most (feed_deliveries minus distinct_runs) encodings.",
82+
});
83+
7484
const backstops = meter.createCounter("realtime_native.backstops", {
7585
description:
7686
"Backstop full resolves by outcome. 'empty' is normal idle behavior; sustained 'delivered' means the notify/replay path missed changes — alert on it.",
@@ -166,6 +176,10 @@ function initializeNativeRealtimeClient(): NativeRealtimeClient {
166176
unsubscribeLingerMs: env.REALTIME_BACKEND_NATIVE_UNSUBSCRIBE_LINGER_MS,
167177
onReplay: (result) => replays.add(1, { result }),
168178
onReplayEviction: (reason) => replayEvictions.add(1, { reason }),
179+
onEmissionFanout: ({ distinctRuns, deliveries }) => {
180+
emissionFeedDeliveries.add(deliveries);
181+
emissionDistinctRuns.add(distinctRuns);
182+
},
169183
replicaLag: lagEstimator
170184
? {
171185
getLagMs: () => lagEstimator.getLagMs(),

0 commit comments

Comments
 (0)