') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); feat(world-vercel): synthesize per-event client spans on the WS transport by shalabhc · Pull Request #3452 · vercel/workflow · GitHub
Skip to content

feat(world-vercel): synthesize per-event client spans on the WS transport - #3452

Merged
shalabhc merged 4 commits into
mainfrom
shalabhc/ws-synthetic-trace-spans
Aug 13, 2026
Merged

feat(world-vercel): synthesize per-event client spans on the WS transport#3452
shalabhc merged 4 commits into
mainfrom
shalabhc/ws-synthetic-trace-spans

Conversation

@shalabhc

@shalabhcshalabhc commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Problem

Websocket transport (#3084) does not produce per-message spans. In HTTP we got per request spans. In the WS transport writes become multiplexed frames on one long-lived socket, so the per-event spans dont exist.

This restores per-event spans by synthesizing a request-shaped span around each frame.

AI Details

Approach

The guiding constraint was comparability: a trace collected with the flag on should line up with one collected with it off, so dashboards and per-event traces survive flipping WORKFLOW_EVENTS_TRANSPORT. The guiding counter-constraint was honesty: a synthetic span must never be mistaken for a real HTTP request.

  • Shared envelope. Extracted withHttpClientSpan / recordClientSpanStatus out of instrumentedFetch in http-core.ts, so the synthetic span is emitted by the same code as the real one and cannot drift from it. InstrumentedFetchOptions now extends HttpClientSpanOptions.
  • Per-write span.postEventFrameOverWs opens http POST with url.full pointing at the v4 REST endpoint the frame is forwarded into — that's what keeps traces and latency dashboards comparable across the flag. Extracted eventsV4Url so this URL cannot drift from the one the HTTP path actually requests.
  • Honesty attributes. Both transports now set workflow.events.transport (http | ws) and workflow.event.type. The WS path additionally sets network.protocol.name=websocket, workflow.events.ws.url (the real wire destination) and workflow.events.ws.req_id (the join key to the server's log line for that frame). Setting workflow.events.transport on both paths is deliberate — it's the only way to slice two otherwise-identical spans against each other.
  • Connection span. Added workflow.events.ws.connect around the upgrade — the one genuinely-HTTP request in this path — carrying workflow.events.ws.reconnect_attempt. Named for the operation so it doesn't bucket with the event writes. This also puts resolveUpgradeHeaders' trace-context injection inside a client span, which AGENTS.md requires of every outgoing world-vercel request.
  • Bug fix.parseServer treated wss: as plaintext and defaulted server.port to 80; it's now TLS-aware (443).

Deliberately out of scope

  • Per-frame traceparent. Each frame can't carry its own trace context without a frame-meta field plus a coordinated server change. Only the upgrade propagates context today. Happy to spec it separately.
  • Vercel's "outgoing requests" view. That instruments global fetch. A WS frame is structurally not a fetch, so it cannot appear there no matter what spans we emit. OTEL traces are the recoverable surface; this closes that one.

Behaviour changes worth flagging

Two assertions in trace-propagation.test.ts changed, both intended:

  1. The upgrade's traceparent now names the connect span rather than the invocation span (it's a child, as every HTTP path already does).
  2. A traceparent is now injected even when no span is active — parity with every other world-vercel request path, where previously the WS upgrade injected nothing.

Tests

New ws-transport-spans.test.ts (12 tests). Unlike events-v4-ws.test.ts it does not mock resolveWsTransport, so spans come from the real selection + transport + adapter stack over a fake socket. Covers span shape and kind, url.full, the honesty attributes, reqId sequencing across writes, parenting to the caller rather than the connection, absence of a span when falling back to HTTP, HTTP 409 and TRANSPORT failure reporting, one span per retry attempt through withEventPostRetry, connect-span attributes, a refused upgrade, and HTTP/WS parity.

Verified: tsc --noEmit clean; vitest run src487/487 passing across 24 files.

Docs updated in docs/content/worlds/v5/vercel.mdx (attribute table comparing the two transports), plus an AGENTS.md note that non-fetch request paths must still open their client span via withHttpClientSpan. Changeset included (@workflow/world-vercel: patch).

🤖 Generated with Claude Code

@changeset-bot

changeset-botBot commented Aug 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: daa7259

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

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

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

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

@vercel

vercelBot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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

ProjectDeploymentActionsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewAug 13, 2026 4:08am
example-nextjs-workflow-webpackReadyReadyPreviewAug 13, 2026 4:08am
example-workflowReadyReadyPreviewAug 13, 2026 4:08am
workbench-astro-workflowReadyReadyPreviewAug 13, 2026 4:08am
workbench-express-workflowReadyReadyPreviewAug 13, 2026 4:08am
workbench-fastify-workflowReadyReadyPreviewAug 13, 2026 4:08am
workbench-hono-workflowReadyReadyPreviewAug 13, 2026 4:08am
workbench-nestjs-workflowReadyReadyPreviewAug 13, 2026 4:08am
workbench-nitro-workflowReadyReadyPreviewAug 13, 2026 4:08am
workbench-nuxt-workflowReadyReadyPreviewAug 13, 2026 4:08am
workbench-python-workflowErrorErrorAug 13, 2026 4:08am
workbench-sveltekit-workflowReadyReadyPreviewAug 13, 2026 4:08am
workbench-tanstack-start-workflowReadyReadyPreviewAug 13, 2026 4:08am
workbench-vite-workflowReadyReadyPreviewAug 13, 2026 4:08am
workflow-docsReadyReadyPreview, v0Aug 13, 2026 4:08am
workflow-swc-playgroundReadyReadyPreviewAug 13, 2026 4:08am
workflow-tarballsReadyReadyPreviewAug 13, 2026 4:08am
workflow-webReadyReadyPreviewAug 13, 2026 4:08am

@github-actions

github-actionsBot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production346605904056
✅ 💻 Local Development351705394056
✅ 📦 Local Production381005584368
✅ 🐘 Local Postgres381005584368
✅ vercel-multi-region270027
✅ vercel-ws-transport537087624
Total151670233217499
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node128028
✅ astro-quickjs128028
✅ example-node128028
✅ example-quickjs128028
✅ express-node128028
✅ express-quickjs128028
✅ fastify-node128028
✅ fastify-quickjs128028
✅ hono-node128028
✅ hono-quickjs128028
✅ nest-node128028
✅ nest-quickjs128028
✅ nextjs-turbopack-node15303
✅ nextjs-turbopack-quickjs15303
✅ nextjs-webpack-node15303
✅ nextjs-webpack-quickjs15303
✅ nitro-node128028
✅ nitro-quickjs128028
✅ nuxt-node128028
✅ nuxt-quickjs128028
✅ sveltekit-node14709
✅ sveltekit-quickjs14709
✅ tanstack-start-node128028
✅ tanstack-start-quickjs128028
✅ vite-node128028
✅ vite-quickjs128028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nest-stable-node130026
✅ nest-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ tanstack-start-node130026
✅ tanstack-start-quickjs130026
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

✅ vercel-ws-transport

AppPassedFailedSkipped
✅ example128028
✅ express128028
✅ nextjs-turbopack15303
✅ vite128028

📋 View full workflow run

@github-actions

github-actionsBot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

Benchmarks are running for daa7259... (run logs)

Results below are from a previous run.

commit c2ea12b · Thu, 13 Aug 2026 01:29:34 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep225 (-6.6%)1368 🔴 (+20%) 🔻1511 🔴 (+29%) 🔻1618 🔴 (+28%) 🔻30
TTFSstream254 (-7.3%)1516 🔴 (+30%) 🔻1535 🔴 (+27%) 🔻1563 🔴 (±0%)30
TTFShook + stream396 (-1.0%)1734 🔴 (+25%) 🔻1799 🔴 (+27%) 🔻1940 🔴 (+30%) 🔻30
STSO1020 steps (inline)134 (+19%) 🔻196 (-7.1%)232 (-7.9%)392 (-5.5%)1019
WO1020 steps193381 (-7.3%)193381 (-7.3%)193381 (-7.3%)193381 (-7.3%)1
SLstream latency100 (+3.1%)175 🔴 (+8.7%)352 🔴 (+63%) 🔻816 🔴 (+118%) 🔻30
SOstream overhead (text)128 (-15%)259 🔴 (-8.8%)673 🔴 (+10%)840 (+8.5%)30
SOstream overhead (structured)124 (-6.1%)208 (-22%) 💚298 (-18%) 💚980 (-96%) 💚30
📜 Previous results (4)

eea5ff1

Wed, 12 Aug 2026 15:51:27 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1264 (+79%) 🔻1462 🔴 (+45%) 🔻1481 🔴 (+46%) 🔻1672 🔴 (+11%)30
TTFSstream1364 (+43%) 🔻1469 🔴 (+49%) 🔻1497 🔴 (+48%) 🔻1605 🔴 (+53%) 🔻30
TTFShook + stream1659 (+37%) 🔻1763 🔴 (+37%) 🔻1817 🔴 (+36%) 🔻1897 🔴 (+18%) 🔻30
STSO1020 steps (inline)1141631913441019
WO1020 steps165277 (-57%) 💚165277 (-57%) 💚165277 (-57%) 💚165277 (-57%) 💚1
SLstream latency85 (+4.9%)121 🔴 (-8.3%)161 🔴 (+15%)216 🔴 (+21%) 🔻30
SOstream overhead (text)100 (-1.0%)163 (-9.4%)190 (-5.9%)515 (+106%) 🔻30
SOstream overhead (structured)105 (+6.1%)167 (+3.1%)196 (+0.5%)883 (+307%) 🔻30

ae04a56

Tue, 11 Aug 2026 16:38:40 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep265 (-70%) 💚1523 🔴 (+24%) 🔻1548 🔴 (+19%) 🔻2035 🔴 (+13%)30
TTFSstream246 (-77%) 💚1456 🔴 (+29%) 🔻1555 🔴 (+33%) 🔻1830 🔴 (+47%) 🔻30
TTFShook + stream474 (-66%) 💚1757 🔴 (+13%)1835 🔴 (+16%) 🔻1960 🔴 (+23%) 🔻30
STSO1020 steps (inline)97 (-38%) 💚164 (-34%) 💚195 (-38%) 💚425 (-40%) 💚1019
WO1020 steps162208 (-33%) 💚162208 (-33%) 💚162208 (-33%) 💚162208 (-33%) 💚1
SLstream latency99 (-21%) 💚182 🔴 (-1.1%)200 🔴 (-15%) 💚276 🔴 (-12%)30
SOstream overhead (text)120 (-19%) 💚218 (-12%)267 (-23%) 💚915 (+54%) 🔻30
SOstream overhead (structured)121 (-25%) 💚215 (-29%) 💚298 (-15%) 💚842 (-20%) 💚30

080e4b2

Tue, 11 Aug 2026 15:28:43 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep248 (-77%) 💚1449 🔴 (+23%) 🔻1568 🔴 (+30%) 🔻1588 🔴 (-7.1%)30
TTFSstream210 (-80%) 💚1433 🔴 (+25%) 🔻1506 🔴 (+29%) 🔻1600 🔴 (+32%) 🔻30
TTFShook + stream410 (-67%) 💚1662 🔴 (+21%) 🔻1727 🔴 (+24%) 🔻1813 🔴 (+20%) 🔻30
STSO1020 steps (inline)89 (-7.3%)152 (-7.3%)174 (-14%)274 (-65%) 💚1019
WO1020 steps149431 (-13%)149431 (-13%)149431 (-13%)149431 (-13%)1
SLstream latency99 (+8.8%)201 🔴 (+31%) 🔻252 🔴 (+47%) 🔻425 🔴 (-24%) 💚30
SOstream overhead (text)128 (-3.0%)225 (-18%) 💚343 (+3.9%)974 (+24%) 🔻30
SOstream overhead (structured)124 (+1.6%)313 🔴 (+28%) 🔻412 (+32%) 🔻621 (+38%) 🔻30

847a1c3

Tue, 11 Aug 2026 06:37:30 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep218 (-79%) 💚1233 🔴 (+4.8%)1266 🔴 (+5.1%)1430 🔴 (-16%) 💚30
TTFSstream1151 (+10%)1211 🔴 (+5.9%)1228 🔴 (+5.3%)1303 🔴 (+7.8%)30
TTFShook + stream1397 (+12%)1516 🔴 (+11%)1587 🔴 (+14%)1629 🔴 (+8.0%)30
STSO1020 steps (inline)86 (-10%)121 (-26%) 💚139 (-32%) 💚208 (-73%) 💚1019
WO1020 steps119887 (-30%) 💚119887 (-30%) 💚119887 (-30%) 💚119887 (-30%) 💚1
SLstream latency74 (-19%) 💚105 🔴 (-32%) 💚121 🔴 (-30%) 💚330 🔴 (-41%) 💚30
SOstream overhead (text)96 (-27%) 💚136 (-51%) 💚143 (-57%) 💚151 (-81%) 💚30
SOstream overhead (structured)96 (-21%) 💚141 (-42%) 💚160 (-49%) 💚290 (-36%) 💚30
ℹ️ Metric definitions & methodology

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@shalabhcshalabhc changed the title feat(world-vercel): synthesize per-event client spans on the WS transportWIP feat(world-vercel): synthesize per-event client spans on the WS transportAug 11, 2026
@shalabhcshalabhc added the ws-transport-test Run the e2e-vercel-ws-transport lanes (WORKFLOW_EVENTS_TRANSPORT=ws) on this PR label Aug 11, 2026
@vercel
vercelBottemporarily deployed to Preview – workflow-docs August 11, 2026 15:11 Inactive
// Named on both transports so a trace or a latency dashboard can tell which
// one served a write — they are otherwise deliberately indistinguishable,
// right down to the span name and `url.full`. See `postEventFrameOverWs`.
attributes: { ...WorkflowEventsTransport('http'), ...attributes },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

the GET call sites will also be tagged with this attribute - is that intentional?

@shalabhcshalabhcAug 13, 2026

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

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

Actually, no was a mistake. fixed.

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM. Just left one comment

Comment threadpackages/world-vercel/src/events-v4.ts Outdated
@shalabhc

Copy link
Copy Markdown
CollaboratorAuthor

Rebased cleanly onto current main with only the intended WS-span commits. The branch was updated with force-with-lease after verifying the 9-file diff and git diff --check.

vercelBotand others added 4 commits August 13, 2026 01:04
…port
PR #3084 added the opt-in `WORKFLOW_EVENTS_TRANSPORT=ws` path and listed
"no client-side span on the WS path" as a known limitation. Because event
writes become multiplexed frames on one long-lived socket rather than
individual `fetch` calls, the per-event `http POST` CLIENT span that the
HTTP transport produced simply disappeared — traces went from one span
per event to nothing between the invocation and the server.
Restore it by synthesizing a request-shaped span around each frame, and
give the upgrade its own span:
- Extract `withHttpClientSpan` / `recordClientSpanStatus` from
`instrumentedFetch` in `http-core.ts` so the synthetic span is emitted
by the same envelope as the real one and cannot drift from it.
`InstrumentedFetchOptions` now extends `HttpClientSpanOptions`.
- `postEventFrameOverWs` opens `http POST` with `url.full` pointing at the
v4 REST endpoint the frame is forwarded into, so per-event traces and
latency dashboards keep working across the flag. Extract `eventsV4Url`
so that URL cannot drift from the one the HTTP path actually requests.
- Tag both transports with `workflow.events.transport` (`http` | `ws`) and
`workflow.event.type`; the WS path additionally sets
`network.protocol.name=websocket`, `workflow.events.ws.url` (the real
wire destination) and `workflow.events.ws.req_id` (join key to the
server's log line for the frame), so the span is never mistaken for a
real HTTP request.
- Add a `workflow.events.ws.connect` span around the upgrade — the one
genuinely-HTTP request here, previously the invisible half of every WS
write's latency — carrying `workflow.events.ws.reconnect_attempt`. This
also puts `resolveUpgradeHeaders`' trace-context injection inside a
client span, as AGENTS.md requires.
- Fix `parseServer` to treat `wss:` as TLS (port 443, not 80).
Out of scope, deliberately: per-frame `traceparent` (needs a frame-meta
field plus a server change) and Vercel's outgoing-requests view (that
instruments global `fetch`, so a frame structurally cannot appear there).
Covered by `ws-transport-spans.test.ts`, which drives the real selection +
transport + adapter stack over a fake socket and asserts span shape,
failure reporting, retry behaviour and HTTP/WS parity.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: shalabhchaturvedi-7802 <shalabh.chaturvedi@vercel.com>
Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: shalabhchaturvedi-7802 <shalabh.chaturvedi@vercel.com>
Co-Authored-By: shalabhc <shalabh.chaturvedi@vercel.com>
Signed-off-by: Shalabh Chaturvedi <shalabh.chaturvedi@vercel.com>
Co-Authored-By: shalabhchaturvedi-7802 <shalabh.chaturvedi@vercel.com>
Signed-off-by: Shalabh Chaturvedi <shalabh.chaturvedi@vercel.com>
Co-Authored-By: Shalabh Chaturvedi <shalabh.chaturvedi@vercel.com>
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 01991ed (AI decision).

This is observability feature work: it adds new synthetic per-event client spans, a workflow.events.ws.connect span, and several new span attributes for the opt-in WebSocket events transport. That transport is main-only — packages/world-vercel/src/ws-transport.ts and docs/content/worlds/v5/vercel.mdx do not exist on stable — so the change builds on APIs absent from the maintenance line. The one genuine fix bundled in is parseServer treating wss: as TLS (port 443 instead of 80) in http-core.ts, which a human could split out and force through if desired, though it only matters for the main-only WS path.

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

01991edeebe15b3b5c38801a5ac1cf2962805e33

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ws-transport-testRun the e2e-vercel-ws-transport lanes (WORKFLOW_EVENTS_TRANSPORT=ws) on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@shalabhc@karthikscale3