Skip to content

Report RSFS/replay latency telemetry on step terminal events#2929

Merged
VaguelySerious merged 4 commits into
mainfrom
peter/rsfs-replay-telemetry
Jul 15, 2026
Merged

Report RSFS/replay latency telemetry on step terminal events#2929
VaguelySerious merged 4 commits into
mainfrom
peter/rsfs-replay-telemetry

Conversation

@VaguelySerious

@VaguelySerious VaguelySerious commented Jul 14, 2026

Copy link
Copy Markdown
Member

What

Adds two new client-latency telemetry fields, riding the exact same path as the existing ttfs/stso fields added in #603:

  • rsfs (run-started-to-first-step): ms from the run_started response being received/parsed by the SDK (or, under turbo, the local run-synthesis instant) to the first step's start POST being issued. Shares ttfs's eligibility gating exactly (invocationStartedClean), plus a runStartedReceivedAtMs anchor.
  • finalSchedulingReplay: ms of only the FINAL replay pass within that window — the runWorkflow() invocation that reached and scheduled the first step — spent in synchronous replay compute (executing the workflow function / event-log replay), excluding awaited network I/O. This is a direct passthrough of the duration captured at the top of the WorkflowSuspension catch block, so it can be reported even when rsfs itself isn't (e.g. under redelivery, before the post-sent anchor is captured). Since RSFS-eligibility implies TTFS-eligibility, finalSchedulingReplay never appears without ttfs also being present.

Motivation: the Workflow Code Yellow dashboard decomposes TTFS into measurable parts, and the run_started→first-step stretch is currently the only unmeasured gap in that breakdown — the workflow.run span can't stand in for it since it mixes in full v4 replays. rsfs closes the sum; finalSchedulingReplay separates "replay compute is slow" from "the step POST hop is slow."

Naming note (per review): this field was originally named firstReplay, which read as "the replay portion of RSFS." That's not quite right — valid RSFS paths can replay more than once before the first step (e.g. a workflow-body setAttributes() detour replays twice), and a redelivery omits earlier invocations' replay work entirely, so the measured value is only the last pass, never accumulated across passes. Renamed to finalSchedulingReplay to make that scope explicit, and sharpened the doc comments at each measurement site accordingly rather than switching to accumulation.

Changes

  • packages/core/src/runtime/step-latency.ts: computeStepLatencyTracking/computeStepLatencyEventData extended to compute and attach rsfs/finalSchedulingReplay.
  • packages/core/src/runtime.ts: runStartedReceivedAtMs anchor (turbo and non-turbo paths), replayDurationMs capture in the WorkflowSuspension.is(err) catch block.
  • packages/core/src/runtime/step-executor.ts: stepStartPostSentAtMs capture immediately before the step_started create fires (both optimistic and non-optimistic paths); span attributes for the new fields.
  • packages/core/src/telemetry/semantic-conventions.ts: StepRsfsMs/StepFinalSchedulingReplayMs constants.
  • packages/world/src/events.ts: rsfs/finalSchedulingReplay added to the shared stepLatencyTelemetryFields Zod schema (spread into both StepCompletedEventSchema/StepFailedEventSchema).
  • packages/world-vercel/src/events.ts: rsfs/finalSchedulingReplay added to the v4 splitEventDataForV4() allowlist — new eventData fields are silently dropped on the v4 wire otherwise.
  • packages/world-vercel/src/events-v4.ts: rsfs/finalSchedulingReplay added to CreateEventV4Input/buildPostFrameMeta().
  • .changeset/rsfs-replay-telemetry.md: minor bump for @workflow/core, @workflow/world, @workflow/world-vercel (feature addition, matching the minor precedent set by the ttfs/stso changeset).

Server-side PR (parses and emits lifecycle.step.rsfs_ms/lifecycle.step.final_scheduling_replay_ms): vercel/workflow-server#626 — should land first; it ignores unknown fields, but the v4 allowlist needs to be live before v4 clients can send these.

Tests

  • pnpm vitest run src/runtime/step-latency.test.ts src/runtime.test.ts (packages/core) — 58 passed.
  • pnpm vitest run src/events.test.ts (packages/world-vercel) — 23 passed.
  • pnpm typecheck (core/world/world-vercel, plus a full package build since the rename requires world/world-vercel's dist to be current) — clean.
  • pnpm biome check scoped to the changed files — only pre-existing noExcessiveCognitiveComplexity warnings on functions touched (not introduced by this change; confirmed present on the pre-rename revision too), no fixes applied beyond formatting.

🤖 Generated with Claude Code

…p terminal events

The runtime now measures run-started-to-first-step (the run_started
response landing, or the local run synthesis instant under turbo, to
the first step's start POST being issued) and synchronous replay
compute duration within that window, attaching them to the run's
first step_completed/step_failed eventData alongside ttfs/stso.

rsfs shares ttfs's exact eligibility gating; replay is a direct
passthrough of the replay duration captured at the top of the
WorkflowSuspension catch block and can be reported even when rsfs
itself is not (e.g. under redelivery before the post-sent anchor is
captured).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@VaguelySerious VaguelySerious requested review from a team and ijjk as code owners July 14, 2026 22:54
@changeset-bot

changeset-bot Bot commented Jul 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 90ffbdc

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
Name Type
@workflow/core Minor
@workflow/world Minor
@workflow/world-vercel Minor
@workflow/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
workflow Minor
@workflow/world-testing Patch
@workflow/world-local Patch
@workflow/world-postgres Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/nuxt Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview, Comment Jul 15, 2026 8:35pm
example-nextjs-workflow-webpack Ready Ready Preview, Comment Jul 15, 2026 8:35pm
example-workflow Ready Ready Preview, Comment Jul 15, 2026 8:35pm
workbench-astro-workflow Ready Ready Preview, Comment Jul 15, 2026 8:35pm
workbench-express-workflow Ready Ready Preview, Comment Jul 15, 2026 8:35pm
workbench-fastify-workflow Ready Ready Preview, Comment Jul 15, 2026 8:35pm
workbench-hono-workflow Ready Ready Preview, Comment Jul 15, 2026 8:35pm
workbench-nitro-workflow Ready Ready Preview, Comment Jul 15, 2026 8:35pm
workbench-nuxt-workflow Ready Ready Preview, Comment Jul 15, 2026 8:35pm
workbench-sveltekit-workflow Ready Ready Preview, Comment Jul 15, 2026 8:35pm
workbench-tanstack-start-workflow Ready Ready Preview, Comment Jul 15, 2026 8:35pm
workbench-vite-workflow Ready Ready Preview, Comment Jul 15, 2026 8:35pm
workflow-docs Ready Ready Preview, Comment, Open in v0 Jul 15, 2026 8:35pm
workflow-swc-playground Ready Ready Preview, Comment Jul 15, 2026 8:35pm
workflow-tarballs Ready Ready Preview, Comment Jul 15, 2026 8:35pm
workflow-web Ready Ready Preview, Comment Jul 15, 2026 8:35pm

@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 90ffbdc · Wed, 15 Jul 2026 20:52:25 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Avg (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS stream 1297 (+8.9%) 1713 🔴 1839 🔴 1927 🔴 30
TTFS hook + stream 1752 (+22%) 2063 🔴 2306 🔴 2943 🔴 30
STSO 1020 steps (1-20) 309 (-2.1%) 371 🔴 445 🔴 531 🔴 19
STSO 1020 steps (101-120) 465 (-2.5%) 516 🔴 626 🔴 862 🔴 19
STSO 1020 steps (1001-1020) 1291 (+26%) 1008 🔴 4046 🔴 4825 🔴 19
WO stream 1297 (+8.9%) 1713 1839 1927 30
WO hook + stream 1752 (+22%) 2063 2306 2943 30
SL stream 4731 (+13%) 5414 🔴 5596 🔴 5711 🔴 30
SL hook + stream 4515 (-5.8%) 5241 🔴 5431 🔴 5519 🔴 30
📜 Previous results (2)

2d5af95

Wed, 15 Jul 2026 19:42:57 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Avg (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS stream 1304 (+1.3%) 1615 🔴 1744 🔴 1930 🔴 30
TTFS hook + stream 1592 (+11%) 1919 🔴 2023 🔴 2108 🔴 30
STSO 1020 steps (1-20) 297 (+8.3%) 325 🔴 395 🔴 617 🔴 19
STSO 1020 steps (101-120) 453 (+8.1%) 503 🔴 618 🔴 693 🔴 19
STSO 1020 steps (1001-1020) 858 (+2.8%) 913 🔴 956 🔴 1016 🔴 19
WO stream 1304 (+1.3%) 1615 1744 1930 30
WO hook + stream 1592 (+11%) 1919 2023 2108 30
SL stream 4253 (-13%) 4792 🔴 5515 🔴 5669 🔴 30
SL hook + stream 4733 (-7.7%) 5254 🔴 5450 🔴 5562 🔴 30

d3e2687

Tue, 14 Jul 2026 23:15:08 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Avg (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS stream 1261 (-8.5%) 1669 🔴 1715 🔴 1764 🔴 30
TTFS hook + stream 1747 (+27%) 1984 🔴 2112 🔴 5297 🔴 30
STSO 1020 steps (1-20) 275 (-8.4%) 338 🔴 442 🔴 527 🔴 19
STSO 1020 steps (101-120) 439 (+1.0%) 466 🔴 555 🔴 651 🔴 19
STSO 1020 steps (1001-1020) 898 (±0%) 942 🔴 1016 🔴 1041 🔴 19
WO stream 1261 (-8.5%) 1669 1715 1764 30
WO hook + stream 1747 (+27%) 1984 2112 5297 30
SL stream 4172 (-17%) 5107 🔴 5683 🔴 6118 🔴 30
SL hook + stream 5020 (±0%) 5625 🔴 5660 🔴 12640 🔴 30

Avg deltas compare against the most recent benchmark run on main at the time of this run.

Metrics — TTFS: time to first step body execution · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (time outside step bodies, client start → last step body exit) · SL: stream latency (first chunk write → visible to the reader)

Scenarios — stream: one step that streams chunks back to the client; no hooks, so the run stays in turbo mode · hook + stream: registers a hook before the same streaming step, which exits turbo mode · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges

🟢/🔴 mark percentiles within/above target. Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

TTFS/WO compare client vs deployment clocks and SL compares the step runner’s clock vs the client’s (NTP-synced in CI). WO ends at the last step body exit, the closest observable proxy for the final step-completion request.

@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

Passed Failed Skipped Total
✅ ▲ Vercel Production 1453 0 230 1683
✅ 💻 Local Development 1617 0 219 1836
✅ 📦 Local Production 1617 0 219 1836
✅ 🐘 Local Postgres 1617 0 219 1836
✅ 🪟 Windows 153 0 0 153
✅ 📋 Other 894 0 177 1071
✅ vercel-multi-region 27 0 0 27
Total 7378 0 1064 8442

Details by Category

✅ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 126 0 27
✅ example 126 0 27
✅ express 126 0 27
✅ fastify 126 0 27
✅ hono 126 0 27
✅ nextjs-turbopack 150 0 3
✅ nextjs-webpack 150 0 3
✅ nitro 126 0 27
✅ nuxt 126 0 27
✅ sveltekit 145 0 8
✅ vite 126 0 27
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 128 0 25
✅ express-stable 128 0 25
✅ fastify-stable 128 0 25
✅ hono-stable 128 0 25
✅ nextjs-turbopack-canary 134 0 19
✅ nextjs-turbopack-stable 153 0 0
✅ nextjs-webpack-canary 134 0 19
✅ nextjs-webpack-stable 153 0 0
✅ nitro-stable 128 0 25
✅ nuxt-stable 128 0 25
✅ sveltekit-stable 147 0 6
✅ vite-stable 128 0 25
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 128 0 25
✅ express-stable 128 0 25
✅ fastify-stable 128 0 25
✅ hono-stable 128 0 25
✅ nextjs-turbopack-canary 134 0 19
✅ nextjs-turbopack-stable 153 0 0
✅ nextjs-webpack-canary 134 0 19
✅ nextjs-webpack-stable 153 0 0
✅ nitro-stable 128 0 25
✅ nuxt-stable 128 0 25
✅ sveltekit-stable 147 0 6
✅ vite-stable 128 0 25
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 128 0 25
✅ express-stable 128 0 25
✅ fastify-stable 128 0 25
✅ hono-stable 128 0 25
✅ nextjs-turbopack-canary 134 0 19
✅ nextjs-turbopack-stable 153 0 0
✅ nextjs-webpack-canary 134 0 19
✅ nextjs-webpack-stable 153 0 0
✅ nitro-stable 128 0 25
✅ nuxt-stable 128 0 25
✅ sveltekit-stable 147 0 6
✅ vite-stable 128 0 25
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 153 0 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 128 0 25
✅ e2e-local-dev-tanstack-start- 128 0 25
✅ e2e-local-postgres-nest-stable 128 0 25
✅ e2e-local-postgres-tanstack-start- 128 0 25
✅ e2e-local-prod-nest-stable 128 0 25
✅ e2e-local-prod-tanstack-start- 128 0 25
✅ e2e-vercel-prod-tanstack-start 126 0 27
✅ vercel-multi-region
App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

// below (see StepLatencyTracking.replayMs). Captured
// here, before `handleSuspension`'s awaited I/O, so
// it excludes that I/O.
const replayDurationMs = Date.now() - replayStart;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we either aggregate replay time across every pre-first-step replay pass, or narrow this metric definition/name? This captures only the current suspension runWorkflow() duration. Valid RSFS/TTFS paths such as workflow-body setAttributes() can replay more than once before the first step, so RSFS covers the whole detour while replay reports only the final pass; a redelivery can omit earlier work too. That makes it misleading to interpret replay as the portion of RSFS spent in replay compute. Accumulating until the first step (or renaming this to something like final_scheduling_replay_ms) would make the semantics explicit. Also, the individual-pass duration is already represented by the existing workflow.run … span; if this metric is intentionally duplicated to provide unsampled/full-population distributions, can we document that rationale?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or narrow this metric definition/name

Doing

the individual-pass duration is already represented by the existing workflow.run … span

Documenting better

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 187510b: renamed to finalSchedulingReplay (lifecycle.step.final_scheduling_replay_ms) and sharpened the comments at both measurement sites — they now state explicitly that this is the final runWorkflow() pass only (not accumulated across pre-first-step passes, so not "the replay portion of RSFS"), and that the duplication with the workflow.run span is deliberate: the server emits it as an unsampled full-population distribution, which the ~7%-sampled span population can't provide.

Server-side counterpart: #626 merged mid-rename with the old name, so the matching server rename is in vercel/workflow-server#628 (deploys before this ships, or the field would be dropped by the allowlist).

…stReplay

Clarifies that this sub-window of rsfs is only measured for a run's
first step. Renames the eventData field, the StepReplayMs semantic
convention (-> StepFirstReplayMs, step.replay_ms -> step.first_replay_ms),
and the v4 split-frame meta allowlist entry, keeping the internal
"workflow replay" duration variables untouched. Also documents that
firstReplay duplicates what OTEL already captures on the run/invocation
span, but is deliberately collected as client telemetry so the server
can emit it as an unsampled metric.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread packages/core/src/runtime/step-executor.ts
…-start path whenever the `run_started` round-trip is still in flight at the latency-computation point, biasing RSFS percentiles low by discarding exactly the slow-`run_started` samples.

This commit fixes the issue reported at packages/core/src/runtime/step-executor.ts:424

## Bug

In `executeStep` (`packages/core/src/runtime/step-executor.ts`), on the **turbo optimistic-start** path the step-start POST timestamp that anchors RSFS's end point is captured only *inside* the `runReadyBarrier.then(...)` callback:

```ts
const startedPromise = (params.runReadyBarrier ?? Promise.resolve()).then(
  () => {
    stepStartPostSentAtMs = Date.now();      // runs only after run_started resolves
    return world.events.create(workflowRunId, { eventType: 'step_started', ... });
  }
);
```

The latency telemetry is then computed **once**, just before user code, at line ~663:

```ts
latencyEventData = computeStepLatencyEventData({
  ...
  stepStartPostSentAtMs,   // still undefined if the barrier hasn't resolved
});
```

Between the optimistic-start setup and that computation the only awaits are local/fast operations (`getEncryptionKey()`, `hydrateStepArguments()`, `getPortLazy()`) — **none** await `runReadyBarrier`. So whenever `run_started` has non-trivial latency (the common production case), the barrier is still pending, `stepStartPostSentAtMs` is `undefined`, and in `computeStepLatencyEventData` (`step-latency.ts`) `rsfs` is omitted:

```ts
const rsfs =
  tracking.rsfsAnchorMs !== undefined &&
  params.stepStartPostSentAtMs !== undefined
    ? Math.max(0, params.stepStartPostSentAtMs - tracking.rsfsAnchorMs)
    : undefined;
```

`reconcileOptimisticStart()` (awaited later, before every terminal write) *does* wait on the barrier, so `stepStartPostSentAtMs` **is** in fact set by the time the terminal event is written — but `latencyEventData` was frozen earlier and never recomputed. Net effect: `rsfs` is recorded only when `run_started` happened to resolve *before* the latency computation (fast runs). Slow-`run_started` invocations — precisely the samples RSFS exists to measure — are dropped. This is a missing-not-at-random bias skewing RSFS percentiles low.

### Concrete trigger
Turbo optimistic start with a `runReadyBarrier` that resolves *after* the body reaches the latency computation. The existing test *"holds the optimistic step_started until runReadyBarrier resolves, but runs the body immediately"* already demonstrates this exact ordering (body runs before `step_started`). I added a regression test with `latencyTracking` anchors and a delayed barrier; with the fix removed it fails (`rsfs` is `undefined` on the terminal `step_completed` event).

## Fix

Added a `backfillOptimisticRsfs()` helper that patches `rsfs` onto the already-computed `latencyEventData` **after** `reconcileOptimisticStart()` has awaited the barrier (so `stepStartPostSentAtMs` is known), called on both the success path and the catch path before the terminal event is written:

```ts
const backfillOptimisticRsfs = (): void => {
  if (!latencyEventData || latencyEventData.rsfs !== undefined) return;
  const anchorMs = params.latencyTracking?.rsfsAnchorMs;
  if (anchorMs === undefined || stepStartPostSentAtMs === undefined) return;
  latencyEventData.rsfs = Math.max(0, stepStartPostSentAtMs - anchorMs);
  if (span) span.setAttributes({ ...Attribute.StepRsfsMs(latencyEventData.rsfs) });
};
```

It recomputes **only** RSFS — TTFS/STSO stay anchored to `executionStartTime` exactly as before. Because a terminal event is only written when the optimistic start settled `ok` (a lost race / throttle / gone short-circuits via `reconcile` before any write), `stepStartPostSentAtMs` is guaranteed set at each backfill point, so RSFS is now present whenever `rsfsAnchorMs` is eligible. The step span is also updated so traces reflect the backfilled value.

### Validation
- New regression test asserts `rsfs` is present/≥0 on the terminal event with a delayed barrier; it **fails without the fix** and passes with it.
- Full `step-handler.test.ts` (36) and `step-latency.test.ts` (33) suites pass.
- `tsc --noEmit` clean.

### Doc side note
The claims in `packages/world/src/events.ts` and `packages/world-vercel/src/events-v4.ts` that `finalSchedulingReplay` is "Only present alongside rsfs" are now **correct** as a code-enforced invariant, so no doc edit was necessary.

Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: VaguelySerious <mittgfu@gmail.com>

@karthikscale3 karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the current head after the requested changes. The turbo optimistic-start RSFS sampling bias is fixed by backfilling after reconciliation on both terminal paths, with delayed-barrier regression coverage. finalSchedulingReplay now precisely documents the non-aggregated final-pass semantics and the unsampled-metric rationale. Approving. Deployment remains gated on workflow-server#628 landing first.

@VaguelySerious VaguelySerious merged commit 1933e29 into main Jul 15, 2026
100 checks passed
@VaguelySerious VaguelySerious deleted the peter/rsfs-replay-telemetry branch July 15, 2026 20:50
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 1933e29 (AI decision).

This commit extends the client-side TTFS/STSO latency telemetry infrastructure (from #603) that exists only on main — verified that step-latency.ts/step-executor.ts are absent on stable, packages/world/src/events.ts has no stepLatencyTelemetryFields/ttfs/stso, and runtime.ts lacks the invocationStartedClean/replayStart/StepLatencyTracking anchors it builds on. The new rsfs/finalSchedulingReplay fields share TTFS's eligibility gating and plug directly into that missing machinery, so it cannot be applied to stable.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

1933e294cf938fb2314f45047033f8720ccf442b

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.

3 participants