feat(world-vercel): client-observed stream write/read e2e latency spans - #2857

Merged
karthikscale3 merged 9 commits into
mainfrom
karthik/stream-latency-otel
Jul 10, 2026
Merged

feat(world-vercel): client-observed stream write/read e2e latency spans#2857
karthikscale3 merged 9 commits into
mainfrom
karthik/stream-latency-otel

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

What

Adds OpenTelemetry client spans that make stream write and read end-to-end latency measurable from the SDK — the client→server timing that server-side metrics can't see (the network hop + client-side legs). These back getWritable() / run.readable.

Write

  • workflow.stream.write span (for write / writeMulti / close), SpanKind.CLIENT.
  • workflow.stream.write.e2e_ms attribute = client→backend round-trip. The PUT is request/response and the backend acks only after capturing the chunk, so this is the client-observed write latency including the network hop. (It equals the span's own duration; exposed as a named attribute for direct querying.)

Read

  • workflow.stream.read.connect span — dispatch → response headers (the network-connect portion).
  • workflow.stream.read span — its duration is the client-observed end-to-end time-to-first-chunk: read dispatch → first non-empty chunk in the reader, including the network hop — also stamped as workflow.stream.read.ttfc_ms. Since that value is only known once the first chunk arrives, it's emitted as a span back-dated to dispatch time (new recordElapsedSpan helper). v3+ servers flush a leading zero-length chunk to commit headers, which is skipped. If no chunk ever arrives, no workflow.stream.read span is emitted (.connect still is).

Common attributes

workflow.run.id (dotted — the standardized run-id attribute), workflow.stream.name, workflow.stream.operation (write | write_multi | close | read), workflow.stream.start_index (read).

How

  • instrumentedFetch gains three optional, defaulted fields — spanName, attributes, and durationAttribute (stamps the measured round-trip under a named key). Reuses the existing envelope (W3C trace-context injection, timeout, error mapping, DEBUG logging); no new span layer, no extra requests.
  • Read TTFC is measured by a passthrough on response.body that emits the back-dated workflow.stream.read span on the first non-empty chunk.

Backward compatibility

Additive OTEL only. With no OpenTelemetry SDK registered, trace() / recordElapsedSpan no-op and spans/attributes are dropped — behavior is byte-identical to today. The new instrumentedFetch options are optional and defaulted, so all other callers are unaffected. (Client spans are emitted only when the reader/writer has OTEL registered; server metrics cover the rest.)

Docs

Documented the stream spans + attributes in docs/content/docs/v5/observability/tracing.mdx.

Docs Preview

PagePreview
Observability → Tracinghttps://workflow-docs-git-karthik-stream-latency-otel.vercel.sh/v5/docs/observability/tracing

Test plan

  • packages/world-vercel unit suite: 229 passed (pnpm test).
  • trace-propagation.test.ts: asserts the write span name + attributes + traceparent injection, and covers the new recordElapsedSpan back-dated span (duration reflects the elapsed interval + attribute set).
  • Typecheck/build clean (pnpm --filter "@workflow/world-vercel..." build).

Stream write/read requests already share the instrumented HTTP envelope
(a CLIENT span + W3C trace-context injection), but the spans were named
for the bare HTTP verb (`http PUT`/`http GET`) and carried only generic
HTTP attributes — so stream latency couldn't be sliced per run/stream.
Name these spans for their operation (`workflow.stream.write` /
`workflow.stream.read`) and tag them with `workflow.run.id`,
`workflow.stream.name`, `workflow.stream.operation`
(write | write_multi | close | read), and `workflow.stream.start_index`
(read). Implemented via new optional `spanName`/`attributes` fields on
`instrumentedFetch`, so other callers are unaffected.
Additive OTEL only: no behavior change when no OpenTelemetry SDK is
registered (the span is undefined and attributes are dropped).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@karthikscale3
karthikscale3 requested review from a team and ijjk as code ownersJuly 9, 2026 16:16
@vercel

vercelBot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9e42061

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

This PR includes changesets to release 17 packages
NameType
@workflow/corePatch
@workflow/world-vercelPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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

@github-actions

github-actionsBot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production145212301683
✅ 💻 Local Development161702191836
✅ 📦 Local Production161702191836
✅ 🐘 Local Postgres161702191836
✅ 🪟 Windows15300153
✅ 📋 Other89401771071
Total7350110648415

❌ Failed Tests

▲ Vercel Production (1 failed)

vite (1 failed):

  • hookGetConflictThenStepParallelWorkflow - hook.getConflict() continuation step runs alongside other steps | wrun_01KX4NDB03XHJ671CT1QNKFTG2 | 🔍 observability

Details by Category

❌ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro126027
✅ example126027
✅ express126027
✅ fastify126027
✅ hono126027
✅ nextjs-turbopack15003
✅ nextjs-webpack15003
✅ nitro126027
✅ nuxt126027
✅ sveltekit14508
❌ vite125127
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15300
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128025
✅ e2e-local-dev-tanstack-start-128025
✅ e2e-local-postgres-nest-stable128025
✅ e2e-local-postgres-tanstack-start-128025
✅ e2e-local-prod-nest-stable128025
✅ e2e-local-prod-tanstack-start-128025
✅ e2e-vercel-prod-tanstack-start126027

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: success
  • Windows: success

Check the workflow run for details.

@github-actions

github-actionsBot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 9e42061 · Fri, 10 Jul 2026 00:15:04 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1183 (+6.4%)1579 🔴1680 🔴1974 🔴30
TTFShook + stream1580 (+23%)1841 🔴1879 🔴1909 🔴30
STSO1020 steps (1-20)258 (-5.1%)282 🔴370 🔴417 🔴19
STSO1020 steps (101-120)396 (-3.7%)413 🔴500 🔴635 🔴19
STSO1020 steps (1001-1020)1262 (+44%)1004 🔴3876 🔴4520 🔴19
WOstream1183 (+6.4%)15791680197430
WOhook + stream1580 (+23%)18411879190930
SLstream4442 (-4.9%)5525 🔴5667 🔴5774 🔴30
SLhook + stream4914 (-2.3%)5251 🔴5634 🔴6522 🔴30
📜 Previous results (4)

a97582e

Thu, 09 Jul 2026 21:13:27 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1229 (+23%)1786 🔴1824 🔴1948 🔴30
TTFShook + stream1339 (+7.0%)1888 🔴1953 🔴2098 🔴30
STSO1020 steps (1-20)291 (+22%)328 🔴481 🔴599 🔴19
STSO1020 steps (101-120)404 (±0%)451 🔴510 🔴536 🔴19
STSO1020 steps (1001-1020)882 (-15%)967 🔴1036 🔴1173 🔴19
WOstream1229 (+23%)17861824194830
WOhook + stream1339 (+7.0%)18881953209830
SLstream4911 (+2.7%)5723 🔴5876 🔴6427 🔴30
SLhook + stream5183 (+3.8%)5651 🔴5712 🔴9615 🔴30

a435ba0

Thu, 09 Jul 2026 20:40:36 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1379 (+38%)1748 🔴1844 🔴2282 🔴30
TTFShook + stream1676 (+34%)2036 🔴2201 🔴2578 🔴30
STSO1020 steps (1-20)333 (+39%)437 🔴523 🔴722 🔴19
STSO1020 steps (101-120)423 (+4.3%)449 🔴490 🔴599 🔴19
STSO1020 steps (1001-1020)868 (-16%)931 🔴1023 🔴1115 🔴19
WOstream1379 (+38%)17481844228230
WOhook + stream1676 (+34%)20362201257830
SLstream4681 (-2.1%)5302 🔴5741 🔴5884 🔴30
SLhook + stream4926 (-1.3%)5555 🔴5809 🔴5873 🔴30

98cd410

Thu, 09 Jul 2026 20:10:49 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1201 (+20%)1502 🔴1595 🔴2305 🔴30
TTFShook + stream1427 (+14%)1748 🔴1820 🔴1864 🔴30
STSO1020 steps (1-20)267 (+12%)294 🔴354 🔴548 🔴19
STSO1020 steps (101-120)382 (-5.9%)388 🔴440 🔴587 🔴19
STSO1020 steps (1001-1020)879 (-15%)930 🔴952 🔴1065 🔴19
WOstream1201 (+20%)15021595230530
WOhook + stream1427 (+14%)17481820186430
SLstream4209 (-12%)4881 🔴5495 🔴5681 🔴30
SLhook + stream4971 (±0%)5423 🔴5625 🔴6528 🔴30

3de7361

Thu, 09 Jul 2026 16:39:51 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1396 (+40%)1684 🔴1911 🔴1951 🔴30
TTFShook + stream1792 (+25%)2255 🔴2563 🔴3634 🔴30
STSO1020 steps (1-20)356 (+23%)381 🔴614 🔴1197 🔴19
STSO1020 steps (101-120)427 (+0.6%)459 🔴492 🔴685 🔴19
STSO1020 steps (1001-1020)901 (+1.9%)991 🔴1038 🔴1132 🔴19
WOstream1396 (+40%)16841911195130
WOhook + stream1792 (+25%)22552563363430
SLstream4616 (-4.2%)4943 🔴5588 🔴5698 🔴30
SLhook + stream4666 (-4.1%)5442 🔴5604 🔴5705 🔴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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
// chunks are skipped. No-op when no OpenTelemetry SDK is registered.
const readSpanKind = await getSpanKind('CLIENT');
let firstChunkSeen = false;
const ttfcProbe = new TransformStream<Uint8Array, Uint8Array>({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Instead of doing a transform stream, can we emit the span from the core serializer WorkflowWriteableStream on first push?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done in a435ba0 — moved the TTFC measurement into WorkflowServerReadableStream (core), emitting workflow.stream.read on the first non-empty chunk enqueued to the consumer, and dropped the TransformStream wrapper. world-vercel keeps only the workflow.stream.read.connect HTTP span; the recordElapsedSpan helper now lives in @workflow/core. This also makes the span backend-agnostic (any world), and it now includes deframing since it's measured at the reader.

…ansform
Per review: move the client-observed time-to-first-chunk measurement out of a
TransformStream wrapper in world-vercel and into WorkflowServerReadableStream in
core, emitting workflow.stream.read on the first non-empty chunk reaching the
consumer. Removes the passthrough, measures at the reader abstraction, and is
backend-agnostic. world-vercel keeps the workflow.stream.read.connect HTTP span;
the recordElapsedSpan helper now lives in @workflow/core.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread.changeset/stream-otel-spans.md Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
VaguelySerious
VaguelySerious previously approved these changes Jul 9, 2026

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Happy to unblock, can adjust metrics later. It seems like the main e2e latency (first write scheduled to first read received on client-side) is still missing

Comment threaddocs/content/docs/v5/observability/tracing.mdx Outdated
| `workflow.stream.name` | The stream name, on stream write/read spans. |
| `workflow.stream.operation` | The stream operation: `write`, `write_multi`, `close`, or `read`. |
| `workflow.stream.write.e2e_ms` | Client→backend round-trip for a stream write, in ms — the backend acks only after capturing the chunk, so this is the client-observed write latency including the network hop. |
| `workflow.stream.read.ttfc_ms` | Client-observed end-to-end time-to-first-chunk for a live read, in ms — read dispatch → first non-empty chunk in the reader, including the network hop. |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
|`workflow.stream.read.ttfc_ms`|Client-observed end-to-end time-to-first-chunk for a live read, in ms — read dispatch → first non-empty chunk in the reader, including the network hop. |
|`workflow.stream.read.ttfc_ms`|Time between opening a read connection and observing and receiving the first chunk back. |

Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Karthik Kalyan <105607645+karthikscale3@users.noreply.github.com>
Comment threadpackages/world-vercel/src/streamer.ts Outdated
…set wording
Per review, rename the write attribute to workflow.stream.write.chunk_rtt (it's
a per-chunk client<->server round-trip, not a full e2e), update the docs row
wording for both write and read attributes, and shorten the changeset.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@karthikscale3

Copy link
Copy Markdown
ContributorAuthor

On the note that the main e2e (first write → first read on the client) is still missing — agreed, and here's where it landed so it's not lost:

  • A true cross-process client→client number (writer app → reader app) isn't cleanly measurable: writer and reader are different processes with unsynchronized clocks, so subtracting their timestamps is skew-corrupted. There's no honest single-clock way to capture it end to end.
  • The accurate, single-clock version of "write → read" is measured server-side in workflow-server#612 as lifecycle.stream.propagation_ms — chunk publisheddelivered to a tailing reader, emitted only for pub/sub-woken deliveries of the exact awaited chunk (so it's real propagation, not a chunk sitting idle for an absent reader).
  • Combined with this PR's client legs (workflow.stream.write.chunk_rtt, workflow.stream.read.ttfc_ms) you can present the full decomposition: client→server write · server propagation · server→client read — each on its own clock.

So the e2e story is covered as a server-mediated propagation metric rather than a cross-client wall-clock. Happy to iterate on naming/shape in a follow-up as you suggested.

@github-actionsgithub-actionsBot mentioned this pull request Jul 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 2c6ee61 (AI decision).

The bulk of this commit builds on main-only infrastructure that doesn't exist on stable: the instrumentedFetch client-span envelope and its packages/world-vercel/src/http-core.ts file are absent on stable (verified — the stable streamer uses plain fetch() with no dispatcher or span options), and the commit adds its new spanName/attributes/durationAttribute options and stream-operation spans on top of that. The touched trace-propagation.test.ts and docs/content/docs/v5/observability/tracing.mdx files also don't exist on stable, so the change cannot apply cleanly and is effectively main-only.

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

2c6ee614b50d12ed850e7589cf296150b2143a56

Sign up for freeto 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.

2 participants

@karthikscale3@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(world-vercel): client-observed stream write/read e2e latency spans - #2857

Merged
karthikscale3 merged 9 commits into
mainfrom
karthik/stream-latency-otel
Jul 10, 2026
Merged

feat(world-vercel): client-observed stream write/read e2e latency spans#2857
karthikscale3 merged 9 commits into
mainfrom
karthik/stream-latency-otel

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

What

Adds OpenTelemetry client spans that make stream write and read end-to-end latency measurable from the SDK — the client→server timing that server-side metrics can't see (the network hop + client-side legs). These back getWritable() / run.readable.

Write

  • workflow.stream.write span (for write / writeMulti / close), SpanKind.CLIENT.
  • workflow.stream.write.e2e_ms attribute = client→backend round-trip. The PUT is request/response and the backend acks only after capturing the chunk, so this is the client-observed write latency including the network hop. (It equals the span's own duration; exposed as a named attribute for direct querying.)

Read

  • workflow.stream.read.connect span — dispatch → response headers (the network-connect portion).
  • workflow.stream.read span — its duration is the client-observed end-to-end time-to-first-chunk: read dispatch → first non-empty chunk in the reader, including the network hop — also stamped as workflow.stream.read.ttfc_ms. Since that value is only known once the first chunk arrives, it's emitted as a span back-dated to dispatch time (new recordElapsedSpan helper). v3+ servers flush a leading zero-length chunk to commit headers, which is skipped. If no chunk ever arrives, no workflow.stream.read span is emitted (.connect still is).

Common attributes

workflow.run.id (dotted — the standardized run-id attribute), workflow.stream.name, workflow.stream.operation (write | write_multi | close | read), workflow.stream.start_index (read).

How

  • instrumentedFetch gains three optional, defaulted fields — spanName, attributes, and durationAttribute (stamps the measured round-trip under a named key). Reuses the existing envelope (W3C trace-context injection, timeout, error mapping, DEBUG logging); no new span layer, no extra requests.
  • Read TTFC is measured by a passthrough on response.body that emits the back-dated workflow.stream.read span on the first non-empty chunk.

Backward compatibility

Additive OTEL only. With no OpenTelemetry SDK registered, trace() / recordElapsedSpan no-op and spans/attributes are dropped — behavior is byte-identical to today. The new instrumentedFetch options are optional and defaulted, so all other callers are unaffected. (Client spans are emitted only when the reader/writer has OTEL registered; server metrics cover the rest.)

Docs

Documented the stream spans + attributes in docs/content/docs/v5/observability/tracing.mdx.

Docs Preview

PagePreview
Observability → Tracinghttps://workflow-docs-git-karthik-stream-latency-otel.vercel.sh/v5/docs/observability/tracing

Test plan

  • packages/world-vercel unit suite: 229 passed (pnpm test).
  • trace-propagation.test.ts: asserts the write span name + attributes + traceparent injection, and covers the new recordElapsedSpan back-dated span (duration reflects the elapsed interval + attribute set).
  • Typecheck/build clean (pnpm --filter "@workflow/world-vercel..." build).

Stream write/read requests already share the instrumented HTTP envelope
(a CLIENT span + W3C trace-context injection), but the spans were named
for the bare HTTP verb (`http PUT`/`http GET`) and carried only generic
HTTP attributes — so stream latency couldn't be sliced per run/stream.
Name these spans for their operation (`workflow.stream.write` /
`workflow.stream.read`) and tag them with `workflow.run.id`,
`workflow.stream.name`, `workflow.stream.operation`
(write | write_multi | close | read), and `workflow.stream.start_index`
(read). Implemented via new optional `spanName`/`attributes` fields on
`instrumentedFetch`, so other callers are unaffected.
Additive OTEL only: no behavior change when no OpenTelemetry SDK is
registered (the span is undefined and attributes are dropped).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@karthikscale3
karthikscale3 requested review from a team and ijjk as code ownersJuly 9, 2026 16:16
@vercel

vercelBot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9e42061

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

This PR includes changesets to release 17 packages
NameType
@workflow/corePatch
@workflow/world-vercelPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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

@github-actions

github-actionsBot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production145212301683
✅ 💻 Local Development161702191836
✅ 📦 Local Production161702191836
✅ 🐘 Local Postgres161702191836
✅ 🪟 Windows15300153
✅ 📋 Other89401771071
Total7350110648415

❌ Failed Tests

▲ Vercel Production (1 failed)

vite (1 failed):

  • hookGetConflictThenStepParallelWorkflow - hook.getConflict() continuation step runs alongside other steps | wrun_01KX4NDB03XHJ671CT1QNKFTG2 | 🔍 observability

Details by Category

❌ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro126027
✅ example126027
✅ express126027
✅ fastify126027
✅ hono126027
✅ nextjs-turbopack15003
✅ nextjs-webpack15003
✅ nitro126027
✅ nuxt126027
✅ sveltekit14508
❌ vite125127
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15300
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128025
✅ e2e-local-dev-tanstack-start-128025
✅ e2e-local-postgres-nest-stable128025
✅ e2e-local-postgres-tanstack-start-128025
✅ e2e-local-prod-nest-stable128025
✅ e2e-local-prod-tanstack-start-128025
✅ e2e-vercel-prod-tanstack-start126027

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: success
  • Windows: success

Check the workflow run for details.

@github-actions

github-actionsBot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 9e42061 · Fri, 10 Jul 2026 00:15:04 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1183 (+6.4%)1579 🔴1680 🔴1974 🔴30
TTFShook + stream1580 (+23%)1841 🔴1879 🔴1909 🔴30
STSO1020 steps (1-20)258 (-5.1%)282 🔴370 🔴417 🔴19
STSO1020 steps (101-120)396 (-3.7%)413 🔴500 🔴635 🔴19
STSO1020 steps (1001-1020)1262 (+44%)1004 🔴3876 🔴4520 🔴19
WOstream1183 (+6.4%)15791680197430
WOhook + stream1580 (+23%)18411879190930
SLstream4442 (-4.9%)5525 🔴5667 🔴5774 🔴30
SLhook + stream4914 (-2.3%)5251 🔴5634 🔴6522 🔴30
📜 Previous results (4)

a97582e

Thu, 09 Jul 2026 21:13:27 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1229 (+23%)1786 🔴1824 🔴1948 🔴30
TTFShook + stream1339 (+7.0%)1888 🔴1953 🔴2098 🔴30
STSO1020 steps (1-20)291 (+22%)328 🔴481 🔴599 🔴19
STSO1020 steps (101-120)404 (±0%)451 🔴510 🔴536 🔴19
STSO1020 steps (1001-1020)882 (-15%)967 🔴1036 🔴1173 🔴19
WOstream1229 (+23%)17861824194830
WOhook + stream1339 (+7.0%)18881953209830
SLstream4911 (+2.7%)5723 🔴5876 🔴6427 🔴30
SLhook + stream5183 (+3.8%)5651 🔴5712 🔴9615 🔴30

a435ba0

Thu, 09 Jul 2026 20:40:36 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1379 (+38%)1748 🔴1844 🔴2282 🔴30
TTFShook + stream1676 (+34%)2036 🔴2201 🔴2578 🔴30
STSO1020 steps (1-20)333 (+39%)437 🔴523 🔴722 🔴19
STSO1020 steps (101-120)423 (+4.3%)449 🔴490 🔴599 🔴19
STSO1020 steps (1001-1020)868 (-16%)931 🔴1023 🔴1115 🔴19
WOstream1379 (+38%)17481844228230
WOhook + stream1676 (+34%)20362201257830
SLstream4681 (-2.1%)5302 🔴5741 🔴5884 🔴30
SLhook + stream4926 (-1.3%)5555 🔴5809 🔴5873 🔴30

98cd410

Thu, 09 Jul 2026 20:10:49 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1201 (+20%)1502 🔴1595 🔴2305 🔴30
TTFShook + stream1427 (+14%)1748 🔴1820 🔴1864 🔴30
STSO1020 steps (1-20)267 (+12%)294 🔴354 🔴548 🔴19
STSO1020 steps (101-120)382 (-5.9%)388 🔴440 🔴587 🔴19
STSO1020 steps (1001-1020)879 (-15%)930 🔴952 🔴1065 🔴19
WOstream1201 (+20%)15021595230530
WOhook + stream1427 (+14%)17481820186430
SLstream4209 (-12%)4881 🔴5495 🔴5681 🔴30
SLhook + stream4971 (±0%)5423 🔴5625 🔴6528 🔴30

3de7361

Thu, 09 Jul 2026 16:39:51 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1396 (+40%)1684 🔴1911 🔴1951 🔴30
TTFShook + stream1792 (+25%)2255 🔴2563 🔴3634 🔴30
STSO1020 steps (1-20)356 (+23%)381 🔴614 🔴1197 🔴19
STSO1020 steps (101-120)427 (+0.6%)459 🔴492 🔴685 🔴19
STSO1020 steps (1001-1020)901 (+1.9%)991 🔴1038 🔴1132 🔴19
WOstream1396 (+40%)16841911195130
WOhook + stream1792 (+25%)22552563363430
SLstream4616 (-4.2%)4943 🔴5588 🔴5698 🔴30
SLhook + stream4666 (-4.1%)5442 🔴5604 🔴5705 🔴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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
// chunks are skipped. No-op when no OpenTelemetry SDK is registered.
const readSpanKind = await getSpanKind('CLIENT');
let firstChunkSeen = false;
const ttfcProbe = new TransformStream<Uint8Array, Uint8Array>({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Instead of doing a transform stream, can we emit the span from the core serializer WorkflowWriteableStream on first push?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done in a435ba0 — moved the TTFC measurement into WorkflowServerReadableStream (core), emitting workflow.stream.read on the first non-empty chunk enqueued to the consumer, and dropped the TransformStream wrapper. world-vercel keeps only the workflow.stream.read.connect HTTP span; the recordElapsedSpan helper now lives in @workflow/core. This also makes the span backend-agnostic (any world), and it now includes deframing since it's measured at the reader.

…ansform
Per review: move the client-observed time-to-first-chunk measurement out of a
TransformStream wrapper in world-vercel and into WorkflowServerReadableStream in
core, emitting workflow.stream.read on the first non-empty chunk reaching the
consumer. Removes the passthrough, measures at the reader abstraction, and is
backend-agnostic. world-vercel keeps the workflow.stream.read.connect HTTP span;
the recordElapsedSpan helper now lives in @workflow/core.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread.changeset/stream-otel-spans.md Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
VaguelySerious
VaguelySerious previously approved these changes Jul 9, 2026

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Happy to unblock, can adjust metrics later. It seems like the main e2e latency (first write scheduled to first read received on client-side) is still missing

Comment threaddocs/content/docs/v5/observability/tracing.mdx Outdated
| `workflow.stream.name` | The stream name, on stream write/read spans. |
| `workflow.stream.operation` | The stream operation: `write`, `write_multi`, `close`, or `read`. |
| `workflow.stream.write.e2e_ms` | Client→backend round-trip for a stream write, in ms — the backend acks only after capturing the chunk, so this is the client-observed write latency including the network hop. |
| `workflow.stream.read.ttfc_ms` | Client-observed end-to-end time-to-first-chunk for a live read, in ms — read dispatch → first non-empty chunk in the reader, including the network hop. |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
|`workflow.stream.read.ttfc_ms`|Client-observed end-to-end time-to-first-chunk for a live read, in ms — read dispatch → first non-empty chunk in the reader, including the network hop. |
|`workflow.stream.read.ttfc_ms`|Time between opening a read connection and observing and receiving the first chunk back. |

Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Karthik Kalyan <105607645+karthikscale3@users.noreply.github.com>
Comment threadpackages/world-vercel/src/streamer.ts Outdated
…set wording
Per review, rename the write attribute to workflow.stream.write.chunk_rtt (it's
a per-chunk client<->server round-trip, not a full e2e), update the docs row
wording for both write and read attributes, and shorten the changeset.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@karthikscale3

Copy link
Copy Markdown
ContributorAuthor

On the note that the main e2e (first write → first read on the client) is still missing — agreed, and here's where it landed so it's not lost:

  • A true cross-process client→client number (writer app → reader app) isn't cleanly measurable: writer and reader are different processes with unsynchronized clocks, so subtracting their timestamps is skew-corrupted. There's no honest single-clock way to capture it end to end.
  • The accurate, single-clock version of "write → read" is measured server-side in workflow-server#612 as lifecycle.stream.propagation_ms — chunk publisheddelivered to a tailing reader, emitted only for pub/sub-woken deliveries of the exact awaited chunk (so it's real propagation, not a chunk sitting idle for an absent reader).
  • Combined with this PR's client legs (workflow.stream.write.chunk_rtt, workflow.stream.read.ttfc_ms) you can present the full decomposition: client→server write · server propagation · server→client read — each on its own clock.

So the e2e story is covered as a server-mediated propagation metric rather than a cross-client wall-clock. Happy to iterate on naming/shape in a follow-up as you suggested.

@github-actionsgithub-actionsBot mentioned this pull request Jul 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 2c6ee61 (AI decision).

The bulk of this commit builds on main-only infrastructure that doesn't exist on stable: the instrumentedFetch client-span envelope and its packages/world-vercel/src/http-core.ts file are absent on stable (verified — the stable streamer uses plain fetch() with no dispatcher or span options), and the commit adds its new spanName/attributes/durationAttribute options and stream-operation spans on top of that. The touched trace-propagation.test.ts and docs/content/docs/v5/observability/tracing.mdx files also don't exist on stable, so the change cannot apply cleanly and is effectively main-only.

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

2c6ee614b50d12ed850e7589cf296150b2143a56

Sign up for freeto 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.

2 participants

@karthikscale3@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(world-vercel): client-observed stream write/read e2e latency spans - #2857

Merged
karthikscale3 merged 9 commits into
mainfrom
karthik/stream-latency-otel
Jul 10, 2026
Merged

feat(world-vercel): client-observed stream write/read e2e latency spans#2857
karthikscale3 merged 9 commits into
mainfrom
karthik/stream-latency-otel

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

What

Adds OpenTelemetry client spans that make stream write and read end-to-end latency measurable from the SDK — the client→server timing that server-side metrics can't see (the network hop + client-side legs). These back getWritable() / run.readable.

Write

  • workflow.stream.write span (for write / writeMulti / close), SpanKind.CLIENT.
  • workflow.stream.write.e2e_ms attribute = client→backend round-trip. The PUT is request/response and the backend acks only after capturing the chunk, so this is the client-observed write latency including the network hop. (It equals the span's own duration; exposed as a named attribute for direct querying.)

Read

  • workflow.stream.read.connect span — dispatch → response headers (the network-connect portion).
  • workflow.stream.read span — its duration is the client-observed end-to-end time-to-first-chunk: read dispatch → first non-empty chunk in the reader, including the network hop — also stamped as workflow.stream.read.ttfc_ms. Since that value is only known once the first chunk arrives, it's emitted as a span back-dated to dispatch time (new recordElapsedSpan helper). v3+ servers flush a leading zero-length chunk to commit headers, which is skipped. If no chunk ever arrives, no workflow.stream.read span is emitted (.connect still is).

Common attributes

workflow.run.id (dotted — the standardized run-id attribute), workflow.stream.name, workflow.stream.operation (write | write_multi | close | read), workflow.stream.start_index (read).

How

  • instrumentedFetch gains three optional, defaulted fields — spanName, attributes, and durationAttribute (stamps the measured round-trip under a named key). Reuses the existing envelope (W3C trace-context injection, timeout, error mapping, DEBUG logging); no new span layer, no extra requests.
  • Read TTFC is measured by a passthrough on response.body that emits the back-dated workflow.stream.read span on the first non-empty chunk.

Backward compatibility

Additive OTEL only. With no OpenTelemetry SDK registered, trace() / recordElapsedSpan no-op and spans/attributes are dropped — behavior is byte-identical to today. The new instrumentedFetch options are optional and defaulted, so all other callers are unaffected. (Client spans are emitted only when the reader/writer has OTEL registered; server metrics cover the rest.)

Docs

Documented the stream spans + attributes in docs/content/docs/v5/observability/tracing.mdx.

Docs Preview

PagePreview
Observability → Tracinghttps://workflow-docs-git-karthik-stream-latency-otel.vercel.sh/v5/docs/observability/tracing

Test plan

  • packages/world-vercel unit suite: 229 passed (pnpm test).
  • trace-propagation.test.ts: asserts the write span name + attributes + traceparent injection, and covers the new recordElapsedSpan back-dated span (duration reflects the elapsed interval + attribute set).
  • Typecheck/build clean (pnpm --filter "@workflow/world-vercel..." build).

Stream write/read requests already share the instrumented HTTP envelope
(a CLIENT span + W3C trace-context injection), but the spans were named
for the bare HTTP verb (`http PUT`/`http GET`) and carried only generic
HTTP attributes — so stream latency couldn't be sliced per run/stream.
Name these spans for their operation (`workflow.stream.write` /
`workflow.stream.read`) and tag them with `workflow.run.id`,
`workflow.stream.name`, `workflow.stream.operation`
(write | write_multi | close | read), and `workflow.stream.start_index`
(read). Implemented via new optional `spanName`/`attributes` fields on
`instrumentedFetch`, so other callers are unaffected.
Additive OTEL only: no behavior change when no OpenTelemetry SDK is
registered (the span is undefined and attributes are dropped).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@karthikscale3
karthikscale3 requested review from a team and ijjk as code ownersJuly 9, 2026 16:16
@vercel

vercelBot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9e42061

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

This PR includes changesets to release 17 packages
NameType
@workflow/corePatch
@workflow/world-vercelPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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

@github-actions

github-actionsBot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production145212301683
✅ 💻 Local Development161702191836
✅ 📦 Local Production161702191836
✅ 🐘 Local Postgres161702191836
✅ 🪟 Windows15300153
✅ 📋 Other89401771071
Total7350110648415

❌ Failed Tests

▲ Vercel Production (1 failed)

vite (1 failed):

  • hookGetConflictThenStepParallelWorkflow - hook.getConflict() continuation step runs alongside other steps | wrun_01KX4NDB03XHJ671CT1QNKFTG2 | 🔍 observability

Details by Category

❌ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro126027
✅ example126027
✅ express126027
✅ fastify126027
✅ hono126027
✅ nextjs-turbopack15003
✅ nextjs-webpack15003
✅ nitro126027
✅ nuxt126027
✅ sveltekit14508
❌ vite125127
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15300
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128025
✅ e2e-local-dev-tanstack-start-128025
✅ e2e-local-postgres-nest-stable128025
✅ e2e-local-postgres-tanstack-start-128025
✅ e2e-local-prod-nest-stable128025
✅ e2e-local-prod-tanstack-start-128025
✅ e2e-vercel-prod-tanstack-start126027

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: success
  • Windows: success

Check the workflow run for details.

@github-actions

github-actionsBot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 9e42061 · Fri, 10 Jul 2026 00:15:04 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1183 (+6.4%)1579 🔴1680 🔴1974 🔴30
TTFShook + stream1580 (+23%)1841 🔴1879 🔴1909 🔴30
STSO1020 steps (1-20)258 (-5.1%)282 🔴370 🔴417 🔴19
STSO1020 steps (101-120)396 (-3.7%)413 🔴500 🔴635 🔴19
STSO1020 steps (1001-1020)1262 (+44%)1004 🔴3876 🔴4520 🔴19
WOstream1183 (+6.4%)15791680197430
WOhook + stream1580 (+23%)18411879190930
SLstream4442 (-4.9%)5525 🔴5667 🔴5774 🔴30
SLhook + stream4914 (-2.3%)5251 🔴5634 🔴6522 🔴30
📜 Previous results (4)

a97582e

Thu, 09 Jul 2026 21:13:27 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1229 (+23%)1786 🔴1824 🔴1948 🔴30
TTFShook + stream1339 (+7.0%)1888 🔴1953 🔴2098 🔴30
STSO1020 steps (1-20)291 (+22%)328 🔴481 🔴599 🔴19
STSO1020 steps (101-120)404 (±0%)451 🔴510 🔴536 🔴19
STSO1020 steps (1001-1020)882 (-15%)967 🔴1036 🔴1173 🔴19
WOstream1229 (+23%)17861824194830
WOhook + stream1339 (+7.0%)18881953209830
SLstream4911 (+2.7%)5723 🔴5876 🔴6427 🔴30
SLhook + stream5183 (+3.8%)5651 🔴5712 🔴9615 🔴30

a435ba0

Thu, 09 Jul 2026 20:40:36 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1379 (+38%)1748 🔴1844 🔴2282 🔴30
TTFShook + stream1676 (+34%)2036 🔴2201 🔴2578 🔴30
STSO1020 steps (1-20)333 (+39%)437 🔴523 🔴722 🔴19
STSO1020 steps (101-120)423 (+4.3%)449 🔴490 🔴599 🔴19
STSO1020 steps (1001-1020)868 (-16%)931 🔴1023 🔴1115 🔴19
WOstream1379 (+38%)17481844228230
WOhook + stream1676 (+34%)20362201257830
SLstream4681 (-2.1%)5302 🔴5741 🔴5884 🔴30
SLhook + stream4926 (-1.3%)5555 🔴5809 🔴5873 🔴30

98cd410

Thu, 09 Jul 2026 20:10:49 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1201 (+20%)1502 🔴1595 🔴2305 🔴30
TTFShook + stream1427 (+14%)1748 🔴1820 🔴1864 🔴30
STSO1020 steps (1-20)267 (+12%)294 🔴354 🔴548 🔴19
STSO1020 steps (101-120)382 (-5.9%)388 🔴440 🔴587 🔴19
STSO1020 steps (1001-1020)879 (-15%)930 🔴952 🔴1065 🔴19
WOstream1201 (+20%)15021595230530
WOhook + stream1427 (+14%)17481820186430
SLstream4209 (-12%)4881 🔴5495 🔴5681 🔴30
SLhook + stream4971 (±0%)5423 🔴5625 🔴6528 🔴30

3de7361

Thu, 09 Jul 2026 16:39:51 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1396 (+40%)1684 🔴1911 🔴1951 🔴30
TTFShook + stream1792 (+25%)2255 🔴2563 🔴3634 🔴30
STSO1020 steps (1-20)356 (+23%)381 🔴614 🔴1197 🔴19
STSO1020 steps (101-120)427 (+0.6%)459 🔴492 🔴685 🔴19
STSO1020 steps (1001-1020)901 (+1.9%)991 🔴1038 🔴1132 🔴19
WOstream1396 (+40%)16841911195130
WOhook + stream1792 (+25%)22552563363430
SLstream4616 (-4.2%)4943 🔴5588 🔴5698 🔴30
SLhook + stream4666 (-4.1%)5442 🔴5604 🔴5705 🔴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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
// chunks are skipped. No-op when no OpenTelemetry SDK is registered.
const readSpanKind = await getSpanKind('CLIENT');
let firstChunkSeen = false;
const ttfcProbe = new TransformStream<Uint8Array, Uint8Array>({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Instead of doing a transform stream, can we emit the span from the core serializer WorkflowWriteableStream on first push?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done in a435ba0 — moved the TTFC measurement into WorkflowServerReadableStream (core), emitting workflow.stream.read on the first non-empty chunk enqueued to the consumer, and dropped the TransformStream wrapper. world-vercel keeps only the workflow.stream.read.connect HTTP span; the recordElapsedSpan helper now lives in @workflow/core. This also makes the span backend-agnostic (any world), and it now includes deframing since it's measured at the reader.

…ansform
Per review: move the client-observed time-to-first-chunk measurement out of a
TransformStream wrapper in world-vercel and into WorkflowServerReadableStream in
core, emitting workflow.stream.read on the first non-empty chunk reaching the
consumer. Removes the passthrough, measures at the reader abstraction, and is
backend-agnostic. world-vercel keeps the workflow.stream.read.connect HTTP span;
the recordElapsedSpan helper now lives in @workflow/core.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread.changeset/stream-otel-spans.md Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
VaguelySerious
VaguelySerious previously approved these changes Jul 9, 2026

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Happy to unblock, can adjust metrics later. It seems like the main e2e latency (first write scheduled to first read received on client-side) is still missing

Comment threaddocs/content/docs/v5/observability/tracing.mdx Outdated
| `workflow.stream.name` | The stream name, on stream write/read spans. |
| `workflow.stream.operation` | The stream operation: `write`, `write_multi`, `close`, or `read`. |
| `workflow.stream.write.e2e_ms` | Client→backend round-trip for a stream write, in ms — the backend acks only after capturing the chunk, so this is the client-observed write latency including the network hop. |
| `workflow.stream.read.ttfc_ms` | Client-observed end-to-end time-to-first-chunk for a live read, in ms — read dispatch → first non-empty chunk in the reader, including the network hop. |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
|`workflow.stream.read.ttfc_ms`|Client-observed end-to-end time-to-first-chunk for a live read, in ms — read dispatch → first non-empty chunk in the reader, including the network hop. |
|`workflow.stream.read.ttfc_ms`|Time between opening a read connection and observing and receiving the first chunk back. |

Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Karthik Kalyan <105607645+karthikscale3@users.noreply.github.com>
Comment threadpackages/world-vercel/src/streamer.ts Outdated
…set wording
Per review, rename the write attribute to workflow.stream.write.chunk_rtt (it's
a per-chunk client<->server round-trip, not a full e2e), update the docs row
wording for both write and read attributes, and shorten the changeset.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@karthikscale3

Copy link
Copy Markdown
ContributorAuthor

On the note that the main e2e (first write → first read on the client) is still missing — agreed, and here's where it landed so it's not lost:

  • A true cross-process client→client number (writer app → reader app) isn't cleanly measurable: writer and reader are different processes with unsynchronized clocks, so subtracting their timestamps is skew-corrupted. There's no honest single-clock way to capture it end to end.
  • The accurate, single-clock version of "write → read" is measured server-side in workflow-server#612 as lifecycle.stream.propagation_ms — chunk publisheddelivered to a tailing reader, emitted only for pub/sub-woken deliveries of the exact awaited chunk (so it's real propagation, not a chunk sitting idle for an absent reader).
  • Combined with this PR's client legs (workflow.stream.write.chunk_rtt, workflow.stream.read.ttfc_ms) you can present the full decomposition: client→server write · server propagation · server→client read — each on its own clock.

So the e2e story is covered as a server-mediated propagation metric rather than a cross-client wall-clock. Happy to iterate on naming/shape in a follow-up as you suggested.

@github-actionsgithub-actionsBot mentioned this pull request Jul 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 2c6ee61 (AI decision).

The bulk of this commit builds on main-only infrastructure that doesn't exist on stable: the instrumentedFetch client-span envelope and its packages/world-vercel/src/http-core.ts file are absent on stable (verified — the stable streamer uses plain fetch() with no dispatcher or span options), and the commit adds its new spanName/attributes/durationAttribute options and stream-operation spans on top of that. The touched trace-propagation.test.ts and docs/content/docs/v5/observability/tracing.mdx files also don't exist on stable, so the change cannot apply cleanly and is effectively main-only.

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

2c6ee614b50d12ed850e7589cf296150b2143a56

Sign up for freeto 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.

2 participants

@karthikscale3@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(world-vercel): client-observed stream write/read e2e latency spans - #2857

Merged
karthikscale3 merged 9 commits into
mainfrom
karthik/stream-latency-otel
Jul 10, 2026
Merged

feat(world-vercel): client-observed stream write/read e2e latency spans#2857
karthikscale3 merged 9 commits into
mainfrom
karthik/stream-latency-otel

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

What

Adds OpenTelemetry client spans that make stream write and read end-to-end latency measurable from the SDK — the client→server timing that server-side metrics can't see (the network hop + client-side legs). These back getWritable() / run.readable.

Write

  • workflow.stream.write span (for write / writeMulti / close), SpanKind.CLIENT.
  • workflow.stream.write.e2e_ms attribute = client→backend round-trip. The PUT is request/response and the backend acks only after capturing the chunk, so this is the client-observed write latency including the network hop. (It equals the span's own duration; exposed as a named attribute for direct querying.)

Read

  • workflow.stream.read.connect span — dispatch → response headers (the network-connect portion).
  • workflow.stream.read span — its duration is the client-observed end-to-end time-to-first-chunk: read dispatch → first non-empty chunk in the reader, including the network hop — also stamped as workflow.stream.read.ttfc_ms. Since that value is only known once the first chunk arrives, it's emitted as a span back-dated to dispatch time (new recordElapsedSpan helper). v3+ servers flush a leading zero-length chunk to commit headers, which is skipped. If no chunk ever arrives, no workflow.stream.read span is emitted (.connect still is).

Common attributes

workflow.run.id (dotted — the standardized run-id attribute), workflow.stream.name, workflow.stream.operation (write | write_multi | close | read), workflow.stream.start_index (read).

How

  • instrumentedFetch gains three optional, defaulted fields — spanName, attributes, and durationAttribute (stamps the measured round-trip under a named key). Reuses the existing envelope (W3C trace-context injection, timeout, error mapping, DEBUG logging); no new span layer, no extra requests.
  • Read TTFC is measured by a passthrough on response.body that emits the back-dated workflow.stream.read span on the first non-empty chunk.

Backward compatibility

Additive OTEL only. With no OpenTelemetry SDK registered, trace() / recordElapsedSpan no-op and spans/attributes are dropped — behavior is byte-identical to today. The new instrumentedFetch options are optional and defaulted, so all other callers are unaffected. (Client spans are emitted only when the reader/writer has OTEL registered; server metrics cover the rest.)

Docs

Documented the stream spans + attributes in docs/content/docs/v5/observability/tracing.mdx.

Docs Preview

PagePreview
Observability → Tracinghttps://workflow-docs-git-karthik-stream-latency-otel.vercel.sh/v5/docs/observability/tracing

Test plan

  • packages/world-vercel unit suite: 229 passed (pnpm test).
  • trace-propagation.test.ts: asserts the write span name + attributes + traceparent injection, and covers the new recordElapsedSpan back-dated span (duration reflects the elapsed interval + attribute set).
  • Typecheck/build clean (pnpm --filter "@workflow/world-vercel..." build).

Stream write/read requests already share the instrumented HTTP envelope
(a CLIENT span + W3C trace-context injection), but the spans were named
for the bare HTTP verb (`http PUT`/`http GET`) and carried only generic
HTTP attributes — so stream latency couldn't be sliced per run/stream.
Name these spans for their operation (`workflow.stream.write` /
`workflow.stream.read`) and tag them with `workflow.run.id`,
`workflow.stream.name`, `workflow.stream.operation`
(write | write_multi | close | read), and `workflow.stream.start_index`
(read). Implemented via new optional `spanName`/`attributes` fields on
`instrumentedFetch`, so other callers are unaffected.
Additive OTEL only: no behavior change when no OpenTelemetry SDK is
registered (the span is undefined and attributes are dropped).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@karthikscale3
karthikscale3 requested review from a team and ijjk as code ownersJuly 9, 2026 16:16
@vercel

vercelBot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9e42061

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

This PR includes changesets to release 17 packages
NameType
@workflow/corePatch
@workflow/world-vercelPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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

@github-actions

github-actionsBot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production145212301683
✅ 💻 Local Development161702191836
✅ 📦 Local Production161702191836
✅ 🐘 Local Postgres161702191836
✅ 🪟 Windows15300153
✅ 📋 Other89401771071
Total7350110648415

❌ Failed Tests

▲ Vercel Production (1 failed)

vite (1 failed):

  • hookGetConflictThenStepParallelWorkflow - hook.getConflict() continuation step runs alongside other steps | wrun_01KX4NDB03XHJ671CT1QNKFTG2 | 🔍 observability

Details by Category

❌ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro126027
✅ example126027
✅ express126027
✅ fastify126027
✅ hono126027
✅ nextjs-turbopack15003
✅ nextjs-webpack15003
✅ nitro126027
✅ nuxt126027
✅ sveltekit14508
❌ vite125127
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15300
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128025
✅ e2e-local-dev-tanstack-start-128025
✅ e2e-local-postgres-nest-stable128025
✅ e2e-local-postgres-tanstack-start-128025
✅ e2e-local-prod-nest-stable128025
✅ e2e-local-prod-tanstack-start-128025
✅ e2e-vercel-prod-tanstack-start126027

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: success
  • Windows: success

Check the workflow run for details.

@github-actions

github-actionsBot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 9e42061 · Fri, 10 Jul 2026 00:15:04 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1183 (+6.4%)1579 🔴1680 🔴1974 🔴30
TTFShook + stream1580 (+23%)1841 🔴1879 🔴1909 🔴30
STSO1020 steps (1-20)258 (-5.1%)282 🔴370 🔴417 🔴19
STSO1020 steps (101-120)396 (-3.7%)413 🔴500 🔴635 🔴19
STSO1020 steps (1001-1020)1262 (+44%)1004 🔴3876 🔴4520 🔴19
WOstream1183 (+6.4%)15791680197430
WOhook + stream1580 (+23%)18411879190930
SLstream4442 (-4.9%)5525 🔴5667 🔴5774 🔴30
SLhook + stream4914 (-2.3%)5251 🔴5634 🔴6522 🔴30
📜 Previous results (4)

a97582e

Thu, 09 Jul 2026 21:13:27 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1229 (+23%)1786 🔴1824 🔴1948 🔴30
TTFShook + stream1339 (+7.0%)1888 🔴1953 🔴2098 🔴30
STSO1020 steps (1-20)291 (+22%)328 🔴481 🔴599 🔴19
STSO1020 steps (101-120)404 (±0%)451 🔴510 🔴536 🔴19
STSO1020 steps (1001-1020)882 (-15%)967 🔴1036 🔴1173 🔴19
WOstream1229 (+23%)17861824194830
WOhook + stream1339 (+7.0%)18881953209830
SLstream4911 (+2.7%)5723 🔴5876 🔴6427 🔴30
SLhook + stream5183 (+3.8%)5651 🔴5712 🔴9615 🔴30

a435ba0

Thu, 09 Jul 2026 20:40:36 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1379 (+38%)1748 🔴1844 🔴2282 🔴30
TTFShook + stream1676 (+34%)2036 🔴2201 🔴2578 🔴30
STSO1020 steps (1-20)333 (+39%)437 🔴523 🔴722 🔴19
STSO1020 steps (101-120)423 (+4.3%)449 🔴490 🔴599 🔴19
STSO1020 steps (1001-1020)868 (-16%)931 🔴1023 🔴1115 🔴19
WOstream1379 (+38%)17481844228230
WOhook + stream1676 (+34%)20362201257830
SLstream4681 (-2.1%)5302 🔴5741 🔴5884 🔴30
SLhook + stream4926 (-1.3%)5555 🔴5809 🔴5873 🔴30

98cd410

Thu, 09 Jul 2026 20:10:49 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1201 (+20%)1502 🔴1595 🔴2305 🔴30
TTFShook + stream1427 (+14%)1748 🔴1820 🔴1864 🔴30
STSO1020 steps (1-20)267 (+12%)294 🔴354 🔴548 🔴19
STSO1020 steps (101-120)382 (-5.9%)388 🔴440 🔴587 🔴19
STSO1020 steps (1001-1020)879 (-15%)930 🔴952 🔴1065 🔴19
WOstream1201 (+20%)15021595230530
WOhook + stream1427 (+14%)17481820186430
SLstream4209 (-12%)4881 🔴5495 🔴5681 🔴30
SLhook + stream4971 (±0%)5423 🔴5625 🔴6528 🔴30

3de7361

Thu, 09 Jul 2026 16:39:51 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1396 (+40%)1684 🔴1911 🔴1951 🔴30
TTFShook + stream1792 (+25%)2255 🔴2563 🔴3634 🔴30
STSO1020 steps (1-20)356 (+23%)381 🔴614 🔴1197 🔴19
STSO1020 steps (101-120)427 (+0.6%)459 🔴492 🔴685 🔴19
STSO1020 steps (1001-1020)901 (+1.9%)991 🔴1038 🔴1132 🔴19
WOstream1396 (+40%)16841911195130
WOhook + stream1792 (+25%)22552563363430
SLstream4616 (-4.2%)4943 🔴5588 🔴5698 🔴30
SLhook + stream4666 (-4.1%)5442 🔴5604 🔴5705 🔴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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
// chunks are skipped. No-op when no OpenTelemetry SDK is registered.
const readSpanKind = await getSpanKind('CLIENT');
let firstChunkSeen = false;
const ttfcProbe = new TransformStream<Uint8Array, Uint8Array>({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Instead of doing a transform stream, can we emit the span from the core serializer WorkflowWriteableStream on first push?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done in a435ba0 — moved the TTFC measurement into WorkflowServerReadableStream (core), emitting workflow.stream.read on the first non-empty chunk enqueued to the consumer, and dropped the TransformStream wrapper. world-vercel keeps only the workflow.stream.read.connect HTTP span; the recordElapsedSpan helper now lives in @workflow/core. This also makes the span backend-agnostic (any world), and it now includes deframing since it's measured at the reader.

…ansform
Per review: move the client-observed time-to-first-chunk measurement out of a
TransformStream wrapper in world-vercel and into WorkflowServerReadableStream in
core, emitting workflow.stream.read on the first non-empty chunk reaching the
consumer. Removes the passthrough, measures at the reader abstraction, and is
backend-agnostic. world-vercel keeps the workflow.stream.read.connect HTTP span;
the recordElapsedSpan helper now lives in @workflow/core.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread.changeset/stream-otel-spans.md Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
VaguelySerious
VaguelySerious previously approved these changes Jul 9, 2026

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Happy to unblock, can adjust metrics later. It seems like the main e2e latency (first write scheduled to first read received on client-side) is still missing

Comment threaddocs/content/docs/v5/observability/tracing.mdx Outdated
| `workflow.stream.name` | The stream name, on stream write/read spans. |
| `workflow.stream.operation` | The stream operation: `write`, `write_multi`, `close`, or `read`. |
| `workflow.stream.write.e2e_ms` | Client→backend round-trip for a stream write, in ms — the backend acks only after capturing the chunk, so this is the client-observed write latency including the network hop. |
| `workflow.stream.read.ttfc_ms` | Client-observed end-to-end time-to-first-chunk for a live read, in ms — read dispatch → first non-empty chunk in the reader, including the network hop. |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
|`workflow.stream.read.ttfc_ms`|Client-observed end-to-end time-to-first-chunk for a live read, in ms — read dispatch → first non-empty chunk in the reader, including the network hop. |
|`workflow.stream.read.ttfc_ms`|Time between opening a read connection and observing and receiving the first chunk back. |

Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Karthik Kalyan <105607645+karthikscale3@users.noreply.github.com>
Comment threadpackages/world-vercel/src/streamer.ts Outdated
…set wording
Per review, rename the write attribute to workflow.stream.write.chunk_rtt (it's
a per-chunk client<->server round-trip, not a full e2e), update the docs row
wording for both write and read attributes, and shorten the changeset.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@karthikscale3

Copy link
Copy Markdown
ContributorAuthor

On the note that the main e2e (first write → first read on the client) is still missing — agreed, and here's where it landed so it's not lost:

  • A true cross-process client→client number (writer app → reader app) isn't cleanly measurable: writer and reader are different processes with unsynchronized clocks, so subtracting their timestamps is skew-corrupted. There's no honest single-clock way to capture it end to end.
  • The accurate, single-clock version of "write → read" is measured server-side in workflow-server#612 as lifecycle.stream.propagation_ms — chunk publisheddelivered to a tailing reader, emitted only for pub/sub-woken deliveries of the exact awaited chunk (so it's real propagation, not a chunk sitting idle for an absent reader).
  • Combined with this PR's client legs (workflow.stream.write.chunk_rtt, workflow.stream.read.ttfc_ms) you can present the full decomposition: client→server write · server propagation · server→client read — each on its own clock.

So the e2e story is covered as a server-mediated propagation metric rather than a cross-client wall-clock. Happy to iterate on naming/shape in a follow-up as you suggested.

@github-actionsgithub-actionsBot mentioned this pull request Jul 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 2c6ee61 (AI decision).

The bulk of this commit builds on main-only infrastructure that doesn't exist on stable: the instrumentedFetch client-span envelope and its packages/world-vercel/src/http-core.ts file are absent on stable (verified — the stable streamer uses plain fetch() with no dispatcher or span options), and the commit adds its new spanName/attributes/durationAttribute options and stream-operation spans on top of that. The touched trace-propagation.test.ts and docs/content/docs/v5/observability/tracing.mdx files also don't exist on stable, so the change cannot apply cleanly and is effectively main-only.

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

2c6ee614b50d12ed850e7589cf296150b2143a56

Sign up for freeto 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.

2 participants

@karthikscale3@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(world-vercel): client-observed stream write/read e2e latency spans - #2857

Merged
karthikscale3 merged 9 commits into
mainfrom
karthik/stream-latency-otel
Jul 10, 2026
Merged

feat(world-vercel): client-observed stream write/read e2e latency spans#2857
karthikscale3 merged 9 commits into
mainfrom
karthik/stream-latency-otel

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

What

Adds OpenTelemetry client spans that make stream write and read end-to-end latency measurable from the SDK — the client→server timing that server-side metrics can't see (the network hop + client-side legs). These back getWritable() / run.readable.

Write

  • workflow.stream.write span (for write / writeMulti / close), SpanKind.CLIENT.
  • workflow.stream.write.e2e_ms attribute = client→backend round-trip. The PUT is request/response and the backend acks only after capturing the chunk, so this is the client-observed write latency including the network hop. (It equals the span's own duration; exposed as a named attribute for direct querying.)

Read

  • workflow.stream.read.connect span — dispatch → response headers (the network-connect portion).
  • workflow.stream.read span — its duration is the client-observed end-to-end time-to-first-chunk: read dispatch → first non-empty chunk in the reader, including the network hop — also stamped as workflow.stream.read.ttfc_ms. Since that value is only known once the first chunk arrives, it's emitted as a span back-dated to dispatch time (new recordElapsedSpan helper). v3+ servers flush a leading zero-length chunk to commit headers, which is skipped. If no chunk ever arrives, no workflow.stream.read span is emitted (.connect still is).

Common attributes

workflow.run.id (dotted — the standardized run-id attribute), workflow.stream.name, workflow.stream.operation (write | write_multi | close | read), workflow.stream.start_index (read).

How

  • instrumentedFetch gains three optional, defaulted fields — spanName, attributes, and durationAttribute (stamps the measured round-trip under a named key). Reuses the existing envelope (W3C trace-context injection, timeout, error mapping, DEBUG logging); no new span layer, no extra requests.
  • Read TTFC is measured by a passthrough on response.body that emits the back-dated workflow.stream.read span on the first non-empty chunk.

Backward compatibility

Additive OTEL only. With no OpenTelemetry SDK registered, trace() / recordElapsedSpan no-op and spans/attributes are dropped — behavior is byte-identical to today. The new instrumentedFetch options are optional and defaulted, so all other callers are unaffected. (Client spans are emitted only when the reader/writer has OTEL registered; server metrics cover the rest.)

Docs

Documented the stream spans + attributes in docs/content/docs/v5/observability/tracing.mdx.

Docs Preview

PagePreview
Observability → Tracinghttps://workflow-docs-git-karthik-stream-latency-otel.vercel.sh/v5/docs/observability/tracing

Test plan

  • packages/world-vercel unit suite: 229 passed (pnpm test).
  • trace-propagation.test.ts: asserts the write span name + attributes + traceparent injection, and covers the new recordElapsedSpan back-dated span (duration reflects the elapsed interval + attribute set).
  • Typecheck/build clean (pnpm --filter "@workflow/world-vercel..." build).

Stream write/read requests already share the instrumented HTTP envelope
(a CLIENT span + W3C trace-context injection), but the spans were named
for the bare HTTP verb (`http PUT`/`http GET`) and carried only generic
HTTP attributes — so stream latency couldn't be sliced per run/stream.
Name these spans for their operation (`workflow.stream.write` /
`workflow.stream.read`) and tag them with `workflow.run.id`,
`workflow.stream.name`, `workflow.stream.operation`
(write | write_multi | close | read), and `workflow.stream.start_index`
(read). Implemented via new optional `spanName`/`attributes` fields on
`instrumentedFetch`, so other callers are unaffected.
Additive OTEL only: no behavior change when no OpenTelemetry SDK is
registered (the span is undefined and attributes are dropped).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@karthikscale3
karthikscale3 requested review from a team and ijjk as code ownersJuly 9, 2026 16:16
@vercel

vercelBot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9e42061

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

This PR includes changesets to release 17 packages
NameType
@workflow/corePatch
@workflow/world-vercelPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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

@github-actions

github-actionsBot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production145212301683
✅ 💻 Local Development161702191836
✅ 📦 Local Production161702191836
✅ 🐘 Local Postgres161702191836
✅ 🪟 Windows15300153
✅ 📋 Other89401771071
Total7350110648415

❌ Failed Tests

▲ Vercel Production (1 failed)

vite (1 failed):

  • hookGetConflictThenStepParallelWorkflow - hook.getConflict() continuation step runs alongside other steps | wrun_01KX4NDB03XHJ671CT1QNKFTG2 | 🔍 observability

Details by Category

❌ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro126027
✅ example126027
✅ express126027
✅ fastify126027
✅ hono126027
✅ nextjs-turbopack15003
✅ nextjs-webpack15003
✅ nitro126027
✅ nuxt126027
✅ sveltekit14508
❌ vite125127
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15300
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128025
✅ e2e-local-dev-tanstack-start-128025
✅ e2e-local-postgres-nest-stable128025
✅ e2e-local-postgres-tanstack-start-128025
✅ e2e-local-prod-nest-stable128025
✅ e2e-local-prod-tanstack-start-128025
✅ e2e-vercel-prod-tanstack-start126027

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: success
  • Windows: success

Check the workflow run for details.

@github-actions

github-actionsBot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 9e42061 · Fri, 10 Jul 2026 00:15:04 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1183 (+6.4%)1579 🔴1680 🔴1974 🔴30
TTFShook + stream1580 (+23%)1841 🔴1879 🔴1909 🔴30
STSO1020 steps (1-20)258 (-5.1%)282 🔴370 🔴417 🔴19
STSO1020 steps (101-120)396 (-3.7%)413 🔴500 🔴635 🔴19
STSO1020 steps (1001-1020)1262 (+44%)1004 🔴3876 🔴4520 🔴19
WOstream1183 (+6.4%)15791680197430
WOhook + stream1580 (+23%)18411879190930
SLstream4442 (-4.9%)5525 🔴5667 🔴5774 🔴30
SLhook + stream4914 (-2.3%)5251 🔴5634 🔴6522 🔴30
📜 Previous results (4)

a97582e

Thu, 09 Jul 2026 21:13:27 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1229 (+23%)1786 🔴1824 🔴1948 🔴30
TTFShook + stream1339 (+7.0%)1888 🔴1953 🔴2098 🔴30
STSO1020 steps (1-20)291 (+22%)328 🔴481 🔴599 🔴19
STSO1020 steps (101-120)404 (±0%)451 🔴510 🔴536 🔴19
STSO1020 steps (1001-1020)882 (-15%)967 🔴1036 🔴1173 🔴19
WOstream1229 (+23%)17861824194830
WOhook + stream1339 (+7.0%)18881953209830
SLstream4911 (+2.7%)5723 🔴5876 🔴6427 🔴30
SLhook + stream5183 (+3.8%)5651 🔴5712 🔴9615 🔴30

a435ba0

Thu, 09 Jul 2026 20:40:36 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1379 (+38%)1748 🔴1844 🔴2282 🔴30
TTFShook + stream1676 (+34%)2036 🔴2201 🔴2578 🔴30
STSO1020 steps (1-20)333 (+39%)437 🔴523 🔴722 🔴19
STSO1020 steps (101-120)423 (+4.3%)449 🔴490 🔴599 🔴19
STSO1020 steps (1001-1020)868 (-16%)931 🔴1023 🔴1115 🔴19
WOstream1379 (+38%)17481844228230
WOhook + stream1676 (+34%)20362201257830
SLstream4681 (-2.1%)5302 🔴5741 🔴5884 🔴30
SLhook + stream4926 (-1.3%)5555 🔴5809 🔴5873 🔴30

98cd410

Thu, 09 Jul 2026 20:10:49 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1201 (+20%)1502 🔴1595 🔴2305 🔴30
TTFShook + stream1427 (+14%)1748 🔴1820 🔴1864 🔴30
STSO1020 steps (1-20)267 (+12%)294 🔴354 🔴548 🔴19
STSO1020 steps (101-120)382 (-5.9%)388 🔴440 🔴587 🔴19
STSO1020 steps (1001-1020)879 (-15%)930 🔴952 🔴1065 🔴19
WOstream1201 (+20%)15021595230530
WOhook + stream1427 (+14%)17481820186430
SLstream4209 (-12%)4881 🔴5495 🔴5681 🔴30
SLhook + stream4971 (±0%)5423 🔴5625 🔴6528 🔴30

3de7361

Thu, 09 Jul 2026 16:39:51 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1396 (+40%)1684 🔴1911 🔴1951 🔴30
TTFShook + stream1792 (+25%)2255 🔴2563 🔴3634 🔴30
STSO1020 steps (1-20)356 (+23%)381 🔴614 🔴1197 🔴19
STSO1020 steps (101-120)427 (+0.6%)459 🔴492 🔴685 🔴19
STSO1020 steps (1001-1020)901 (+1.9%)991 🔴1038 🔴1132 🔴19
WOstream1396 (+40%)16841911195130
WOhook + stream1792 (+25%)22552563363430
SLstream4616 (-4.2%)4943 🔴5588 🔴5698 🔴30
SLhook + stream4666 (-4.1%)5442 🔴5604 🔴5705 🔴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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
// chunks are skipped. No-op when no OpenTelemetry SDK is registered.
const readSpanKind = await getSpanKind('CLIENT');
let firstChunkSeen = false;
const ttfcProbe = new TransformStream<Uint8Array, Uint8Array>({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Instead of doing a transform stream, can we emit the span from the core serializer WorkflowWriteableStream on first push?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done in a435ba0 — moved the TTFC measurement into WorkflowServerReadableStream (core), emitting workflow.stream.read on the first non-empty chunk enqueued to the consumer, and dropped the TransformStream wrapper. world-vercel keeps only the workflow.stream.read.connect HTTP span; the recordElapsedSpan helper now lives in @workflow/core. This also makes the span backend-agnostic (any world), and it now includes deframing since it's measured at the reader.

…ansform
Per review: move the client-observed time-to-first-chunk measurement out of a
TransformStream wrapper in world-vercel and into WorkflowServerReadableStream in
core, emitting workflow.stream.read on the first non-empty chunk reaching the
consumer. Removes the passthrough, measures at the reader abstraction, and is
backend-agnostic. world-vercel keeps the workflow.stream.read.connect HTTP span;
the recordElapsedSpan helper now lives in @workflow/core.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread.changeset/stream-otel-spans.md Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
VaguelySerious
VaguelySerious previously approved these changes Jul 9, 2026

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Happy to unblock, can adjust metrics later. It seems like the main e2e latency (first write scheduled to first read received on client-side) is still missing

Comment threaddocs/content/docs/v5/observability/tracing.mdx Outdated
| `workflow.stream.name` | The stream name, on stream write/read spans. |
| `workflow.stream.operation` | The stream operation: `write`, `write_multi`, `close`, or `read`. |
| `workflow.stream.write.e2e_ms` | Client→backend round-trip for a stream write, in ms — the backend acks only after capturing the chunk, so this is the client-observed write latency including the network hop. |
| `workflow.stream.read.ttfc_ms` | Client-observed end-to-end time-to-first-chunk for a live read, in ms — read dispatch → first non-empty chunk in the reader, including the network hop. |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
|`workflow.stream.read.ttfc_ms`|Client-observed end-to-end time-to-first-chunk for a live read, in ms — read dispatch → first non-empty chunk in the reader, including the network hop. |
|`workflow.stream.read.ttfc_ms`|Time between opening a read connection and observing and receiving the first chunk back. |

Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Karthik Kalyan <105607645+karthikscale3@users.noreply.github.com>
Comment threadpackages/world-vercel/src/streamer.ts Outdated
…set wording
Per review, rename the write attribute to workflow.stream.write.chunk_rtt (it's
a per-chunk client<->server round-trip, not a full e2e), update the docs row
wording for both write and read attributes, and shorten the changeset.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@karthikscale3

Copy link
Copy Markdown
ContributorAuthor

On the note that the main e2e (first write → first read on the client) is still missing — agreed, and here's where it landed so it's not lost:

  • A true cross-process client→client number (writer app → reader app) isn't cleanly measurable: writer and reader are different processes with unsynchronized clocks, so subtracting their timestamps is skew-corrupted. There's no honest single-clock way to capture it end to end.
  • The accurate, single-clock version of "write → read" is measured server-side in workflow-server#612 as lifecycle.stream.propagation_ms — chunk publisheddelivered to a tailing reader, emitted only for pub/sub-woken deliveries of the exact awaited chunk (so it's real propagation, not a chunk sitting idle for an absent reader).
  • Combined with this PR's client legs (workflow.stream.write.chunk_rtt, workflow.stream.read.ttfc_ms) you can present the full decomposition: client→server write · server propagation · server→client read — each on its own clock.

So the e2e story is covered as a server-mediated propagation metric rather than a cross-client wall-clock. Happy to iterate on naming/shape in a follow-up as you suggested.

@github-actionsgithub-actionsBot mentioned this pull request Jul 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 2c6ee61 (AI decision).

The bulk of this commit builds on main-only infrastructure that doesn't exist on stable: the instrumentedFetch client-span envelope and its packages/world-vercel/src/http-core.ts file are absent on stable (verified — the stable streamer uses plain fetch() with no dispatcher or span options), and the commit adds its new spanName/attributes/durationAttribute options and stream-operation spans on top of that. The touched trace-propagation.test.ts and docs/content/docs/v5/observability/tracing.mdx files also don't exist on stable, so the change cannot apply cleanly and is effectively main-only.

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

2c6ee614b50d12ed850e7589cf296150b2143a56

Sign up for freeto 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.

2 participants

@karthikscale3@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(world-vercel): client-observed stream write/read e2e latency spans - #2857

Merged
karthikscale3 merged 9 commits into
mainfrom
karthik/stream-latency-otel
Jul 10, 2026
Merged

feat(world-vercel): client-observed stream write/read e2e latency spans#2857
karthikscale3 merged 9 commits into
mainfrom
karthik/stream-latency-otel

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

What

Adds OpenTelemetry client spans that make stream write and read end-to-end latency measurable from the SDK — the client→server timing that server-side metrics can't see (the network hop + client-side legs). These back getWritable() / run.readable.

Write

  • workflow.stream.write span (for write / writeMulti / close), SpanKind.CLIENT.
  • workflow.stream.write.e2e_ms attribute = client→backend round-trip. The PUT is request/response and the backend acks only after capturing the chunk, so this is the client-observed write latency including the network hop. (It equals the span's own duration; exposed as a named attribute for direct querying.)

Read

  • workflow.stream.read.connect span — dispatch → response headers (the network-connect portion).
  • workflow.stream.read span — its duration is the client-observed end-to-end time-to-first-chunk: read dispatch → first non-empty chunk in the reader, including the network hop — also stamped as workflow.stream.read.ttfc_ms. Since that value is only known once the first chunk arrives, it's emitted as a span back-dated to dispatch time (new recordElapsedSpan helper). v3+ servers flush a leading zero-length chunk to commit headers, which is skipped. If no chunk ever arrives, no workflow.stream.read span is emitted (.connect still is).

Common attributes

workflow.run.id (dotted — the standardized run-id attribute), workflow.stream.name, workflow.stream.operation (write | write_multi | close | read), workflow.stream.start_index (read).

How

  • instrumentedFetch gains three optional, defaulted fields — spanName, attributes, and durationAttribute (stamps the measured round-trip under a named key). Reuses the existing envelope (W3C trace-context injection, timeout, error mapping, DEBUG logging); no new span layer, no extra requests.
  • Read TTFC is measured by a passthrough on response.body that emits the back-dated workflow.stream.read span on the first non-empty chunk.

Backward compatibility

Additive OTEL only. With no OpenTelemetry SDK registered, trace() / recordElapsedSpan no-op and spans/attributes are dropped — behavior is byte-identical to today. The new instrumentedFetch options are optional and defaulted, so all other callers are unaffected. (Client spans are emitted only when the reader/writer has OTEL registered; server metrics cover the rest.)

Docs

Documented the stream spans + attributes in docs/content/docs/v5/observability/tracing.mdx.

Docs Preview

PagePreview
Observability → Tracinghttps://workflow-docs-git-karthik-stream-latency-otel.vercel.sh/v5/docs/observability/tracing

Test plan

  • packages/world-vercel unit suite: 229 passed (pnpm test).
  • trace-propagation.test.ts: asserts the write span name + attributes + traceparent injection, and covers the new recordElapsedSpan back-dated span (duration reflects the elapsed interval + attribute set).
  • Typecheck/build clean (pnpm --filter "@workflow/world-vercel..." build).

Stream write/read requests already share the instrumented HTTP envelope
(a CLIENT span + W3C trace-context injection), but the spans were named
for the bare HTTP verb (`http PUT`/`http GET`) and carried only generic
HTTP attributes — so stream latency couldn't be sliced per run/stream.
Name these spans for their operation (`workflow.stream.write` /
`workflow.stream.read`) and tag them with `workflow.run.id`,
`workflow.stream.name`, `workflow.stream.operation`
(write | write_multi | close | read), and `workflow.stream.start_index`
(read). Implemented via new optional `spanName`/`attributes` fields on
`instrumentedFetch`, so other callers are unaffected.
Additive OTEL only: no behavior change when no OpenTelemetry SDK is
registered (the span is undefined and attributes are dropped).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@karthikscale3
karthikscale3 requested review from a team and ijjk as code ownersJuly 9, 2026 16:16
@vercel

vercelBot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9e42061

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

This PR includes changesets to release 17 packages
NameType
@workflow/corePatch
@workflow/world-vercelPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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

@github-actions

github-actionsBot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production145212301683
✅ 💻 Local Development161702191836
✅ 📦 Local Production161702191836
✅ 🐘 Local Postgres161702191836
✅ 🪟 Windows15300153
✅ 📋 Other89401771071
Total7350110648415

❌ Failed Tests

▲ Vercel Production (1 failed)

vite (1 failed):

  • hookGetConflictThenStepParallelWorkflow - hook.getConflict() continuation step runs alongside other steps | wrun_01KX4NDB03XHJ671CT1QNKFTG2 | 🔍 observability

Details by Category

❌ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro126027
✅ example126027
✅ express126027
✅ fastify126027
✅ hono126027
✅ nextjs-turbopack15003
✅ nextjs-webpack15003
✅ nitro126027
✅ nuxt126027
✅ sveltekit14508
❌ vite125127
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15300
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128025
✅ e2e-local-dev-tanstack-start-128025
✅ e2e-local-postgres-nest-stable128025
✅ e2e-local-postgres-tanstack-start-128025
✅ e2e-local-prod-nest-stable128025
✅ e2e-local-prod-tanstack-start-128025
✅ e2e-vercel-prod-tanstack-start126027

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: success
  • Windows: success

Check the workflow run for details.

@github-actions

github-actionsBot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 9e42061 · Fri, 10 Jul 2026 00:15:04 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1183 (+6.4%)1579 🔴1680 🔴1974 🔴30
TTFShook + stream1580 (+23%)1841 🔴1879 🔴1909 🔴30
STSO1020 steps (1-20)258 (-5.1%)282 🔴370 🔴417 🔴19
STSO1020 steps (101-120)396 (-3.7%)413 🔴500 🔴635 🔴19
STSO1020 steps (1001-1020)1262 (+44%)1004 🔴3876 🔴4520 🔴19
WOstream1183 (+6.4%)15791680197430
WOhook + stream1580 (+23%)18411879190930
SLstream4442 (-4.9%)5525 🔴5667 🔴5774 🔴30
SLhook + stream4914 (-2.3%)5251 🔴5634 🔴6522 🔴30
📜 Previous results (4)

a97582e

Thu, 09 Jul 2026 21:13:27 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1229 (+23%)1786 🔴1824 🔴1948 🔴30
TTFShook + stream1339 (+7.0%)1888 🔴1953 🔴2098 🔴30
STSO1020 steps (1-20)291 (+22%)328 🔴481 🔴599 🔴19
STSO1020 steps (101-120)404 (±0%)451 🔴510 🔴536 🔴19
STSO1020 steps (1001-1020)882 (-15%)967 🔴1036 🔴1173 🔴19
WOstream1229 (+23%)17861824194830
WOhook + stream1339 (+7.0%)18881953209830
SLstream4911 (+2.7%)5723 🔴5876 🔴6427 🔴30
SLhook + stream5183 (+3.8%)5651 🔴5712 🔴9615 🔴30

a435ba0

Thu, 09 Jul 2026 20:40:36 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1379 (+38%)1748 🔴1844 🔴2282 🔴30
TTFShook + stream1676 (+34%)2036 🔴2201 🔴2578 🔴30
STSO1020 steps (1-20)333 (+39%)437 🔴523 🔴722 🔴19
STSO1020 steps (101-120)423 (+4.3%)449 🔴490 🔴599 🔴19
STSO1020 steps (1001-1020)868 (-16%)931 🔴1023 🔴1115 🔴19
WOstream1379 (+38%)17481844228230
WOhook + stream1676 (+34%)20362201257830
SLstream4681 (-2.1%)5302 🔴5741 🔴5884 🔴30
SLhook + stream4926 (-1.3%)5555 🔴5809 🔴5873 🔴30

98cd410

Thu, 09 Jul 2026 20:10:49 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1201 (+20%)1502 🔴1595 🔴2305 🔴30
TTFShook + stream1427 (+14%)1748 🔴1820 🔴1864 🔴30
STSO1020 steps (1-20)267 (+12%)294 🔴354 🔴548 🔴19
STSO1020 steps (101-120)382 (-5.9%)388 🔴440 🔴587 🔴19
STSO1020 steps (1001-1020)879 (-15%)930 🔴952 🔴1065 🔴19
WOstream1201 (+20%)15021595230530
WOhook + stream1427 (+14%)17481820186430
SLstream4209 (-12%)4881 🔴5495 🔴5681 🔴30
SLhook + stream4971 (±0%)5423 🔴5625 🔴6528 🔴30

3de7361

Thu, 09 Jul 2026 16:39:51 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1396 (+40%)1684 🔴1911 🔴1951 🔴30
TTFShook + stream1792 (+25%)2255 🔴2563 🔴3634 🔴30
STSO1020 steps (1-20)356 (+23%)381 🔴614 🔴1197 🔴19
STSO1020 steps (101-120)427 (+0.6%)459 🔴492 🔴685 🔴19
STSO1020 steps (1001-1020)901 (+1.9%)991 🔴1038 🔴1132 🔴19
WOstream1396 (+40%)16841911195130
WOhook + stream1792 (+25%)22552563363430
SLstream4616 (-4.2%)4943 🔴5588 🔴5698 🔴30
SLhook + stream4666 (-4.1%)5442 🔴5604 🔴5705 🔴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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
// chunks are skipped. No-op when no OpenTelemetry SDK is registered.
const readSpanKind = await getSpanKind('CLIENT');
let firstChunkSeen = false;
const ttfcProbe = new TransformStream<Uint8Array, Uint8Array>({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Instead of doing a transform stream, can we emit the span from the core serializer WorkflowWriteableStream on first push?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done in a435ba0 — moved the TTFC measurement into WorkflowServerReadableStream (core), emitting workflow.stream.read on the first non-empty chunk enqueued to the consumer, and dropped the TransformStream wrapper. world-vercel keeps only the workflow.stream.read.connect HTTP span; the recordElapsedSpan helper now lives in @workflow/core. This also makes the span backend-agnostic (any world), and it now includes deframing since it's measured at the reader.

…ansform
Per review: move the client-observed time-to-first-chunk measurement out of a
TransformStream wrapper in world-vercel and into WorkflowServerReadableStream in
core, emitting workflow.stream.read on the first non-empty chunk reaching the
consumer. Removes the passthrough, measures at the reader abstraction, and is
backend-agnostic. world-vercel keeps the workflow.stream.read.connect HTTP span;
the recordElapsedSpan helper now lives in @workflow/core.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread.changeset/stream-otel-spans.md Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
VaguelySerious
VaguelySerious previously approved these changes Jul 9, 2026

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Happy to unblock, can adjust metrics later. It seems like the main e2e latency (first write scheduled to first read received on client-side) is still missing

Comment threaddocs/content/docs/v5/observability/tracing.mdx Outdated
| `workflow.stream.name` | The stream name, on stream write/read spans. |
| `workflow.stream.operation` | The stream operation: `write`, `write_multi`, `close`, or `read`. |
| `workflow.stream.write.e2e_ms` | Client→backend round-trip for a stream write, in ms — the backend acks only after capturing the chunk, so this is the client-observed write latency including the network hop. |
| `workflow.stream.read.ttfc_ms` | Client-observed end-to-end time-to-first-chunk for a live read, in ms — read dispatch → first non-empty chunk in the reader, including the network hop. |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
|`workflow.stream.read.ttfc_ms`|Client-observed end-to-end time-to-first-chunk for a live read, in ms — read dispatch → first non-empty chunk in the reader, including the network hop. |
|`workflow.stream.read.ttfc_ms`|Time between opening a read connection and observing and receiving the first chunk back. |

Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Karthik Kalyan <105607645+karthikscale3@users.noreply.github.com>
Comment threadpackages/world-vercel/src/streamer.ts Outdated
…set wording
Per review, rename the write attribute to workflow.stream.write.chunk_rtt (it's
a per-chunk client<->server round-trip, not a full e2e), update the docs row
wording for both write and read attributes, and shorten the changeset.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@karthikscale3

Copy link
Copy Markdown
ContributorAuthor

On the note that the main e2e (first write → first read on the client) is still missing — agreed, and here's where it landed so it's not lost:

  • A true cross-process client→client number (writer app → reader app) isn't cleanly measurable: writer and reader are different processes with unsynchronized clocks, so subtracting their timestamps is skew-corrupted. There's no honest single-clock way to capture it end to end.
  • The accurate, single-clock version of "write → read" is measured server-side in workflow-server#612 as lifecycle.stream.propagation_ms — chunk publisheddelivered to a tailing reader, emitted only for pub/sub-woken deliveries of the exact awaited chunk (so it's real propagation, not a chunk sitting idle for an absent reader).
  • Combined with this PR's client legs (workflow.stream.write.chunk_rtt, workflow.stream.read.ttfc_ms) you can present the full decomposition: client→server write · server propagation · server→client read — each on its own clock.

So the e2e story is covered as a server-mediated propagation metric rather than a cross-client wall-clock. Happy to iterate on naming/shape in a follow-up as you suggested.

@github-actionsgithub-actionsBot mentioned this pull request Jul 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 2c6ee61 (AI decision).

The bulk of this commit builds on main-only infrastructure that doesn't exist on stable: the instrumentedFetch client-span envelope and its packages/world-vercel/src/http-core.ts file are absent on stable (verified — the stable streamer uses plain fetch() with no dispatcher or span options), and the commit adds its new spanName/attributes/durationAttribute options and stream-operation spans on top of that. The touched trace-propagation.test.ts and docs/content/docs/v5/observability/tracing.mdx files also don't exist on stable, so the change cannot apply cleanly and is effectively main-only.

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

2c6ee614b50d12ed850e7589cf296150b2143a56

Sign up for freeto 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.

2 participants

@karthikscale3@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(world-vercel): client-observed stream write/read e2e latency spans - #2857

Merged
karthikscale3 merged 9 commits into
mainfrom
karthik/stream-latency-otel
Jul 10, 2026
Merged

feat(world-vercel): client-observed stream write/read e2e latency spans#2857
karthikscale3 merged 9 commits into
mainfrom
karthik/stream-latency-otel

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

What

Adds OpenTelemetry client spans that make stream write and read end-to-end latency measurable from the SDK — the client→server timing that server-side metrics can't see (the network hop + client-side legs). These back getWritable() / run.readable.

Write

  • workflow.stream.write span (for write / writeMulti / close), SpanKind.CLIENT.
  • workflow.stream.write.e2e_ms attribute = client→backend round-trip. The PUT is request/response and the backend acks only after capturing the chunk, so this is the client-observed write latency including the network hop. (It equals the span's own duration; exposed as a named attribute for direct querying.)

Read

  • workflow.stream.read.connect span — dispatch → response headers (the network-connect portion).
  • workflow.stream.read span — its duration is the client-observed end-to-end time-to-first-chunk: read dispatch → first non-empty chunk in the reader, including the network hop — also stamped as workflow.stream.read.ttfc_ms. Since that value is only known once the first chunk arrives, it's emitted as a span back-dated to dispatch time (new recordElapsedSpan helper). v3+ servers flush a leading zero-length chunk to commit headers, which is skipped. If no chunk ever arrives, no workflow.stream.read span is emitted (.connect still is).

Common attributes

workflow.run.id (dotted — the standardized run-id attribute), workflow.stream.name, workflow.stream.operation (write | write_multi | close | read), workflow.stream.start_index (read).

How

  • instrumentedFetch gains three optional, defaulted fields — spanName, attributes, and durationAttribute (stamps the measured round-trip under a named key). Reuses the existing envelope (W3C trace-context injection, timeout, error mapping, DEBUG logging); no new span layer, no extra requests.
  • Read TTFC is measured by a passthrough on response.body that emits the back-dated workflow.stream.read span on the first non-empty chunk.

Backward compatibility

Additive OTEL only. With no OpenTelemetry SDK registered, trace() / recordElapsedSpan no-op and spans/attributes are dropped — behavior is byte-identical to today. The new instrumentedFetch options are optional and defaulted, so all other callers are unaffected. (Client spans are emitted only when the reader/writer has OTEL registered; server metrics cover the rest.)

Docs

Documented the stream spans + attributes in docs/content/docs/v5/observability/tracing.mdx.

Docs Preview

PagePreview
Observability → Tracinghttps://workflow-docs-git-karthik-stream-latency-otel.vercel.sh/v5/docs/observability/tracing

Test plan

  • packages/world-vercel unit suite: 229 passed (pnpm test).
  • trace-propagation.test.ts: asserts the write span name + attributes + traceparent injection, and covers the new recordElapsedSpan back-dated span (duration reflects the elapsed interval + attribute set).
  • Typecheck/build clean (pnpm --filter "@workflow/world-vercel..." build).

Stream write/read requests already share the instrumented HTTP envelope
(a CLIENT span + W3C trace-context injection), but the spans were named
for the bare HTTP verb (`http PUT`/`http GET`) and carried only generic
HTTP attributes — so stream latency couldn't be sliced per run/stream.
Name these spans for their operation (`workflow.stream.write` /
`workflow.stream.read`) and tag them with `workflow.run.id`,
`workflow.stream.name`, `workflow.stream.operation`
(write | write_multi | close | read), and `workflow.stream.start_index`
(read). Implemented via new optional `spanName`/`attributes` fields on
`instrumentedFetch`, so other callers are unaffected.
Additive OTEL only: no behavior change when no OpenTelemetry SDK is
registered (the span is undefined and attributes are dropped).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@karthikscale3
karthikscale3 requested review from a team and ijjk as code ownersJuly 9, 2026 16:16
@vercel

vercelBot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9e42061

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

This PR includes changesets to release 17 packages
NameType
@workflow/corePatch
@workflow/world-vercelPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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

@github-actions

github-actionsBot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production145212301683
✅ 💻 Local Development161702191836
✅ 📦 Local Production161702191836
✅ 🐘 Local Postgres161702191836
✅ 🪟 Windows15300153
✅ 📋 Other89401771071
Total7350110648415

❌ Failed Tests

▲ Vercel Production (1 failed)

vite (1 failed):

  • hookGetConflictThenStepParallelWorkflow - hook.getConflict() continuation step runs alongside other steps | wrun_01KX4NDB03XHJ671CT1QNKFTG2 | 🔍 observability

Details by Category

❌ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro126027
✅ example126027
✅ express126027
✅ fastify126027
✅ hono126027
✅ nextjs-turbopack15003
✅ nextjs-webpack15003
✅ nitro126027
✅ nuxt126027
✅ sveltekit14508
❌ vite125127
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15300
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128025
✅ e2e-local-dev-tanstack-start-128025
✅ e2e-local-postgres-nest-stable128025
✅ e2e-local-postgres-tanstack-start-128025
✅ e2e-local-prod-nest-stable128025
✅ e2e-local-prod-tanstack-start-128025
✅ e2e-vercel-prod-tanstack-start126027

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: success
  • Windows: success

Check the workflow run for details.

@github-actions

github-actionsBot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 9e42061 · Fri, 10 Jul 2026 00:15:04 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1183 (+6.4%)1579 🔴1680 🔴1974 🔴30
TTFShook + stream1580 (+23%)1841 🔴1879 🔴1909 🔴30
STSO1020 steps (1-20)258 (-5.1%)282 🔴370 🔴417 🔴19
STSO1020 steps (101-120)396 (-3.7%)413 🔴500 🔴635 🔴19
STSO1020 steps (1001-1020)1262 (+44%)1004 🔴3876 🔴4520 🔴19
WOstream1183 (+6.4%)15791680197430
WOhook + stream1580 (+23%)18411879190930
SLstream4442 (-4.9%)5525 🔴5667 🔴5774 🔴30
SLhook + stream4914 (-2.3%)5251 🔴5634 🔴6522 🔴30
📜 Previous results (4)

a97582e

Thu, 09 Jul 2026 21:13:27 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1229 (+23%)1786 🔴1824 🔴1948 🔴30
TTFShook + stream1339 (+7.0%)1888 🔴1953 🔴2098 🔴30
STSO1020 steps (1-20)291 (+22%)328 🔴481 🔴599 🔴19
STSO1020 steps (101-120)404 (±0%)451 🔴510 🔴536 🔴19
STSO1020 steps (1001-1020)882 (-15%)967 🔴1036 🔴1173 🔴19
WOstream1229 (+23%)17861824194830
WOhook + stream1339 (+7.0%)18881953209830
SLstream4911 (+2.7%)5723 🔴5876 🔴6427 🔴30
SLhook + stream5183 (+3.8%)5651 🔴5712 🔴9615 🔴30

a435ba0

Thu, 09 Jul 2026 20:40:36 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1379 (+38%)1748 🔴1844 🔴2282 🔴30
TTFShook + stream1676 (+34%)2036 🔴2201 🔴2578 🔴30
STSO1020 steps (1-20)333 (+39%)437 🔴523 🔴722 🔴19
STSO1020 steps (101-120)423 (+4.3%)449 🔴490 🔴599 🔴19
STSO1020 steps (1001-1020)868 (-16%)931 🔴1023 🔴1115 🔴19
WOstream1379 (+38%)17481844228230
WOhook + stream1676 (+34%)20362201257830
SLstream4681 (-2.1%)5302 🔴5741 🔴5884 🔴30
SLhook + stream4926 (-1.3%)5555 🔴5809 🔴5873 🔴30

98cd410

Thu, 09 Jul 2026 20:10:49 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1201 (+20%)1502 🔴1595 🔴2305 🔴30
TTFShook + stream1427 (+14%)1748 🔴1820 🔴1864 🔴30
STSO1020 steps (1-20)267 (+12%)294 🔴354 🔴548 🔴19
STSO1020 steps (101-120)382 (-5.9%)388 🔴440 🔴587 🔴19
STSO1020 steps (1001-1020)879 (-15%)930 🔴952 🔴1065 🔴19
WOstream1201 (+20%)15021595230530
WOhook + stream1427 (+14%)17481820186430
SLstream4209 (-12%)4881 🔴5495 🔴5681 🔴30
SLhook + stream4971 (±0%)5423 🔴5625 🔴6528 🔴30

3de7361

Thu, 09 Jul 2026 16:39:51 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1396 (+40%)1684 🔴1911 🔴1951 🔴30
TTFShook + stream1792 (+25%)2255 🔴2563 🔴3634 🔴30
STSO1020 steps (1-20)356 (+23%)381 🔴614 🔴1197 🔴19
STSO1020 steps (101-120)427 (+0.6%)459 🔴492 🔴685 🔴19
STSO1020 steps (1001-1020)901 (+1.9%)991 🔴1038 🔴1132 🔴19
WOstream1396 (+40%)16841911195130
WOhook + stream1792 (+25%)22552563363430
SLstream4616 (-4.2%)4943 🔴5588 🔴5698 🔴30
SLhook + stream4666 (-4.1%)5442 🔴5604 🔴5705 🔴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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
// chunks are skipped. No-op when no OpenTelemetry SDK is registered.
const readSpanKind = await getSpanKind('CLIENT');
let firstChunkSeen = false;
const ttfcProbe = new TransformStream<Uint8Array, Uint8Array>({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Instead of doing a transform stream, can we emit the span from the core serializer WorkflowWriteableStream on first push?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done in a435ba0 — moved the TTFC measurement into WorkflowServerReadableStream (core), emitting workflow.stream.read on the first non-empty chunk enqueued to the consumer, and dropped the TransformStream wrapper. world-vercel keeps only the workflow.stream.read.connect HTTP span; the recordElapsedSpan helper now lives in @workflow/core. This also makes the span backend-agnostic (any world), and it now includes deframing since it's measured at the reader.

…ansform
Per review: move the client-observed time-to-first-chunk measurement out of a
TransformStream wrapper in world-vercel and into WorkflowServerReadableStream in
core, emitting workflow.stream.read on the first non-empty chunk reaching the
consumer. Removes the passthrough, measures at the reader abstraction, and is
backend-agnostic. world-vercel keeps the workflow.stream.read.connect HTTP span;
the recordElapsedSpan helper now lives in @workflow/core.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread.changeset/stream-otel-spans.md Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
VaguelySerious
VaguelySerious previously approved these changes Jul 9, 2026

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Happy to unblock, can adjust metrics later. It seems like the main e2e latency (first write scheduled to first read received on client-side) is still missing

Comment threaddocs/content/docs/v5/observability/tracing.mdx Outdated
| `workflow.stream.name` | The stream name, on stream write/read spans. |
| `workflow.stream.operation` | The stream operation: `write`, `write_multi`, `close`, or `read`. |
| `workflow.stream.write.e2e_ms` | Client→backend round-trip for a stream write, in ms — the backend acks only after capturing the chunk, so this is the client-observed write latency including the network hop. |
| `workflow.stream.read.ttfc_ms` | Client-observed end-to-end time-to-first-chunk for a live read, in ms — read dispatch → first non-empty chunk in the reader, including the network hop. |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
|`workflow.stream.read.ttfc_ms`|Client-observed end-to-end time-to-first-chunk for a live read, in ms — read dispatch → first non-empty chunk in the reader, including the network hop. |
|`workflow.stream.read.ttfc_ms`|Time between opening a read connection and observing and receiving the first chunk back. |

Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Karthik Kalyan <105607645+karthikscale3@users.noreply.github.com>
Comment threadpackages/world-vercel/src/streamer.ts Outdated
…set wording
Per review, rename the write attribute to workflow.stream.write.chunk_rtt (it's
a per-chunk client<->server round-trip, not a full e2e), update the docs row
wording for both write and read attributes, and shorten the changeset.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@karthikscale3

Copy link
Copy Markdown
ContributorAuthor

On the note that the main e2e (first write → first read on the client) is still missing — agreed, and here's where it landed so it's not lost:

  • A true cross-process client→client number (writer app → reader app) isn't cleanly measurable: writer and reader are different processes with unsynchronized clocks, so subtracting their timestamps is skew-corrupted. There's no honest single-clock way to capture it end to end.
  • The accurate, single-clock version of "write → read" is measured server-side in workflow-server#612 as lifecycle.stream.propagation_ms — chunk publisheddelivered to a tailing reader, emitted only for pub/sub-woken deliveries of the exact awaited chunk (so it's real propagation, not a chunk sitting idle for an absent reader).
  • Combined with this PR's client legs (workflow.stream.write.chunk_rtt, workflow.stream.read.ttfc_ms) you can present the full decomposition: client→server write · server propagation · server→client read — each on its own clock.

So the e2e story is covered as a server-mediated propagation metric rather than a cross-client wall-clock. Happy to iterate on naming/shape in a follow-up as you suggested.

@github-actionsgithub-actionsBot mentioned this pull request Jul 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 2c6ee61 (AI decision).

The bulk of this commit builds on main-only infrastructure that doesn't exist on stable: the instrumentedFetch client-span envelope and its packages/world-vercel/src/http-core.ts file are absent on stable (verified — the stable streamer uses plain fetch() with no dispatcher or span options), and the commit adds its new spanName/attributes/durationAttribute options and stream-operation spans on top of that. The touched trace-propagation.test.ts and docs/content/docs/v5/observability/tracing.mdx files also don't exist on stable, so the change cannot apply cleanly and is effectively main-only.

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

2c6ee614b50d12ed850e7589cf296150b2143a56

Sign up for freeto 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.

2 participants

@karthikscale3@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(world-vercel): client-observed stream write/read e2e latency spans - #2857

Merged
karthikscale3 merged 9 commits into
mainfrom
karthik/stream-latency-otel
Jul 10, 2026
Merged

feat(world-vercel): client-observed stream write/read e2e latency spans#2857
karthikscale3 merged 9 commits into
mainfrom
karthik/stream-latency-otel

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

What

Adds OpenTelemetry client spans that make stream write and read end-to-end latency measurable from the SDK — the client→server timing that server-side metrics can't see (the network hop + client-side legs). These back getWritable() / run.readable.

Write

  • workflow.stream.write span (for write / writeMulti / close), SpanKind.CLIENT.
  • workflow.stream.write.e2e_ms attribute = client→backend round-trip. The PUT is request/response and the backend acks only after capturing the chunk, so this is the client-observed write latency including the network hop. (It equals the span's own duration; exposed as a named attribute for direct querying.)

Read

  • workflow.stream.read.connect span — dispatch → response headers (the network-connect portion).
  • workflow.stream.read span — its duration is the client-observed end-to-end time-to-first-chunk: read dispatch → first non-empty chunk in the reader, including the network hop — also stamped as workflow.stream.read.ttfc_ms. Since that value is only known once the first chunk arrives, it's emitted as a span back-dated to dispatch time (new recordElapsedSpan helper). v3+ servers flush a leading zero-length chunk to commit headers, which is skipped. If no chunk ever arrives, no workflow.stream.read span is emitted (.connect still is).

Common attributes

workflow.run.id (dotted — the standardized run-id attribute), workflow.stream.name, workflow.stream.operation (write | write_multi | close | read), workflow.stream.start_index (read).

How

  • instrumentedFetch gains three optional, defaulted fields — spanName, attributes, and durationAttribute (stamps the measured round-trip under a named key). Reuses the existing envelope (W3C trace-context injection, timeout, error mapping, DEBUG logging); no new span layer, no extra requests.
  • Read TTFC is measured by a passthrough on response.body that emits the back-dated workflow.stream.read span on the first non-empty chunk.

Backward compatibility

Additive OTEL only. With no OpenTelemetry SDK registered, trace() / recordElapsedSpan no-op and spans/attributes are dropped — behavior is byte-identical to today. The new instrumentedFetch options are optional and defaulted, so all other callers are unaffected. (Client spans are emitted only when the reader/writer has OTEL registered; server metrics cover the rest.)

Docs

Documented the stream spans + attributes in docs/content/docs/v5/observability/tracing.mdx.

Docs Preview

PagePreview
Observability → Tracinghttps://workflow-docs-git-karthik-stream-latency-otel.vercel.sh/v5/docs/observability/tracing

Test plan

  • packages/world-vercel unit suite: 229 passed (pnpm test).
  • trace-propagation.test.ts: asserts the write span name + attributes + traceparent injection, and covers the new recordElapsedSpan back-dated span (duration reflects the elapsed interval + attribute set).
  • Typecheck/build clean (pnpm --filter "@workflow/world-vercel..." build).

Stream write/read requests already share the instrumented HTTP envelope
(a CLIENT span + W3C trace-context injection), but the spans were named
for the bare HTTP verb (`http PUT`/`http GET`) and carried only generic
HTTP attributes — so stream latency couldn't be sliced per run/stream.
Name these spans for their operation (`workflow.stream.write` /
`workflow.stream.read`) and tag them with `workflow.run.id`,
`workflow.stream.name`, `workflow.stream.operation`
(write | write_multi | close | read), and `workflow.stream.start_index`
(read). Implemented via new optional `spanName`/`attributes` fields on
`instrumentedFetch`, so other callers are unaffected.
Additive OTEL only: no behavior change when no OpenTelemetry SDK is
registered (the span is undefined and attributes are dropped).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@karthikscale3
karthikscale3 requested review from a team and ijjk as code ownersJuly 9, 2026 16:16
@vercel

vercelBot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9e42061

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

This PR includes changesets to release 17 packages
NameType
@workflow/corePatch
@workflow/world-vercelPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/nuxtPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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

@github-actions

github-actionsBot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production145212301683
✅ 💻 Local Development161702191836
✅ 📦 Local Production161702191836
✅ 🐘 Local Postgres161702191836
✅ 🪟 Windows15300153
✅ 📋 Other89401771071
Total7350110648415

❌ Failed Tests

▲ Vercel Production (1 failed)

vite (1 failed):

  • hookGetConflictThenStepParallelWorkflow - hook.getConflict() continuation step runs alongside other steps | wrun_01KX4NDB03XHJ671CT1QNKFTG2 | 🔍 observability

Details by Category

❌ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro126027
✅ example126027
✅ express126027
✅ fastify126027
✅ hono126027
✅ nextjs-turbopack15003
✅ nextjs-webpack15003
✅ nitro126027
✅ nuxt126027
✅ sveltekit14508
❌ vite125127
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15300
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128025
✅ e2e-local-dev-tanstack-start-128025
✅ e2e-local-postgres-nest-stable128025
✅ e2e-local-postgres-tanstack-start-128025
✅ e2e-local-prod-nest-stable128025
✅ e2e-local-prod-tanstack-start-128025
✅ e2e-vercel-prod-tanstack-start126027

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: success
  • Windows: success

Check the workflow run for details.

@github-actions

github-actionsBot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 9e42061 · Fri, 10 Jul 2026 00:15:04 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1183 (+6.4%)1579 🔴1680 🔴1974 🔴30
TTFShook + stream1580 (+23%)1841 🔴1879 🔴1909 🔴30
STSO1020 steps (1-20)258 (-5.1%)282 🔴370 🔴417 🔴19
STSO1020 steps (101-120)396 (-3.7%)413 🔴500 🔴635 🔴19
STSO1020 steps (1001-1020)1262 (+44%)1004 🔴3876 🔴4520 🔴19
WOstream1183 (+6.4%)15791680197430
WOhook + stream1580 (+23%)18411879190930
SLstream4442 (-4.9%)5525 🔴5667 🔴5774 🔴30
SLhook + stream4914 (-2.3%)5251 🔴5634 🔴6522 🔴30
📜 Previous results (4)

a97582e

Thu, 09 Jul 2026 21:13:27 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1229 (+23%)1786 🔴1824 🔴1948 🔴30
TTFShook + stream1339 (+7.0%)1888 🔴1953 🔴2098 🔴30
STSO1020 steps (1-20)291 (+22%)328 🔴481 🔴599 🔴19
STSO1020 steps (101-120)404 (±0%)451 🔴510 🔴536 🔴19
STSO1020 steps (1001-1020)882 (-15%)967 🔴1036 🔴1173 🔴19
WOstream1229 (+23%)17861824194830
WOhook + stream1339 (+7.0%)18881953209830
SLstream4911 (+2.7%)5723 🔴5876 🔴6427 🔴30
SLhook + stream5183 (+3.8%)5651 🔴5712 🔴9615 🔴30

a435ba0

Thu, 09 Jul 2026 20:40:36 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1379 (+38%)1748 🔴1844 🔴2282 🔴30
TTFShook + stream1676 (+34%)2036 🔴2201 🔴2578 🔴30
STSO1020 steps (1-20)333 (+39%)437 🔴523 🔴722 🔴19
STSO1020 steps (101-120)423 (+4.3%)449 🔴490 🔴599 🔴19
STSO1020 steps (1001-1020)868 (-16%)931 🔴1023 🔴1115 🔴19
WOstream1379 (+38%)17481844228230
WOhook + stream1676 (+34%)20362201257830
SLstream4681 (-2.1%)5302 🔴5741 🔴5884 🔴30
SLhook + stream4926 (-1.3%)5555 🔴5809 🔴5873 🔴30

98cd410

Thu, 09 Jul 2026 20:10:49 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1201 (+20%)1502 🔴1595 🔴2305 🔴30
TTFShook + stream1427 (+14%)1748 🔴1820 🔴1864 🔴30
STSO1020 steps (1-20)267 (+12%)294 🔴354 🔴548 🔴19
STSO1020 steps (101-120)382 (-5.9%)388 🔴440 🔴587 🔴19
STSO1020 steps (1001-1020)879 (-15%)930 🔴952 🔴1065 🔴19
WOstream1201 (+20%)15021595230530
WOhook + stream1427 (+14%)17481820186430
SLstream4209 (-12%)4881 🔴5495 🔴5681 🔴30
SLhook + stream4971 (±0%)5423 🔴5625 🔴6528 🔴30

3de7361

Thu, 09 Jul 2026 16:39:51 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1396 (+40%)1684 🔴1911 🔴1951 🔴30
TTFShook + stream1792 (+25%)2255 🔴2563 🔴3634 🔴30
STSO1020 steps (1-20)356 (+23%)381 🔴614 🔴1197 🔴19
STSO1020 steps (101-120)427 (+0.6%)459 🔴492 🔴685 🔴19
STSO1020 steps (1001-1020)901 (+1.9%)991 🔴1038 🔴1132 🔴19
WOstream1396 (+40%)16841911195130
WOhook + stream1792 (+25%)22552563363430
SLstream4616 (-4.2%)4943 🔴5588 🔴5698 🔴30
SLhook + stream4666 (-4.1%)5442 🔴5604 🔴5705 🔴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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
// chunks are skipped. No-op when no OpenTelemetry SDK is registered.
const readSpanKind = await getSpanKind('CLIENT');
let firstChunkSeen = false;
const ttfcProbe = new TransformStream<Uint8Array, Uint8Array>({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Instead of doing a transform stream, can we emit the span from the core serializer WorkflowWriteableStream on first push?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Done in a435ba0 — moved the TTFC measurement into WorkflowServerReadableStream (core), emitting workflow.stream.read on the first non-empty chunk enqueued to the consumer, and dropped the TransformStream wrapper. world-vercel keeps only the workflow.stream.read.connect HTTP span; the recordElapsedSpan helper now lives in @workflow/core. This also makes the span backend-agnostic (any world), and it now includes deframing since it's measured at the reader.

…ansform
Per review: move the client-observed time-to-first-chunk measurement out of a
TransformStream wrapper in world-vercel and into WorkflowServerReadableStream in
core, emitting workflow.stream.read on the first non-empty chunk reaching the
consumer. Removes the passthrough, measures at the reader abstraction, and is
backend-agnostic. world-vercel keeps the workflow.stream.read.connect HTTP span;
the recordElapsedSpan helper now lives in @workflow/core.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread.changeset/stream-otel-spans.md Outdated
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
VaguelySerious
VaguelySerious previously approved these changes Jul 9, 2026

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Happy to unblock, can adjust metrics later. It seems like the main e2e latency (first write scheduled to first read received on client-side) is still missing

Comment threaddocs/content/docs/v5/observability/tracing.mdx Outdated
| `workflow.stream.name` | The stream name, on stream write/read spans. |
| `workflow.stream.operation` | The stream operation: `write`, `write_multi`, `close`, or `read`. |
| `workflow.stream.write.e2e_ms` | Client→backend round-trip for a stream write, in ms — the backend acks only after capturing the chunk, so this is the client-observed write latency including the network hop. |
| `workflow.stream.read.ttfc_ms` | Client-observed end-to-end time-to-first-chunk for a live read, in ms — read dispatch → first non-empty chunk in the reader, including the network hop. |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
|`workflow.stream.read.ttfc_ms`|Client-observed end-to-end time-to-first-chunk for a live read, in ms — read dispatch → first non-empty chunk in the reader, including the network hop. |
|`workflow.stream.read.ttfc_ms`|Time between opening a read connection and observing and receiving the first chunk back. |

Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Karthik Kalyan <105607645+karthikscale3@users.noreply.github.com>
Comment threadpackages/world-vercel/src/streamer.ts Outdated
…set wording
Per review, rename the write attribute to workflow.stream.write.chunk_rtt (it's
a per-chunk client<->server round-trip, not a full e2e), update the docs row
wording for both write and read attributes, and shorten the changeset.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@karthikscale3

Copy link
Copy Markdown
ContributorAuthor

On the note that the main e2e (first write → first read on the client) is still missing — agreed, and here's where it landed so it's not lost:

  • A true cross-process client→client number (writer app → reader app) isn't cleanly measurable: writer and reader are different processes with unsynchronized clocks, so subtracting their timestamps is skew-corrupted. There's no honest single-clock way to capture it end to end.
  • The accurate, single-clock version of "write → read" is measured server-side in workflow-server#612 as lifecycle.stream.propagation_ms — chunk publisheddelivered to a tailing reader, emitted only for pub/sub-woken deliveries of the exact awaited chunk (so it's real propagation, not a chunk sitting idle for an absent reader).
  • Combined with this PR's client legs (workflow.stream.write.chunk_rtt, workflow.stream.read.ttfc_ms) you can present the full decomposition: client→server write · server propagation · server→client read — each on its own clock.

So the e2e story is covered as a server-mediated propagation metric rather than a cross-client wall-clock. Happy to iterate on naming/shape in a follow-up as you suggested.

@github-actionsgithub-actionsBot mentioned this pull request Jul 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 2c6ee61 (AI decision).

The bulk of this commit builds on main-only infrastructure that doesn't exist on stable: the instrumentedFetch client-span envelope and its packages/world-vercel/src/http-core.ts file are absent on stable (verified — the stable streamer uses plain fetch() with no dispatcher or span options), and the commit adds its new spanName/attributes/durationAttribute options and stream-operation spans on top of that. The touched trace-propagation.test.ts and docs/content/docs/v5/observability/tracing.mdx files also don't exist on stable, so the change cannot apply cleanly and is effectively main-only.

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

2c6ee614b50d12ed850e7589cf296150b2143a56

Sign up for freeto 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.

2 participants

@karthikscale3@VaguelySerious