Skip to content

otel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces - #2363

Merged
karthikscale3 merged 8 commits into
mainfrom
karthik/otel-trace-correlation
Jun 15, 2026
Merged

otel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces#2363
karthikscale3 merged 8 commits into
mainfrom
karthik/otel-trace-correlation

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Problem: mega-traces

Today the workflow queue handlers restore the run-origin trace context from the message's traceCarrier and make it the parent of every WORKFLOW_V2 / STEP invocation span. Since each invocation re-serializes its own context onto the next queue message, a single workflow run becomes one giant trace — spanning hours of sleeps/retries and dozens of stitched-together function invocations. These mega-traces are slow to load, hard to read, and frequently broken in Datadog (span limits, late-arriving spans, partial flushes).

Separately, the world-vercel HTTP client creates a CLIENT span for every workflow-server request but never injects traceparent into the outgoing headers — propagation only happened if the customer's app happened to have undici auto-instrumentation, so workflow-server spans usually couldn't join the caller's trace.

Linked-trace mode (new default)

This PR introduces WORKFLOW_TRACE_MODE with two values:

  • linked (new default): each invocation's WORKFLOW_V2 <name> / STEP <name> span is created as a new trace root (SpanOptions.root: true) with span links to:

    • the incoming delivery context (the active span extracted from the queue delivery request — once the platform re-injects producer context on deliveries, this points at the enqueue site), and
    • the run-origin context from the message's traceCarrier (skipped when absent/invalid or identical to the delivery link).

    Re-enqueued messages forward the original run-origin traceCarrier unchanged, so every future invocation of the run links back to the same origin. Traces stay small and bounded per invocation, while links preserve full run-level correlation.

  • continuous: exactly the previous behavior — restored run-origin context parents the invocation span, with a link to the delivery context, and re-enqueues serialize the current context. Set WORKFLOW_TRACE_MODE=continuous to opt back in.

Both modes keep withWorkflowBaggage wrapping, all existing span attributes (including workflow.trace.propagated), and add a new workflow.trace.mode attribute recording the active mode.

Explicit traceparent injection on workflow-server calls

world-vercel's makeRequest now injects W3C context (traceparent, tracestate, baggage) into the outgoing request headers from inside the http <method> CLIENT span, via a new injectTraceContextIntoHeaders(headers) helper in world-vercel's lazy telemetry module. workflow-server can now reliably parent its spans to the SDK's client span regardless of the customer's instrumentation setup.

Queue sends (@vercel/queue) are intentionally untouched here — VQS treats message headers as allowlisted custom headers; HTTP-layer injection for queue sends is handled in the @vercel/queue client itself.

Behavioral changes to telemetry (please read)

The API is backward compatible, but the new linked default changes the shape of emitted traces in ways existing dashboards and queries can feel. Set WORKFLOW_TRACE_MODE=continuous to restore the previous shape exactly.

  1. A run no longer shares one trace ID. Previously, the trace of the request that called start() contained the entire workflow execution — every WORKFLOW_V2/STEP span across all invocations carried the run-origin trace ID. Now each invocation is its own root trace. Anything keyed on a shared per-run trace ID (saved trace queries, "open my request's trace and see the run" debugging flows, trace-ID joins) must switch to span links or the workflow.run.id attribute.
  2. Sampling semantics change. Parent-based samplers previously made one decision at start() that covered the whole run consistently. Each invocation root now samples independently — ratio samplers will produce partially-sampled runs, and the number of root spans/traces increases to one per invocation (relevant for trace-volume-based vendor billing and rate-limiting samplers).
  3. Parent/child topology changes.WORKFLOW_V2/STEP spans had a remote parent; they are now parentless roots. Queries filtering on parent relationships and service-map edges from the calling service to the workflow handler will change.
  4. Re-enqueue traceCarrier semantics change. Queue messages now forward the original run-origin carrier unchanged, rather than each invocation's current context. Custom worlds or tooling that introspect message carriers and assume "carrier = most recent invocation context" will observe different values.

Not changed: all existing span attributes and baggage keys, and the no-OTEL no-op behavior. One footnote: app-set baggage entries now also leave the process as a baggage HTTP request header on backend calls (they already left via traceCarrier in events).

Friendlier span names

Workflow/step span names previously used uppercase prefixes with full machine names (WORKFLOW_V2 workflow//./src/jobs/order//processOrder). They are now short and lowercase: workflow.execute processOrder, step.execute chargeCard, workflow.start processOrder. New workflowDisplayName/stepDisplayName helpers in @workflow/utils resolve both the raw machine name and the queue-sanitized form (workflow----src-jobs-order--processOrder) seen by queue handlers; unrecognized formats fall back to the raw string. The full machine name remains available in the workflow.name / step.name span attributes. This is also a span-name change for anyone querying WORKFLOW_V2/STEP names — same v5-beta reasoning as above.

Backward compatibility

  • No OTEL registered: everything no-ops exactly as before — @opentelemetry/api stays an optional peer dep, the default no-op propagator injects nothing, and no headers are added.
  • WORKFLOW_TRACE_MODE=continuous restores the prior trace shape bit-for-bit (parenting, links, and carrier chaining).
  • Servers ignore the new headers harmlessly:traceparent/tracestate/baggage are standard W3C headers; receivers without tracing simply drop them.

Testing

  • packages/core/src/runtime-trace-mode.test.ts: default is linked; linked creates a root span with links to both delivery + run-origin contexts; continuous preserves the legacy parented shape; linked forwards the original traceCarrier on re-enqueues while continuous serializes the current context. Uses a real in-memory OTEL SDK (BasicTracerProvider + InMemorySpanExporter + W3C propagator).
  • packages/world-vercel/src/trace-propagation.test.ts: traceparent lands on the outgoing request and matches the http GET client span; clean no-op without an active span context.
  • pnpm build, pnpm typecheck, full unit suites for packages/core (1124 passed) and packages/world-vercel (134 passed), Biome format/lint clean.

Backport policy

Do not backport to stable (v4). The linked default is a deliberate telemetry-shape change scoped to the v5 beta major — backporting it would change trace topology, per-run trace IDs, and sampling behavior for GA v4 users mid-major. v4 keeps its current behavior until users upgrade to v5; the platform side is fully tolerant of v4 SDKs.

Documentation

Adds docs/content/docs/v5/observability/tracing.mdx (linked from the Observability index): enabling OTEL, emitted spans and attributes, linked trace mode and span links, WORKFLOW_TRACE_MODE reference with a v4 behavior-change callout, and context-propagation/baggage notes. v4 docs intentionally untouched.

Rollout notes

Server-side support for storing and re-injecting trace context on queue deliveries ships separately on the platform. This PR is safe to merge and release independently: without the platform-side support, behavior is unchanged apart from the new (ignorable) W3C headers and the bounded linked-trace shape.

Follow-up: bump @vercel/queue in @workflow/world-vercel once a release with HTTP-layer trace-context injection is published, so queue sends carry trace headers as well.

🤖 Generated with Claude Code

@karthikscale3
karthikscale3 requested a review from a team as a code ownerJune 11, 2026 16:10
@vercel

vercelBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.041s (-8.4% 🟢)1.006s (~)0.965s101.00x
💻 LocalNitro0.045s (~)1.007s (~)0.961s101.12x
🐘 PostgresExpress0.061s (-3.3%)1.012s (~)0.951s101.50x
🐘 PostgresNitro0.061s (+3.9%)1.012s (~)0.951s101.51x
💻 LocalNext.js (Turbopack)0.062s (+3.7%)1.005s (~)0.943s101.54x
🐘 PostgresNext.js (Turbopack)0.069s (-1.4%)1.013s (~)0.944s101.70x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.308s (-6.5% 🟢)2.408s (+5.4% 🔺)2.099s101.00x
▲ VercelNitro0.331s (+31.6% 🔺)2.509s (+13.4% 🔺)2.179s101.07x
▲ VercelExpress0.423s (+34.2% 🔺)2.501s (-6.2% 🟢)2.077s101.37x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.089s (-1.3%)2.006s (~)0.917s101.00x
🐘 PostgresExpress1.109s (~)2.010s (~)0.901s101.02x
💻 LocalNitro1.110s (+1.7%)2.006s (~)0.896s101.02x
🐘 PostgresNitro1.117s (~)2.010s (~)0.894s101.02x
💻 LocalNext.js (Turbopack)1.140s (+1.0%)2.006s (~)0.866s101.05x
🐘 PostgresNext.js (Turbopack)1.161s (+1.9%)2.010s (~)0.849s101.07x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express1.680s (-9.9% 🟢)3.794s (+10.9% 🔺)2.114s101.00x
▲ VercelNext.js (Turbopack)1.765s (-17.8% 🟢)3.784s (+5.4% 🔺)2.019s101.05x
▲ VercelNitro1.800s (~)3.823s (+2.2%)2.023s101.07x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro10.556s (~)11.020s (~)0.464s31.00x
💻 LocalExpress10.566s (~)11.022s (~)0.456s31.00x
💻 LocalNitro10.574s (~)11.022s (~)0.448s31.00x
🐘 PostgresExpress10.576s (~)11.019s (~)0.443s31.00x
💻 LocalNext.js (Turbopack)10.794s (~)11.021s (~)0.227s31.02x
🐘 PostgresNext.js (Turbopack)10.946s (~)11.351s (~)0.405s31.04x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)14.431s (-14.2% 🟢)16.671s (-8.8% 🟢)2.240s21.00x
▲ VercelNitro14.546s (-2.8%)16.712s (-1.3%)2.166s21.01x
▲ VercelExpress14.781s (-7.0% 🟢)16.758s (-4.0%)1.977s21.02x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express13.761s (~)14.027s (~)0.266s51.00x
💻 LocalNitro13.782s (~)14.028s (~)0.246s51.00x
🐘 PostgresExpress13.786s (-0.5%)14.020s (~)0.233s51.00x
🐘 PostgresNitro13.862s (~)14.021s (~)0.159s51.01x
💻 LocalNext.js (Turbopack)14.321s (~)15.030s (~)0.709s41.04x
🐘 PostgresNext.js (Turbopack)14.461s (~)15.018s (~)0.557s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro23.321s (-20.2% 🟢)25.623s (-17.7% 🟢)2.302s31.00x
▲ VercelExpress23.347s (-15.1% 🟢)25.294s (-11.8% 🟢)1.947s31.00x
▲ VercelNext.js (Turbopack)23.574s (-18.5% 🟢)25.768s (-14.4% 🟢)2.194s31.01x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express12.399s (-0.9%)13.025s (~)0.626s71.00x
🐘 PostgresExpress12.452s (-0.5%)13.019s (~)0.567s71.00x
💻 LocalNitro12.585s (+1.0%)13.025s (~)0.440s71.01x
🐘 PostgresNitro12.914s (-1.3%)13.304s (~)0.390s71.04x
💻 LocalNext.js (Turbopack)13.639s (~)14.027s (-1.0%)0.388s71.10x
🐘 PostgresNext.js (Turbopack)14.158s (+1.3%)14.590s (~)0.432s71.14x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro32.772s (+2.6%)35.131s (+3.4%)2.359s31.00x
▲ VercelExpress34.356s (+4.2%)36.946s (+6.2% 🔺)2.590s31.05x
▲ VercelNext.js (Turbopack)34.608s (+7.4% 🔺)37.163s (+11.4% 🔺)2.555s31.06x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.196s (-1.7%)2.006s (~)0.810s151.00x
🐘 PostgresExpress1.217s (~)2.008s (~)0.791s151.02x
🐘 PostgresNitro1.241s (+1.2%)2.008s (~)0.768s151.04x
💻 LocalNitro1.241s (+2.4%)2.007s (~)0.765s151.04x
🐘 PostgresNext.js (Turbopack)1.291s (+1.6%)2.008s (~)0.717s151.08x
💻 LocalNext.js (Turbopack)1.374s (+6.9% 🔺)2.006s (~)0.632s151.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.381s (-6.9% 🟢)4.170s (+1.9%)1.789s81.00x
▲ VercelExpress2.418s (-49.2% 🟢)4.158s (-35.9% 🟢)1.740s81.02x
▲ VercelNext.js (Turbopack)2.438s (-53.9% 🟢)3.988s (-39.1% 🟢)1.550s81.02x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.413s (-6.1% 🟢)2.318s (~)0.905s131.00x
🐘 PostgresNitro1.415s (+2.9%)2.317s (-7.6% 🟢)0.902s131.00x
🐘 PostgresNext.js (Turbopack)1.655s (+6.0% 🔺)2.316s (+4.2%)0.661s131.17x
💻 LocalExpress1.721s (-6.7% 🟢)2.006s (-6.7% 🟢)0.284s151.22x
💻 LocalNext.js (Turbopack)1.820s (+12.1% 🔺)2.073s (+3.3%)0.254s151.29x
💻 LocalNitro2.079s (+16.6% 🔺)2.471s (+23.2% 🔺)0.392s131.47x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.682s (-11.9% 🟢)4.262s (-17.0% 🟢)1.580s81.00x
▲ VercelExpress3.635s (-3.3%)5.594s (+10.7% 🔺)1.959s71.36x
▲ VercelNext.js (Turbopack)4.256s (+1.4%)6.350s (+14.5% 🔺)2.094s51.59x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.544s (-7.3% 🟢)3.887s (-3.1%)2.343s81.00x
🐘 PostgresNitro1.657s (-7.4% 🟢)4.136s (+12.4% 🔺)2.478s81.07x
🐘 PostgresNext.js (Turbopack)3.528s (+5.1% 🔺)4.300s (+3.9%)0.772s72.29x
💻 LocalExpress4.700s (-15.7% 🟢)5.179s (-13.9% 🟢)0.479s63.04x
💻 LocalNext.js (Turbopack)5.238s (+12.7% 🔺)5.512s (+6.4% 🔺)0.274s63.39x
💻 LocalNitro6.891s (+34.2% 🔺)7.516s (+36.3% 🔺)0.625s44.46x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)4.729s (-24.8% 🟢)6.624s (-15.0% 🟢)1.895s51.00x
▲ VercelExpress5.058s (+11.2% 🔺)7.110s (+16.3% 🔺)2.052s51.07x
▲ VercelNitro5.934s (-13.6% 🟢)8.393s (-3.0%)2.459s41.25x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.212s (~)2.008s (~)0.796s151.00x
🐘 PostgresExpress1.219s (~)2.008s (-3.2%)0.789s151.01x
🐘 PostgresNext.js (Turbopack)1.326s (+2.8%)2.008s (~)0.682s151.09x
💻 LocalNext.js (Turbopack)1.392s (~)2.006s (~)0.614s151.15x
💻 LocalExpress1.577s (~)2.006s (~)0.430s151.30x
💻 LocalNitro1.635s (+2.6%)2.007s (~)0.372s151.35x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.345s (-15.9% 🟢)4.021s (-7.5% 🟢)1.676s81.00x
▲ VercelNext.js (Turbopack)2.375s (-3.8%)3.997s (+9.1% 🔺)1.623s81.01x
▲ VercelNitro3.013s (+19.4% 🔺)4.535s (+15.4% 🔺)1.522s71.28x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.364s (~)2.151s (-7.1% 🟢)0.788s141.00x
🐘 PostgresNitro1.404s (~)2.317s (~)0.913s131.03x
🐘 PostgresNext.js (Turbopack)1.557s (-4.2%)2.393s (+3.3%)0.836s131.14x
💻 LocalExpress2.014s (-13.4% 🟢)2.508s (-11.3% 🟢)0.494s121.48x
💻 LocalNext.js (Turbopack)2.020s (+1.0%)2.735s (+5.6% 🔺)0.715s111.48x
💻 LocalNitro2.227s (+2.2%)2.758s (+0.8%)0.531s121.63x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.771s (-3.9%)4.319s (~)1.548s81.00x
▲ VercelNext.js (Turbopack)2.915s (-1.8%)4.814s (+7.1% 🔺)1.900s71.05x
▲ VercelExpress3.336s (+2.4%)5.038s (+6.4% 🔺)1.702s61.20x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.772s (-4.9%)4.014s (-6.6% 🟢)2.241s81.00x
🐘 PostgresNitro1.886s (+17.2% 🔺)4.441s (+14.2% 🔺)2.556s71.06x
🐘 PostgresNext.js (Turbopack)3.388s (+3.7%)4.142s (-2.8%)0.754s81.91x
💻 LocalExpress5.290s (-24.9% 🟢)5.850s (-23.2% 🟢)0.560s62.99x
💻 LocalNext.js (Turbopack)6.189s (+38.2% 🔺)6.416s (+23.9% 🔺)0.226s53.49x
💻 LocalNitro6.932s (+19.4% 🔺)7.218s (+12.5% 🔺)0.286s53.91x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.744s (-28.8% 🟢)6.281s (-1.9%)2.537s51.00x
▲ VercelNext.js (Turbopack)3.946s (-18.4% 🟢)5.974s (-9.9% 🟢)2.028s61.05x
▲ VercelNitro4.515s (-3.1%)6.349s (+1.5%)1.834s51.21x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.586s (-2.8%)1.006s (-1.7%)0.420s601.00x
🐘 PostgresExpress0.593s (+2.9%)1.041s (+3.5%)0.448s581.01x
💻 LocalExpress0.615s (-3.4%)1.005s (-3.3%)0.390s601.05x
💻 LocalNitro0.670s (+4.6%)1.040s (+1.8%)0.370s581.14x
🐘 PostgresNext.js (Turbopack)0.835s (~)1.023s (~)0.189s591.42x
💻 LocalNext.js (Turbopack)0.870s (+2.0%)1.004s (~)0.135s601.48x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.493s (-24.9% 🟢)8.569s (-15.7% 🟢)2.076s81.00x
▲ VercelNitro6.528s (-27.6% 🟢)8.584s (-18.7% 🟢)2.056s71.01x
▲ VercelNext.js (Turbopack)6.529s (~)8.488s (+6.1% 🔺)1.959s81.01x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.388s (-5.6% 🟢)2.007s (-3.3%)0.619s451.00x
🐘 PostgresNitro1.407s (-3.4%)2.008s (-2.2%)0.601s451.01x
💻 LocalExpress1.514s (-1.9%)2.006s (~)0.491s451.09x
💻 LocalNitro1.646s (+8.0% 🔺)2.030s (+1.2%)0.383s451.19x
🐘 PostgresNext.js (Turbopack)1.991s (+2.6%)2.367s (+12.7% 🔺)0.375s391.43x
💻 LocalNext.js (Turbopack)2.174s (+3.6%)3.008s (+2.2%)0.835s301.57x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express15.578s (-29.1% 🟢)18.039s (-23.2% 🟢)2.461s51.00x
▲ VercelNitro16.348s (-9.2% 🟢)18.489s (-6.5% 🟢)2.141s51.05x
▲ VercelNext.js (Turbopack)16.650s (-9.7% 🟢)19.019s (-4.4%)2.369s51.07x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.822s (+1.1%)3.137s (~)0.315s391.00x
🐘 PostgresExpress2.869s (+3.9%)3.280s (+6.3% 🔺)0.410s371.02x
💻 LocalExpress3.265s (-0.8%)4.009s (~)0.744s301.16x
💻 LocalNitro3.436s (+4.4%)4.077s (+1.7%)0.640s301.22x
🐘 PostgresNext.js (Turbopack)3.936s (+2.7%)4.183s (+3.5%)0.247s291.39x
💻 LocalNext.js (Turbopack)4.327s (-3.0%)5.010s (-0.8%)0.683s241.53x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro29.059s (-8.3% 🟢)31.839s (-4.9%)2.780s41.00x
▲ VercelExpress30.029s (-9.5% 🟢)33.287s (-3.4%)3.258s41.03x
▲ VercelNext.js (Turbopack)32.694s (-1.4%)35.494s (+3.0%)2.800s41.13x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.256s (+1.6%)1.006s (~)0.750s601.00x
🐘 PostgresNitro0.256s (-1.4%)1.006s (-1.6%)0.750s601.00x
🐘 PostgresNext.js (Turbopack)0.302s (+1.2%)1.006s (~)0.704s601.18x
💻 LocalNitro0.431s (+1.5%)1.005s (~)0.574s601.68x
💻 LocalExpress0.435s (-1.5%)1.004s (~)0.569s601.70x
💻 LocalNext.js (Turbopack)0.521s (-12.1% 🟢)1.005s (-1.7%)0.484s602.04x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.115s (-15.5% 🟢)3.980s (-1.9%)1.866s161.00x
▲ VercelNext.js (Turbopack)2.203s (+7.0% 🔺)4.126s (+21.7% 🔺)1.922s151.04x
▲ VercelNitro2.523s (+18.9% 🔺)4.388s (+13.4% 🔺)1.865s141.19x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.415s (+3.7%)1.041s (~)0.627s871.00x
🐘 PostgresNitro0.424s (+0.5%)1.041s (-2.3%)0.617s871.02x
🐘 PostgresNext.js (Turbopack)0.577s (-7.4% 🟢)1.078s (-8.3% 🟢)0.501s841.39x
💻 LocalNitro2.071s (-7.6% 🟢)2.658s (-3.9%)0.586s345.00x
💻 LocalExpress2.325s (+13.4% 🔺)2.853s (+7.4% 🔺)0.528s325.61x
💻 LocalNext.js (Turbopack)2.611s (~)3.334s (-1.5%)0.723s286.30x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.672s (+1.7%)5.578s (+4.3%)1.907s171.00x
▲ VercelExpress3.683s (+11.8% 🔺)5.655s (+19.5% 🔺)1.972s161.00x
▲ VercelNitro3.775s (-16.7% 🟢)5.889s (-5.0% 🟢)2.114s161.03x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.785s (-0.8%)1.271s (-6.3% 🟢)0.486s951.00x
🐘 PostgresNitro0.844s (+3.8%)1.371s (-7.6% 🟢)0.527s881.08x
🐘 PostgresNext.js (Turbopack)2.677s (-6.4% 🟢)3.711s (+2.2%)1.034s333.41x
💻 LocalExpress9.827s (-3.2%)10.362s (-3.9%)0.535s1212.52x
💻 LocalNitro9.859s (+3.5%)10.363s (+1.7%)0.504s1212.56x
💻 LocalNext.js (Turbopack)10.346s (-5.7% 🟢)11.301s (-4.6%)0.956s1113.18x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.976s (-14.5% 🟢)8.118s (-6.4% 🟢)2.142s151.00x
▲ VercelExpress6.135s (-15.9% 🟢)8.528s (-3.9%)2.394s151.03x
▲ VercelNext.js (Turbopack)7.343s (-10.4% 🟢)9.606s (-1.9%)2.264s131.23x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.165s (~)2.004s (~)0.011s (-15.3% 🟢)2.017s (~)0.852s101.00x
💻 LocalNitro1.172s (~)2.005s (~)0.013s (+7.6% 🔺)2.021s (~)0.849s101.01x
🐘 PostgresExpress1.179s (~)2.001s (~)0.001s (~)2.011s (~)0.832s101.01x
🐘 PostgresNitro1.182s (+1.3%)1.997s (~)0.001s (-7.1% 🟢)2.010s (~)0.829s101.01x
💻 LocalNext.js (Turbopack)1.205s (~)2.003s (~)0.012s (+10.2% 🔺)2.019s (~)0.814s101.03x
🐘 PostgresNext.js (Turbopack)1.244s (+0.8%)2.001s (~)0.001s (+11.1% 🔺)2.011s (~)0.767s101.07x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.637s (+5.1% 🔺)3.726s (+10.3% 🔺)1.437s (+19.6% 🔺)5.659s (+13.9% 🔺)3.022s101.00x
▲ VercelNitro2.776s (+19.7% 🔺)4.150s (+24.5% 🔺)0.672s (-37.1% 🟢)5.469s (+13.2% 🔺)2.693s101.05x
▲ VercelExpress2.788s (+17.0% 🔺)4.107s (+28.5% 🔺)1.469s (+11.7% 🔺)6.069s (+23.3% 🔺)3.281s101.06x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.568s (-1.8%)2.006s (~)0.005s (+1.3%)2.028s (~)0.460s301.00x
💻 LocalNitro1.602s (~)2.010s (~)0.011s (-12.0% 🟢)2.024s (~)0.422s301.02x
💻 LocalExpress1.602s (~)2.009s (~)0.015s (+17.6% 🔺)2.027s (~)0.425s301.02x
🐘 PostgresNitro1.638s (+2.7%)2.002s (~)0.005s (-2.0%)2.027s (~)0.389s301.04x
💻 LocalNext.js (Turbopack)1.734s (+0.9%)2.008s (~)0.012s (-1.1%)2.024s (~)0.290s301.11x
🐘 PostgresNext.js (Turbopack)1.796s (+2.0%)2.009s (~)0.006s (+8.2% 🔺)2.030s (~)0.234s301.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.353s (-6.8% 🟢)7.876s (~)0.536s (+132.4% 🔺)8.902s (+3.4%)2.550s71.00x
▲ VercelNitro6.526s (-1.8%)8.158s (+3.1%)0.330s (+49.2% 🔺)8.975s (+4.3%)2.448s71.03x
▲ VercelNext.js (Turbopack)6.780s (-1.5%)8.291s (+3.3%)0.629s (+158.3% 🔺)9.487s (+8.5% 🔺)2.707s71.07x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.776s (+1.9%)1.029s (-1.8%)0.000s (-33.3% 🟢)1.061s (~)0.286s571.00x
🐘 PostgresNitro0.868s (+9.4% 🔺)1.079s (+3.0%)0.000s (-100.0% 🟢)1.122s (+5.8% 🔺)0.254s541.12x
🐘 PostgresNext.js (Turbopack)1.025s (+2.7%)1.469s (+2.8%)0.000s (+Infinity% 🔺)1.486s (+3.5%)0.461s411.32x
💻 LocalNitro1.427s (+3.1%)2.014s (~)0.000s (-11.1% 🟢)2.016s (~)0.589s301.84x
💻 LocalNext.js (Turbopack)1.476s (-0.6%)2.013s (~)0.000s (+133.3% 🔺)2.016s (~)0.540s301.90x
💻 LocalExpress1.492s (+2.1%)2.014s (~)0.000s (-65.2% 🟢)2.016s (~)0.524s301.92x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.152s (-28.2% 🟢)4.709s (-17.2% 🟢)0.001s (-25.9% 🟢)5.242s (-16.1% 🟢)2.090s121.00x
▲ VercelExpress3.292s (-31.2% 🟢)4.944s (-10.0% 🟢)0.000s (+81.8% 🔺)5.485s (-10.9% 🟢)2.193s111.04x
▲ VercelNext.js (Turbopack)3.597s (-0.7%)5.203s (+9.6% 🔺)0.000s (NaN%)5.709s (+10.4% 🔺)2.112s121.14x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

fan-out fan-in 10 streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.522s (-8.1% 🟢)2.063s (-5.1% 🟢)0.000s (+93.1% 🔺)2.093s (-4.8%)0.570s291.00x
🐘 PostgresNitro1.659s (+2.3%)2.258s (+5.5% 🔺)0.000s (+3.7%)2.273s (+5.6% 🔺)0.614s271.09x
🐘 PostgresNext.js (Turbopack)2.252s (+7.5% 🔺)2.729s (+2.8%)0.000s (+213.6% 🔺)2.776s (+4.3%)0.524s221.48x
💻 LocalNext.js (Turbopack)2.852s (-2.2%)3.470s (-1.5%)0.001s (+175.0% 🔺)3.473s (-1.6%)0.621s181.87x
💻 LocalNitro3.071s (+1.2%)3.674s (~)0.001s (-9.1% 🟢)3.677s (~)0.606s172.02x
💻 LocalExpress3.290s (+2.6%)4.026s (+1.5%)0.001s (+353.3% 🔺)4.030s (+1.6%)0.740s152.16x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.411s (-11.9% 🟢)7.163s (-5.1% 🟢)0.004s (+Infinity% 🔺)7.652s (-4.2%)2.241s81.00x
▲ VercelNext.js (Turbopack)6.145s (+25.8% 🔺)7.777s (+25.8% 🔺)0.000s (+Infinity% 🔺)8.258s (+25.1% 🔺)2.113s81.14x
▲ VercelExpress6.943s (+39.7% 🔺)8.421s (+43.0% 🔺)0.000s (NaN%)8.996s (+42.1% 🔺)2.053s81.28x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalExpress15/21
🐘 PostgresExpress17/21
▲ VercelNitro9/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres14/21
Next.js (Turbopack)🐘 Postgres14/21
Nitro🐘 Postgres15/21
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Redis + BullMQ: Community world (local development)
  • 🌐 Cloudflare: Community world (local development)
  • 🌐 MySQL: Community world (local development)
  • 🌐 Azure: Community world (local development)
  • 🌐 NATS JetStream: Community world (local development)
  • 🌐 Upstash: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production144202191661
✅ 💻 Local Development189502192114
✅ 📦 Local Production189502192114
✅ 🐘 Local Postgres188102332114
✅ 🪟 Windows15100151
✅ 📋 Other87901781057
Total8143010689211

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro125026
✅ example125026
✅ express125026
✅ fastify125026
✅ hono125026
✅ nextjs-turbopack14902
✅ nextjs-webpack14902
✅ nitro125026
✅ nuxt125026
✅ sveltekit14407
✅ vite125026
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable126025
✅ express-stable126025
✅ fastify-stable126025
✅ hono-stable126025
✅ nextjs-turbopack-canary132019
✅ nextjs-turbopack-stable-lazy-discovery-disabled15100
✅ nextjs-turbopack-stable-lazy-discovery-enabled15100
✅ nextjs-webpack-canary132019
✅ nextjs-webpack-stable-lazy-discovery-disabled15100
✅ nextjs-webpack-stable-lazy-discovery-enabled15100
✅ nitro-stable126025
✅ nuxt-stable126025
✅ sveltekit-stable14506
✅ vite-stable126025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable126025
✅ express-stable126025
✅ fastify-stable126025
✅ hono-stable126025
✅ nextjs-turbopack-canary132019
✅ nextjs-turbopack-stable-lazy-discovery-disabled15100
✅ nextjs-turbopack-stable-lazy-discovery-enabled15100
✅ nextjs-webpack-canary132019
✅ nextjs-webpack-stable-lazy-discovery-disabled15100
✅ nextjs-webpack-stable-lazy-discovery-enabled15100
✅ nitro-stable126025
✅ nuxt-stable126025
✅ sveltekit-stable14506
✅ vite-stable126025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable125026
✅ express-stable125026
✅ fastify-stable125026
✅ hono-stable125026
✅ nextjs-turbopack-canary131020
✅ nextjs-turbopack-stable-lazy-discovery-disabled15001
✅ nextjs-turbopack-stable-lazy-discovery-enabled15001
✅ nextjs-webpack-canary131020
✅ nextjs-webpack-stable-lazy-discovery-disabled15001
✅ nextjs-webpack-stable-lazy-discovery-enabled15001
✅ nitro-stable125026
✅ nuxt-stable125026
✅ sveltekit-stable14407
✅ vite-stable125026
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15100
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable126025
✅ e2e-local-dev-tanstack-start-126025
✅ e2e-local-postgres-nest-stable125026
✅ e2e-local-postgres-tanstack-start-125026
✅ e2e-local-prod-nest-stable126025
✅ e2e-local-prod-tanstack-start-126025
✅ e2e-vercel-prod-tanstack-start125026

📋 View full workflow run

@changeset-bot

changeset-botBot commented Jun 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 22c35aa

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

This PR includes changesets to release 22 packages
NameType
@workflow/coreMinor
workflowMinor
@workflow/world-vercelMinor
@workflow/utilsMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
@workflow/aiMajor
@workflow/errorsPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@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

@pranaygppranaygp 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.

Reviewed with a focus on attribute consistency, forwards compatibility, DX, and perf overhead. Overall this is solid: the linked-mode semantics are coherent with the other three PRs in the stack (baggage keys match what workflow-server#514 reads; the run-origin carrier semantics match the server's executionContext.traceCarrier-based span links, which are also pinned to run origin; world-vercel consumes deliveries via @vercel/queue.handleCallback, so vqs#181's consumer-side extraction is exactly what feeds linkToCurrentContext). Perf-wise the change is a net reduction when OTEL is active (linked mode skips a propagation.inject per re-enqueue) and stays a memoized no-op without an SDK. Ran the new test suites and typecheck locally — all green.

No blocking issues. Inline comments below: one behavioral edge around empty {} carriers in linked mode, a code-duplication suggestion, a DX nit on unrecognized WORKFLOW_TRACE_MODE values, two display-name edge cases, and a doc accuracy fix on span kinds.

Comment threadpackages/core/src/runtime.ts Outdated
// continuous mode the current (active) context is serialized so the
// trace keeps chaining.
const getNextTraceCarrier = (): Promise<Record<string, string>> =>
traceMode === 'linked' && traceContext

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.

traceContext here can be {} and still take the linked branch: start() always attaches a carrier, and serializeTraceCarrier() returns {} both when no OTEL SDK is registered at the origin and when OTEL is registered but start() runs outside any active span (background job, script). For such runs, linked mode forwards the empty object forever, while the undefined branch adaptively falls back to serializeTraceCarrier() (making the first instrumented invocation the de-facto run origin for future links).

Consider treating an empty carrier like an absent one — e.g. traceContext && Object.keys(traceContext).length > 0 — so both "no usable origin" shapes behave the same (same applies to the copy in step-handler.ts). Related side effect (pre-existing, but more visible now): workflow.trace.propagated is !!traceContext, so it reports true for {} even though there's nothing usable to link to.

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.

Fixed in 5b3ca9f. Added isUsableTraceCarrier() and normalized the incoming carrier at the top of both queue handlers, so {} counts as "no usable origin" everywhere the mode logic branches — linked mode falls back to serializing the current context (first instrumented invocation becomes the de-facto origin) instead of forwarding {} forever. Also took the related side effect: workflow.trace.propagated now reports whether a usable (non-empty) carrier arrived. Test added pinning traceCarrier: {} ≡ no carrier.

// so every future invocation links back to the same origin; in
// continuous mode the current (active) context is serialized so the
// trace keeps chaining.
const getNextTraceCarrier = (): Promise<Record<string, string>> =>

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.

This block — getNextTraceCarrier plus the origin-link dedup below (lines ~191–210) — is duplicated nearly verbatim from runtime.ts (~343–366). Since this encodes the core linked-mode invariants (forward the original carrier; dedup origin vs delivery link), consider extracting two small helpers into telemetry.ts, e.g. nextTraceCarrier(traceMode, traceContext) and buildInvocationSpanLinks(traceMode, traceContext), so the semantics can't drift between the workflow and step handlers.

Bonus if you do: resume-hook.ts (~186–193) has a hand-rolled version of carrier→link that lacks the isSpanContextValid guard your new linkToTraceCarrier has — it could reuse the helper too.

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.

Fixed in 5b3ca9f. Extracted both invariants into telemetry.ts as getNextTraceCarrier(traceMode, incomingCarrier) and buildInvocationSpanLinks(traceMode, incomingCarrier) (exact prior semantics, pinned by the existing trace-mode tests), now used by both runtime.ts and step-handler.ts. Took the bonus too: resume-hook.ts now uses linkToTraceCarrier and gains the isSpanContextValid guard it was missing.

Comment threadpackages/core/src/telemetry.ts Outdated
* Defaults to `'linked'`; any value other than `'continuous'` selects it.
*/
export function getWorkflowTraceMode(): WorkflowTraceMode {
return process.env.WORKFLOW_TRACE_MODE === 'continuous'

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.

Any unrecognized value silently selects linked — a typo like WORKFLOW_TRACE_MODE=continous changes trace topology with zero signal, and if a future SDK version adds a third mode, older SDKs will silently reinterpret it as linked. A one-time runtimeLogger.warn for non-empty unrecognized values would make misconfiguration debuggable and give forward compatibility a soft landing. (Resolving once into a module-level constant would also give you the warn-once behavior for free — the env var can't meaningfully change mid-process anyway.)

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.

Fixed in 5b3ca9f. Kept the dynamic per-call env read (the trace-mode tests flip WORKFLOW_TRACE_MODE per test, so a module-level constant would break them) and added a one-time runtimeLogger.warn per distinct unrecognized non-empty value, naming the value and the accepted ones before falling back to linked. Test asserts the warning fires exactly once for a continous typo.

Comment threadpackages/utils/src/parse-name.ts Outdated
if (!name.startsWith(`${tag}--`)) return null;
// The `//` separators became `--`, and within the function-name part any
// nested-function `/` became `-`. Function names are JS identifiers (no
// dashes), so the innermost name is the last dash-free segment.

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.

Two best-effort edges worth noting in this comment (or handling):

  1. $ is a valid JS identifier character and gets sanitized to -, so step//…//process$Order in sanitized form displays as Order — "no dashes" isn't strictly true for identifiers.
  2. Default exports diverge between the two input forms: parseName maps default/__default to the module short name, but this sanitized path returns the literal default. The same workflow can then show as workflow.start order (raw name in start()) but workflow.execute default (sanitized name in the queue handler). Mapping default to the preceding segment here would keep the two span names consistent.

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.

Fixed in 5b3ca9f. (2) is handled: shortNameFromSanitized now maps default/__default to the preceding module segment's short name, mirroring parseName, so default exports display consistently (e.g. order) in both workflow.start and workflow.execute — pinned by a test. (1) is documented as an accepted best-effort limitation in the comment ($ sanitizes to -, so process$Order displays as Order), with a test pinning the behavior.

| --- | --- | --- |
| `workflow.start <name>` | internal | `start()` is called in your application code |
| `workflow.execute <name>` | internal (root) | a queue delivery invokes the workflow — replay, orchestration, and inline steps run under it |
| `step.execute <name>` | internal | a step function executes |

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.

Kind is inaccurate for the queue-delivered case: step-handler.ts creates this span with SpanKind.CONSUMER (only inline steps executed within workflow.execute are internal), and in linked mode the queue-delivered step.execute span is also a new trace root, same as workflow.execute. Suggest something like: internal (inline) / consumer + root (queue-delivered).

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.

Fixed in 5b3ca9f. Table now reads workflow.executeconsumer (root) (it's CONSUMER as of this commit, see the other thread) and step.executeinternal (inline) / consumer + root (queue-delivered), per your suggested wording.

return trace(
`WORKFLOW_V2 ${workflowName}`,
{ links: spanLinks },
`workflow.execute ${workflowDisplayName(workflowName)}`,

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.

Pre-existing inconsistency, but this PR's v5 window is the cheapest moment to fix it: this queue-delivered span has default INTERNAL kind while the equivalent queue-delivered step.execute span uses CONSUMER. Messaging semconv would suggest CONSUMER here too — and it would pair nicely with the PRODUCER-kind vqs.send span being added on the other side in vercel/vqs#181. Fine as a follow-up, but if you want it, doing it inside the same beta avoids a second span-shape change.

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 5b3ca9f — agreed this beta is the cheapest window. The queue-delivered workflow.execute span now sets kind: CONSUMER via the same getSpanKind('CONSUMER') pattern step-handler uses (both modes), pairing with the PRODUCER vqs.send span in vercel/vqs#181. Added a SpanKind.CONSUMER assertion to the trace-mode test and a changeset bullet noting the internal→consumer kind change.

@pranaygppranaygp 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.

Pre-emptively approving — no blocking bugs, perf is clean (net reduction when OTEL is active, memoized no-op without it), and the cross-repo semantics line up with vqs-server#615 / workflow-server#514 / vqs#181.

@karthikscale3 please address the inline comments from my review (#2363 (review)) before merging — in particular:

  1. Empty {} carrier in linked mode (runtime.ts:344 + the step-handler.ts copy): runs started from uninstrumented contexts silently lose run-level correlation links; treat an empty carrier like an absent one.
  2. Silent fallback on unrecognized WORKFLOW_TRACE_MODE values (telemetry.ts:31): a typo flips trace topology with zero signal; add a warn-once.

The rest (dedup extraction, display-name edges, docs span-kind row, CONSUMER kind for workflow.execute) are nice-to-haves — fine in this PR or as follow-ups.

karthikscale3and others added 8 commits June 15, 2026 11:22
…per-invocation traces
- Add WORKFLOW_TRACE_MODE ('linked' default, 'continuous' legacy) to the
workflow and step queue handlers. In linked mode, WORKFLOW_V2/STEP spans
start a new trace root with span links to the incoming delivery context
and the run-origin context, and re-enqueued messages forward the
ORIGINAL run-origin trace carrier unchanged.
- world-vercel now explicitly injects W3C traceparent/tracestate/baggage
headers on outgoing workflow-server HTTP requests from inside the
client span (no-op without an OTEL SDK registered).
- New workflow.trace.mode span attribute; unit tests for both modes and
for header injection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Documents OTEL spans/attributes, linked trace mode and WORKFLOW_TRACE_MODE,
span links, context propagation, and the v4 behavior-change callout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WORKFLOW_V2/STEP prefixes with full machine names (workflow//./src/...//fn)
become workflow.execute / step.execute / workflow.start with the short
function name. New workflowDisplayName/stepDisplayName helpers in
@workflow/utils handle both raw and queue-sanitized name forms; full names
remain in the workflow.name/step.name attributes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ame edge cases, consumer span kind
- Treat an empty ({}) trace carrier as absent everywhere the trace-mode
logic branches, so linked mode falls back to a fresh origin instead of
forwarding a useless {} forever; workflow.trace.propagated now reports
whether a usable carrier arrived.
- Extract the duplicated linked-mode logic into shared telemetry helpers
getNextTraceCarrier() and buildInvocationSpanLinks(), used by both the
workflow and step queue handlers; resume-hook now uses
linkToTraceCarrier (gaining the isSpanContextValid guard).
- Warn once per distinct unrecognized WORKFLOW_TRACE_MODE value instead
of silently selecting linked.
- shortNameFromSanitized: map default/__default to the module short name
(mirroring parseName) and document the `$`-sanitization limitation.
- Queue-delivered workflow.execute spans now use the CONSUMER span kind,
matching queue-delivered step.execute spans; docs span table and
changeset updated accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

DiffPackageSupply Chain
Security
VulnerabilityQualityMaintenanceLicense
Addednpm/​@​opentelemetry/​context-async-hooks@​1.30.1741008896100

View full report

@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 926a5e7 (AI decision).

This is a deliberate telemetry-shape change that flips the default to the new linked trace mode, altering trace topology, per-run trace IDs, sampling semantics, and span names — a behavioral change explicitly scoped to the v5 beta major. The PR author explicitly requests no backport because applying it to GA v4 users mid-major would silently change their trace behavior, and the accompanying docs target only docs/content/docs/v5/, so it falls under "major/breaking changes intended for the next major release."

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

926a5e7c6a50c1e74f2e2cc37324caa0f6442d85

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@pranaygp
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
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;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
otel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces by karthikscale3 · Pull Request #2363 · vercel/workflow · GitHub
Skip to content

otel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces - #2363

Merged
karthikscale3 merged 8 commits into
mainfrom
karthik/otel-trace-correlation
Jun 15, 2026
Merged

otel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces#2363
karthikscale3 merged 8 commits into
mainfrom
karthik/otel-trace-correlation

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Problem: mega-traces

Today the workflow queue handlers restore the run-origin trace context from the message's traceCarrier and make it the parent of every WORKFLOW_V2 / STEP invocation span. Since each invocation re-serializes its own context onto the next queue message, a single workflow run becomes one giant trace — spanning hours of sleeps/retries and dozens of stitched-together function invocations. These mega-traces are slow to load, hard to read, and frequently broken in Datadog (span limits, late-arriving spans, partial flushes).

Separately, the world-vercel HTTP client creates a CLIENT span for every workflow-server request but never injects traceparent into the outgoing headers — propagation only happened if the customer's app happened to have undici auto-instrumentation, so workflow-server spans usually couldn't join the caller's trace.

Linked-trace mode (new default)

This PR introduces WORKFLOW_TRACE_MODE with two values:

  • linked (new default): each invocation's WORKFLOW_V2 <name> / STEP <name> span is created as a new trace root (SpanOptions.root: true) with span links to:

    • the incoming delivery context (the active span extracted from the queue delivery request — once the platform re-injects producer context on deliveries, this points at the enqueue site), and
    • the run-origin context from the message's traceCarrier (skipped when absent/invalid or identical to the delivery link).

    Re-enqueued messages forward the original run-origin traceCarrier unchanged, so every future invocation of the run links back to the same origin. Traces stay small and bounded per invocation, while links preserve full run-level correlation.

  • continuous: exactly the previous behavior — restored run-origin context parents the invocation span, with a link to the delivery context, and re-enqueues serialize the current context. Set WORKFLOW_TRACE_MODE=continuous to opt back in.

Both modes keep withWorkflowBaggage wrapping, all existing span attributes (including workflow.trace.propagated), and add a new workflow.trace.mode attribute recording the active mode.

Explicit traceparent injection on workflow-server calls

world-vercel's makeRequest now injects W3C context (traceparent, tracestate, baggage) into the outgoing request headers from inside the http <method> CLIENT span, via a new injectTraceContextIntoHeaders(headers) helper in world-vercel's lazy telemetry module. workflow-server can now reliably parent its spans to the SDK's client span regardless of the customer's instrumentation setup.

Queue sends (@vercel/queue) are intentionally untouched here — VQS treats message headers as allowlisted custom headers; HTTP-layer injection for queue sends is handled in the @vercel/queue client itself.

Behavioral changes to telemetry (please read)

The API is backward compatible, but the new linked default changes the shape of emitted traces in ways existing dashboards and queries can feel. Set WORKFLOW_TRACE_MODE=continuous to restore the previous shape exactly.

  1. A run no longer shares one trace ID. Previously, the trace of the request that called start() contained the entire workflow execution — every WORKFLOW_V2/STEP span across all invocations carried the run-origin trace ID. Now each invocation is its own root trace. Anything keyed on a shared per-run trace ID (saved trace queries, "open my request's trace and see the run" debugging flows, trace-ID joins) must switch to span links or the workflow.run.id attribute.
  2. Sampling semantics change. Parent-based samplers previously made one decision at start() that covered the whole run consistently. Each invocation root now samples independently — ratio samplers will produce partially-sampled runs, and the number of root spans/traces increases to one per invocation (relevant for trace-volume-based vendor billing and rate-limiting samplers).
  3. Parent/child topology changes.WORKFLOW_V2/STEP spans had a remote parent; they are now parentless roots. Queries filtering on parent relationships and service-map edges from the calling service to the workflow handler will change.
  4. Re-enqueue traceCarrier semantics change. Queue messages now forward the original run-origin carrier unchanged, rather than each invocation's current context. Custom worlds or tooling that introspect message carriers and assume "carrier = most recent invocation context" will observe different values.

Not changed: all existing span attributes and baggage keys, and the no-OTEL no-op behavior. One footnote: app-set baggage entries now also leave the process as a baggage HTTP request header on backend calls (they already left via traceCarrier in events).

Friendlier span names

Workflow/step span names previously used uppercase prefixes with full machine names (WORKFLOW_V2 workflow//./src/jobs/order//processOrder). They are now short and lowercase: workflow.execute processOrder, step.execute chargeCard, workflow.start processOrder. New workflowDisplayName/stepDisplayName helpers in @workflow/utils resolve both the raw machine name and the queue-sanitized form (workflow----src-jobs-order--processOrder) seen by queue handlers; unrecognized formats fall back to the raw string. The full machine name remains available in the workflow.name / step.name span attributes. This is also a span-name change for anyone querying WORKFLOW_V2/STEP names — same v5-beta reasoning as above.

Backward compatibility

  • No OTEL registered: everything no-ops exactly as before — @opentelemetry/api stays an optional peer dep, the default no-op propagator injects nothing, and no headers are added.
  • WORKFLOW_TRACE_MODE=continuous restores the prior trace shape bit-for-bit (parenting, links, and carrier chaining).
  • Servers ignore the new headers harmlessly:traceparent/tracestate/baggage are standard W3C headers; receivers without tracing simply drop them.

Testing

  • packages/core/src/runtime-trace-mode.test.ts: default is linked; linked creates a root span with links to both delivery + run-origin contexts; continuous preserves the legacy parented shape; linked forwards the original traceCarrier on re-enqueues while continuous serializes the current context. Uses a real in-memory OTEL SDK (BasicTracerProvider + InMemorySpanExporter + W3C propagator).
  • packages/world-vercel/src/trace-propagation.test.ts: traceparent lands on the outgoing request and matches the http GET client span; clean no-op without an active span context.
  • pnpm build, pnpm typecheck, full unit suites for packages/core (1124 passed) and packages/world-vercel (134 passed), Biome format/lint clean.

Backport policy

Do not backport to stable (v4). The linked default is a deliberate telemetry-shape change scoped to the v5 beta major — backporting it would change trace topology, per-run trace IDs, and sampling behavior for GA v4 users mid-major. v4 keeps its current behavior until users upgrade to v5; the platform side is fully tolerant of v4 SDKs.

Documentation

Adds docs/content/docs/v5/observability/tracing.mdx (linked from the Observability index): enabling OTEL, emitted spans and attributes, linked trace mode and span links, WORKFLOW_TRACE_MODE reference with a v4 behavior-change callout, and context-propagation/baggage notes. v4 docs intentionally untouched.

Rollout notes

Server-side support for storing and re-injecting trace context on queue deliveries ships separately on the platform. This PR is safe to merge and release independently: without the platform-side support, behavior is unchanged apart from the new (ignorable) W3C headers and the bounded linked-trace shape.

Follow-up: bump @vercel/queue in @workflow/world-vercel once a release with HTTP-layer trace-context injection is published, so queue sends carry trace headers as well.

🤖 Generated with Claude Code

@karthikscale3
karthikscale3 requested a review from a team as a code ownerJune 11, 2026 16:10
@vercel

vercelBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.041s (-8.4% 🟢)1.006s (~)0.965s101.00x
💻 LocalNitro0.045s (~)1.007s (~)0.961s101.12x
🐘 PostgresExpress0.061s (-3.3%)1.012s (~)0.951s101.50x
🐘 PostgresNitro0.061s (+3.9%)1.012s (~)0.951s101.51x
💻 LocalNext.js (Turbopack)0.062s (+3.7%)1.005s (~)0.943s101.54x
🐘 PostgresNext.js (Turbopack)0.069s (-1.4%)1.013s (~)0.944s101.70x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.308s (-6.5% 🟢)2.408s (+5.4% 🔺)2.099s101.00x
▲ VercelNitro0.331s (+31.6% 🔺)2.509s (+13.4% 🔺)2.179s101.07x
▲ VercelExpress0.423s (+34.2% 🔺)2.501s (-6.2% 🟢)2.077s101.37x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.089s (-1.3%)2.006s (~)0.917s101.00x
🐘 PostgresExpress1.109s (~)2.010s (~)0.901s101.02x
💻 LocalNitro1.110s (+1.7%)2.006s (~)0.896s101.02x
🐘 PostgresNitro1.117s (~)2.010s (~)0.894s101.02x
💻 LocalNext.js (Turbopack)1.140s (+1.0%)2.006s (~)0.866s101.05x
🐘 PostgresNext.js (Turbopack)1.161s (+1.9%)2.010s (~)0.849s101.07x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express1.680s (-9.9% 🟢)3.794s (+10.9% 🔺)2.114s101.00x
▲ VercelNext.js (Turbopack)1.765s (-17.8% 🟢)3.784s (+5.4% 🔺)2.019s101.05x
▲ VercelNitro1.800s (~)3.823s (+2.2%)2.023s101.07x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro10.556s (~)11.020s (~)0.464s31.00x
💻 LocalExpress10.566s (~)11.022s (~)0.456s31.00x
💻 LocalNitro10.574s (~)11.022s (~)0.448s31.00x
🐘 PostgresExpress10.576s (~)11.019s (~)0.443s31.00x
💻 LocalNext.js (Turbopack)10.794s (~)11.021s (~)0.227s31.02x
🐘 PostgresNext.js (Turbopack)10.946s (~)11.351s (~)0.405s31.04x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)14.431s (-14.2% 🟢)16.671s (-8.8% 🟢)2.240s21.00x
▲ VercelNitro14.546s (-2.8%)16.712s (-1.3%)2.166s21.01x
▲ VercelExpress14.781s (-7.0% 🟢)16.758s (-4.0%)1.977s21.02x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express13.761s (~)14.027s (~)0.266s51.00x
💻 LocalNitro13.782s (~)14.028s (~)0.246s51.00x
🐘 PostgresExpress13.786s (-0.5%)14.020s (~)0.233s51.00x
🐘 PostgresNitro13.862s (~)14.021s (~)0.159s51.01x
💻 LocalNext.js (Turbopack)14.321s (~)15.030s (~)0.709s41.04x
🐘 PostgresNext.js (Turbopack)14.461s (~)15.018s (~)0.557s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro23.321s (-20.2% 🟢)25.623s (-17.7% 🟢)2.302s31.00x
▲ VercelExpress23.347s (-15.1% 🟢)25.294s (-11.8% 🟢)1.947s31.00x
▲ VercelNext.js (Turbopack)23.574s (-18.5% 🟢)25.768s (-14.4% 🟢)2.194s31.01x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express12.399s (-0.9%)13.025s (~)0.626s71.00x
🐘 PostgresExpress12.452s (-0.5%)13.019s (~)0.567s71.00x
💻 LocalNitro12.585s (+1.0%)13.025s (~)0.440s71.01x
🐘 PostgresNitro12.914s (-1.3%)13.304s (~)0.390s71.04x
💻 LocalNext.js (Turbopack)13.639s (~)14.027s (-1.0%)0.388s71.10x
🐘 PostgresNext.js (Turbopack)14.158s (+1.3%)14.590s (~)0.432s71.14x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro32.772s (+2.6%)35.131s (+3.4%)2.359s31.00x
▲ VercelExpress34.356s (+4.2%)36.946s (+6.2% 🔺)2.590s31.05x
▲ VercelNext.js (Turbopack)34.608s (+7.4% 🔺)37.163s (+11.4% 🔺)2.555s31.06x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.196s (-1.7%)2.006s (~)0.810s151.00x
🐘 PostgresExpress1.217s (~)2.008s (~)0.791s151.02x
🐘 PostgresNitro1.241s (+1.2%)2.008s (~)0.768s151.04x
💻 LocalNitro1.241s (+2.4%)2.007s (~)0.765s151.04x
🐘 PostgresNext.js (Turbopack)1.291s (+1.6%)2.008s (~)0.717s151.08x
💻 LocalNext.js (Turbopack)1.374s (+6.9% 🔺)2.006s (~)0.632s151.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.381s (-6.9% 🟢)4.170s (+1.9%)1.789s81.00x
▲ VercelExpress2.418s (-49.2% 🟢)4.158s (-35.9% 🟢)1.740s81.02x
▲ VercelNext.js (Turbopack)2.438s (-53.9% 🟢)3.988s (-39.1% 🟢)1.550s81.02x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.413s (-6.1% 🟢)2.318s (~)0.905s131.00x
🐘 PostgresNitro1.415s (+2.9%)2.317s (-7.6% 🟢)0.902s131.00x
🐘 PostgresNext.js (Turbopack)1.655s (+6.0% 🔺)2.316s (+4.2%)0.661s131.17x
💻 LocalExpress1.721s (-6.7% 🟢)2.006s (-6.7% 🟢)0.284s151.22x
💻 LocalNext.js (Turbopack)1.820s (+12.1% 🔺)2.073s (+3.3%)0.254s151.29x
💻 LocalNitro2.079s (+16.6% 🔺)2.471s (+23.2% 🔺)0.392s131.47x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.682s (-11.9% 🟢)4.262s (-17.0% 🟢)1.580s81.00x
▲ VercelExpress3.635s (-3.3%)5.594s (+10.7% 🔺)1.959s71.36x
▲ VercelNext.js (Turbopack)4.256s (+1.4%)6.350s (+14.5% 🔺)2.094s51.59x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.544s (-7.3% 🟢)3.887s (-3.1%)2.343s81.00x
🐘 PostgresNitro1.657s (-7.4% 🟢)4.136s (+12.4% 🔺)2.478s81.07x
🐘 PostgresNext.js (Turbopack)3.528s (+5.1% 🔺)4.300s (+3.9%)0.772s72.29x
💻 LocalExpress4.700s (-15.7% 🟢)5.179s (-13.9% 🟢)0.479s63.04x
💻 LocalNext.js (Turbopack)5.238s (+12.7% 🔺)5.512s (+6.4% 🔺)0.274s63.39x
💻 LocalNitro6.891s (+34.2% 🔺)7.516s (+36.3% 🔺)0.625s44.46x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)4.729s (-24.8% 🟢)6.624s (-15.0% 🟢)1.895s51.00x
▲ VercelExpress5.058s (+11.2% 🔺)7.110s (+16.3% 🔺)2.052s51.07x
▲ VercelNitro5.934s (-13.6% 🟢)8.393s (-3.0%)2.459s41.25x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.212s (~)2.008s (~)0.796s151.00x
🐘 PostgresExpress1.219s (~)2.008s (-3.2%)0.789s151.01x
🐘 PostgresNext.js (Turbopack)1.326s (+2.8%)2.008s (~)0.682s151.09x
💻 LocalNext.js (Turbopack)1.392s (~)2.006s (~)0.614s151.15x
💻 LocalExpress1.577s (~)2.006s (~)0.430s151.30x
💻 LocalNitro1.635s (+2.6%)2.007s (~)0.372s151.35x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.345s (-15.9% 🟢)4.021s (-7.5% 🟢)1.676s81.00x
▲ VercelNext.js (Turbopack)2.375s (-3.8%)3.997s (+9.1% 🔺)1.623s81.01x
▲ VercelNitro3.013s (+19.4% 🔺)4.535s (+15.4% 🔺)1.522s71.28x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.364s (~)2.151s (-7.1% 🟢)0.788s141.00x
🐘 PostgresNitro1.404s (~)2.317s (~)0.913s131.03x
🐘 PostgresNext.js (Turbopack)1.557s (-4.2%)2.393s (+3.3%)0.836s131.14x
💻 LocalExpress2.014s (-13.4% 🟢)2.508s (-11.3% 🟢)0.494s121.48x
💻 LocalNext.js (Turbopack)2.020s (+1.0%)2.735s (+5.6% 🔺)0.715s111.48x
💻 LocalNitro2.227s (+2.2%)2.758s (+0.8%)0.531s121.63x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.771s (-3.9%)4.319s (~)1.548s81.00x
▲ VercelNext.js (Turbopack)2.915s (-1.8%)4.814s (+7.1% 🔺)1.900s71.05x
▲ VercelExpress3.336s (+2.4%)5.038s (+6.4% 🔺)1.702s61.20x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.772s (-4.9%)4.014s (-6.6% 🟢)2.241s81.00x
🐘 PostgresNitro1.886s (+17.2% 🔺)4.441s (+14.2% 🔺)2.556s71.06x
🐘 PostgresNext.js (Turbopack)3.388s (+3.7%)4.142s (-2.8%)0.754s81.91x
💻 LocalExpress5.290s (-24.9% 🟢)5.850s (-23.2% 🟢)0.560s62.99x
💻 LocalNext.js (Turbopack)6.189s (+38.2% 🔺)6.416s (+23.9% 🔺)0.226s53.49x
💻 LocalNitro6.932s (+19.4% 🔺)7.218s (+12.5% 🔺)0.286s53.91x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.744s (-28.8% 🟢)6.281s (-1.9%)2.537s51.00x
▲ VercelNext.js (Turbopack)3.946s (-18.4% 🟢)5.974s (-9.9% 🟢)2.028s61.05x
▲ VercelNitro4.515s (-3.1%)6.349s (+1.5%)1.834s51.21x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.586s (-2.8%)1.006s (-1.7%)0.420s601.00x
🐘 PostgresExpress0.593s (+2.9%)1.041s (+3.5%)0.448s581.01x
💻 LocalExpress0.615s (-3.4%)1.005s (-3.3%)0.390s601.05x
💻 LocalNitro0.670s (+4.6%)1.040s (+1.8%)0.370s581.14x
🐘 PostgresNext.js (Turbopack)0.835s (~)1.023s (~)0.189s591.42x
💻 LocalNext.js (Turbopack)0.870s (+2.0%)1.004s (~)0.135s601.48x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.493s (-24.9% 🟢)8.569s (-15.7% 🟢)2.076s81.00x
▲ VercelNitro6.528s (-27.6% 🟢)8.584s (-18.7% 🟢)2.056s71.01x
▲ VercelNext.js (Turbopack)6.529s (~)8.488s (+6.1% 🔺)1.959s81.01x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.388s (-5.6% 🟢)2.007s (-3.3%)0.619s451.00x
🐘 PostgresNitro1.407s (-3.4%)2.008s (-2.2%)0.601s451.01x
💻 LocalExpress1.514s (-1.9%)2.006s (~)0.491s451.09x
💻 LocalNitro1.646s (+8.0% 🔺)2.030s (+1.2%)0.383s451.19x
🐘 PostgresNext.js (Turbopack)1.991s (+2.6%)2.367s (+12.7% 🔺)0.375s391.43x
💻 LocalNext.js (Turbopack)2.174s (+3.6%)3.008s (+2.2%)0.835s301.57x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express15.578s (-29.1% 🟢)18.039s (-23.2% 🟢)2.461s51.00x
▲ VercelNitro16.348s (-9.2% 🟢)18.489s (-6.5% 🟢)2.141s51.05x
▲ VercelNext.js (Turbopack)16.650s (-9.7% 🟢)19.019s (-4.4%)2.369s51.07x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.822s (+1.1%)3.137s (~)0.315s391.00x
🐘 PostgresExpress2.869s (+3.9%)3.280s (+6.3% 🔺)0.410s371.02x
💻 LocalExpress3.265s (-0.8%)4.009s (~)0.744s301.16x
💻 LocalNitro3.436s (+4.4%)4.077s (+1.7%)0.640s301.22x
🐘 PostgresNext.js (Turbopack)3.936s (+2.7%)4.183s (+3.5%)0.247s291.39x
💻 LocalNext.js (Turbopack)4.327s (-3.0%)5.010s (-0.8%)0.683s241.53x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro29.059s (-8.3% 🟢)31.839s (-4.9%)2.780s41.00x
▲ VercelExpress30.029s (-9.5% 🟢)33.287s (-3.4%)3.258s41.03x
▲ VercelNext.js (Turbopack)32.694s (-1.4%)35.494s (+3.0%)2.800s41.13x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.256s (+1.6%)1.006s (~)0.750s601.00x
🐘 PostgresNitro0.256s (-1.4%)1.006s (-1.6%)0.750s601.00x
🐘 PostgresNext.js (Turbopack)0.302s (+1.2%)1.006s (~)0.704s601.18x
💻 LocalNitro0.431s (+1.5%)1.005s (~)0.574s601.68x
💻 LocalExpress0.435s (-1.5%)1.004s (~)0.569s601.70x
💻 LocalNext.js (Turbopack)0.521s (-12.1% 🟢)1.005s (-1.7%)0.484s602.04x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.115s (-15.5% 🟢)3.980s (-1.9%)1.866s161.00x
▲ VercelNext.js (Turbopack)2.203s (+7.0% 🔺)4.126s (+21.7% 🔺)1.922s151.04x
▲ VercelNitro2.523s (+18.9% 🔺)4.388s (+13.4% 🔺)1.865s141.19x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.415s (+3.7%)1.041s (~)0.627s871.00x
🐘 PostgresNitro0.424s (+0.5%)1.041s (-2.3%)0.617s871.02x
🐘 PostgresNext.js (Turbopack)0.577s (-7.4% 🟢)1.078s (-8.3% 🟢)0.501s841.39x
💻 LocalNitro2.071s (-7.6% 🟢)2.658s (-3.9%)0.586s345.00x
💻 LocalExpress2.325s (+13.4% 🔺)2.853s (+7.4% 🔺)0.528s325.61x
💻 LocalNext.js (Turbopack)2.611s (~)3.334s (-1.5%)0.723s286.30x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.672s (+1.7%)5.578s (+4.3%)1.907s171.00x
▲ VercelExpress3.683s (+11.8% 🔺)5.655s (+19.5% 🔺)1.972s161.00x
▲ VercelNitro3.775s (-16.7% 🟢)5.889s (-5.0% 🟢)2.114s161.03x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.785s (-0.8%)1.271s (-6.3% 🟢)0.486s951.00x
🐘 PostgresNitro0.844s (+3.8%)1.371s (-7.6% 🟢)0.527s881.08x
🐘 PostgresNext.js (Turbopack)2.677s (-6.4% 🟢)3.711s (+2.2%)1.034s333.41x
💻 LocalExpress9.827s (-3.2%)10.362s (-3.9%)0.535s1212.52x
💻 LocalNitro9.859s (+3.5%)10.363s (+1.7%)0.504s1212.56x
💻 LocalNext.js (Turbopack)10.346s (-5.7% 🟢)11.301s (-4.6%)0.956s1113.18x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.976s (-14.5% 🟢)8.118s (-6.4% 🟢)2.142s151.00x
▲ VercelExpress6.135s (-15.9% 🟢)8.528s (-3.9%)2.394s151.03x
▲ VercelNext.js (Turbopack)7.343s (-10.4% 🟢)9.606s (-1.9%)2.264s131.23x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.165s (~)2.004s (~)0.011s (-15.3% 🟢)2.017s (~)0.852s101.00x
💻 LocalNitro1.172s (~)2.005s (~)0.013s (+7.6% 🔺)2.021s (~)0.849s101.01x
🐘 PostgresExpress1.179s (~)2.001s (~)0.001s (~)2.011s (~)0.832s101.01x
🐘 PostgresNitro1.182s (+1.3%)1.997s (~)0.001s (-7.1% 🟢)2.010s (~)0.829s101.01x
💻 LocalNext.js (Turbopack)1.205s (~)2.003s (~)0.012s (+10.2% 🔺)2.019s (~)0.814s101.03x
🐘 PostgresNext.js (Turbopack)1.244s (+0.8%)2.001s (~)0.001s (+11.1% 🔺)2.011s (~)0.767s101.07x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.637s (+5.1% 🔺)3.726s (+10.3% 🔺)1.437s (+19.6% 🔺)5.659s (+13.9% 🔺)3.022s101.00x
▲ VercelNitro2.776s (+19.7% 🔺)4.150s (+24.5% 🔺)0.672s (-37.1% 🟢)5.469s (+13.2% 🔺)2.693s101.05x
▲ VercelExpress2.788s (+17.0% 🔺)4.107s (+28.5% 🔺)1.469s (+11.7% 🔺)6.069s (+23.3% 🔺)3.281s101.06x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.568s (-1.8%)2.006s (~)0.005s (+1.3%)2.028s (~)0.460s301.00x
💻 LocalNitro1.602s (~)2.010s (~)0.011s (-12.0% 🟢)2.024s (~)0.422s301.02x
💻 LocalExpress1.602s (~)2.009s (~)0.015s (+17.6% 🔺)2.027s (~)0.425s301.02x
🐘 PostgresNitro1.638s (+2.7%)2.002s (~)0.005s (-2.0%)2.027s (~)0.389s301.04x
💻 LocalNext.js (Turbopack)1.734s (+0.9%)2.008s (~)0.012s (-1.1%)2.024s (~)0.290s301.11x
🐘 PostgresNext.js (Turbopack)1.796s (+2.0%)2.009s (~)0.006s (+8.2% 🔺)2.030s (~)0.234s301.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.353s (-6.8% 🟢)7.876s (~)0.536s (+132.4% 🔺)8.902s (+3.4%)2.550s71.00x
▲ VercelNitro6.526s (-1.8%)8.158s (+3.1%)0.330s (+49.2% 🔺)8.975s (+4.3%)2.448s71.03x
▲ VercelNext.js (Turbopack)6.780s (-1.5%)8.291s (+3.3%)0.629s (+158.3% 🔺)9.487s (+8.5% 🔺)2.707s71.07x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.776s (+1.9%)1.029s (-1.8%)0.000s (-33.3% 🟢)1.061s (~)0.286s571.00x
🐘 PostgresNitro0.868s (+9.4% 🔺)1.079s (+3.0%)0.000s (-100.0% 🟢)1.122s (+5.8% 🔺)0.254s541.12x
🐘 PostgresNext.js (Turbopack)1.025s (+2.7%)1.469s (+2.8%)0.000s (+Infinity% 🔺)1.486s (+3.5%)0.461s411.32x
💻 LocalNitro1.427s (+3.1%)2.014s (~)0.000s (-11.1% 🟢)2.016s (~)0.589s301.84x
💻 LocalNext.js (Turbopack)1.476s (-0.6%)2.013s (~)0.000s (+133.3% 🔺)2.016s (~)0.540s301.90x
💻 LocalExpress1.492s (+2.1%)2.014s (~)0.000s (-65.2% 🟢)2.016s (~)0.524s301.92x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.152s (-28.2% 🟢)4.709s (-17.2% 🟢)0.001s (-25.9% 🟢)5.242s (-16.1% 🟢)2.090s121.00x
▲ VercelExpress3.292s (-31.2% 🟢)4.944s (-10.0% 🟢)0.000s (+81.8% 🔺)5.485s (-10.9% 🟢)2.193s111.04x
▲ VercelNext.js (Turbopack)3.597s (-0.7%)5.203s (+9.6% 🔺)0.000s (NaN%)5.709s (+10.4% 🔺)2.112s121.14x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

fan-out fan-in 10 streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.522s (-8.1% 🟢)2.063s (-5.1% 🟢)0.000s (+93.1% 🔺)2.093s (-4.8%)0.570s291.00x
🐘 PostgresNitro1.659s (+2.3%)2.258s (+5.5% 🔺)0.000s (+3.7%)2.273s (+5.6% 🔺)0.614s271.09x
🐘 PostgresNext.js (Turbopack)2.252s (+7.5% 🔺)2.729s (+2.8%)0.000s (+213.6% 🔺)2.776s (+4.3%)0.524s221.48x
💻 LocalNext.js (Turbopack)2.852s (-2.2%)3.470s (-1.5%)0.001s (+175.0% 🔺)3.473s (-1.6%)0.621s181.87x
💻 LocalNitro3.071s (+1.2%)3.674s (~)0.001s (-9.1% 🟢)3.677s (~)0.606s172.02x
💻 LocalExpress3.290s (+2.6%)4.026s (+1.5%)0.001s (+353.3% 🔺)4.030s (+1.6%)0.740s152.16x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.411s (-11.9% 🟢)7.163s (-5.1% 🟢)0.004s (+Infinity% 🔺)7.652s (-4.2%)2.241s81.00x
▲ VercelNext.js (Turbopack)6.145s (+25.8% 🔺)7.777s (+25.8% 🔺)0.000s (+Infinity% 🔺)8.258s (+25.1% 🔺)2.113s81.14x
▲ VercelExpress6.943s (+39.7% 🔺)8.421s (+43.0% 🔺)0.000s (NaN%)8.996s (+42.1% 🔺)2.053s81.28x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalExpress15/21
🐘 PostgresExpress17/21
▲ VercelNitro9/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres14/21
Next.js (Turbopack)🐘 Postgres14/21
Nitro🐘 Postgres15/21
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Redis + BullMQ: Community world (local development)
  • 🌐 Cloudflare: Community world (local development)
  • 🌐 MySQL: Community world (local development)
  • 🌐 Azure: Community world (local development)
  • 🌐 NATS JetStream: Community world (local development)
  • 🌐 Upstash: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production144202191661
✅ 💻 Local Development189502192114
✅ 📦 Local Production189502192114
✅ 🐘 Local Postgres188102332114
✅ 🪟 Windows15100151
✅ 📋 Other87901781057
Total8143010689211

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro125026
✅ example125026
✅ express125026
✅ fastify125026
✅ hono125026
✅ nextjs-turbopack14902
✅ nextjs-webpack14902
✅ nitro125026
✅ nuxt125026
✅ sveltekit14407
✅ vite125026
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable126025
✅ express-stable126025
✅ fastify-stable126025
✅ hono-stable126025
✅ nextjs-turbopack-canary132019
✅ nextjs-turbopack-stable-lazy-discovery-disabled15100
✅ nextjs-turbopack-stable-lazy-discovery-enabled15100
✅ nextjs-webpack-canary132019
✅ nextjs-webpack-stable-lazy-discovery-disabled15100
✅ nextjs-webpack-stable-lazy-discovery-enabled15100
✅ nitro-stable126025
✅ nuxt-stable126025
✅ sveltekit-stable14506
✅ vite-stable126025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable126025
✅ express-stable126025
✅ fastify-stable126025
✅ hono-stable126025
✅ nextjs-turbopack-canary132019
✅ nextjs-turbopack-stable-lazy-discovery-disabled15100
✅ nextjs-turbopack-stable-lazy-discovery-enabled15100
✅ nextjs-webpack-canary132019
✅ nextjs-webpack-stable-lazy-discovery-disabled15100
✅ nextjs-webpack-stable-lazy-discovery-enabled15100
✅ nitro-stable126025
✅ nuxt-stable126025
✅ sveltekit-stable14506
✅ vite-stable126025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable125026
✅ express-stable125026
✅ fastify-stable125026
✅ hono-stable125026
✅ nextjs-turbopack-canary131020
✅ nextjs-turbopack-stable-lazy-discovery-disabled15001
✅ nextjs-turbopack-stable-lazy-discovery-enabled15001
✅ nextjs-webpack-canary131020
✅ nextjs-webpack-stable-lazy-discovery-disabled15001
✅ nextjs-webpack-stable-lazy-discovery-enabled15001
✅ nitro-stable125026
✅ nuxt-stable125026
✅ sveltekit-stable14407
✅ vite-stable125026
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15100
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable126025
✅ e2e-local-dev-tanstack-start-126025
✅ e2e-local-postgres-nest-stable125026
✅ e2e-local-postgres-tanstack-start-125026
✅ e2e-local-prod-nest-stable126025
✅ e2e-local-prod-tanstack-start-126025
✅ e2e-vercel-prod-tanstack-start125026

📋 View full workflow run

@changeset-bot

changeset-botBot commented Jun 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 22c35aa

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

This PR includes changesets to release 22 packages
NameType
@workflow/coreMinor
workflowMinor
@workflow/world-vercelMinor
@workflow/utilsMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
@workflow/aiMajor
@workflow/errorsPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@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

@pranaygppranaygp 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.

Reviewed with a focus on attribute consistency, forwards compatibility, DX, and perf overhead. Overall this is solid: the linked-mode semantics are coherent with the other three PRs in the stack (baggage keys match what workflow-server#514 reads; the run-origin carrier semantics match the server's executionContext.traceCarrier-based span links, which are also pinned to run origin; world-vercel consumes deliveries via @vercel/queue.handleCallback, so vqs#181's consumer-side extraction is exactly what feeds linkToCurrentContext). Perf-wise the change is a net reduction when OTEL is active (linked mode skips a propagation.inject per re-enqueue) and stays a memoized no-op without an SDK. Ran the new test suites and typecheck locally — all green.

No blocking issues. Inline comments below: one behavioral edge around empty {} carriers in linked mode, a code-duplication suggestion, a DX nit on unrecognized WORKFLOW_TRACE_MODE values, two display-name edge cases, and a doc accuracy fix on span kinds.

Comment threadpackages/core/src/runtime.ts Outdated
// continuous mode the current (active) context is serialized so the
// trace keeps chaining.
const getNextTraceCarrier = (): Promise<Record<string, string>> =>
traceMode === 'linked' && traceContext

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.

traceContext here can be {} and still take the linked branch: start() always attaches a carrier, and serializeTraceCarrier() returns {} both when no OTEL SDK is registered at the origin and when OTEL is registered but start() runs outside any active span (background job, script). For such runs, linked mode forwards the empty object forever, while the undefined branch adaptively falls back to serializeTraceCarrier() (making the first instrumented invocation the de-facto run origin for future links).

Consider treating an empty carrier like an absent one — e.g. traceContext && Object.keys(traceContext).length > 0 — so both "no usable origin" shapes behave the same (same applies to the copy in step-handler.ts). Related side effect (pre-existing, but more visible now): workflow.trace.propagated is !!traceContext, so it reports true for {} even though there's nothing usable to link to.

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.

Fixed in 5b3ca9f. Added isUsableTraceCarrier() and normalized the incoming carrier at the top of both queue handlers, so {} counts as "no usable origin" everywhere the mode logic branches — linked mode falls back to serializing the current context (first instrumented invocation becomes the de-facto origin) instead of forwarding {} forever. Also took the related side effect: workflow.trace.propagated now reports whether a usable (non-empty) carrier arrived. Test added pinning traceCarrier: {} ≡ no carrier.

// so every future invocation links back to the same origin; in
// continuous mode the current (active) context is serialized so the
// trace keeps chaining.
const getNextTraceCarrier = (): Promise<Record<string, string>> =>

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.

This block — getNextTraceCarrier plus the origin-link dedup below (lines ~191–210) — is duplicated nearly verbatim from runtime.ts (~343–366). Since this encodes the core linked-mode invariants (forward the original carrier; dedup origin vs delivery link), consider extracting two small helpers into telemetry.ts, e.g. nextTraceCarrier(traceMode, traceContext) and buildInvocationSpanLinks(traceMode, traceContext), so the semantics can't drift between the workflow and step handlers.

Bonus if you do: resume-hook.ts (~186–193) has a hand-rolled version of carrier→link that lacks the isSpanContextValid guard your new linkToTraceCarrier has — it could reuse the helper too.

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.

Fixed in 5b3ca9f. Extracted both invariants into telemetry.ts as getNextTraceCarrier(traceMode, incomingCarrier) and buildInvocationSpanLinks(traceMode, incomingCarrier) (exact prior semantics, pinned by the existing trace-mode tests), now used by both runtime.ts and step-handler.ts. Took the bonus too: resume-hook.ts now uses linkToTraceCarrier and gains the isSpanContextValid guard it was missing.

Comment threadpackages/core/src/telemetry.ts Outdated
* Defaults to `'linked'`; any value other than `'continuous'` selects it.
*/
export function getWorkflowTraceMode(): WorkflowTraceMode {
return process.env.WORKFLOW_TRACE_MODE === 'continuous'

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.

Any unrecognized value silently selects linked — a typo like WORKFLOW_TRACE_MODE=continous changes trace topology with zero signal, and if a future SDK version adds a third mode, older SDKs will silently reinterpret it as linked. A one-time runtimeLogger.warn for non-empty unrecognized values would make misconfiguration debuggable and give forward compatibility a soft landing. (Resolving once into a module-level constant would also give you the warn-once behavior for free — the env var can't meaningfully change mid-process anyway.)

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.

Fixed in 5b3ca9f. Kept the dynamic per-call env read (the trace-mode tests flip WORKFLOW_TRACE_MODE per test, so a module-level constant would break them) and added a one-time runtimeLogger.warn per distinct unrecognized non-empty value, naming the value and the accepted ones before falling back to linked. Test asserts the warning fires exactly once for a continous typo.

Comment threadpackages/utils/src/parse-name.ts Outdated
if (!name.startsWith(`${tag}--`)) return null;
// The `//` separators became `--`, and within the function-name part any
// nested-function `/` became `-`. Function names are JS identifiers (no
// dashes), so the innermost name is the last dash-free segment.

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.

Two best-effort edges worth noting in this comment (or handling):

  1. $ is a valid JS identifier character and gets sanitized to -, so step//…//process$Order in sanitized form displays as Order — "no dashes" isn't strictly true for identifiers.
  2. Default exports diverge between the two input forms: parseName maps default/__default to the module short name, but this sanitized path returns the literal default. The same workflow can then show as workflow.start order (raw name in start()) but workflow.execute default (sanitized name in the queue handler). Mapping default to the preceding segment here would keep the two span names consistent.

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.

Fixed in 5b3ca9f. (2) is handled: shortNameFromSanitized now maps default/__default to the preceding module segment's short name, mirroring parseName, so default exports display consistently (e.g. order) in both workflow.start and workflow.execute — pinned by a test. (1) is documented as an accepted best-effort limitation in the comment ($ sanitizes to -, so process$Order displays as Order), with a test pinning the behavior.

| --- | --- | --- |
| `workflow.start <name>` | internal | `start()` is called in your application code |
| `workflow.execute <name>` | internal (root) | a queue delivery invokes the workflow — replay, orchestration, and inline steps run under it |
| `step.execute <name>` | internal | a step function executes |

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.

Kind is inaccurate for the queue-delivered case: step-handler.ts creates this span with SpanKind.CONSUMER (only inline steps executed within workflow.execute are internal), and in linked mode the queue-delivered step.execute span is also a new trace root, same as workflow.execute. Suggest something like: internal (inline) / consumer + root (queue-delivered).

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.

Fixed in 5b3ca9f. Table now reads workflow.executeconsumer (root) (it's CONSUMER as of this commit, see the other thread) and step.executeinternal (inline) / consumer + root (queue-delivered), per your suggested wording.

return trace(
`WORKFLOW_V2 ${workflowName}`,
{ links: spanLinks },
`workflow.execute ${workflowDisplayName(workflowName)}`,

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.

Pre-existing inconsistency, but this PR's v5 window is the cheapest moment to fix it: this queue-delivered span has default INTERNAL kind while the equivalent queue-delivered step.execute span uses CONSUMER. Messaging semconv would suggest CONSUMER here too — and it would pair nicely with the PRODUCER-kind vqs.send span being added on the other side in vercel/vqs#181. Fine as a follow-up, but if you want it, doing it inside the same beta avoids a second span-shape change.

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 5b3ca9f — agreed this beta is the cheapest window. The queue-delivered workflow.execute span now sets kind: CONSUMER via the same getSpanKind('CONSUMER') pattern step-handler uses (both modes), pairing with the PRODUCER vqs.send span in vercel/vqs#181. Added a SpanKind.CONSUMER assertion to the trace-mode test and a changeset bullet noting the internal→consumer kind change.

@pranaygppranaygp 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.

Pre-emptively approving — no blocking bugs, perf is clean (net reduction when OTEL is active, memoized no-op without it), and the cross-repo semantics line up with vqs-server#615 / workflow-server#514 / vqs#181.

@karthikscale3 please address the inline comments from my review (#2363 (review)) before merging — in particular:

  1. Empty {} carrier in linked mode (runtime.ts:344 + the step-handler.ts copy): runs started from uninstrumented contexts silently lose run-level correlation links; treat an empty carrier like an absent one.
  2. Silent fallback on unrecognized WORKFLOW_TRACE_MODE values (telemetry.ts:31): a typo flips trace topology with zero signal; add a warn-once.

The rest (dedup extraction, display-name edges, docs span-kind row, CONSUMER kind for workflow.execute) are nice-to-haves — fine in this PR or as follow-ups.

karthikscale3and others added 8 commits June 15, 2026 11:22
…per-invocation traces
- Add WORKFLOW_TRACE_MODE ('linked' default, 'continuous' legacy) to the
workflow and step queue handlers. In linked mode, WORKFLOW_V2/STEP spans
start a new trace root with span links to the incoming delivery context
and the run-origin context, and re-enqueued messages forward the
ORIGINAL run-origin trace carrier unchanged.
- world-vercel now explicitly injects W3C traceparent/tracestate/baggage
headers on outgoing workflow-server HTTP requests from inside the
client span (no-op without an OTEL SDK registered).
- New workflow.trace.mode span attribute; unit tests for both modes and
for header injection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Documents OTEL spans/attributes, linked trace mode and WORKFLOW_TRACE_MODE,
span links, context propagation, and the v4 behavior-change callout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WORKFLOW_V2/STEP prefixes with full machine names (workflow//./src/...//fn)
become workflow.execute / step.execute / workflow.start with the short
function name. New workflowDisplayName/stepDisplayName helpers in
@workflow/utils handle both raw and queue-sanitized name forms; full names
remain in the workflow.name/step.name attributes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ame edge cases, consumer span kind
- Treat an empty ({}) trace carrier as absent everywhere the trace-mode
logic branches, so linked mode falls back to a fresh origin instead of
forwarding a useless {} forever; workflow.trace.propagated now reports
whether a usable carrier arrived.
- Extract the duplicated linked-mode logic into shared telemetry helpers
getNextTraceCarrier() and buildInvocationSpanLinks(), used by both the
workflow and step queue handlers; resume-hook now uses
linkToTraceCarrier (gaining the isSpanContextValid guard).
- Warn once per distinct unrecognized WORKFLOW_TRACE_MODE value instead
of silently selecting linked.
- shortNameFromSanitized: map default/__default to the module short name
(mirroring parseName) and document the `$`-sanitization limitation.
- Queue-delivered workflow.execute spans now use the CONSUMER span kind,
matching queue-delivered step.execute spans; docs span table and
changeset updated accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

DiffPackageSupply Chain
Security
VulnerabilityQualityMaintenanceLicense
Addednpm/​@​opentelemetry/​context-async-hooks@​1.30.1741008896100

View full report

@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 926a5e7 (AI decision).

This is a deliberate telemetry-shape change that flips the default to the new linked trace mode, altering trace topology, per-run trace IDs, sampling semantics, and span names — a behavioral change explicitly scoped to the v5 beta major. The PR author explicitly requests no backport because applying it to GA v4 users mid-major would silently change their trace behavior, and the accompanying docs target only docs/content/docs/v5/, so it falls under "major/breaking changes intended for the next major release."

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

926a5e7c6a50c1e74f2e2cc37324caa0f6442d85

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@pranaygp
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' otel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces by karthikscale3 · Pull Request #2363 · vercel/workflow · GitHub
Skip to content

otel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces - #2363

Merged
karthikscale3 merged 8 commits into
mainfrom
karthik/otel-trace-correlation
Jun 15, 2026
Merged

otel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces#2363
karthikscale3 merged 8 commits into
mainfrom
karthik/otel-trace-correlation

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Problem: mega-traces

Today the workflow queue handlers restore the run-origin trace context from the message's traceCarrier and make it the parent of every WORKFLOW_V2 / STEP invocation span. Since each invocation re-serializes its own context onto the next queue message, a single workflow run becomes one giant trace — spanning hours of sleeps/retries and dozens of stitched-together function invocations. These mega-traces are slow to load, hard to read, and frequently broken in Datadog (span limits, late-arriving spans, partial flushes).

Separately, the world-vercel HTTP client creates a CLIENT span for every workflow-server request but never injects traceparent into the outgoing headers — propagation only happened if the customer's app happened to have undici auto-instrumentation, so workflow-server spans usually couldn't join the caller's trace.

Linked-trace mode (new default)

This PR introduces WORKFLOW_TRACE_MODE with two values:

  • linked (new default): each invocation's WORKFLOW_V2 <name> / STEP <name> span is created as a new trace root (SpanOptions.root: true) with span links to:

    • the incoming delivery context (the active span extracted from the queue delivery request — once the platform re-injects producer context on deliveries, this points at the enqueue site), and
    • the run-origin context from the message's traceCarrier (skipped when absent/invalid or identical to the delivery link).

    Re-enqueued messages forward the original run-origin traceCarrier unchanged, so every future invocation of the run links back to the same origin. Traces stay small and bounded per invocation, while links preserve full run-level correlation.

  • continuous: exactly the previous behavior — restored run-origin context parents the invocation span, with a link to the delivery context, and re-enqueues serialize the current context. Set WORKFLOW_TRACE_MODE=continuous to opt back in.

Both modes keep withWorkflowBaggage wrapping, all existing span attributes (including workflow.trace.propagated), and add a new workflow.trace.mode attribute recording the active mode.

Explicit traceparent injection on workflow-server calls

world-vercel's makeRequest now injects W3C context (traceparent, tracestate, baggage) into the outgoing request headers from inside the http <method> CLIENT span, via a new injectTraceContextIntoHeaders(headers) helper in world-vercel's lazy telemetry module. workflow-server can now reliably parent its spans to the SDK's client span regardless of the customer's instrumentation setup.

Queue sends (@vercel/queue) are intentionally untouched here — VQS treats message headers as allowlisted custom headers; HTTP-layer injection for queue sends is handled in the @vercel/queue client itself.

Behavioral changes to telemetry (please read)

The API is backward compatible, but the new linked default changes the shape of emitted traces in ways existing dashboards and queries can feel. Set WORKFLOW_TRACE_MODE=continuous to restore the previous shape exactly.

  1. A run no longer shares one trace ID. Previously, the trace of the request that called start() contained the entire workflow execution — every WORKFLOW_V2/STEP span across all invocations carried the run-origin trace ID. Now each invocation is its own root trace. Anything keyed on a shared per-run trace ID (saved trace queries, "open my request's trace and see the run" debugging flows, trace-ID joins) must switch to span links or the workflow.run.id attribute.
  2. Sampling semantics change. Parent-based samplers previously made one decision at start() that covered the whole run consistently. Each invocation root now samples independently — ratio samplers will produce partially-sampled runs, and the number of root spans/traces increases to one per invocation (relevant for trace-volume-based vendor billing and rate-limiting samplers).
  3. Parent/child topology changes.WORKFLOW_V2/STEP spans had a remote parent; they are now parentless roots. Queries filtering on parent relationships and service-map edges from the calling service to the workflow handler will change.
  4. Re-enqueue traceCarrier semantics change. Queue messages now forward the original run-origin carrier unchanged, rather than each invocation's current context. Custom worlds or tooling that introspect message carriers and assume "carrier = most recent invocation context" will observe different values.

Not changed: all existing span attributes and baggage keys, and the no-OTEL no-op behavior. One footnote: app-set baggage entries now also leave the process as a baggage HTTP request header on backend calls (they already left via traceCarrier in events).

Friendlier span names

Workflow/step span names previously used uppercase prefixes with full machine names (WORKFLOW_V2 workflow//./src/jobs/order//processOrder). They are now short and lowercase: workflow.execute processOrder, step.execute chargeCard, workflow.start processOrder. New workflowDisplayName/stepDisplayName helpers in @workflow/utils resolve both the raw machine name and the queue-sanitized form (workflow----src-jobs-order--processOrder) seen by queue handlers; unrecognized formats fall back to the raw string. The full machine name remains available in the workflow.name / step.name span attributes. This is also a span-name change for anyone querying WORKFLOW_V2/STEP names — same v5-beta reasoning as above.

Backward compatibility

  • No OTEL registered: everything no-ops exactly as before — @opentelemetry/api stays an optional peer dep, the default no-op propagator injects nothing, and no headers are added.
  • WORKFLOW_TRACE_MODE=continuous restores the prior trace shape bit-for-bit (parenting, links, and carrier chaining).
  • Servers ignore the new headers harmlessly:traceparent/tracestate/baggage are standard W3C headers; receivers without tracing simply drop them.

Testing

  • packages/core/src/runtime-trace-mode.test.ts: default is linked; linked creates a root span with links to both delivery + run-origin contexts; continuous preserves the legacy parented shape; linked forwards the original traceCarrier on re-enqueues while continuous serializes the current context. Uses a real in-memory OTEL SDK (BasicTracerProvider + InMemorySpanExporter + W3C propagator).
  • packages/world-vercel/src/trace-propagation.test.ts: traceparent lands on the outgoing request and matches the http GET client span; clean no-op without an active span context.
  • pnpm build, pnpm typecheck, full unit suites for packages/core (1124 passed) and packages/world-vercel (134 passed), Biome format/lint clean.

Backport policy

Do not backport to stable (v4). The linked default is a deliberate telemetry-shape change scoped to the v5 beta major — backporting it would change trace topology, per-run trace IDs, and sampling behavior for GA v4 users mid-major. v4 keeps its current behavior until users upgrade to v5; the platform side is fully tolerant of v4 SDKs.

Documentation

Adds docs/content/docs/v5/observability/tracing.mdx (linked from the Observability index): enabling OTEL, emitted spans and attributes, linked trace mode and span links, WORKFLOW_TRACE_MODE reference with a v4 behavior-change callout, and context-propagation/baggage notes. v4 docs intentionally untouched.

Rollout notes

Server-side support for storing and re-injecting trace context on queue deliveries ships separately on the platform. This PR is safe to merge and release independently: without the platform-side support, behavior is unchanged apart from the new (ignorable) W3C headers and the bounded linked-trace shape.

Follow-up: bump @vercel/queue in @workflow/world-vercel once a release with HTTP-layer trace-context injection is published, so queue sends carry trace headers as well.

🤖 Generated with Claude Code

@karthikscale3
karthikscale3 requested a review from a team as a code ownerJune 11, 2026 16:10
@vercel

vercelBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.041s (-8.4% 🟢)1.006s (~)0.965s101.00x
💻 LocalNitro0.045s (~)1.007s (~)0.961s101.12x
🐘 PostgresExpress0.061s (-3.3%)1.012s (~)0.951s101.50x
🐘 PostgresNitro0.061s (+3.9%)1.012s (~)0.951s101.51x
💻 LocalNext.js (Turbopack)0.062s (+3.7%)1.005s (~)0.943s101.54x
🐘 PostgresNext.js (Turbopack)0.069s (-1.4%)1.013s (~)0.944s101.70x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.308s (-6.5% 🟢)2.408s (+5.4% 🔺)2.099s101.00x
▲ VercelNitro0.331s (+31.6% 🔺)2.509s (+13.4% 🔺)2.179s101.07x
▲ VercelExpress0.423s (+34.2% 🔺)2.501s (-6.2% 🟢)2.077s101.37x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.089s (-1.3%)2.006s (~)0.917s101.00x
🐘 PostgresExpress1.109s (~)2.010s (~)0.901s101.02x
💻 LocalNitro1.110s (+1.7%)2.006s (~)0.896s101.02x
🐘 PostgresNitro1.117s (~)2.010s (~)0.894s101.02x
💻 LocalNext.js (Turbopack)1.140s (+1.0%)2.006s (~)0.866s101.05x
🐘 PostgresNext.js (Turbopack)1.161s (+1.9%)2.010s (~)0.849s101.07x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express1.680s (-9.9% 🟢)3.794s (+10.9% 🔺)2.114s101.00x
▲ VercelNext.js (Turbopack)1.765s (-17.8% 🟢)3.784s (+5.4% 🔺)2.019s101.05x
▲ VercelNitro1.800s (~)3.823s (+2.2%)2.023s101.07x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro10.556s (~)11.020s (~)0.464s31.00x
💻 LocalExpress10.566s (~)11.022s (~)0.456s31.00x
💻 LocalNitro10.574s (~)11.022s (~)0.448s31.00x
🐘 PostgresExpress10.576s (~)11.019s (~)0.443s31.00x
💻 LocalNext.js (Turbopack)10.794s (~)11.021s (~)0.227s31.02x
🐘 PostgresNext.js (Turbopack)10.946s (~)11.351s (~)0.405s31.04x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)14.431s (-14.2% 🟢)16.671s (-8.8% 🟢)2.240s21.00x
▲ VercelNitro14.546s (-2.8%)16.712s (-1.3%)2.166s21.01x
▲ VercelExpress14.781s (-7.0% 🟢)16.758s (-4.0%)1.977s21.02x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express13.761s (~)14.027s (~)0.266s51.00x
💻 LocalNitro13.782s (~)14.028s (~)0.246s51.00x
🐘 PostgresExpress13.786s (-0.5%)14.020s (~)0.233s51.00x
🐘 PostgresNitro13.862s (~)14.021s (~)0.159s51.01x
💻 LocalNext.js (Turbopack)14.321s (~)15.030s (~)0.709s41.04x
🐘 PostgresNext.js (Turbopack)14.461s (~)15.018s (~)0.557s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro23.321s (-20.2% 🟢)25.623s (-17.7% 🟢)2.302s31.00x
▲ VercelExpress23.347s (-15.1% 🟢)25.294s (-11.8% 🟢)1.947s31.00x
▲ VercelNext.js (Turbopack)23.574s (-18.5% 🟢)25.768s (-14.4% 🟢)2.194s31.01x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express12.399s (-0.9%)13.025s (~)0.626s71.00x
🐘 PostgresExpress12.452s (-0.5%)13.019s (~)0.567s71.00x
💻 LocalNitro12.585s (+1.0%)13.025s (~)0.440s71.01x
🐘 PostgresNitro12.914s (-1.3%)13.304s (~)0.390s71.04x
💻 LocalNext.js (Turbopack)13.639s (~)14.027s (-1.0%)0.388s71.10x
🐘 PostgresNext.js (Turbopack)14.158s (+1.3%)14.590s (~)0.432s71.14x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro32.772s (+2.6%)35.131s (+3.4%)2.359s31.00x
▲ VercelExpress34.356s (+4.2%)36.946s (+6.2% 🔺)2.590s31.05x
▲ VercelNext.js (Turbopack)34.608s (+7.4% 🔺)37.163s (+11.4% 🔺)2.555s31.06x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.196s (-1.7%)2.006s (~)0.810s151.00x
🐘 PostgresExpress1.217s (~)2.008s (~)0.791s151.02x
🐘 PostgresNitro1.241s (+1.2%)2.008s (~)0.768s151.04x
💻 LocalNitro1.241s (+2.4%)2.007s (~)0.765s151.04x
🐘 PostgresNext.js (Turbopack)1.291s (+1.6%)2.008s (~)0.717s151.08x
💻 LocalNext.js (Turbopack)1.374s (+6.9% 🔺)2.006s (~)0.632s151.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.381s (-6.9% 🟢)4.170s (+1.9%)1.789s81.00x
▲ VercelExpress2.418s (-49.2% 🟢)4.158s (-35.9% 🟢)1.740s81.02x
▲ VercelNext.js (Turbopack)2.438s (-53.9% 🟢)3.988s (-39.1% 🟢)1.550s81.02x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.413s (-6.1% 🟢)2.318s (~)0.905s131.00x
🐘 PostgresNitro1.415s (+2.9%)2.317s (-7.6% 🟢)0.902s131.00x
🐘 PostgresNext.js (Turbopack)1.655s (+6.0% 🔺)2.316s (+4.2%)0.661s131.17x
💻 LocalExpress1.721s (-6.7% 🟢)2.006s (-6.7% 🟢)0.284s151.22x
💻 LocalNext.js (Turbopack)1.820s (+12.1% 🔺)2.073s (+3.3%)0.254s151.29x
💻 LocalNitro2.079s (+16.6% 🔺)2.471s (+23.2% 🔺)0.392s131.47x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.682s (-11.9% 🟢)4.262s (-17.0% 🟢)1.580s81.00x
▲ VercelExpress3.635s (-3.3%)5.594s (+10.7% 🔺)1.959s71.36x
▲ VercelNext.js (Turbopack)4.256s (+1.4%)6.350s (+14.5% 🔺)2.094s51.59x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.544s (-7.3% 🟢)3.887s (-3.1%)2.343s81.00x
🐘 PostgresNitro1.657s (-7.4% 🟢)4.136s (+12.4% 🔺)2.478s81.07x
🐘 PostgresNext.js (Turbopack)3.528s (+5.1% 🔺)4.300s (+3.9%)0.772s72.29x
💻 LocalExpress4.700s (-15.7% 🟢)5.179s (-13.9% 🟢)0.479s63.04x
💻 LocalNext.js (Turbopack)5.238s (+12.7% 🔺)5.512s (+6.4% 🔺)0.274s63.39x
💻 LocalNitro6.891s (+34.2% 🔺)7.516s (+36.3% 🔺)0.625s44.46x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)4.729s (-24.8% 🟢)6.624s (-15.0% 🟢)1.895s51.00x
▲ VercelExpress5.058s (+11.2% 🔺)7.110s (+16.3% 🔺)2.052s51.07x
▲ VercelNitro5.934s (-13.6% 🟢)8.393s (-3.0%)2.459s41.25x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.212s (~)2.008s (~)0.796s151.00x
🐘 PostgresExpress1.219s (~)2.008s (-3.2%)0.789s151.01x
🐘 PostgresNext.js (Turbopack)1.326s (+2.8%)2.008s (~)0.682s151.09x
💻 LocalNext.js (Turbopack)1.392s (~)2.006s (~)0.614s151.15x
💻 LocalExpress1.577s (~)2.006s (~)0.430s151.30x
💻 LocalNitro1.635s (+2.6%)2.007s (~)0.372s151.35x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.345s (-15.9% 🟢)4.021s (-7.5% 🟢)1.676s81.00x
▲ VercelNext.js (Turbopack)2.375s (-3.8%)3.997s (+9.1% 🔺)1.623s81.01x
▲ VercelNitro3.013s (+19.4% 🔺)4.535s (+15.4% 🔺)1.522s71.28x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.364s (~)2.151s (-7.1% 🟢)0.788s141.00x
🐘 PostgresNitro1.404s (~)2.317s (~)0.913s131.03x
🐘 PostgresNext.js (Turbopack)1.557s (-4.2%)2.393s (+3.3%)0.836s131.14x
💻 LocalExpress2.014s (-13.4% 🟢)2.508s (-11.3% 🟢)0.494s121.48x
💻 LocalNext.js (Turbopack)2.020s (+1.0%)2.735s (+5.6% 🔺)0.715s111.48x
💻 LocalNitro2.227s (+2.2%)2.758s (+0.8%)0.531s121.63x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.771s (-3.9%)4.319s (~)1.548s81.00x
▲ VercelNext.js (Turbopack)2.915s (-1.8%)4.814s (+7.1% 🔺)1.900s71.05x
▲ VercelExpress3.336s (+2.4%)5.038s (+6.4% 🔺)1.702s61.20x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.772s (-4.9%)4.014s (-6.6% 🟢)2.241s81.00x
🐘 PostgresNitro1.886s (+17.2% 🔺)4.441s (+14.2% 🔺)2.556s71.06x
🐘 PostgresNext.js (Turbopack)3.388s (+3.7%)4.142s (-2.8%)0.754s81.91x
💻 LocalExpress5.290s (-24.9% 🟢)5.850s (-23.2% 🟢)0.560s62.99x
💻 LocalNext.js (Turbopack)6.189s (+38.2% 🔺)6.416s (+23.9% 🔺)0.226s53.49x
💻 LocalNitro6.932s (+19.4% 🔺)7.218s (+12.5% 🔺)0.286s53.91x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.744s (-28.8% 🟢)6.281s (-1.9%)2.537s51.00x
▲ VercelNext.js (Turbopack)3.946s (-18.4% 🟢)5.974s (-9.9% 🟢)2.028s61.05x
▲ VercelNitro4.515s (-3.1%)6.349s (+1.5%)1.834s51.21x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.586s (-2.8%)1.006s (-1.7%)0.420s601.00x
🐘 PostgresExpress0.593s (+2.9%)1.041s (+3.5%)0.448s581.01x
💻 LocalExpress0.615s (-3.4%)1.005s (-3.3%)0.390s601.05x
💻 LocalNitro0.670s (+4.6%)1.040s (+1.8%)0.370s581.14x
🐘 PostgresNext.js (Turbopack)0.835s (~)1.023s (~)0.189s591.42x
💻 LocalNext.js (Turbopack)0.870s (+2.0%)1.004s (~)0.135s601.48x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.493s (-24.9% 🟢)8.569s (-15.7% 🟢)2.076s81.00x
▲ VercelNitro6.528s (-27.6% 🟢)8.584s (-18.7% 🟢)2.056s71.01x
▲ VercelNext.js (Turbopack)6.529s (~)8.488s (+6.1% 🔺)1.959s81.01x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.388s (-5.6% 🟢)2.007s (-3.3%)0.619s451.00x
🐘 PostgresNitro1.407s (-3.4%)2.008s (-2.2%)0.601s451.01x
💻 LocalExpress1.514s (-1.9%)2.006s (~)0.491s451.09x
💻 LocalNitro1.646s (+8.0% 🔺)2.030s (+1.2%)0.383s451.19x
🐘 PostgresNext.js (Turbopack)1.991s (+2.6%)2.367s (+12.7% 🔺)0.375s391.43x
💻 LocalNext.js (Turbopack)2.174s (+3.6%)3.008s (+2.2%)0.835s301.57x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express15.578s (-29.1% 🟢)18.039s (-23.2% 🟢)2.461s51.00x
▲ VercelNitro16.348s (-9.2% 🟢)18.489s (-6.5% 🟢)2.141s51.05x
▲ VercelNext.js (Turbopack)16.650s (-9.7% 🟢)19.019s (-4.4%)2.369s51.07x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.822s (+1.1%)3.137s (~)0.315s391.00x
🐘 PostgresExpress2.869s (+3.9%)3.280s (+6.3% 🔺)0.410s371.02x
💻 LocalExpress3.265s (-0.8%)4.009s (~)0.744s301.16x
💻 LocalNitro3.436s (+4.4%)4.077s (+1.7%)0.640s301.22x
🐘 PostgresNext.js (Turbopack)3.936s (+2.7%)4.183s (+3.5%)0.247s291.39x
💻 LocalNext.js (Turbopack)4.327s (-3.0%)5.010s (-0.8%)0.683s241.53x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro29.059s (-8.3% 🟢)31.839s (-4.9%)2.780s41.00x
▲ VercelExpress30.029s (-9.5% 🟢)33.287s (-3.4%)3.258s41.03x
▲ VercelNext.js (Turbopack)32.694s (-1.4%)35.494s (+3.0%)2.800s41.13x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.256s (+1.6%)1.006s (~)0.750s601.00x
🐘 PostgresNitro0.256s (-1.4%)1.006s (-1.6%)0.750s601.00x
🐘 PostgresNext.js (Turbopack)0.302s (+1.2%)1.006s (~)0.704s601.18x
💻 LocalNitro0.431s (+1.5%)1.005s (~)0.574s601.68x
💻 LocalExpress0.435s (-1.5%)1.004s (~)0.569s601.70x
💻 LocalNext.js (Turbopack)0.521s (-12.1% 🟢)1.005s (-1.7%)0.484s602.04x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.115s (-15.5% 🟢)3.980s (-1.9%)1.866s161.00x
▲ VercelNext.js (Turbopack)2.203s (+7.0% 🔺)4.126s (+21.7% 🔺)1.922s151.04x
▲ VercelNitro2.523s (+18.9% 🔺)4.388s (+13.4% 🔺)1.865s141.19x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.415s (+3.7%)1.041s (~)0.627s871.00x
🐘 PostgresNitro0.424s (+0.5%)1.041s (-2.3%)0.617s871.02x
🐘 PostgresNext.js (Turbopack)0.577s (-7.4% 🟢)1.078s (-8.3% 🟢)0.501s841.39x
💻 LocalNitro2.071s (-7.6% 🟢)2.658s (-3.9%)0.586s345.00x
💻 LocalExpress2.325s (+13.4% 🔺)2.853s (+7.4% 🔺)0.528s325.61x
💻 LocalNext.js (Turbopack)2.611s (~)3.334s (-1.5%)0.723s286.30x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.672s (+1.7%)5.578s (+4.3%)1.907s171.00x
▲ VercelExpress3.683s (+11.8% 🔺)5.655s (+19.5% 🔺)1.972s161.00x
▲ VercelNitro3.775s (-16.7% 🟢)5.889s (-5.0% 🟢)2.114s161.03x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.785s (-0.8%)1.271s (-6.3% 🟢)0.486s951.00x
🐘 PostgresNitro0.844s (+3.8%)1.371s (-7.6% 🟢)0.527s881.08x
🐘 PostgresNext.js (Turbopack)2.677s (-6.4% 🟢)3.711s (+2.2%)1.034s333.41x
💻 LocalExpress9.827s (-3.2%)10.362s (-3.9%)0.535s1212.52x
💻 LocalNitro9.859s (+3.5%)10.363s (+1.7%)0.504s1212.56x
💻 LocalNext.js (Turbopack)10.346s (-5.7% 🟢)11.301s (-4.6%)0.956s1113.18x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.976s (-14.5% 🟢)8.118s (-6.4% 🟢)2.142s151.00x
▲ VercelExpress6.135s (-15.9% 🟢)8.528s (-3.9%)2.394s151.03x
▲ VercelNext.js (Turbopack)7.343s (-10.4% 🟢)9.606s (-1.9%)2.264s131.23x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.165s (~)2.004s (~)0.011s (-15.3% 🟢)2.017s (~)0.852s101.00x
💻 LocalNitro1.172s (~)2.005s (~)0.013s (+7.6% 🔺)2.021s (~)0.849s101.01x
🐘 PostgresExpress1.179s (~)2.001s (~)0.001s (~)2.011s (~)0.832s101.01x
🐘 PostgresNitro1.182s (+1.3%)1.997s (~)0.001s (-7.1% 🟢)2.010s (~)0.829s101.01x
💻 LocalNext.js (Turbopack)1.205s (~)2.003s (~)0.012s (+10.2% 🔺)2.019s (~)0.814s101.03x
🐘 PostgresNext.js (Turbopack)1.244s (+0.8%)2.001s (~)0.001s (+11.1% 🔺)2.011s (~)0.767s101.07x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.637s (+5.1% 🔺)3.726s (+10.3% 🔺)1.437s (+19.6% 🔺)5.659s (+13.9% 🔺)3.022s101.00x
▲ VercelNitro2.776s (+19.7% 🔺)4.150s (+24.5% 🔺)0.672s (-37.1% 🟢)5.469s (+13.2% 🔺)2.693s101.05x
▲ VercelExpress2.788s (+17.0% 🔺)4.107s (+28.5% 🔺)1.469s (+11.7% 🔺)6.069s (+23.3% 🔺)3.281s101.06x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.568s (-1.8%)2.006s (~)0.005s (+1.3%)2.028s (~)0.460s301.00x
💻 LocalNitro1.602s (~)2.010s (~)0.011s (-12.0% 🟢)2.024s (~)0.422s301.02x
💻 LocalExpress1.602s (~)2.009s (~)0.015s (+17.6% 🔺)2.027s (~)0.425s301.02x
🐘 PostgresNitro1.638s (+2.7%)2.002s (~)0.005s (-2.0%)2.027s (~)0.389s301.04x
💻 LocalNext.js (Turbopack)1.734s (+0.9%)2.008s (~)0.012s (-1.1%)2.024s (~)0.290s301.11x
🐘 PostgresNext.js (Turbopack)1.796s (+2.0%)2.009s (~)0.006s (+8.2% 🔺)2.030s (~)0.234s301.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.353s (-6.8% 🟢)7.876s (~)0.536s (+132.4% 🔺)8.902s (+3.4%)2.550s71.00x
▲ VercelNitro6.526s (-1.8%)8.158s (+3.1%)0.330s (+49.2% 🔺)8.975s (+4.3%)2.448s71.03x
▲ VercelNext.js (Turbopack)6.780s (-1.5%)8.291s (+3.3%)0.629s (+158.3% 🔺)9.487s (+8.5% 🔺)2.707s71.07x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.776s (+1.9%)1.029s (-1.8%)0.000s (-33.3% 🟢)1.061s (~)0.286s571.00x
🐘 PostgresNitro0.868s (+9.4% 🔺)1.079s (+3.0%)0.000s (-100.0% 🟢)1.122s (+5.8% 🔺)0.254s541.12x
🐘 PostgresNext.js (Turbopack)1.025s (+2.7%)1.469s (+2.8%)0.000s (+Infinity% 🔺)1.486s (+3.5%)0.461s411.32x
💻 LocalNitro1.427s (+3.1%)2.014s (~)0.000s (-11.1% 🟢)2.016s (~)0.589s301.84x
💻 LocalNext.js (Turbopack)1.476s (-0.6%)2.013s (~)0.000s (+133.3% 🔺)2.016s (~)0.540s301.90x
💻 LocalExpress1.492s (+2.1%)2.014s (~)0.000s (-65.2% 🟢)2.016s (~)0.524s301.92x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.152s (-28.2% 🟢)4.709s (-17.2% 🟢)0.001s (-25.9% 🟢)5.242s (-16.1% 🟢)2.090s121.00x
▲ VercelExpress3.292s (-31.2% 🟢)4.944s (-10.0% 🟢)0.000s (+81.8% 🔺)5.485s (-10.9% 🟢)2.193s111.04x
▲ VercelNext.js (Turbopack)3.597s (-0.7%)5.203s (+9.6% 🔺)0.000s (NaN%)5.709s (+10.4% 🔺)2.112s121.14x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

fan-out fan-in 10 streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.522s (-8.1% 🟢)2.063s (-5.1% 🟢)0.000s (+93.1% 🔺)2.093s (-4.8%)0.570s291.00x
🐘 PostgresNitro1.659s (+2.3%)2.258s (+5.5% 🔺)0.000s (+3.7%)2.273s (+5.6% 🔺)0.614s271.09x
🐘 PostgresNext.js (Turbopack)2.252s (+7.5% 🔺)2.729s (+2.8%)0.000s (+213.6% 🔺)2.776s (+4.3%)0.524s221.48x
💻 LocalNext.js (Turbopack)2.852s (-2.2%)3.470s (-1.5%)0.001s (+175.0% 🔺)3.473s (-1.6%)0.621s181.87x
💻 LocalNitro3.071s (+1.2%)3.674s (~)0.001s (-9.1% 🟢)3.677s (~)0.606s172.02x
💻 LocalExpress3.290s (+2.6%)4.026s (+1.5%)0.001s (+353.3% 🔺)4.030s (+1.6%)0.740s152.16x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.411s (-11.9% 🟢)7.163s (-5.1% 🟢)0.004s (+Infinity% 🔺)7.652s (-4.2%)2.241s81.00x
▲ VercelNext.js (Turbopack)6.145s (+25.8% 🔺)7.777s (+25.8% 🔺)0.000s (+Infinity% 🔺)8.258s (+25.1% 🔺)2.113s81.14x
▲ VercelExpress6.943s (+39.7% 🔺)8.421s (+43.0% 🔺)0.000s (NaN%)8.996s (+42.1% 🔺)2.053s81.28x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalExpress15/21
🐘 PostgresExpress17/21
▲ VercelNitro9/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres14/21
Next.js (Turbopack)🐘 Postgres14/21
Nitro🐘 Postgres15/21
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Redis + BullMQ: Community world (local development)
  • 🌐 Cloudflare: Community world (local development)
  • 🌐 MySQL: Community world (local development)
  • 🌐 Azure: Community world (local development)
  • 🌐 NATS JetStream: Community world (local development)
  • 🌐 Upstash: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production144202191661
✅ 💻 Local Development189502192114
✅ 📦 Local Production189502192114
✅ 🐘 Local Postgres188102332114
✅ 🪟 Windows15100151
✅ 📋 Other87901781057
Total8143010689211

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro125026
✅ example125026
✅ express125026
✅ fastify125026
✅ hono125026
✅ nextjs-turbopack14902
✅ nextjs-webpack14902
✅ nitro125026
✅ nuxt125026
✅ sveltekit14407
✅ vite125026
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable126025
✅ express-stable126025
✅ fastify-stable126025
✅ hono-stable126025
✅ nextjs-turbopack-canary132019
✅ nextjs-turbopack-stable-lazy-discovery-disabled15100
✅ nextjs-turbopack-stable-lazy-discovery-enabled15100
✅ nextjs-webpack-canary132019
✅ nextjs-webpack-stable-lazy-discovery-disabled15100
✅ nextjs-webpack-stable-lazy-discovery-enabled15100
✅ nitro-stable126025
✅ nuxt-stable126025
✅ sveltekit-stable14506
✅ vite-stable126025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable126025
✅ express-stable126025
✅ fastify-stable126025
✅ hono-stable126025
✅ nextjs-turbopack-canary132019
✅ nextjs-turbopack-stable-lazy-discovery-disabled15100
✅ nextjs-turbopack-stable-lazy-discovery-enabled15100
✅ nextjs-webpack-canary132019
✅ nextjs-webpack-stable-lazy-discovery-disabled15100
✅ nextjs-webpack-stable-lazy-discovery-enabled15100
✅ nitro-stable126025
✅ nuxt-stable126025
✅ sveltekit-stable14506
✅ vite-stable126025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable125026
✅ express-stable125026
✅ fastify-stable125026
✅ hono-stable125026
✅ nextjs-turbopack-canary131020
✅ nextjs-turbopack-stable-lazy-discovery-disabled15001
✅ nextjs-turbopack-stable-lazy-discovery-enabled15001
✅ nextjs-webpack-canary131020
✅ nextjs-webpack-stable-lazy-discovery-disabled15001
✅ nextjs-webpack-stable-lazy-discovery-enabled15001
✅ nitro-stable125026
✅ nuxt-stable125026
✅ sveltekit-stable14407
✅ vite-stable125026
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15100
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable126025
✅ e2e-local-dev-tanstack-start-126025
✅ e2e-local-postgres-nest-stable125026
✅ e2e-local-postgres-tanstack-start-125026
✅ e2e-local-prod-nest-stable126025
✅ e2e-local-prod-tanstack-start-126025
✅ e2e-vercel-prod-tanstack-start125026

📋 View full workflow run

@changeset-bot

changeset-botBot commented Jun 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 22c35aa

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

This PR includes changesets to release 22 packages
NameType
@workflow/coreMinor
workflowMinor
@workflow/world-vercelMinor
@workflow/utilsMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
@workflow/aiMajor
@workflow/errorsPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@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

@pranaygppranaygp 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.

Reviewed with a focus on attribute consistency, forwards compatibility, DX, and perf overhead. Overall this is solid: the linked-mode semantics are coherent with the other three PRs in the stack (baggage keys match what workflow-server#514 reads; the run-origin carrier semantics match the server's executionContext.traceCarrier-based span links, which are also pinned to run origin; world-vercel consumes deliveries via @vercel/queue.handleCallback, so vqs#181's consumer-side extraction is exactly what feeds linkToCurrentContext). Perf-wise the change is a net reduction when OTEL is active (linked mode skips a propagation.inject per re-enqueue) and stays a memoized no-op without an SDK. Ran the new test suites and typecheck locally — all green.

No blocking issues. Inline comments below: one behavioral edge around empty {} carriers in linked mode, a code-duplication suggestion, a DX nit on unrecognized WORKFLOW_TRACE_MODE values, two display-name edge cases, and a doc accuracy fix on span kinds.

Comment threadpackages/core/src/runtime.ts Outdated
// continuous mode the current (active) context is serialized so the
// trace keeps chaining.
const getNextTraceCarrier = (): Promise<Record<string, string>> =>
traceMode === 'linked' && traceContext

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.

traceContext here can be {} and still take the linked branch: start() always attaches a carrier, and serializeTraceCarrier() returns {} both when no OTEL SDK is registered at the origin and when OTEL is registered but start() runs outside any active span (background job, script). For such runs, linked mode forwards the empty object forever, while the undefined branch adaptively falls back to serializeTraceCarrier() (making the first instrumented invocation the de-facto run origin for future links).

Consider treating an empty carrier like an absent one — e.g. traceContext && Object.keys(traceContext).length > 0 — so both "no usable origin" shapes behave the same (same applies to the copy in step-handler.ts). Related side effect (pre-existing, but more visible now): workflow.trace.propagated is !!traceContext, so it reports true for {} even though there's nothing usable to link to.

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.

Fixed in 5b3ca9f. Added isUsableTraceCarrier() and normalized the incoming carrier at the top of both queue handlers, so {} counts as "no usable origin" everywhere the mode logic branches — linked mode falls back to serializing the current context (first instrumented invocation becomes the de-facto origin) instead of forwarding {} forever. Also took the related side effect: workflow.trace.propagated now reports whether a usable (non-empty) carrier arrived. Test added pinning traceCarrier: {} ≡ no carrier.

// so every future invocation links back to the same origin; in
// continuous mode the current (active) context is serialized so the
// trace keeps chaining.
const getNextTraceCarrier = (): Promise<Record<string, string>> =>

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.

This block — getNextTraceCarrier plus the origin-link dedup below (lines ~191–210) — is duplicated nearly verbatim from runtime.ts (~343–366). Since this encodes the core linked-mode invariants (forward the original carrier; dedup origin vs delivery link), consider extracting two small helpers into telemetry.ts, e.g. nextTraceCarrier(traceMode, traceContext) and buildInvocationSpanLinks(traceMode, traceContext), so the semantics can't drift between the workflow and step handlers.

Bonus if you do: resume-hook.ts (~186–193) has a hand-rolled version of carrier→link that lacks the isSpanContextValid guard your new linkToTraceCarrier has — it could reuse the helper too.

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.

Fixed in 5b3ca9f. Extracted both invariants into telemetry.ts as getNextTraceCarrier(traceMode, incomingCarrier) and buildInvocationSpanLinks(traceMode, incomingCarrier) (exact prior semantics, pinned by the existing trace-mode tests), now used by both runtime.ts and step-handler.ts. Took the bonus too: resume-hook.ts now uses linkToTraceCarrier and gains the isSpanContextValid guard it was missing.

Comment threadpackages/core/src/telemetry.ts Outdated
* Defaults to `'linked'`; any value other than `'continuous'` selects it.
*/
export function getWorkflowTraceMode(): WorkflowTraceMode {
return process.env.WORKFLOW_TRACE_MODE === 'continuous'

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.

Any unrecognized value silently selects linked — a typo like WORKFLOW_TRACE_MODE=continous changes trace topology with zero signal, and if a future SDK version adds a third mode, older SDKs will silently reinterpret it as linked. A one-time runtimeLogger.warn for non-empty unrecognized values would make misconfiguration debuggable and give forward compatibility a soft landing. (Resolving once into a module-level constant would also give you the warn-once behavior for free — the env var can't meaningfully change mid-process anyway.)

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.

Fixed in 5b3ca9f. Kept the dynamic per-call env read (the trace-mode tests flip WORKFLOW_TRACE_MODE per test, so a module-level constant would break them) and added a one-time runtimeLogger.warn per distinct unrecognized non-empty value, naming the value and the accepted ones before falling back to linked. Test asserts the warning fires exactly once for a continous typo.

Comment threadpackages/utils/src/parse-name.ts Outdated
if (!name.startsWith(`${tag}--`)) return null;
// The `//` separators became `--`, and within the function-name part any
// nested-function `/` became `-`. Function names are JS identifiers (no
// dashes), so the innermost name is the last dash-free segment.

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.

Two best-effort edges worth noting in this comment (or handling):

  1. $ is a valid JS identifier character and gets sanitized to -, so step//…//process$Order in sanitized form displays as Order — "no dashes" isn't strictly true for identifiers.
  2. Default exports diverge between the two input forms: parseName maps default/__default to the module short name, but this sanitized path returns the literal default. The same workflow can then show as workflow.start order (raw name in start()) but workflow.execute default (sanitized name in the queue handler). Mapping default to the preceding segment here would keep the two span names consistent.

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.

Fixed in 5b3ca9f. (2) is handled: shortNameFromSanitized now maps default/__default to the preceding module segment's short name, mirroring parseName, so default exports display consistently (e.g. order) in both workflow.start and workflow.execute — pinned by a test. (1) is documented as an accepted best-effort limitation in the comment ($ sanitizes to -, so process$Order displays as Order), with a test pinning the behavior.

| --- | --- | --- |
| `workflow.start <name>` | internal | `start()` is called in your application code |
| `workflow.execute <name>` | internal (root) | a queue delivery invokes the workflow — replay, orchestration, and inline steps run under it |
| `step.execute <name>` | internal | a step function executes |

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.

Kind is inaccurate for the queue-delivered case: step-handler.ts creates this span with SpanKind.CONSUMER (only inline steps executed within workflow.execute are internal), and in linked mode the queue-delivered step.execute span is also a new trace root, same as workflow.execute. Suggest something like: internal (inline) / consumer + root (queue-delivered).

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.

Fixed in 5b3ca9f. Table now reads workflow.executeconsumer (root) (it's CONSUMER as of this commit, see the other thread) and step.executeinternal (inline) / consumer + root (queue-delivered), per your suggested wording.

return trace(
`WORKFLOW_V2 ${workflowName}`,
{ links: spanLinks },
`workflow.execute ${workflowDisplayName(workflowName)}`,

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.

Pre-existing inconsistency, but this PR's v5 window is the cheapest moment to fix it: this queue-delivered span has default INTERNAL kind while the equivalent queue-delivered step.execute span uses CONSUMER. Messaging semconv would suggest CONSUMER here too — and it would pair nicely with the PRODUCER-kind vqs.send span being added on the other side in vercel/vqs#181. Fine as a follow-up, but if you want it, doing it inside the same beta avoids a second span-shape change.

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 5b3ca9f — agreed this beta is the cheapest window. The queue-delivered workflow.execute span now sets kind: CONSUMER via the same getSpanKind('CONSUMER') pattern step-handler uses (both modes), pairing with the PRODUCER vqs.send span in vercel/vqs#181. Added a SpanKind.CONSUMER assertion to the trace-mode test and a changeset bullet noting the internal→consumer kind change.

@pranaygppranaygp 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.

Pre-emptively approving — no blocking bugs, perf is clean (net reduction when OTEL is active, memoized no-op without it), and the cross-repo semantics line up with vqs-server#615 / workflow-server#514 / vqs#181.

@karthikscale3 please address the inline comments from my review (#2363 (review)) before merging — in particular:

  1. Empty {} carrier in linked mode (runtime.ts:344 + the step-handler.ts copy): runs started from uninstrumented contexts silently lose run-level correlation links; treat an empty carrier like an absent one.
  2. Silent fallback on unrecognized WORKFLOW_TRACE_MODE values (telemetry.ts:31): a typo flips trace topology with zero signal; add a warn-once.

The rest (dedup extraction, display-name edges, docs span-kind row, CONSUMER kind for workflow.execute) are nice-to-haves — fine in this PR or as follow-ups.

karthikscale3and others added 8 commits June 15, 2026 11:22
…per-invocation traces
- Add WORKFLOW_TRACE_MODE ('linked' default, 'continuous' legacy) to the
workflow and step queue handlers. In linked mode, WORKFLOW_V2/STEP spans
start a new trace root with span links to the incoming delivery context
and the run-origin context, and re-enqueued messages forward the
ORIGINAL run-origin trace carrier unchanged.
- world-vercel now explicitly injects W3C traceparent/tracestate/baggage
headers on outgoing workflow-server HTTP requests from inside the
client span (no-op without an OTEL SDK registered).
- New workflow.trace.mode span attribute; unit tests for both modes and
for header injection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Documents OTEL spans/attributes, linked trace mode and WORKFLOW_TRACE_MODE,
span links, context propagation, and the v4 behavior-change callout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WORKFLOW_V2/STEP prefixes with full machine names (workflow//./src/...//fn)
become workflow.execute / step.execute / workflow.start with the short
function name. New workflowDisplayName/stepDisplayName helpers in
@workflow/utils handle both raw and queue-sanitized name forms; full names
remain in the workflow.name/step.name attributes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ame edge cases, consumer span kind
- Treat an empty ({}) trace carrier as absent everywhere the trace-mode
logic branches, so linked mode falls back to a fresh origin instead of
forwarding a useless {} forever; workflow.trace.propagated now reports
whether a usable carrier arrived.
- Extract the duplicated linked-mode logic into shared telemetry helpers
getNextTraceCarrier() and buildInvocationSpanLinks(), used by both the
workflow and step queue handlers; resume-hook now uses
linkToTraceCarrier (gaining the isSpanContextValid guard).
- Warn once per distinct unrecognized WORKFLOW_TRACE_MODE value instead
of silently selecting linked.
- shortNameFromSanitized: map default/__default to the module short name
(mirroring parseName) and document the `$`-sanitization limitation.
- Queue-delivered workflow.execute spans now use the CONSUMER span kind,
matching queue-delivered step.execute spans; docs span table and
changeset updated accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

DiffPackageSupply Chain
Security
VulnerabilityQualityMaintenanceLicense
Addednpm/​@​opentelemetry/​context-async-hooks@​1.30.1741008896100

View full report

@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 926a5e7 (AI decision).

This is a deliberate telemetry-shape change that flips the default to the new linked trace mode, altering trace topology, per-run trace IDs, sampling semantics, and span names — a behavioral change explicitly scoped to the v5 beta major. The PR author explicitly requests no backport because applying it to GA v4 users mid-major would silently change their trace behavior, and the accompanying docs target only docs/content/docs/v5/, so it falls under "major/breaking changes intended for the next major release."

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

926a5e7c6a50c1e74f2e2cc37324caa0f6442d85

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@pranaygp
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', '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('^' + ".*" + ' otel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces by karthikscale3 · Pull Request #2363 · vercel/workflow · GitHub
Skip to content

otel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces - #2363

Merged
karthikscale3 merged 8 commits into
mainfrom
karthik/otel-trace-correlation
Jun 15, 2026
Merged

otel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces#2363
karthikscale3 merged 8 commits into
mainfrom
karthik/otel-trace-correlation

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Problem: mega-traces

Today the workflow queue handlers restore the run-origin trace context from the message's traceCarrier and make it the parent of every WORKFLOW_V2 / STEP invocation span. Since each invocation re-serializes its own context onto the next queue message, a single workflow run becomes one giant trace — spanning hours of sleeps/retries and dozens of stitched-together function invocations. These mega-traces are slow to load, hard to read, and frequently broken in Datadog (span limits, late-arriving spans, partial flushes).

Separately, the world-vercel HTTP client creates a CLIENT span for every workflow-server request but never injects traceparent into the outgoing headers — propagation only happened if the customer's app happened to have undici auto-instrumentation, so workflow-server spans usually couldn't join the caller's trace.

Linked-trace mode (new default)

This PR introduces WORKFLOW_TRACE_MODE with two values:

  • linked (new default): each invocation's WORKFLOW_V2 <name> / STEP <name> span is created as a new trace root (SpanOptions.root: true) with span links to:

    • the incoming delivery context (the active span extracted from the queue delivery request — once the platform re-injects producer context on deliveries, this points at the enqueue site), and
    • the run-origin context from the message's traceCarrier (skipped when absent/invalid or identical to the delivery link).

    Re-enqueued messages forward the original run-origin traceCarrier unchanged, so every future invocation of the run links back to the same origin. Traces stay small and bounded per invocation, while links preserve full run-level correlation.

  • continuous: exactly the previous behavior — restored run-origin context parents the invocation span, with a link to the delivery context, and re-enqueues serialize the current context. Set WORKFLOW_TRACE_MODE=continuous to opt back in.

Both modes keep withWorkflowBaggage wrapping, all existing span attributes (including workflow.trace.propagated), and add a new workflow.trace.mode attribute recording the active mode.

Explicit traceparent injection on workflow-server calls

world-vercel's makeRequest now injects W3C context (traceparent, tracestate, baggage) into the outgoing request headers from inside the http <method> CLIENT span, via a new injectTraceContextIntoHeaders(headers) helper in world-vercel's lazy telemetry module. workflow-server can now reliably parent its spans to the SDK's client span regardless of the customer's instrumentation setup.

Queue sends (@vercel/queue) are intentionally untouched here — VQS treats message headers as allowlisted custom headers; HTTP-layer injection for queue sends is handled in the @vercel/queue client itself.

Behavioral changes to telemetry (please read)

The API is backward compatible, but the new linked default changes the shape of emitted traces in ways existing dashboards and queries can feel. Set WORKFLOW_TRACE_MODE=continuous to restore the previous shape exactly.

  1. A run no longer shares one trace ID. Previously, the trace of the request that called start() contained the entire workflow execution — every WORKFLOW_V2/STEP span across all invocations carried the run-origin trace ID. Now each invocation is its own root trace. Anything keyed on a shared per-run trace ID (saved trace queries, "open my request's trace and see the run" debugging flows, trace-ID joins) must switch to span links or the workflow.run.id attribute.
  2. Sampling semantics change. Parent-based samplers previously made one decision at start() that covered the whole run consistently. Each invocation root now samples independently — ratio samplers will produce partially-sampled runs, and the number of root spans/traces increases to one per invocation (relevant for trace-volume-based vendor billing and rate-limiting samplers).
  3. Parent/child topology changes.WORKFLOW_V2/STEP spans had a remote parent; they are now parentless roots. Queries filtering on parent relationships and service-map edges from the calling service to the workflow handler will change.
  4. Re-enqueue traceCarrier semantics change. Queue messages now forward the original run-origin carrier unchanged, rather than each invocation's current context. Custom worlds or tooling that introspect message carriers and assume "carrier = most recent invocation context" will observe different values.

Not changed: all existing span attributes and baggage keys, and the no-OTEL no-op behavior. One footnote: app-set baggage entries now also leave the process as a baggage HTTP request header on backend calls (they already left via traceCarrier in events).

Friendlier span names

Workflow/step span names previously used uppercase prefixes with full machine names (WORKFLOW_V2 workflow//./src/jobs/order//processOrder). They are now short and lowercase: workflow.execute processOrder, step.execute chargeCard, workflow.start processOrder. New workflowDisplayName/stepDisplayName helpers in @workflow/utils resolve both the raw machine name and the queue-sanitized form (workflow----src-jobs-order--processOrder) seen by queue handlers; unrecognized formats fall back to the raw string. The full machine name remains available in the workflow.name / step.name span attributes. This is also a span-name change for anyone querying WORKFLOW_V2/STEP names — same v5-beta reasoning as above.

Backward compatibility

  • No OTEL registered: everything no-ops exactly as before — @opentelemetry/api stays an optional peer dep, the default no-op propagator injects nothing, and no headers are added.
  • WORKFLOW_TRACE_MODE=continuous restores the prior trace shape bit-for-bit (parenting, links, and carrier chaining).
  • Servers ignore the new headers harmlessly:traceparent/tracestate/baggage are standard W3C headers; receivers without tracing simply drop them.

Testing

  • packages/core/src/runtime-trace-mode.test.ts: default is linked; linked creates a root span with links to both delivery + run-origin contexts; continuous preserves the legacy parented shape; linked forwards the original traceCarrier on re-enqueues while continuous serializes the current context. Uses a real in-memory OTEL SDK (BasicTracerProvider + InMemorySpanExporter + W3C propagator).
  • packages/world-vercel/src/trace-propagation.test.ts: traceparent lands on the outgoing request and matches the http GET client span; clean no-op without an active span context.
  • pnpm build, pnpm typecheck, full unit suites for packages/core (1124 passed) and packages/world-vercel (134 passed), Biome format/lint clean.

Backport policy

Do not backport to stable (v4). The linked default is a deliberate telemetry-shape change scoped to the v5 beta major — backporting it would change trace topology, per-run trace IDs, and sampling behavior for GA v4 users mid-major. v4 keeps its current behavior until users upgrade to v5; the platform side is fully tolerant of v4 SDKs.

Documentation

Adds docs/content/docs/v5/observability/tracing.mdx (linked from the Observability index): enabling OTEL, emitted spans and attributes, linked trace mode and span links, WORKFLOW_TRACE_MODE reference with a v4 behavior-change callout, and context-propagation/baggage notes. v4 docs intentionally untouched.

Rollout notes

Server-side support for storing and re-injecting trace context on queue deliveries ships separately on the platform. This PR is safe to merge and release independently: without the platform-side support, behavior is unchanged apart from the new (ignorable) W3C headers and the bounded linked-trace shape.

Follow-up: bump @vercel/queue in @workflow/world-vercel once a release with HTTP-layer trace-context injection is published, so queue sends carry trace headers as well.

🤖 Generated with Claude Code

@karthikscale3
karthikscale3 requested a review from a team as a code ownerJune 11, 2026 16:10
@vercel

vercelBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.041s (-8.4% 🟢)1.006s (~)0.965s101.00x
💻 LocalNitro0.045s (~)1.007s (~)0.961s101.12x
🐘 PostgresExpress0.061s (-3.3%)1.012s (~)0.951s101.50x
🐘 PostgresNitro0.061s (+3.9%)1.012s (~)0.951s101.51x
💻 LocalNext.js (Turbopack)0.062s (+3.7%)1.005s (~)0.943s101.54x
🐘 PostgresNext.js (Turbopack)0.069s (-1.4%)1.013s (~)0.944s101.70x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.308s (-6.5% 🟢)2.408s (+5.4% 🔺)2.099s101.00x
▲ VercelNitro0.331s (+31.6% 🔺)2.509s (+13.4% 🔺)2.179s101.07x
▲ VercelExpress0.423s (+34.2% 🔺)2.501s (-6.2% 🟢)2.077s101.37x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.089s (-1.3%)2.006s (~)0.917s101.00x
🐘 PostgresExpress1.109s (~)2.010s (~)0.901s101.02x
💻 LocalNitro1.110s (+1.7%)2.006s (~)0.896s101.02x
🐘 PostgresNitro1.117s (~)2.010s (~)0.894s101.02x
💻 LocalNext.js (Turbopack)1.140s (+1.0%)2.006s (~)0.866s101.05x
🐘 PostgresNext.js (Turbopack)1.161s (+1.9%)2.010s (~)0.849s101.07x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express1.680s (-9.9% 🟢)3.794s (+10.9% 🔺)2.114s101.00x
▲ VercelNext.js (Turbopack)1.765s (-17.8% 🟢)3.784s (+5.4% 🔺)2.019s101.05x
▲ VercelNitro1.800s (~)3.823s (+2.2%)2.023s101.07x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro10.556s (~)11.020s (~)0.464s31.00x
💻 LocalExpress10.566s (~)11.022s (~)0.456s31.00x
💻 LocalNitro10.574s (~)11.022s (~)0.448s31.00x
🐘 PostgresExpress10.576s (~)11.019s (~)0.443s31.00x
💻 LocalNext.js (Turbopack)10.794s (~)11.021s (~)0.227s31.02x
🐘 PostgresNext.js (Turbopack)10.946s (~)11.351s (~)0.405s31.04x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)14.431s (-14.2% 🟢)16.671s (-8.8% 🟢)2.240s21.00x
▲ VercelNitro14.546s (-2.8%)16.712s (-1.3%)2.166s21.01x
▲ VercelExpress14.781s (-7.0% 🟢)16.758s (-4.0%)1.977s21.02x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express13.761s (~)14.027s (~)0.266s51.00x
💻 LocalNitro13.782s (~)14.028s (~)0.246s51.00x
🐘 PostgresExpress13.786s (-0.5%)14.020s (~)0.233s51.00x
🐘 PostgresNitro13.862s (~)14.021s (~)0.159s51.01x
💻 LocalNext.js (Turbopack)14.321s (~)15.030s (~)0.709s41.04x
🐘 PostgresNext.js (Turbopack)14.461s (~)15.018s (~)0.557s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro23.321s (-20.2% 🟢)25.623s (-17.7% 🟢)2.302s31.00x
▲ VercelExpress23.347s (-15.1% 🟢)25.294s (-11.8% 🟢)1.947s31.00x
▲ VercelNext.js (Turbopack)23.574s (-18.5% 🟢)25.768s (-14.4% 🟢)2.194s31.01x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express12.399s (-0.9%)13.025s (~)0.626s71.00x
🐘 PostgresExpress12.452s (-0.5%)13.019s (~)0.567s71.00x
💻 LocalNitro12.585s (+1.0%)13.025s (~)0.440s71.01x
🐘 PostgresNitro12.914s (-1.3%)13.304s (~)0.390s71.04x
💻 LocalNext.js (Turbopack)13.639s (~)14.027s (-1.0%)0.388s71.10x
🐘 PostgresNext.js (Turbopack)14.158s (+1.3%)14.590s (~)0.432s71.14x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro32.772s (+2.6%)35.131s (+3.4%)2.359s31.00x
▲ VercelExpress34.356s (+4.2%)36.946s (+6.2% 🔺)2.590s31.05x
▲ VercelNext.js (Turbopack)34.608s (+7.4% 🔺)37.163s (+11.4% 🔺)2.555s31.06x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.196s (-1.7%)2.006s (~)0.810s151.00x
🐘 PostgresExpress1.217s (~)2.008s (~)0.791s151.02x
🐘 PostgresNitro1.241s (+1.2%)2.008s (~)0.768s151.04x
💻 LocalNitro1.241s (+2.4%)2.007s (~)0.765s151.04x
🐘 PostgresNext.js (Turbopack)1.291s (+1.6%)2.008s (~)0.717s151.08x
💻 LocalNext.js (Turbopack)1.374s (+6.9% 🔺)2.006s (~)0.632s151.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.381s (-6.9% 🟢)4.170s (+1.9%)1.789s81.00x
▲ VercelExpress2.418s (-49.2% 🟢)4.158s (-35.9% 🟢)1.740s81.02x
▲ VercelNext.js (Turbopack)2.438s (-53.9% 🟢)3.988s (-39.1% 🟢)1.550s81.02x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.413s (-6.1% 🟢)2.318s (~)0.905s131.00x
🐘 PostgresNitro1.415s (+2.9%)2.317s (-7.6% 🟢)0.902s131.00x
🐘 PostgresNext.js (Turbopack)1.655s (+6.0% 🔺)2.316s (+4.2%)0.661s131.17x
💻 LocalExpress1.721s (-6.7% 🟢)2.006s (-6.7% 🟢)0.284s151.22x
💻 LocalNext.js (Turbopack)1.820s (+12.1% 🔺)2.073s (+3.3%)0.254s151.29x
💻 LocalNitro2.079s (+16.6% 🔺)2.471s (+23.2% 🔺)0.392s131.47x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.682s (-11.9% 🟢)4.262s (-17.0% 🟢)1.580s81.00x
▲ VercelExpress3.635s (-3.3%)5.594s (+10.7% 🔺)1.959s71.36x
▲ VercelNext.js (Turbopack)4.256s (+1.4%)6.350s (+14.5% 🔺)2.094s51.59x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.544s (-7.3% 🟢)3.887s (-3.1%)2.343s81.00x
🐘 PostgresNitro1.657s (-7.4% 🟢)4.136s (+12.4% 🔺)2.478s81.07x
🐘 PostgresNext.js (Turbopack)3.528s (+5.1% 🔺)4.300s (+3.9%)0.772s72.29x
💻 LocalExpress4.700s (-15.7% 🟢)5.179s (-13.9% 🟢)0.479s63.04x
💻 LocalNext.js (Turbopack)5.238s (+12.7% 🔺)5.512s (+6.4% 🔺)0.274s63.39x
💻 LocalNitro6.891s (+34.2% 🔺)7.516s (+36.3% 🔺)0.625s44.46x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)4.729s (-24.8% 🟢)6.624s (-15.0% 🟢)1.895s51.00x
▲ VercelExpress5.058s (+11.2% 🔺)7.110s (+16.3% 🔺)2.052s51.07x
▲ VercelNitro5.934s (-13.6% 🟢)8.393s (-3.0%)2.459s41.25x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.212s (~)2.008s (~)0.796s151.00x
🐘 PostgresExpress1.219s (~)2.008s (-3.2%)0.789s151.01x
🐘 PostgresNext.js (Turbopack)1.326s (+2.8%)2.008s (~)0.682s151.09x
💻 LocalNext.js (Turbopack)1.392s (~)2.006s (~)0.614s151.15x
💻 LocalExpress1.577s (~)2.006s (~)0.430s151.30x
💻 LocalNitro1.635s (+2.6%)2.007s (~)0.372s151.35x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.345s (-15.9% 🟢)4.021s (-7.5% 🟢)1.676s81.00x
▲ VercelNext.js (Turbopack)2.375s (-3.8%)3.997s (+9.1% 🔺)1.623s81.01x
▲ VercelNitro3.013s (+19.4% 🔺)4.535s (+15.4% 🔺)1.522s71.28x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.364s (~)2.151s (-7.1% 🟢)0.788s141.00x
🐘 PostgresNitro1.404s (~)2.317s (~)0.913s131.03x
🐘 PostgresNext.js (Turbopack)1.557s (-4.2%)2.393s (+3.3%)0.836s131.14x
💻 LocalExpress2.014s (-13.4% 🟢)2.508s (-11.3% 🟢)0.494s121.48x
💻 LocalNext.js (Turbopack)2.020s (+1.0%)2.735s (+5.6% 🔺)0.715s111.48x
💻 LocalNitro2.227s (+2.2%)2.758s (+0.8%)0.531s121.63x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.771s (-3.9%)4.319s (~)1.548s81.00x
▲ VercelNext.js (Turbopack)2.915s (-1.8%)4.814s (+7.1% 🔺)1.900s71.05x
▲ VercelExpress3.336s (+2.4%)5.038s (+6.4% 🔺)1.702s61.20x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.772s (-4.9%)4.014s (-6.6% 🟢)2.241s81.00x
🐘 PostgresNitro1.886s (+17.2% 🔺)4.441s (+14.2% 🔺)2.556s71.06x
🐘 PostgresNext.js (Turbopack)3.388s (+3.7%)4.142s (-2.8%)0.754s81.91x
💻 LocalExpress5.290s (-24.9% 🟢)5.850s (-23.2% 🟢)0.560s62.99x
💻 LocalNext.js (Turbopack)6.189s (+38.2% 🔺)6.416s (+23.9% 🔺)0.226s53.49x
💻 LocalNitro6.932s (+19.4% 🔺)7.218s (+12.5% 🔺)0.286s53.91x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.744s (-28.8% 🟢)6.281s (-1.9%)2.537s51.00x
▲ VercelNext.js (Turbopack)3.946s (-18.4% 🟢)5.974s (-9.9% 🟢)2.028s61.05x
▲ VercelNitro4.515s (-3.1%)6.349s (+1.5%)1.834s51.21x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.586s (-2.8%)1.006s (-1.7%)0.420s601.00x
🐘 PostgresExpress0.593s (+2.9%)1.041s (+3.5%)0.448s581.01x
💻 LocalExpress0.615s (-3.4%)1.005s (-3.3%)0.390s601.05x
💻 LocalNitro0.670s (+4.6%)1.040s (+1.8%)0.370s581.14x
🐘 PostgresNext.js (Turbopack)0.835s (~)1.023s (~)0.189s591.42x
💻 LocalNext.js (Turbopack)0.870s (+2.0%)1.004s (~)0.135s601.48x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.493s (-24.9% 🟢)8.569s (-15.7% 🟢)2.076s81.00x
▲ VercelNitro6.528s (-27.6% 🟢)8.584s (-18.7% 🟢)2.056s71.01x
▲ VercelNext.js (Turbopack)6.529s (~)8.488s (+6.1% 🔺)1.959s81.01x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.388s (-5.6% 🟢)2.007s (-3.3%)0.619s451.00x
🐘 PostgresNitro1.407s (-3.4%)2.008s (-2.2%)0.601s451.01x
💻 LocalExpress1.514s (-1.9%)2.006s (~)0.491s451.09x
💻 LocalNitro1.646s (+8.0% 🔺)2.030s (+1.2%)0.383s451.19x
🐘 PostgresNext.js (Turbopack)1.991s (+2.6%)2.367s (+12.7% 🔺)0.375s391.43x
💻 LocalNext.js (Turbopack)2.174s (+3.6%)3.008s (+2.2%)0.835s301.57x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express15.578s (-29.1% 🟢)18.039s (-23.2% 🟢)2.461s51.00x
▲ VercelNitro16.348s (-9.2% 🟢)18.489s (-6.5% 🟢)2.141s51.05x
▲ VercelNext.js (Turbopack)16.650s (-9.7% 🟢)19.019s (-4.4%)2.369s51.07x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.822s (+1.1%)3.137s (~)0.315s391.00x
🐘 PostgresExpress2.869s (+3.9%)3.280s (+6.3% 🔺)0.410s371.02x
💻 LocalExpress3.265s (-0.8%)4.009s (~)0.744s301.16x
💻 LocalNitro3.436s (+4.4%)4.077s (+1.7%)0.640s301.22x
🐘 PostgresNext.js (Turbopack)3.936s (+2.7%)4.183s (+3.5%)0.247s291.39x
💻 LocalNext.js (Turbopack)4.327s (-3.0%)5.010s (-0.8%)0.683s241.53x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro29.059s (-8.3% 🟢)31.839s (-4.9%)2.780s41.00x
▲ VercelExpress30.029s (-9.5% 🟢)33.287s (-3.4%)3.258s41.03x
▲ VercelNext.js (Turbopack)32.694s (-1.4%)35.494s (+3.0%)2.800s41.13x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.256s (+1.6%)1.006s (~)0.750s601.00x
🐘 PostgresNitro0.256s (-1.4%)1.006s (-1.6%)0.750s601.00x
🐘 PostgresNext.js (Turbopack)0.302s (+1.2%)1.006s (~)0.704s601.18x
💻 LocalNitro0.431s (+1.5%)1.005s (~)0.574s601.68x
💻 LocalExpress0.435s (-1.5%)1.004s (~)0.569s601.70x
💻 LocalNext.js (Turbopack)0.521s (-12.1% 🟢)1.005s (-1.7%)0.484s602.04x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.115s (-15.5% 🟢)3.980s (-1.9%)1.866s161.00x
▲ VercelNext.js (Turbopack)2.203s (+7.0% 🔺)4.126s (+21.7% 🔺)1.922s151.04x
▲ VercelNitro2.523s (+18.9% 🔺)4.388s (+13.4% 🔺)1.865s141.19x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.415s (+3.7%)1.041s (~)0.627s871.00x
🐘 PostgresNitro0.424s (+0.5%)1.041s (-2.3%)0.617s871.02x
🐘 PostgresNext.js (Turbopack)0.577s (-7.4% 🟢)1.078s (-8.3% 🟢)0.501s841.39x
💻 LocalNitro2.071s (-7.6% 🟢)2.658s (-3.9%)0.586s345.00x
💻 LocalExpress2.325s (+13.4% 🔺)2.853s (+7.4% 🔺)0.528s325.61x
💻 LocalNext.js (Turbopack)2.611s (~)3.334s (-1.5%)0.723s286.30x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.672s (+1.7%)5.578s (+4.3%)1.907s171.00x
▲ VercelExpress3.683s (+11.8% 🔺)5.655s (+19.5% 🔺)1.972s161.00x
▲ VercelNitro3.775s (-16.7% 🟢)5.889s (-5.0% 🟢)2.114s161.03x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.785s (-0.8%)1.271s (-6.3% 🟢)0.486s951.00x
🐘 PostgresNitro0.844s (+3.8%)1.371s (-7.6% 🟢)0.527s881.08x
🐘 PostgresNext.js (Turbopack)2.677s (-6.4% 🟢)3.711s (+2.2%)1.034s333.41x
💻 LocalExpress9.827s (-3.2%)10.362s (-3.9%)0.535s1212.52x
💻 LocalNitro9.859s (+3.5%)10.363s (+1.7%)0.504s1212.56x
💻 LocalNext.js (Turbopack)10.346s (-5.7% 🟢)11.301s (-4.6%)0.956s1113.18x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.976s (-14.5% 🟢)8.118s (-6.4% 🟢)2.142s151.00x
▲ VercelExpress6.135s (-15.9% 🟢)8.528s (-3.9%)2.394s151.03x
▲ VercelNext.js (Turbopack)7.343s (-10.4% 🟢)9.606s (-1.9%)2.264s131.23x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.165s (~)2.004s (~)0.011s (-15.3% 🟢)2.017s (~)0.852s101.00x
💻 LocalNitro1.172s (~)2.005s (~)0.013s (+7.6% 🔺)2.021s (~)0.849s101.01x
🐘 PostgresExpress1.179s (~)2.001s (~)0.001s (~)2.011s (~)0.832s101.01x
🐘 PostgresNitro1.182s (+1.3%)1.997s (~)0.001s (-7.1% 🟢)2.010s (~)0.829s101.01x
💻 LocalNext.js (Turbopack)1.205s (~)2.003s (~)0.012s (+10.2% 🔺)2.019s (~)0.814s101.03x
🐘 PostgresNext.js (Turbopack)1.244s (+0.8%)2.001s (~)0.001s (+11.1% 🔺)2.011s (~)0.767s101.07x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.637s (+5.1% 🔺)3.726s (+10.3% 🔺)1.437s (+19.6% 🔺)5.659s (+13.9% 🔺)3.022s101.00x
▲ VercelNitro2.776s (+19.7% 🔺)4.150s (+24.5% 🔺)0.672s (-37.1% 🟢)5.469s (+13.2% 🔺)2.693s101.05x
▲ VercelExpress2.788s (+17.0% 🔺)4.107s (+28.5% 🔺)1.469s (+11.7% 🔺)6.069s (+23.3% 🔺)3.281s101.06x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.568s (-1.8%)2.006s (~)0.005s (+1.3%)2.028s (~)0.460s301.00x
💻 LocalNitro1.602s (~)2.010s (~)0.011s (-12.0% 🟢)2.024s (~)0.422s301.02x
💻 LocalExpress1.602s (~)2.009s (~)0.015s (+17.6% 🔺)2.027s (~)0.425s301.02x
🐘 PostgresNitro1.638s (+2.7%)2.002s (~)0.005s (-2.0%)2.027s (~)0.389s301.04x
💻 LocalNext.js (Turbopack)1.734s (+0.9%)2.008s (~)0.012s (-1.1%)2.024s (~)0.290s301.11x
🐘 PostgresNext.js (Turbopack)1.796s (+2.0%)2.009s (~)0.006s (+8.2% 🔺)2.030s (~)0.234s301.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.353s (-6.8% 🟢)7.876s (~)0.536s (+132.4% 🔺)8.902s (+3.4%)2.550s71.00x
▲ VercelNitro6.526s (-1.8%)8.158s (+3.1%)0.330s (+49.2% 🔺)8.975s (+4.3%)2.448s71.03x
▲ VercelNext.js (Turbopack)6.780s (-1.5%)8.291s (+3.3%)0.629s (+158.3% 🔺)9.487s (+8.5% 🔺)2.707s71.07x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.776s (+1.9%)1.029s (-1.8%)0.000s (-33.3% 🟢)1.061s (~)0.286s571.00x
🐘 PostgresNitro0.868s (+9.4% 🔺)1.079s (+3.0%)0.000s (-100.0% 🟢)1.122s (+5.8% 🔺)0.254s541.12x
🐘 PostgresNext.js (Turbopack)1.025s (+2.7%)1.469s (+2.8%)0.000s (+Infinity% 🔺)1.486s (+3.5%)0.461s411.32x
💻 LocalNitro1.427s (+3.1%)2.014s (~)0.000s (-11.1% 🟢)2.016s (~)0.589s301.84x
💻 LocalNext.js (Turbopack)1.476s (-0.6%)2.013s (~)0.000s (+133.3% 🔺)2.016s (~)0.540s301.90x
💻 LocalExpress1.492s (+2.1%)2.014s (~)0.000s (-65.2% 🟢)2.016s (~)0.524s301.92x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.152s (-28.2% 🟢)4.709s (-17.2% 🟢)0.001s (-25.9% 🟢)5.242s (-16.1% 🟢)2.090s121.00x
▲ VercelExpress3.292s (-31.2% 🟢)4.944s (-10.0% 🟢)0.000s (+81.8% 🔺)5.485s (-10.9% 🟢)2.193s111.04x
▲ VercelNext.js (Turbopack)3.597s (-0.7%)5.203s (+9.6% 🔺)0.000s (NaN%)5.709s (+10.4% 🔺)2.112s121.14x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

fan-out fan-in 10 streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.522s (-8.1% 🟢)2.063s (-5.1% 🟢)0.000s (+93.1% 🔺)2.093s (-4.8%)0.570s291.00x
🐘 PostgresNitro1.659s (+2.3%)2.258s (+5.5% 🔺)0.000s (+3.7%)2.273s (+5.6% 🔺)0.614s271.09x
🐘 PostgresNext.js (Turbopack)2.252s (+7.5% 🔺)2.729s (+2.8%)0.000s (+213.6% 🔺)2.776s (+4.3%)0.524s221.48x
💻 LocalNext.js (Turbopack)2.852s (-2.2%)3.470s (-1.5%)0.001s (+175.0% 🔺)3.473s (-1.6%)0.621s181.87x
💻 LocalNitro3.071s (+1.2%)3.674s (~)0.001s (-9.1% 🟢)3.677s (~)0.606s172.02x
💻 LocalExpress3.290s (+2.6%)4.026s (+1.5%)0.001s (+353.3% 🔺)4.030s (+1.6%)0.740s152.16x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.411s (-11.9% 🟢)7.163s (-5.1% 🟢)0.004s (+Infinity% 🔺)7.652s (-4.2%)2.241s81.00x
▲ VercelNext.js (Turbopack)6.145s (+25.8% 🔺)7.777s (+25.8% 🔺)0.000s (+Infinity% 🔺)8.258s (+25.1% 🔺)2.113s81.14x
▲ VercelExpress6.943s (+39.7% 🔺)8.421s (+43.0% 🔺)0.000s (NaN%)8.996s (+42.1% 🔺)2.053s81.28x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalExpress15/21
🐘 PostgresExpress17/21
▲ VercelNitro9/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres14/21
Next.js (Turbopack)🐘 Postgres14/21
Nitro🐘 Postgres15/21
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Redis + BullMQ: Community world (local development)
  • 🌐 Cloudflare: Community world (local development)
  • 🌐 MySQL: Community world (local development)
  • 🌐 Azure: Community world (local development)
  • 🌐 NATS JetStream: Community world (local development)
  • 🌐 Upstash: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production144202191661
✅ 💻 Local Development189502192114
✅ 📦 Local Production189502192114
✅ 🐘 Local Postgres188102332114
✅ 🪟 Windows15100151
✅ 📋 Other87901781057
Total8143010689211

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro125026
✅ example125026
✅ express125026
✅ fastify125026
✅ hono125026
✅ nextjs-turbopack14902
✅ nextjs-webpack14902
✅ nitro125026
✅ nuxt125026
✅ sveltekit14407
✅ vite125026
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable126025
✅ express-stable126025
✅ fastify-stable126025
✅ hono-stable126025
✅ nextjs-turbopack-canary132019
✅ nextjs-turbopack-stable-lazy-discovery-disabled15100
✅ nextjs-turbopack-stable-lazy-discovery-enabled15100
✅ nextjs-webpack-canary132019
✅ nextjs-webpack-stable-lazy-discovery-disabled15100
✅ nextjs-webpack-stable-lazy-discovery-enabled15100
✅ nitro-stable126025
✅ nuxt-stable126025
✅ sveltekit-stable14506
✅ vite-stable126025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable126025
✅ express-stable126025
✅ fastify-stable126025
✅ hono-stable126025
✅ nextjs-turbopack-canary132019
✅ nextjs-turbopack-stable-lazy-discovery-disabled15100
✅ nextjs-turbopack-stable-lazy-discovery-enabled15100
✅ nextjs-webpack-canary132019
✅ nextjs-webpack-stable-lazy-discovery-disabled15100
✅ nextjs-webpack-stable-lazy-discovery-enabled15100
✅ nitro-stable126025
✅ nuxt-stable126025
✅ sveltekit-stable14506
✅ vite-stable126025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable125026
✅ express-stable125026
✅ fastify-stable125026
✅ hono-stable125026
✅ nextjs-turbopack-canary131020
✅ nextjs-turbopack-stable-lazy-discovery-disabled15001
✅ nextjs-turbopack-stable-lazy-discovery-enabled15001
✅ nextjs-webpack-canary131020
✅ nextjs-webpack-stable-lazy-discovery-disabled15001
✅ nextjs-webpack-stable-lazy-discovery-enabled15001
✅ nitro-stable125026
✅ nuxt-stable125026
✅ sveltekit-stable14407
✅ vite-stable125026
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15100
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable126025
✅ e2e-local-dev-tanstack-start-126025
✅ e2e-local-postgres-nest-stable125026
✅ e2e-local-postgres-tanstack-start-125026
✅ e2e-local-prod-nest-stable126025
✅ e2e-local-prod-tanstack-start-126025
✅ e2e-vercel-prod-tanstack-start125026

📋 View full workflow run

@changeset-bot

changeset-botBot commented Jun 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 22c35aa

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

This PR includes changesets to release 22 packages
NameType
@workflow/coreMinor
workflowMinor
@workflow/world-vercelMinor
@workflow/utilsMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
@workflow/aiMajor
@workflow/errorsPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@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

@pranaygppranaygp 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.

Reviewed with a focus on attribute consistency, forwards compatibility, DX, and perf overhead. Overall this is solid: the linked-mode semantics are coherent with the other three PRs in the stack (baggage keys match what workflow-server#514 reads; the run-origin carrier semantics match the server's executionContext.traceCarrier-based span links, which are also pinned to run origin; world-vercel consumes deliveries via @vercel/queue.handleCallback, so vqs#181's consumer-side extraction is exactly what feeds linkToCurrentContext). Perf-wise the change is a net reduction when OTEL is active (linked mode skips a propagation.inject per re-enqueue) and stays a memoized no-op without an SDK. Ran the new test suites and typecheck locally — all green.

No blocking issues. Inline comments below: one behavioral edge around empty {} carriers in linked mode, a code-duplication suggestion, a DX nit on unrecognized WORKFLOW_TRACE_MODE values, two display-name edge cases, and a doc accuracy fix on span kinds.

Comment threadpackages/core/src/runtime.ts Outdated
// continuous mode the current (active) context is serialized so the
// trace keeps chaining.
const getNextTraceCarrier = (): Promise<Record<string, string>> =>
traceMode === 'linked' && traceContext

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.

traceContext here can be {} and still take the linked branch: start() always attaches a carrier, and serializeTraceCarrier() returns {} both when no OTEL SDK is registered at the origin and when OTEL is registered but start() runs outside any active span (background job, script). For such runs, linked mode forwards the empty object forever, while the undefined branch adaptively falls back to serializeTraceCarrier() (making the first instrumented invocation the de-facto run origin for future links).

Consider treating an empty carrier like an absent one — e.g. traceContext && Object.keys(traceContext).length > 0 — so both "no usable origin" shapes behave the same (same applies to the copy in step-handler.ts). Related side effect (pre-existing, but more visible now): workflow.trace.propagated is !!traceContext, so it reports true for {} even though there's nothing usable to link to.

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.

Fixed in 5b3ca9f. Added isUsableTraceCarrier() and normalized the incoming carrier at the top of both queue handlers, so {} counts as "no usable origin" everywhere the mode logic branches — linked mode falls back to serializing the current context (first instrumented invocation becomes the de-facto origin) instead of forwarding {} forever. Also took the related side effect: workflow.trace.propagated now reports whether a usable (non-empty) carrier arrived. Test added pinning traceCarrier: {} ≡ no carrier.

// so every future invocation links back to the same origin; in
// continuous mode the current (active) context is serialized so the
// trace keeps chaining.
const getNextTraceCarrier = (): Promise<Record<string, string>> =>

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.

This block — getNextTraceCarrier plus the origin-link dedup below (lines ~191–210) — is duplicated nearly verbatim from runtime.ts (~343–366). Since this encodes the core linked-mode invariants (forward the original carrier; dedup origin vs delivery link), consider extracting two small helpers into telemetry.ts, e.g. nextTraceCarrier(traceMode, traceContext) and buildInvocationSpanLinks(traceMode, traceContext), so the semantics can't drift between the workflow and step handlers.

Bonus if you do: resume-hook.ts (~186–193) has a hand-rolled version of carrier→link that lacks the isSpanContextValid guard your new linkToTraceCarrier has — it could reuse the helper too.

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.

Fixed in 5b3ca9f. Extracted both invariants into telemetry.ts as getNextTraceCarrier(traceMode, incomingCarrier) and buildInvocationSpanLinks(traceMode, incomingCarrier) (exact prior semantics, pinned by the existing trace-mode tests), now used by both runtime.ts and step-handler.ts. Took the bonus too: resume-hook.ts now uses linkToTraceCarrier and gains the isSpanContextValid guard it was missing.

Comment threadpackages/core/src/telemetry.ts Outdated
* Defaults to `'linked'`; any value other than `'continuous'` selects it.
*/
export function getWorkflowTraceMode(): WorkflowTraceMode {
return process.env.WORKFLOW_TRACE_MODE === 'continuous'

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.

Any unrecognized value silently selects linked — a typo like WORKFLOW_TRACE_MODE=continous changes trace topology with zero signal, and if a future SDK version adds a third mode, older SDKs will silently reinterpret it as linked. A one-time runtimeLogger.warn for non-empty unrecognized values would make misconfiguration debuggable and give forward compatibility a soft landing. (Resolving once into a module-level constant would also give you the warn-once behavior for free — the env var can't meaningfully change mid-process anyway.)

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.

Fixed in 5b3ca9f. Kept the dynamic per-call env read (the trace-mode tests flip WORKFLOW_TRACE_MODE per test, so a module-level constant would break them) and added a one-time runtimeLogger.warn per distinct unrecognized non-empty value, naming the value and the accepted ones before falling back to linked. Test asserts the warning fires exactly once for a continous typo.

Comment threadpackages/utils/src/parse-name.ts Outdated
if (!name.startsWith(`${tag}--`)) return null;
// The `//` separators became `--`, and within the function-name part any
// nested-function `/` became `-`. Function names are JS identifiers (no
// dashes), so the innermost name is the last dash-free segment.

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.

Two best-effort edges worth noting in this comment (or handling):

  1. $ is a valid JS identifier character and gets sanitized to -, so step//…//process$Order in sanitized form displays as Order — "no dashes" isn't strictly true for identifiers.
  2. Default exports diverge between the two input forms: parseName maps default/__default to the module short name, but this sanitized path returns the literal default. The same workflow can then show as workflow.start order (raw name in start()) but workflow.execute default (sanitized name in the queue handler). Mapping default to the preceding segment here would keep the two span names consistent.

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.

Fixed in 5b3ca9f. (2) is handled: shortNameFromSanitized now maps default/__default to the preceding module segment's short name, mirroring parseName, so default exports display consistently (e.g. order) in both workflow.start and workflow.execute — pinned by a test. (1) is documented as an accepted best-effort limitation in the comment ($ sanitizes to -, so process$Order displays as Order), with a test pinning the behavior.

| --- | --- | --- |
| `workflow.start <name>` | internal | `start()` is called in your application code |
| `workflow.execute <name>` | internal (root) | a queue delivery invokes the workflow — replay, orchestration, and inline steps run under it |
| `step.execute <name>` | internal | a step function executes |

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.

Kind is inaccurate for the queue-delivered case: step-handler.ts creates this span with SpanKind.CONSUMER (only inline steps executed within workflow.execute are internal), and in linked mode the queue-delivered step.execute span is also a new trace root, same as workflow.execute. Suggest something like: internal (inline) / consumer + root (queue-delivered).

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.

Fixed in 5b3ca9f. Table now reads workflow.executeconsumer (root) (it's CONSUMER as of this commit, see the other thread) and step.executeinternal (inline) / consumer + root (queue-delivered), per your suggested wording.

return trace(
`WORKFLOW_V2 ${workflowName}`,
{ links: spanLinks },
`workflow.execute ${workflowDisplayName(workflowName)}`,

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.

Pre-existing inconsistency, but this PR's v5 window is the cheapest moment to fix it: this queue-delivered span has default INTERNAL kind while the equivalent queue-delivered step.execute span uses CONSUMER. Messaging semconv would suggest CONSUMER here too — and it would pair nicely with the PRODUCER-kind vqs.send span being added on the other side in vercel/vqs#181. Fine as a follow-up, but if you want it, doing it inside the same beta avoids a second span-shape change.

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 5b3ca9f — agreed this beta is the cheapest window. The queue-delivered workflow.execute span now sets kind: CONSUMER via the same getSpanKind('CONSUMER') pattern step-handler uses (both modes), pairing with the PRODUCER vqs.send span in vercel/vqs#181. Added a SpanKind.CONSUMER assertion to the trace-mode test and a changeset bullet noting the internal→consumer kind change.

@pranaygppranaygp 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.

Pre-emptively approving — no blocking bugs, perf is clean (net reduction when OTEL is active, memoized no-op without it), and the cross-repo semantics line up with vqs-server#615 / workflow-server#514 / vqs#181.

@karthikscale3 please address the inline comments from my review (#2363 (review)) before merging — in particular:

  1. Empty {} carrier in linked mode (runtime.ts:344 + the step-handler.ts copy): runs started from uninstrumented contexts silently lose run-level correlation links; treat an empty carrier like an absent one.
  2. Silent fallback on unrecognized WORKFLOW_TRACE_MODE values (telemetry.ts:31): a typo flips trace topology with zero signal; add a warn-once.

The rest (dedup extraction, display-name edges, docs span-kind row, CONSUMER kind for workflow.execute) are nice-to-haves — fine in this PR or as follow-ups.

karthikscale3and others added 8 commits June 15, 2026 11:22
…per-invocation traces
- Add WORKFLOW_TRACE_MODE ('linked' default, 'continuous' legacy) to the
workflow and step queue handlers. In linked mode, WORKFLOW_V2/STEP spans
start a new trace root with span links to the incoming delivery context
and the run-origin context, and re-enqueued messages forward the
ORIGINAL run-origin trace carrier unchanged.
- world-vercel now explicitly injects W3C traceparent/tracestate/baggage
headers on outgoing workflow-server HTTP requests from inside the
client span (no-op without an OTEL SDK registered).
- New workflow.trace.mode span attribute; unit tests for both modes and
for header injection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Documents OTEL spans/attributes, linked trace mode and WORKFLOW_TRACE_MODE,
span links, context propagation, and the v4 behavior-change callout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WORKFLOW_V2/STEP prefixes with full machine names (workflow//./src/...//fn)
become workflow.execute / step.execute / workflow.start with the short
function name. New workflowDisplayName/stepDisplayName helpers in
@workflow/utils handle both raw and queue-sanitized name forms; full names
remain in the workflow.name/step.name attributes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ame edge cases, consumer span kind
- Treat an empty ({}) trace carrier as absent everywhere the trace-mode
logic branches, so linked mode falls back to a fresh origin instead of
forwarding a useless {} forever; workflow.trace.propagated now reports
whether a usable carrier arrived.
- Extract the duplicated linked-mode logic into shared telemetry helpers
getNextTraceCarrier() and buildInvocationSpanLinks(), used by both the
workflow and step queue handlers; resume-hook now uses
linkToTraceCarrier (gaining the isSpanContextValid guard).
- Warn once per distinct unrecognized WORKFLOW_TRACE_MODE value instead
of silently selecting linked.
- shortNameFromSanitized: map default/__default to the module short name
(mirroring parseName) and document the `$`-sanitization limitation.
- Queue-delivered workflow.execute spans now use the CONSUMER span kind,
matching queue-delivered step.execute spans; docs span table and
changeset updated accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

DiffPackageSupply Chain
Security
VulnerabilityQualityMaintenanceLicense
Addednpm/​@​opentelemetry/​context-async-hooks@​1.30.1741008896100

View full report

@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 926a5e7 (AI decision).

This is a deliberate telemetry-shape change that flips the default to the new linked trace mode, altering trace topology, per-run trace IDs, sampling semantics, and span names — a behavioral change explicitly scoped to the v5 beta major. The PR author explicitly requests no backport because applying it to GA v4 users mid-major would silently change their trace behavior, and the accompanying docs target only docs/content/docs/v5/, so it falls under "major/breaking changes intended for the next major release."

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

926a5e7c6a50c1e74f2e2cc37324caa0f6442d85

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@pranaygp
, '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" + ' otel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces by karthikscale3 · Pull Request #2363 · vercel/workflow · GitHub
Skip to content

otel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces - #2363

Merged
karthikscale3 merged 8 commits into
mainfrom
karthik/otel-trace-correlation
Jun 15, 2026
Merged

otel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces#2363
karthikscale3 merged 8 commits into
mainfrom
karthik/otel-trace-correlation

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Problem: mega-traces

Today the workflow queue handlers restore the run-origin trace context from the message's traceCarrier and make it the parent of every WORKFLOW_V2 / STEP invocation span. Since each invocation re-serializes its own context onto the next queue message, a single workflow run becomes one giant trace — spanning hours of sleeps/retries and dozens of stitched-together function invocations. These mega-traces are slow to load, hard to read, and frequently broken in Datadog (span limits, late-arriving spans, partial flushes).

Separately, the world-vercel HTTP client creates a CLIENT span for every workflow-server request but never injects traceparent into the outgoing headers — propagation only happened if the customer's app happened to have undici auto-instrumentation, so workflow-server spans usually couldn't join the caller's trace.

Linked-trace mode (new default)

This PR introduces WORKFLOW_TRACE_MODE with two values:

  • linked (new default): each invocation's WORKFLOW_V2 <name> / STEP <name> span is created as a new trace root (SpanOptions.root: true) with span links to:

    • the incoming delivery context (the active span extracted from the queue delivery request — once the platform re-injects producer context on deliveries, this points at the enqueue site), and
    • the run-origin context from the message's traceCarrier (skipped when absent/invalid or identical to the delivery link).

    Re-enqueued messages forward the original run-origin traceCarrier unchanged, so every future invocation of the run links back to the same origin. Traces stay small and bounded per invocation, while links preserve full run-level correlation.

  • continuous: exactly the previous behavior — restored run-origin context parents the invocation span, with a link to the delivery context, and re-enqueues serialize the current context. Set WORKFLOW_TRACE_MODE=continuous to opt back in.

Both modes keep withWorkflowBaggage wrapping, all existing span attributes (including workflow.trace.propagated), and add a new workflow.trace.mode attribute recording the active mode.

Explicit traceparent injection on workflow-server calls

world-vercel's makeRequest now injects W3C context (traceparent, tracestate, baggage) into the outgoing request headers from inside the http <method> CLIENT span, via a new injectTraceContextIntoHeaders(headers) helper in world-vercel's lazy telemetry module. workflow-server can now reliably parent its spans to the SDK's client span regardless of the customer's instrumentation setup.

Queue sends (@vercel/queue) are intentionally untouched here — VQS treats message headers as allowlisted custom headers; HTTP-layer injection for queue sends is handled in the @vercel/queue client itself.

Behavioral changes to telemetry (please read)

The API is backward compatible, but the new linked default changes the shape of emitted traces in ways existing dashboards and queries can feel. Set WORKFLOW_TRACE_MODE=continuous to restore the previous shape exactly.

  1. A run no longer shares one trace ID. Previously, the trace of the request that called start() contained the entire workflow execution — every WORKFLOW_V2/STEP span across all invocations carried the run-origin trace ID. Now each invocation is its own root trace. Anything keyed on a shared per-run trace ID (saved trace queries, "open my request's trace and see the run" debugging flows, trace-ID joins) must switch to span links or the workflow.run.id attribute.
  2. Sampling semantics change. Parent-based samplers previously made one decision at start() that covered the whole run consistently. Each invocation root now samples independently — ratio samplers will produce partially-sampled runs, and the number of root spans/traces increases to one per invocation (relevant for trace-volume-based vendor billing and rate-limiting samplers).
  3. Parent/child topology changes.WORKFLOW_V2/STEP spans had a remote parent; they are now parentless roots. Queries filtering on parent relationships and service-map edges from the calling service to the workflow handler will change.
  4. Re-enqueue traceCarrier semantics change. Queue messages now forward the original run-origin carrier unchanged, rather than each invocation's current context. Custom worlds or tooling that introspect message carriers and assume "carrier = most recent invocation context" will observe different values.

Not changed: all existing span attributes and baggage keys, and the no-OTEL no-op behavior. One footnote: app-set baggage entries now also leave the process as a baggage HTTP request header on backend calls (they already left via traceCarrier in events).

Friendlier span names

Workflow/step span names previously used uppercase prefixes with full machine names (WORKFLOW_V2 workflow//./src/jobs/order//processOrder). They are now short and lowercase: workflow.execute processOrder, step.execute chargeCard, workflow.start processOrder. New workflowDisplayName/stepDisplayName helpers in @workflow/utils resolve both the raw machine name and the queue-sanitized form (workflow----src-jobs-order--processOrder) seen by queue handlers; unrecognized formats fall back to the raw string. The full machine name remains available in the workflow.name / step.name span attributes. This is also a span-name change for anyone querying WORKFLOW_V2/STEP names — same v5-beta reasoning as above.

Backward compatibility

  • No OTEL registered: everything no-ops exactly as before — @opentelemetry/api stays an optional peer dep, the default no-op propagator injects nothing, and no headers are added.
  • WORKFLOW_TRACE_MODE=continuous restores the prior trace shape bit-for-bit (parenting, links, and carrier chaining).
  • Servers ignore the new headers harmlessly:traceparent/tracestate/baggage are standard W3C headers; receivers without tracing simply drop them.

Testing

  • packages/core/src/runtime-trace-mode.test.ts: default is linked; linked creates a root span with links to both delivery + run-origin contexts; continuous preserves the legacy parented shape; linked forwards the original traceCarrier on re-enqueues while continuous serializes the current context. Uses a real in-memory OTEL SDK (BasicTracerProvider + InMemorySpanExporter + W3C propagator).
  • packages/world-vercel/src/trace-propagation.test.ts: traceparent lands on the outgoing request and matches the http GET client span; clean no-op without an active span context.
  • pnpm build, pnpm typecheck, full unit suites for packages/core (1124 passed) and packages/world-vercel (134 passed), Biome format/lint clean.

Backport policy

Do not backport to stable (v4). The linked default is a deliberate telemetry-shape change scoped to the v5 beta major — backporting it would change trace topology, per-run trace IDs, and sampling behavior for GA v4 users mid-major. v4 keeps its current behavior until users upgrade to v5; the platform side is fully tolerant of v4 SDKs.

Documentation

Adds docs/content/docs/v5/observability/tracing.mdx (linked from the Observability index): enabling OTEL, emitted spans and attributes, linked trace mode and span links, WORKFLOW_TRACE_MODE reference with a v4 behavior-change callout, and context-propagation/baggage notes. v4 docs intentionally untouched.

Rollout notes

Server-side support for storing and re-injecting trace context on queue deliveries ships separately on the platform. This PR is safe to merge and release independently: without the platform-side support, behavior is unchanged apart from the new (ignorable) W3C headers and the bounded linked-trace shape.

Follow-up: bump @vercel/queue in @workflow/world-vercel once a release with HTTP-layer trace-context injection is published, so queue sends carry trace headers as well.

🤖 Generated with Claude Code

@karthikscale3
karthikscale3 requested a review from a team as a code ownerJune 11, 2026 16:10
@vercel

vercelBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.041s (-8.4% 🟢)1.006s (~)0.965s101.00x
💻 LocalNitro0.045s (~)1.007s (~)0.961s101.12x
🐘 PostgresExpress0.061s (-3.3%)1.012s (~)0.951s101.50x
🐘 PostgresNitro0.061s (+3.9%)1.012s (~)0.951s101.51x
💻 LocalNext.js (Turbopack)0.062s (+3.7%)1.005s (~)0.943s101.54x
🐘 PostgresNext.js (Turbopack)0.069s (-1.4%)1.013s (~)0.944s101.70x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.308s (-6.5% 🟢)2.408s (+5.4% 🔺)2.099s101.00x
▲ VercelNitro0.331s (+31.6% 🔺)2.509s (+13.4% 🔺)2.179s101.07x
▲ VercelExpress0.423s (+34.2% 🔺)2.501s (-6.2% 🟢)2.077s101.37x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.089s (-1.3%)2.006s (~)0.917s101.00x
🐘 PostgresExpress1.109s (~)2.010s (~)0.901s101.02x
💻 LocalNitro1.110s (+1.7%)2.006s (~)0.896s101.02x
🐘 PostgresNitro1.117s (~)2.010s (~)0.894s101.02x
💻 LocalNext.js (Turbopack)1.140s (+1.0%)2.006s (~)0.866s101.05x
🐘 PostgresNext.js (Turbopack)1.161s (+1.9%)2.010s (~)0.849s101.07x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express1.680s (-9.9% 🟢)3.794s (+10.9% 🔺)2.114s101.00x
▲ VercelNext.js (Turbopack)1.765s (-17.8% 🟢)3.784s (+5.4% 🔺)2.019s101.05x
▲ VercelNitro1.800s (~)3.823s (+2.2%)2.023s101.07x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro10.556s (~)11.020s (~)0.464s31.00x
💻 LocalExpress10.566s (~)11.022s (~)0.456s31.00x
💻 LocalNitro10.574s (~)11.022s (~)0.448s31.00x
🐘 PostgresExpress10.576s (~)11.019s (~)0.443s31.00x
💻 LocalNext.js (Turbopack)10.794s (~)11.021s (~)0.227s31.02x
🐘 PostgresNext.js (Turbopack)10.946s (~)11.351s (~)0.405s31.04x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)14.431s (-14.2% 🟢)16.671s (-8.8% 🟢)2.240s21.00x
▲ VercelNitro14.546s (-2.8%)16.712s (-1.3%)2.166s21.01x
▲ VercelExpress14.781s (-7.0% 🟢)16.758s (-4.0%)1.977s21.02x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express13.761s (~)14.027s (~)0.266s51.00x
💻 LocalNitro13.782s (~)14.028s (~)0.246s51.00x
🐘 PostgresExpress13.786s (-0.5%)14.020s (~)0.233s51.00x
🐘 PostgresNitro13.862s (~)14.021s (~)0.159s51.01x
💻 LocalNext.js (Turbopack)14.321s (~)15.030s (~)0.709s41.04x
🐘 PostgresNext.js (Turbopack)14.461s (~)15.018s (~)0.557s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro23.321s (-20.2% 🟢)25.623s (-17.7% 🟢)2.302s31.00x
▲ VercelExpress23.347s (-15.1% 🟢)25.294s (-11.8% 🟢)1.947s31.00x
▲ VercelNext.js (Turbopack)23.574s (-18.5% 🟢)25.768s (-14.4% 🟢)2.194s31.01x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express12.399s (-0.9%)13.025s (~)0.626s71.00x
🐘 PostgresExpress12.452s (-0.5%)13.019s (~)0.567s71.00x
💻 LocalNitro12.585s (+1.0%)13.025s (~)0.440s71.01x
🐘 PostgresNitro12.914s (-1.3%)13.304s (~)0.390s71.04x
💻 LocalNext.js (Turbopack)13.639s (~)14.027s (-1.0%)0.388s71.10x
🐘 PostgresNext.js (Turbopack)14.158s (+1.3%)14.590s (~)0.432s71.14x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro32.772s (+2.6%)35.131s (+3.4%)2.359s31.00x
▲ VercelExpress34.356s (+4.2%)36.946s (+6.2% 🔺)2.590s31.05x
▲ VercelNext.js (Turbopack)34.608s (+7.4% 🔺)37.163s (+11.4% 🔺)2.555s31.06x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.196s (-1.7%)2.006s (~)0.810s151.00x
🐘 PostgresExpress1.217s (~)2.008s (~)0.791s151.02x
🐘 PostgresNitro1.241s (+1.2%)2.008s (~)0.768s151.04x
💻 LocalNitro1.241s (+2.4%)2.007s (~)0.765s151.04x
🐘 PostgresNext.js (Turbopack)1.291s (+1.6%)2.008s (~)0.717s151.08x
💻 LocalNext.js (Turbopack)1.374s (+6.9% 🔺)2.006s (~)0.632s151.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.381s (-6.9% 🟢)4.170s (+1.9%)1.789s81.00x
▲ VercelExpress2.418s (-49.2% 🟢)4.158s (-35.9% 🟢)1.740s81.02x
▲ VercelNext.js (Turbopack)2.438s (-53.9% 🟢)3.988s (-39.1% 🟢)1.550s81.02x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.413s (-6.1% 🟢)2.318s (~)0.905s131.00x
🐘 PostgresNitro1.415s (+2.9%)2.317s (-7.6% 🟢)0.902s131.00x
🐘 PostgresNext.js (Turbopack)1.655s (+6.0% 🔺)2.316s (+4.2%)0.661s131.17x
💻 LocalExpress1.721s (-6.7% 🟢)2.006s (-6.7% 🟢)0.284s151.22x
💻 LocalNext.js (Turbopack)1.820s (+12.1% 🔺)2.073s (+3.3%)0.254s151.29x
💻 LocalNitro2.079s (+16.6% 🔺)2.471s (+23.2% 🔺)0.392s131.47x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.682s (-11.9% 🟢)4.262s (-17.0% 🟢)1.580s81.00x
▲ VercelExpress3.635s (-3.3%)5.594s (+10.7% 🔺)1.959s71.36x
▲ VercelNext.js (Turbopack)4.256s (+1.4%)6.350s (+14.5% 🔺)2.094s51.59x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.544s (-7.3% 🟢)3.887s (-3.1%)2.343s81.00x
🐘 PostgresNitro1.657s (-7.4% 🟢)4.136s (+12.4% 🔺)2.478s81.07x
🐘 PostgresNext.js (Turbopack)3.528s (+5.1% 🔺)4.300s (+3.9%)0.772s72.29x
💻 LocalExpress4.700s (-15.7% 🟢)5.179s (-13.9% 🟢)0.479s63.04x
💻 LocalNext.js (Turbopack)5.238s (+12.7% 🔺)5.512s (+6.4% 🔺)0.274s63.39x
💻 LocalNitro6.891s (+34.2% 🔺)7.516s (+36.3% 🔺)0.625s44.46x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)4.729s (-24.8% 🟢)6.624s (-15.0% 🟢)1.895s51.00x
▲ VercelExpress5.058s (+11.2% 🔺)7.110s (+16.3% 🔺)2.052s51.07x
▲ VercelNitro5.934s (-13.6% 🟢)8.393s (-3.0%)2.459s41.25x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.212s (~)2.008s (~)0.796s151.00x
🐘 PostgresExpress1.219s (~)2.008s (-3.2%)0.789s151.01x
🐘 PostgresNext.js (Turbopack)1.326s (+2.8%)2.008s (~)0.682s151.09x
💻 LocalNext.js (Turbopack)1.392s (~)2.006s (~)0.614s151.15x
💻 LocalExpress1.577s (~)2.006s (~)0.430s151.30x
💻 LocalNitro1.635s (+2.6%)2.007s (~)0.372s151.35x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.345s (-15.9% 🟢)4.021s (-7.5% 🟢)1.676s81.00x
▲ VercelNext.js (Turbopack)2.375s (-3.8%)3.997s (+9.1% 🔺)1.623s81.01x
▲ VercelNitro3.013s (+19.4% 🔺)4.535s (+15.4% 🔺)1.522s71.28x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.364s (~)2.151s (-7.1% 🟢)0.788s141.00x
🐘 PostgresNitro1.404s (~)2.317s (~)0.913s131.03x
🐘 PostgresNext.js (Turbopack)1.557s (-4.2%)2.393s (+3.3%)0.836s131.14x
💻 LocalExpress2.014s (-13.4% 🟢)2.508s (-11.3% 🟢)0.494s121.48x
💻 LocalNext.js (Turbopack)2.020s (+1.0%)2.735s (+5.6% 🔺)0.715s111.48x
💻 LocalNitro2.227s (+2.2%)2.758s (+0.8%)0.531s121.63x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.771s (-3.9%)4.319s (~)1.548s81.00x
▲ VercelNext.js (Turbopack)2.915s (-1.8%)4.814s (+7.1% 🔺)1.900s71.05x
▲ VercelExpress3.336s (+2.4%)5.038s (+6.4% 🔺)1.702s61.20x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.772s (-4.9%)4.014s (-6.6% 🟢)2.241s81.00x
🐘 PostgresNitro1.886s (+17.2% 🔺)4.441s (+14.2% 🔺)2.556s71.06x
🐘 PostgresNext.js (Turbopack)3.388s (+3.7%)4.142s (-2.8%)0.754s81.91x
💻 LocalExpress5.290s (-24.9% 🟢)5.850s (-23.2% 🟢)0.560s62.99x
💻 LocalNext.js (Turbopack)6.189s (+38.2% 🔺)6.416s (+23.9% 🔺)0.226s53.49x
💻 LocalNitro6.932s (+19.4% 🔺)7.218s (+12.5% 🔺)0.286s53.91x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.744s (-28.8% 🟢)6.281s (-1.9%)2.537s51.00x
▲ VercelNext.js (Turbopack)3.946s (-18.4% 🟢)5.974s (-9.9% 🟢)2.028s61.05x
▲ VercelNitro4.515s (-3.1%)6.349s (+1.5%)1.834s51.21x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.586s (-2.8%)1.006s (-1.7%)0.420s601.00x
🐘 PostgresExpress0.593s (+2.9%)1.041s (+3.5%)0.448s581.01x
💻 LocalExpress0.615s (-3.4%)1.005s (-3.3%)0.390s601.05x
💻 LocalNitro0.670s (+4.6%)1.040s (+1.8%)0.370s581.14x
🐘 PostgresNext.js (Turbopack)0.835s (~)1.023s (~)0.189s591.42x
💻 LocalNext.js (Turbopack)0.870s (+2.0%)1.004s (~)0.135s601.48x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.493s (-24.9% 🟢)8.569s (-15.7% 🟢)2.076s81.00x
▲ VercelNitro6.528s (-27.6% 🟢)8.584s (-18.7% 🟢)2.056s71.01x
▲ VercelNext.js (Turbopack)6.529s (~)8.488s (+6.1% 🔺)1.959s81.01x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.388s (-5.6% 🟢)2.007s (-3.3%)0.619s451.00x
🐘 PostgresNitro1.407s (-3.4%)2.008s (-2.2%)0.601s451.01x
💻 LocalExpress1.514s (-1.9%)2.006s (~)0.491s451.09x
💻 LocalNitro1.646s (+8.0% 🔺)2.030s (+1.2%)0.383s451.19x
🐘 PostgresNext.js (Turbopack)1.991s (+2.6%)2.367s (+12.7% 🔺)0.375s391.43x
💻 LocalNext.js (Turbopack)2.174s (+3.6%)3.008s (+2.2%)0.835s301.57x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express15.578s (-29.1% 🟢)18.039s (-23.2% 🟢)2.461s51.00x
▲ VercelNitro16.348s (-9.2% 🟢)18.489s (-6.5% 🟢)2.141s51.05x
▲ VercelNext.js (Turbopack)16.650s (-9.7% 🟢)19.019s (-4.4%)2.369s51.07x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.822s (+1.1%)3.137s (~)0.315s391.00x
🐘 PostgresExpress2.869s (+3.9%)3.280s (+6.3% 🔺)0.410s371.02x
💻 LocalExpress3.265s (-0.8%)4.009s (~)0.744s301.16x
💻 LocalNitro3.436s (+4.4%)4.077s (+1.7%)0.640s301.22x
🐘 PostgresNext.js (Turbopack)3.936s (+2.7%)4.183s (+3.5%)0.247s291.39x
💻 LocalNext.js (Turbopack)4.327s (-3.0%)5.010s (-0.8%)0.683s241.53x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro29.059s (-8.3% 🟢)31.839s (-4.9%)2.780s41.00x
▲ VercelExpress30.029s (-9.5% 🟢)33.287s (-3.4%)3.258s41.03x
▲ VercelNext.js (Turbopack)32.694s (-1.4%)35.494s (+3.0%)2.800s41.13x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.256s (+1.6%)1.006s (~)0.750s601.00x
🐘 PostgresNitro0.256s (-1.4%)1.006s (-1.6%)0.750s601.00x
🐘 PostgresNext.js (Turbopack)0.302s (+1.2%)1.006s (~)0.704s601.18x
💻 LocalNitro0.431s (+1.5%)1.005s (~)0.574s601.68x
💻 LocalExpress0.435s (-1.5%)1.004s (~)0.569s601.70x
💻 LocalNext.js (Turbopack)0.521s (-12.1% 🟢)1.005s (-1.7%)0.484s602.04x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.115s (-15.5% 🟢)3.980s (-1.9%)1.866s161.00x
▲ VercelNext.js (Turbopack)2.203s (+7.0% 🔺)4.126s (+21.7% 🔺)1.922s151.04x
▲ VercelNitro2.523s (+18.9% 🔺)4.388s (+13.4% 🔺)1.865s141.19x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.415s (+3.7%)1.041s (~)0.627s871.00x
🐘 PostgresNitro0.424s (+0.5%)1.041s (-2.3%)0.617s871.02x
🐘 PostgresNext.js (Turbopack)0.577s (-7.4% 🟢)1.078s (-8.3% 🟢)0.501s841.39x
💻 LocalNitro2.071s (-7.6% 🟢)2.658s (-3.9%)0.586s345.00x
💻 LocalExpress2.325s (+13.4% 🔺)2.853s (+7.4% 🔺)0.528s325.61x
💻 LocalNext.js (Turbopack)2.611s (~)3.334s (-1.5%)0.723s286.30x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.672s (+1.7%)5.578s (+4.3%)1.907s171.00x
▲ VercelExpress3.683s (+11.8% 🔺)5.655s (+19.5% 🔺)1.972s161.00x
▲ VercelNitro3.775s (-16.7% 🟢)5.889s (-5.0% 🟢)2.114s161.03x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.785s (-0.8%)1.271s (-6.3% 🟢)0.486s951.00x
🐘 PostgresNitro0.844s (+3.8%)1.371s (-7.6% 🟢)0.527s881.08x
🐘 PostgresNext.js (Turbopack)2.677s (-6.4% 🟢)3.711s (+2.2%)1.034s333.41x
💻 LocalExpress9.827s (-3.2%)10.362s (-3.9%)0.535s1212.52x
💻 LocalNitro9.859s (+3.5%)10.363s (+1.7%)0.504s1212.56x
💻 LocalNext.js (Turbopack)10.346s (-5.7% 🟢)11.301s (-4.6%)0.956s1113.18x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.976s (-14.5% 🟢)8.118s (-6.4% 🟢)2.142s151.00x
▲ VercelExpress6.135s (-15.9% 🟢)8.528s (-3.9%)2.394s151.03x
▲ VercelNext.js (Turbopack)7.343s (-10.4% 🟢)9.606s (-1.9%)2.264s131.23x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.165s (~)2.004s (~)0.011s (-15.3% 🟢)2.017s (~)0.852s101.00x
💻 LocalNitro1.172s (~)2.005s (~)0.013s (+7.6% 🔺)2.021s (~)0.849s101.01x
🐘 PostgresExpress1.179s (~)2.001s (~)0.001s (~)2.011s (~)0.832s101.01x
🐘 PostgresNitro1.182s (+1.3%)1.997s (~)0.001s (-7.1% 🟢)2.010s (~)0.829s101.01x
💻 LocalNext.js (Turbopack)1.205s (~)2.003s (~)0.012s (+10.2% 🔺)2.019s (~)0.814s101.03x
🐘 PostgresNext.js (Turbopack)1.244s (+0.8%)2.001s (~)0.001s (+11.1% 🔺)2.011s (~)0.767s101.07x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.637s (+5.1% 🔺)3.726s (+10.3% 🔺)1.437s (+19.6% 🔺)5.659s (+13.9% 🔺)3.022s101.00x
▲ VercelNitro2.776s (+19.7% 🔺)4.150s (+24.5% 🔺)0.672s (-37.1% 🟢)5.469s (+13.2% 🔺)2.693s101.05x
▲ VercelExpress2.788s (+17.0% 🔺)4.107s (+28.5% 🔺)1.469s (+11.7% 🔺)6.069s (+23.3% 🔺)3.281s101.06x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.568s (-1.8%)2.006s (~)0.005s (+1.3%)2.028s (~)0.460s301.00x
💻 LocalNitro1.602s (~)2.010s (~)0.011s (-12.0% 🟢)2.024s (~)0.422s301.02x
💻 LocalExpress1.602s (~)2.009s (~)0.015s (+17.6% 🔺)2.027s (~)0.425s301.02x
🐘 PostgresNitro1.638s (+2.7%)2.002s (~)0.005s (-2.0%)2.027s (~)0.389s301.04x
💻 LocalNext.js (Turbopack)1.734s (+0.9%)2.008s (~)0.012s (-1.1%)2.024s (~)0.290s301.11x
🐘 PostgresNext.js (Turbopack)1.796s (+2.0%)2.009s (~)0.006s (+8.2% 🔺)2.030s (~)0.234s301.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.353s (-6.8% 🟢)7.876s (~)0.536s (+132.4% 🔺)8.902s (+3.4%)2.550s71.00x
▲ VercelNitro6.526s (-1.8%)8.158s (+3.1%)0.330s (+49.2% 🔺)8.975s (+4.3%)2.448s71.03x
▲ VercelNext.js (Turbopack)6.780s (-1.5%)8.291s (+3.3%)0.629s (+158.3% 🔺)9.487s (+8.5% 🔺)2.707s71.07x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.776s (+1.9%)1.029s (-1.8%)0.000s (-33.3% 🟢)1.061s (~)0.286s571.00x
🐘 PostgresNitro0.868s (+9.4% 🔺)1.079s (+3.0%)0.000s (-100.0% 🟢)1.122s (+5.8% 🔺)0.254s541.12x
🐘 PostgresNext.js (Turbopack)1.025s (+2.7%)1.469s (+2.8%)0.000s (+Infinity% 🔺)1.486s (+3.5%)0.461s411.32x
💻 LocalNitro1.427s (+3.1%)2.014s (~)0.000s (-11.1% 🟢)2.016s (~)0.589s301.84x
💻 LocalNext.js (Turbopack)1.476s (-0.6%)2.013s (~)0.000s (+133.3% 🔺)2.016s (~)0.540s301.90x
💻 LocalExpress1.492s (+2.1%)2.014s (~)0.000s (-65.2% 🟢)2.016s (~)0.524s301.92x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.152s (-28.2% 🟢)4.709s (-17.2% 🟢)0.001s (-25.9% 🟢)5.242s (-16.1% 🟢)2.090s121.00x
▲ VercelExpress3.292s (-31.2% 🟢)4.944s (-10.0% 🟢)0.000s (+81.8% 🔺)5.485s (-10.9% 🟢)2.193s111.04x
▲ VercelNext.js (Turbopack)3.597s (-0.7%)5.203s (+9.6% 🔺)0.000s (NaN%)5.709s (+10.4% 🔺)2.112s121.14x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

fan-out fan-in 10 streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.522s (-8.1% 🟢)2.063s (-5.1% 🟢)0.000s (+93.1% 🔺)2.093s (-4.8%)0.570s291.00x
🐘 PostgresNitro1.659s (+2.3%)2.258s (+5.5% 🔺)0.000s (+3.7%)2.273s (+5.6% 🔺)0.614s271.09x
🐘 PostgresNext.js (Turbopack)2.252s (+7.5% 🔺)2.729s (+2.8%)0.000s (+213.6% 🔺)2.776s (+4.3%)0.524s221.48x
💻 LocalNext.js (Turbopack)2.852s (-2.2%)3.470s (-1.5%)0.001s (+175.0% 🔺)3.473s (-1.6%)0.621s181.87x
💻 LocalNitro3.071s (+1.2%)3.674s (~)0.001s (-9.1% 🟢)3.677s (~)0.606s172.02x
💻 LocalExpress3.290s (+2.6%)4.026s (+1.5%)0.001s (+353.3% 🔺)4.030s (+1.6%)0.740s152.16x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.411s (-11.9% 🟢)7.163s (-5.1% 🟢)0.004s (+Infinity% 🔺)7.652s (-4.2%)2.241s81.00x
▲ VercelNext.js (Turbopack)6.145s (+25.8% 🔺)7.777s (+25.8% 🔺)0.000s (+Infinity% 🔺)8.258s (+25.1% 🔺)2.113s81.14x
▲ VercelExpress6.943s (+39.7% 🔺)8.421s (+43.0% 🔺)0.000s (NaN%)8.996s (+42.1% 🔺)2.053s81.28x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalExpress15/21
🐘 PostgresExpress17/21
▲ VercelNitro9/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres14/21
Next.js (Turbopack)🐘 Postgres14/21
Nitro🐘 Postgres15/21
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Redis + BullMQ: Community world (local development)
  • 🌐 Cloudflare: Community world (local development)
  • 🌐 MySQL: Community world (local development)
  • 🌐 Azure: Community world (local development)
  • 🌐 NATS JetStream: Community world (local development)
  • 🌐 Upstash: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production144202191661
✅ 💻 Local Development189502192114
✅ 📦 Local Production189502192114
✅ 🐘 Local Postgres188102332114
✅ 🪟 Windows15100151
✅ 📋 Other87901781057
Total8143010689211

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro125026
✅ example125026
✅ express125026
✅ fastify125026
✅ hono125026
✅ nextjs-turbopack14902
✅ nextjs-webpack14902
✅ nitro125026
✅ nuxt125026
✅ sveltekit14407
✅ vite125026
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable126025
✅ express-stable126025
✅ fastify-stable126025
✅ hono-stable126025
✅ nextjs-turbopack-canary132019
✅ nextjs-turbopack-stable-lazy-discovery-disabled15100
✅ nextjs-turbopack-stable-lazy-discovery-enabled15100
✅ nextjs-webpack-canary132019
✅ nextjs-webpack-stable-lazy-discovery-disabled15100
✅ nextjs-webpack-stable-lazy-discovery-enabled15100
✅ nitro-stable126025
✅ nuxt-stable126025
✅ sveltekit-stable14506
✅ vite-stable126025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable126025
✅ express-stable126025
✅ fastify-stable126025
✅ hono-stable126025
✅ nextjs-turbopack-canary132019
✅ nextjs-turbopack-stable-lazy-discovery-disabled15100
✅ nextjs-turbopack-stable-lazy-discovery-enabled15100
✅ nextjs-webpack-canary132019
✅ nextjs-webpack-stable-lazy-discovery-disabled15100
✅ nextjs-webpack-stable-lazy-discovery-enabled15100
✅ nitro-stable126025
✅ nuxt-stable126025
✅ sveltekit-stable14506
✅ vite-stable126025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable125026
✅ express-stable125026
✅ fastify-stable125026
✅ hono-stable125026
✅ nextjs-turbopack-canary131020
✅ nextjs-turbopack-stable-lazy-discovery-disabled15001
✅ nextjs-turbopack-stable-lazy-discovery-enabled15001
✅ nextjs-webpack-canary131020
✅ nextjs-webpack-stable-lazy-discovery-disabled15001
✅ nextjs-webpack-stable-lazy-discovery-enabled15001
✅ nitro-stable125026
✅ nuxt-stable125026
✅ sveltekit-stable14407
✅ vite-stable125026
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15100
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable126025
✅ e2e-local-dev-tanstack-start-126025
✅ e2e-local-postgres-nest-stable125026
✅ e2e-local-postgres-tanstack-start-125026
✅ e2e-local-prod-nest-stable126025
✅ e2e-local-prod-tanstack-start-126025
✅ e2e-vercel-prod-tanstack-start125026

📋 View full workflow run

@changeset-bot

changeset-botBot commented Jun 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 22c35aa

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

This PR includes changesets to release 22 packages
NameType
@workflow/coreMinor
workflowMinor
@workflow/world-vercelMinor
@workflow/utilsMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
@workflow/aiMajor
@workflow/errorsPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@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

@pranaygppranaygp 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.

Reviewed with a focus on attribute consistency, forwards compatibility, DX, and perf overhead. Overall this is solid: the linked-mode semantics are coherent with the other three PRs in the stack (baggage keys match what workflow-server#514 reads; the run-origin carrier semantics match the server's executionContext.traceCarrier-based span links, which are also pinned to run origin; world-vercel consumes deliveries via @vercel/queue.handleCallback, so vqs#181's consumer-side extraction is exactly what feeds linkToCurrentContext). Perf-wise the change is a net reduction when OTEL is active (linked mode skips a propagation.inject per re-enqueue) and stays a memoized no-op without an SDK. Ran the new test suites and typecheck locally — all green.

No blocking issues. Inline comments below: one behavioral edge around empty {} carriers in linked mode, a code-duplication suggestion, a DX nit on unrecognized WORKFLOW_TRACE_MODE values, two display-name edge cases, and a doc accuracy fix on span kinds.

Comment threadpackages/core/src/runtime.ts Outdated
// continuous mode the current (active) context is serialized so the
// trace keeps chaining.
const getNextTraceCarrier = (): Promise<Record<string, string>> =>
traceMode === 'linked' && traceContext

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.

traceContext here can be {} and still take the linked branch: start() always attaches a carrier, and serializeTraceCarrier() returns {} both when no OTEL SDK is registered at the origin and when OTEL is registered but start() runs outside any active span (background job, script). For such runs, linked mode forwards the empty object forever, while the undefined branch adaptively falls back to serializeTraceCarrier() (making the first instrumented invocation the de-facto run origin for future links).

Consider treating an empty carrier like an absent one — e.g. traceContext && Object.keys(traceContext).length > 0 — so both "no usable origin" shapes behave the same (same applies to the copy in step-handler.ts). Related side effect (pre-existing, but more visible now): workflow.trace.propagated is !!traceContext, so it reports true for {} even though there's nothing usable to link to.

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.

Fixed in 5b3ca9f. Added isUsableTraceCarrier() and normalized the incoming carrier at the top of both queue handlers, so {} counts as "no usable origin" everywhere the mode logic branches — linked mode falls back to serializing the current context (first instrumented invocation becomes the de-facto origin) instead of forwarding {} forever. Also took the related side effect: workflow.trace.propagated now reports whether a usable (non-empty) carrier arrived. Test added pinning traceCarrier: {} ≡ no carrier.

// so every future invocation links back to the same origin; in
// continuous mode the current (active) context is serialized so the
// trace keeps chaining.
const getNextTraceCarrier = (): Promise<Record<string, string>> =>

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.

This block — getNextTraceCarrier plus the origin-link dedup below (lines ~191–210) — is duplicated nearly verbatim from runtime.ts (~343–366). Since this encodes the core linked-mode invariants (forward the original carrier; dedup origin vs delivery link), consider extracting two small helpers into telemetry.ts, e.g. nextTraceCarrier(traceMode, traceContext) and buildInvocationSpanLinks(traceMode, traceContext), so the semantics can't drift between the workflow and step handlers.

Bonus if you do: resume-hook.ts (~186–193) has a hand-rolled version of carrier→link that lacks the isSpanContextValid guard your new linkToTraceCarrier has — it could reuse the helper too.

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.

Fixed in 5b3ca9f. Extracted both invariants into telemetry.ts as getNextTraceCarrier(traceMode, incomingCarrier) and buildInvocationSpanLinks(traceMode, incomingCarrier) (exact prior semantics, pinned by the existing trace-mode tests), now used by both runtime.ts and step-handler.ts. Took the bonus too: resume-hook.ts now uses linkToTraceCarrier and gains the isSpanContextValid guard it was missing.

Comment threadpackages/core/src/telemetry.ts Outdated
* Defaults to `'linked'`; any value other than `'continuous'` selects it.
*/
export function getWorkflowTraceMode(): WorkflowTraceMode {
return process.env.WORKFLOW_TRACE_MODE === 'continuous'

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.

Any unrecognized value silently selects linked — a typo like WORKFLOW_TRACE_MODE=continous changes trace topology with zero signal, and if a future SDK version adds a third mode, older SDKs will silently reinterpret it as linked. A one-time runtimeLogger.warn for non-empty unrecognized values would make misconfiguration debuggable and give forward compatibility a soft landing. (Resolving once into a module-level constant would also give you the warn-once behavior for free — the env var can't meaningfully change mid-process anyway.)

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.

Fixed in 5b3ca9f. Kept the dynamic per-call env read (the trace-mode tests flip WORKFLOW_TRACE_MODE per test, so a module-level constant would break them) and added a one-time runtimeLogger.warn per distinct unrecognized non-empty value, naming the value and the accepted ones before falling back to linked. Test asserts the warning fires exactly once for a continous typo.

Comment threadpackages/utils/src/parse-name.ts Outdated
if (!name.startsWith(`${tag}--`)) return null;
// The `//` separators became `--`, and within the function-name part any
// nested-function `/` became `-`. Function names are JS identifiers (no
// dashes), so the innermost name is the last dash-free segment.

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.

Two best-effort edges worth noting in this comment (or handling):

  1. $ is a valid JS identifier character and gets sanitized to -, so step//…//process$Order in sanitized form displays as Order — "no dashes" isn't strictly true for identifiers.
  2. Default exports diverge between the two input forms: parseName maps default/__default to the module short name, but this sanitized path returns the literal default. The same workflow can then show as workflow.start order (raw name in start()) but workflow.execute default (sanitized name in the queue handler). Mapping default to the preceding segment here would keep the two span names consistent.

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.

Fixed in 5b3ca9f. (2) is handled: shortNameFromSanitized now maps default/__default to the preceding module segment's short name, mirroring parseName, so default exports display consistently (e.g. order) in both workflow.start and workflow.execute — pinned by a test. (1) is documented as an accepted best-effort limitation in the comment ($ sanitizes to -, so process$Order displays as Order), with a test pinning the behavior.

| --- | --- | --- |
| `workflow.start <name>` | internal | `start()` is called in your application code |
| `workflow.execute <name>` | internal (root) | a queue delivery invokes the workflow — replay, orchestration, and inline steps run under it |
| `step.execute <name>` | internal | a step function executes |

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.

Kind is inaccurate for the queue-delivered case: step-handler.ts creates this span with SpanKind.CONSUMER (only inline steps executed within workflow.execute are internal), and in linked mode the queue-delivered step.execute span is also a new trace root, same as workflow.execute. Suggest something like: internal (inline) / consumer + root (queue-delivered).

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.

Fixed in 5b3ca9f. Table now reads workflow.executeconsumer (root) (it's CONSUMER as of this commit, see the other thread) and step.executeinternal (inline) / consumer + root (queue-delivered), per your suggested wording.

return trace(
`WORKFLOW_V2 ${workflowName}`,
{ links: spanLinks },
`workflow.execute ${workflowDisplayName(workflowName)}`,

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.

Pre-existing inconsistency, but this PR's v5 window is the cheapest moment to fix it: this queue-delivered span has default INTERNAL kind while the equivalent queue-delivered step.execute span uses CONSUMER. Messaging semconv would suggest CONSUMER here too — and it would pair nicely with the PRODUCER-kind vqs.send span being added on the other side in vercel/vqs#181. Fine as a follow-up, but if you want it, doing it inside the same beta avoids a second span-shape change.

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 5b3ca9f — agreed this beta is the cheapest window. The queue-delivered workflow.execute span now sets kind: CONSUMER via the same getSpanKind('CONSUMER') pattern step-handler uses (both modes), pairing with the PRODUCER vqs.send span in vercel/vqs#181. Added a SpanKind.CONSUMER assertion to the trace-mode test and a changeset bullet noting the internal→consumer kind change.

@pranaygppranaygp 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.

Pre-emptively approving — no blocking bugs, perf is clean (net reduction when OTEL is active, memoized no-op without it), and the cross-repo semantics line up with vqs-server#615 / workflow-server#514 / vqs#181.

@karthikscale3 please address the inline comments from my review (#2363 (review)) before merging — in particular:

  1. Empty {} carrier in linked mode (runtime.ts:344 + the step-handler.ts copy): runs started from uninstrumented contexts silently lose run-level correlation links; treat an empty carrier like an absent one.
  2. Silent fallback on unrecognized WORKFLOW_TRACE_MODE values (telemetry.ts:31): a typo flips trace topology with zero signal; add a warn-once.

The rest (dedup extraction, display-name edges, docs span-kind row, CONSUMER kind for workflow.execute) are nice-to-haves — fine in this PR or as follow-ups.

karthikscale3and others added 8 commits June 15, 2026 11:22
…per-invocation traces
- Add WORKFLOW_TRACE_MODE ('linked' default, 'continuous' legacy) to the
workflow and step queue handlers. In linked mode, WORKFLOW_V2/STEP spans
start a new trace root with span links to the incoming delivery context
and the run-origin context, and re-enqueued messages forward the
ORIGINAL run-origin trace carrier unchanged.
- world-vercel now explicitly injects W3C traceparent/tracestate/baggage
headers on outgoing workflow-server HTTP requests from inside the
client span (no-op without an OTEL SDK registered).
- New workflow.trace.mode span attribute; unit tests for both modes and
for header injection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Documents OTEL spans/attributes, linked trace mode and WORKFLOW_TRACE_MODE,
span links, context propagation, and the v4 behavior-change callout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WORKFLOW_V2/STEP prefixes with full machine names (workflow//./src/...//fn)
become workflow.execute / step.execute / workflow.start with the short
function name. New workflowDisplayName/stepDisplayName helpers in
@workflow/utils handle both raw and queue-sanitized name forms; full names
remain in the workflow.name/step.name attributes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ame edge cases, consumer span kind
- Treat an empty ({}) trace carrier as absent everywhere the trace-mode
logic branches, so linked mode falls back to a fresh origin instead of
forwarding a useless {} forever; workflow.trace.propagated now reports
whether a usable carrier arrived.
- Extract the duplicated linked-mode logic into shared telemetry helpers
getNextTraceCarrier() and buildInvocationSpanLinks(), used by both the
workflow and step queue handlers; resume-hook now uses
linkToTraceCarrier (gaining the isSpanContextValid guard).
- Warn once per distinct unrecognized WORKFLOW_TRACE_MODE value instead
of silently selecting linked.
- shortNameFromSanitized: map default/__default to the module short name
(mirroring parseName) and document the `$`-sanitization limitation.
- Queue-delivered workflow.execute spans now use the CONSUMER span kind,
matching queue-delivered step.execute spans; docs span table and
changeset updated accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

DiffPackageSupply Chain
Security
VulnerabilityQualityMaintenanceLicense
Addednpm/​@​opentelemetry/​context-async-hooks@​1.30.1741008896100

View full report

@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 926a5e7 (AI decision).

This is a deliberate telemetry-shape change that flips the default to the new linked trace mode, altering trace topology, per-run trace IDs, sampling semantics, and span names — a behavioral change explicitly scoped to the v5 beta major. The PR author explicitly requests no backport because applying it to GA v4 users mid-major would silently change their trace behavior, and the accompanying docs target only docs/content/docs/v5/, so it falls under "major/breaking changes intended for the next major release."

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

926a5e7c6a50c1e74f2e2cc37324caa0f6442d85

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@pranaygp
, '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('^' + ".*" + ' otel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces by karthikscale3 · Pull Request #2363 · vercel/workflow · GitHub
Skip to content

otel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces - #2363

Merged
karthikscale3 merged 8 commits into
mainfrom
karthik/otel-trace-correlation
Jun 15, 2026
Merged

otel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces#2363
karthikscale3 merged 8 commits into
mainfrom
karthik/otel-trace-correlation

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Problem: mega-traces

Today the workflow queue handlers restore the run-origin trace context from the message's traceCarrier and make it the parent of every WORKFLOW_V2 / STEP invocation span. Since each invocation re-serializes its own context onto the next queue message, a single workflow run becomes one giant trace — spanning hours of sleeps/retries and dozens of stitched-together function invocations. These mega-traces are slow to load, hard to read, and frequently broken in Datadog (span limits, late-arriving spans, partial flushes).

Separately, the world-vercel HTTP client creates a CLIENT span for every workflow-server request but never injects traceparent into the outgoing headers — propagation only happened if the customer's app happened to have undici auto-instrumentation, so workflow-server spans usually couldn't join the caller's trace.

Linked-trace mode (new default)

This PR introduces WORKFLOW_TRACE_MODE with two values:

  • linked (new default): each invocation's WORKFLOW_V2 <name> / STEP <name> span is created as a new trace root (SpanOptions.root: true) with span links to:

    • the incoming delivery context (the active span extracted from the queue delivery request — once the platform re-injects producer context on deliveries, this points at the enqueue site), and
    • the run-origin context from the message's traceCarrier (skipped when absent/invalid or identical to the delivery link).

    Re-enqueued messages forward the original run-origin traceCarrier unchanged, so every future invocation of the run links back to the same origin. Traces stay small and bounded per invocation, while links preserve full run-level correlation.

  • continuous: exactly the previous behavior — restored run-origin context parents the invocation span, with a link to the delivery context, and re-enqueues serialize the current context. Set WORKFLOW_TRACE_MODE=continuous to opt back in.

Both modes keep withWorkflowBaggage wrapping, all existing span attributes (including workflow.trace.propagated), and add a new workflow.trace.mode attribute recording the active mode.

Explicit traceparent injection on workflow-server calls

world-vercel's makeRequest now injects W3C context (traceparent, tracestate, baggage) into the outgoing request headers from inside the http <method> CLIENT span, via a new injectTraceContextIntoHeaders(headers) helper in world-vercel's lazy telemetry module. workflow-server can now reliably parent its spans to the SDK's client span regardless of the customer's instrumentation setup.

Queue sends (@vercel/queue) are intentionally untouched here — VQS treats message headers as allowlisted custom headers; HTTP-layer injection for queue sends is handled in the @vercel/queue client itself.

Behavioral changes to telemetry (please read)

The API is backward compatible, but the new linked default changes the shape of emitted traces in ways existing dashboards and queries can feel. Set WORKFLOW_TRACE_MODE=continuous to restore the previous shape exactly.

  1. A run no longer shares one trace ID. Previously, the trace of the request that called start() contained the entire workflow execution — every WORKFLOW_V2/STEP span across all invocations carried the run-origin trace ID. Now each invocation is its own root trace. Anything keyed on a shared per-run trace ID (saved trace queries, "open my request's trace and see the run" debugging flows, trace-ID joins) must switch to span links or the workflow.run.id attribute.
  2. Sampling semantics change. Parent-based samplers previously made one decision at start() that covered the whole run consistently. Each invocation root now samples independently — ratio samplers will produce partially-sampled runs, and the number of root spans/traces increases to one per invocation (relevant for trace-volume-based vendor billing and rate-limiting samplers).
  3. Parent/child topology changes.WORKFLOW_V2/STEP spans had a remote parent; they are now parentless roots. Queries filtering on parent relationships and service-map edges from the calling service to the workflow handler will change.
  4. Re-enqueue traceCarrier semantics change. Queue messages now forward the original run-origin carrier unchanged, rather than each invocation's current context. Custom worlds or tooling that introspect message carriers and assume "carrier = most recent invocation context" will observe different values.

Not changed: all existing span attributes and baggage keys, and the no-OTEL no-op behavior. One footnote: app-set baggage entries now also leave the process as a baggage HTTP request header on backend calls (they already left via traceCarrier in events).

Friendlier span names

Workflow/step span names previously used uppercase prefixes with full machine names (WORKFLOW_V2 workflow//./src/jobs/order//processOrder). They are now short and lowercase: workflow.execute processOrder, step.execute chargeCard, workflow.start processOrder. New workflowDisplayName/stepDisplayName helpers in @workflow/utils resolve both the raw machine name and the queue-sanitized form (workflow----src-jobs-order--processOrder) seen by queue handlers; unrecognized formats fall back to the raw string. The full machine name remains available in the workflow.name / step.name span attributes. This is also a span-name change for anyone querying WORKFLOW_V2/STEP names — same v5-beta reasoning as above.

Backward compatibility

  • No OTEL registered: everything no-ops exactly as before — @opentelemetry/api stays an optional peer dep, the default no-op propagator injects nothing, and no headers are added.
  • WORKFLOW_TRACE_MODE=continuous restores the prior trace shape bit-for-bit (parenting, links, and carrier chaining).
  • Servers ignore the new headers harmlessly:traceparent/tracestate/baggage are standard W3C headers; receivers without tracing simply drop them.

Testing

  • packages/core/src/runtime-trace-mode.test.ts: default is linked; linked creates a root span with links to both delivery + run-origin contexts; continuous preserves the legacy parented shape; linked forwards the original traceCarrier on re-enqueues while continuous serializes the current context. Uses a real in-memory OTEL SDK (BasicTracerProvider + InMemorySpanExporter + W3C propagator).
  • packages/world-vercel/src/trace-propagation.test.ts: traceparent lands on the outgoing request and matches the http GET client span; clean no-op without an active span context.
  • pnpm build, pnpm typecheck, full unit suites for packages/core (1124 passed) and packages/world-vercel (134 passed), Biome format/lint clean.

Backport policy

Do not backport to stable (v4). The linked default is a deliberate telemetry-shape change scoped to the v5 beta major — backporting it would change trace topology, per-run trace IDs, and sampling behavior for GA v4 users mid-major. v4 keeps its current behavior until users upgrade to v5; the platform side is fully tolerant of v4 SDKs.

Documentation

Adds docs/content/docs/v5/observability/tracing.mdx (linked from the Observability index): enabling OTEL, emitted spans and attributes, linked trace mode and span links, WORKFLOW_TRACE_MODE reference with a v4 behavior-change callout, and context-propagation/baggage notes. v4 docs intentionally untouched.

Rollout notes

Server-side support for storing and re-injecting trace context on queue deliveries ships separately on the platform. This PR is safe to merge and release independently: without the platform-side support, behavior is unchanged apart from the new (ignorable) W3C headers and the bounded linked-trace shape.

Follow-up: bump @vercel/queue in @workflow/world-vercel once a release with HTTP-layer trace-context injection is published, so queue sends carry trace headers as well.

🤖 Generated with Claude Code

@karthikscale3
karthikscale3 requested a review from a team as a code ownerJune 11, 2026 16:10
@vercel

vercelBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.041s (-8.4% 🟢)1.006s (~)0.965s101.00x
💻 LocalNitro0.045s (~)1.007s (~)0.961s101.12x
🐘 PostgresExpress0.061s (-3.3%)1.012s (~)0.951s101.50x
🐘 PostgresNitro0.061s (+3.9%)1.012s (~)0.951s101.51x
💻 LocalNext.js (Turbopack)0.062s (+3.7%)1.005s (~)0.943s101.54x
🐘 PostgresNext.js (Turbopack)0.069s (-1.4%)1.013s (~)0.944s101.70x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.308s (-6.5% 🟢)2.408s (+5.4% 🔺)2.099s101.00x
▲ VercelNitro0.331s (+31.6% 🔺)2.509s (+13.4% 🔺)2.179s101.07x
▲ VercelExpress0.423s (+34.2% 🔺)2.501s (-6.2% 🟢)2.077s101.37x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.089s (-1.3%)2.006s (~)0.917s101.00x
🐘 PostgresExpress1.109s (~)2.010s (~)0.901s101.02x
💻 LocalNitro1.110s (+1.7%)2.006s (~)0.896s101.02x
🐘 PostgresNitro1.117s (~)2.010s (~)0.894s101.02x
💻 LocalNext.js (Turbopack)1.140s (+1.0%)2.006s (~)0.866s101.05x
🐘 PostgresNext.js (Turbopack)1.161s (+1.9%)2.010s (~)0.849s101.07x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express1.680s (-9.9% 🟢)3.794s (+10.9% 🔺)2.114s101.00x
▲ VercelNext.js (Turbopack)1.765s (-17.8% 🟢)3.784s (+5.4% 🔺)2.019s101.05x
▲ VercelNitro1.800s (~)3.823s (+2.2%)2.023s101.07x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro10.556s (~)11.020s (~)0.464s31.00x
💻 LocalExpress10.566s (~)11.022s (~)0.456s31.00x
💻 LocalNitro10.574s (~)11.022s (~)0.448s31.00x
🐘 PostgresExpress10.576s (~)11.019s (~)0.443s31.00x
💻 LocalNext.js (Turbopack)10.794s (~)11.021s (~)0.227s31.02x
🐘 PostgresNext.js (Turbopack)10.946s (~)11.351s (~)0.405s31.04x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)14.431s (-14.2% 🟢)16.671s (-8.8% 🟢)2.240s21.00x
▲ VercelNitro14.546s (-2.8%)16.712s (-1.3%)2.166s21.01x
▲ VercelExpress14.781s (-7.0% 🟢)16.758s (-4.0%)1.977s21.02x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express13.761s (~)14.027s (~)0.266s51.00x
💻 LocalNitro13.782s (~)14.028s (~)0.246s51.00x
🐘 PostgresExpress13.786s (-0.5%)14.020s (~)0.233s51.00x
🐘 PostgresNitro13.862s (~)14.021s (~)0.159s51.01x
💻 LocalNext.js (Turbopack)14.321s (~)15.030s (~)0.709s41.04x
🐘 PostgresNext.js (Turbopack)14.461s (~)15.018s (~)0.557s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro23.321s (-20.2% 🟢)25.623s (-17.7% 🟢)2.302s31.00x
▲ VercelExpress23.347s (-15.1% 🟢)25.294s (-11.8% 🟢)1.947s31.00x
▲ VercelNext.js (Turbopack)23.574s (-18.5% 🟢)25.768s (-14.4% 🟢)2.194s31.01x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express12.399s (-0.9%)13.025s (~)0.626s71.00x
🐘 PostgresExpress12.452s (-0.5%)13.019s (~)0.567s71.00x
💻 LocalNitro12.585s (+1.0%)13.025s (~)0.440s71.01x
🐘 PostgresNitro12.914s (-1.3%)13.304s (~)0.390s71.04x
💻 LocalNext.js (Turbopack)13.639s (~)14.027s (-1.0%)0.388s71.10x
🐘 PostgresNext.js (Turbopack)14.158s (+1.3%)14.590s (~)0.432s71.14x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro32.772s (+2.6%)35.131s (+3.4%)2.359s31.00x
▲ VercelExpress34.356s (+4.2%)36.946s (+6.2% 🔺)2.590s31.05x
▲ VercelNext.js (Turbopack)34.608s (+7.4% 🔺)37.163s (+11.4% 🔺)2.555s31.06x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.196s (-1.7%)2.006s (~)0.810s151.00x
🐘 PostgresExpress1.217s (~)2.008s (~)0.791s151.02x
🐘 PostgresNitro1.241s (+1.2%)2.008s (~)0.768s151.04x
💻 LocalNitro1.241s (+2.4%)2.007s (~)0.765s151.04x
🐘 PostgresNext.js (Turbopack)1.291s (+1.6%)2.008s (~)0.717s151.08x
💻 LocalNext.js (Turbopack)1.374s (+6.9% 🔺)2.006s (~)0.632s151.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.381s (-6.9% 🟢)4.170s (+1.9%)1.789s81.00x
▲ VercelExpress2.418s (-49.2% 🟢)4.158s (-35.9% 🟢)1.740s81.02x
▲ VercelNext.js (Turbopack)2.438s (-53.9% 🟢)3.988s (-39.1% 🟢)1.550s81.02x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.413s (-6.1% 🟢)2.318s (~)0.905s131.00x
🐘 PostgresNitro1.415s (+2.9%)2.317s (-7.6% 🟢)0.902s131.00x
🐘 PostgresNext.js (Turbopack)1.655s (+6.0% 🔺)2.316s (+4.2%)0.661s131.17x
💻 LocalExpress1.721s (-6.7% 🟢)2.006s (-6.7% 🟢)0.284s151.22x
💻 LocalNext.js (Turbopack)1.820s (+12.1% 🔺)2.073s (+3.3%)0.254s151.29x
💻 LocalNitro2.079s (+16.6% 🔺)2.471s (+23.2% 🔺)0.392s131.47x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.682s (-11.9% 🟢)4.262s (-17.0% 🟢)1.580s81.00x
▲ VercelExpress3.635s (-3.3%)5.594s (+10.7% 🔺)1.959s71.36x
▲ VercelNext.js (Turbopack)4.256s (+1.4%)6.350s (+14.5% 🔺)2.094s51.59x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.544s (-7.3% 🟢)3.887s (-3.1%)2.343s81.00x
🐘 PostgresNitro1.657s (-7.4% 🟢)4.136s (+12.4% 🔺)2.478s81.07x
🐘 PostgresNext.js (Turbopack)3.528s (+5.1% 🔺)4.300s (+3.9%)0.772s72.29x
💻 LocalExpress4.700s (-15.7% 🟢)5.179s (-13.9% 🟢)0.479s63.04x
💻 LocalNext.js (Turbopack)5.238s (+12.7% 🔺)5.512s (+6.4% 🔺)0.274s63.39x
💻 LocalNitro6.891s (+34.2% 🔺)7.516s (+36.3% 🔺)0.625s44.46x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)4.729s (-24.8% 🟢)6.624s (-15.0% 🟢)1.895s51.00x
▲ VercelExpress5.058s (+11.2% 🔺)7.110s (+16.3% 🔺)2.052s51.07x
▲ VercelNitro5.934s (-13.6% 🟢)8.393s (-3.0%)2.459s41.25x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.212s (~)2.008s (~)0.796s151.00x
🐘 PostgresExpress1.219s (~)2.008s (-3.2%)0.789s151.01x
🐘 PostgresNext.js (Turbopack)1.326s (+2.8%)2.008s (~)0.682s151.09x
💻 LocalNext.js (Turbopack)1.392s (~)2.006s (~)0.614s151.15x
💻 LocalExpress1.577s (~)2.006s (~)0.430s151.30x
💻 LocalNitro1.635s (+2.6%)2.007s (~)0.372s151.35x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.345s (-15.9% 🟢)4.021s (-7.5% 🟢)1.676s81.00x
▲ VercelNext.js (Turbopack)2.375s (-3.8%)3.997s (+9.1% 🔺)1.623s81.01x
▲ VercelNitro3.013s (+19.4% 🔺)4.535s (+15.4% 🔺)1.522s71.28x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.364s (~)2.151s (-7.1% 🟢)0.788s141.00x
🐘 PostgresNitro1.404s (~)2.317s (~)0.913s131.03x
🐘 PostgresNext.js (Turbopack)1.557s (-4.2%)2.393s (+3.3%)0.836s131.14x
💻 LocalExpress2.014s (-13.4% 🟢)2.508s (-11.3% 🟢)0.494s121.48x
💻 LocalNext.js (Turbopack)2.020s (+1.0%)2.735s (+5.6% 🔺)0.715s111.48x
💻 LocalNitro2.227s (+2.2%)2.758s (+0.8%)0.531s121.63x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.771s (-3.9%)4.319s (~)1.548s81.00x
▲ VercelNext.js (Turbopack)2.915s (-1.8%)4.814s (+7.1% 🔺)1.900s71.05x
▲ VercelExpress3.336s (+2.4%)5.038s (+6.4% 🔺)1.702s61.20x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.772s (-4.9%)4.014s (-6.6% 🟢)2.241s81.00x
🐘 PostgresNitro1.886s (+17.2% 🔺)4.441s (+14.2% 🔺)2.556s71.06x
🐘 PostgresNext.js (Turbopack)3.388s (+3.7%)4.142s (-2.8%)0.754s81.91x
💻 LocalExpress5.290s (-24.9% 🟢)5.850s (-23.2% 🟢)0.560s62.99x
💻 LocalNext.js (Turbopack)6.189s (+38.2% 🔺)6.416s (+23.9% 🔺)0.226s53.49x
💻 LocalNitro6.932s (+19.4% 🔺)7.218s (+12.5% 🔺)0.286s53.91x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.744s (-28.8% 🟢)6.281s (-1.9%)2.537s51.00x
▲ VercelNext.js (Turbopack)3.946s (-18.4% 🟢)5.974s (-9.9% 🟢)2.028s61.05x
▲ VercelNitro4.515s (-3.1%)6.349s (+1.5%)1.834s51.21x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.586s (-2.8%)1.006s (-1.7%)0.420s601.00x
🐘 PostgresExpress0.593s (+2.9%)1.041s (+3.5%)0.448s581.01x
💻 LocalExpress0.615s (-3.4%)1.005s (-3.3%)0.390s601.05x
💻 LocalNitro0.670s (+4.6%)1.040s (+1.8%)0.370s581.14x
🐘 PostgresNext.js (Turbopack)0.835s (~)1.023s (~)0.189s591.42x
💻 LocalNext.js (Turbopack)0.870s (+2.0%)1.004s (~)0.135s601.48x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.493s (-24.9% 🟢)8.569s (-15.7% 🟢)2.076s81.00x
▲ VercelNitro6.528s (-27.6% 🟢)8.584s (-18.7% 🟢)2.056s71.01x
▲ VercelNext.js (Turbopack)6.529s (~)8.488s (+6.1% 🔺)1.959s81.01x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.388s (-5.6% 🟢)2.007s (-3.3%)0.619s451.00x
🐘 PostgresNitro1.407s (-3.4%)2.008s (-2.2%)0.601s451.01x
💻 LocalExpress1.514s (-1.9%)2.006s (~)0.491s451.09x
💻 LocalNitro1.646s (+8.0% 🔺)2.030s (+1.2%)0.383s451.19x
🐘 PostgresNext.js (Turbopack)1.991s (+2.6%)2.367s (+12.7% 🔺)0.375s391.43x
💻 LocalNext.js (Turbopack)2.174s (+3.6%)3.008s (+2.2%)0.835s301.57x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express15.578s (-29.1% 🟢)18.039s (-23.2% 🟢)2.461s51.00x
▲ VercelNitro16.348s (-9.2% 🟢)18.489s (-6.5% 🟢)2.141s51.05x
▲ VercelNext.js (Turbopack)16.650s (-9.7% 🟢)19.019s (-4.4%)2.369s51.07x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.822s (+1.1%)3.137s (~)0.315s391.00x
🐘 PostgresExpress2.869s (+3.9%)3.280s (+6.3% 🔺)0.410s371.02x
💻 LocalExpress3.265s (-0.8%)4.009s (~)0.744s301.16x
💻 LocalNitro3.436s (+4.4%)4.077s (+1.7%)0.640s301.22x
🐘 PostgresNext.js (Turbopack)3.936s (+2.7%)4.183s (+3.5%)0.247s291.39x
💻 LocalNext.js (Turbopack)4.327s (-3.0%)5.010s (-0.8%)0.683s241.53x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro29.059s (-8.3% 🟢)31.839s (-4.9%)2.780s41.00x
▲ VercelExpress30.029s (-9.5% 🟢)33.287s (-3.4%)3.258s41.03x
▲ VercelNext.js (Turbopack)32.694s (-1.4%)35.494s (+3.0%)2.800s41.13x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.256s (+1.6%)1.006s (~)0.750s601.00x
🐘 PostgresNitro0.256s (-1.4%)1.006s (-1.6%)0.750s601.00x
🐘 PostgresNext.js (Turbopack)0.302s (+1.2%)1.006s (~)0.704s601.18x
💻 LocalNitro0.431s (+1.5%)1.005s (~)0.574s601.68x
💻 LocalExpress0.435s (-1.5%)1.004s (~)0.569s601.70x
💻 LocalNext.js (Turbopack)0.521s (-12.1% 🟢)1.005s (-1.7%)0.484s602.04x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.115s (-15.5% 🟢)3.980s (-1.9%)1.866s161.00x
▲ VercelNext.js (Turbopack)2.203s (+7.0% 🔺)4.126s (+21.7% 🔺)1.922s151.04x
▲ VercelNitro2.523s (+18.9% 🔺)4.388s (+13.4% 🔺)1.865s141.19x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.415s (+3.7%)1.041s (~)0.627s871.00x
🐘 PostgresNitro0.424s (+0.5%)1.041s (-2.3%)0.617s871.02x
🐘 PostgresNext.js (Turbopack)0.577s (-7.4% 🟢)1.078s (-8.3% 🟢)0.501s841.39x
💻 LocalNitro2.071s (-7.6% 🟢)2.658s (-3.9%)0.586s345.00x
💻 LocalExpress2.325s (+13.4% 🔺)2.853s (+7.4% 🔺)0.528s325.61x
💻 LocalNext.js (Turbopack)2.611s (~)3.334s (-1.5%)0.723s286.30x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.672s (+1.7%)5.578s (+4.3%)1.907s171.00x
▲ VercelExpress3.683s (+11.8% 🔺)5.655s (+19.5% 🔺)1.972s161.00x
▲ VercelNitro3.775s (-16.7% 🟢)5.889s (-5.0% 🟢)2.114s161.03x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.785s (-0.8%)1.271s (-6.3% 🟢)0.486s951.00x
🐘 PostgresNitro0.844s (+3.8%)1.371s (-7.6% 🟢)0.527s881.08x
🐘 PostgresNext.js (Turbopack)2.677s (-6.4% 🟢)3.711s (+2.2%)1.034s333.41x
💻 LocalExpress9.827s (-3.2%)10.362s (-3.9%)0.535s1212.52x
💻 LocalNitro9.859s (+3.5%)10.363s (+1.7%)0.504s1212.56x
💻 LocalNext.js (Turbopack)10.346s (-5.7% 🟢)11.301s (-4.6%)0.956s1113.18x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.976s (-14.5% 🟢)8.118s (-6.4% 🟢)2.142s151.00x
▲ VercelExpress6.135s (-15.9% 🟢)8.528s (-3.9%)2.394s151.03x
▲ VercelNext.js (Turbopack)7.343s (-10.4% 🟢)9.606s (-1.9%)2.264s131.23x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.165s (~)2.004s (~)0.011s (-15.3% 🟢)2.017s (~)0.852s101.00x
💻 LocalNitro1.172s (~)2.005s (~)0.013s (+7.6% 🔺)2.021s (~)0.849s101.01x
🐘 PostgresExpress1.179s (~)2.001s (~)0.001s (~)2.011s (~)0.832s101.01x
🐘 PostgresNitro1.182s (+1.3%)1.997s (~)0.001s (-7.1% 🟢)2.010s (~)0.829s101.01x
💻 LocalNext.js (Turbopack)1.205s (~)2.003s (~)0.012s (+10.2% 🔺)2.019s (~)0.814s101.03x
🐘 PostgresNext.js (Turbopack)1.244s (+0.8%)2.001s (~)0.001s (+11.1% 🔺)2.011s (~)0.767s101.07x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.637s (+5.1% 🔺)3.726s (+10.3% 🔺)1.437s (+19.6% 🔺)5.659s (+13.9% 🔺)3.022s101.00x
▲ VercelNitro2.776s (+19.7% 🔺)4.150s (+24.5% 🔺)0.672s (-37.1% 🟢)5.469s (+13.2% 🔺)2.693s101.05x
▲ VercelExpress2.788s (+17.0% 🔺)4.107s (+28.5% 🔺)1.469s (+11.7% 🔺)6.069s (+23.3% 🔺)3.281s101.06x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.568s (-1.8%)2.006s (~)0.005s (+1.3%)2.028s (~)0.460s301.00x
💻 LocalNitro1.602s (~)2.010s (~)0.011s (-12.0% 🟢)2.024s (~)0.422s301.02x
💻 LocalExpress1.602s (~)2.009s (~)0.015s (+17.6% 🔺)2.027s (~)0.425s301.02x
🐘 PostgresNitro1.638s (+2.7%)2.002s (~)0.005s (-2.0%)2.027s (~)0.389s301.04x
💻 LocalNext.js (Turbopack)1.734s (+0.9%)2.008s (~)0.012s (-1.1%)2.024s (~)0.290s301.11x
🐘 PostgresNext.js (Turbopack)1.796s (+2.0%)2.009s (~)0.006s (+8.2% 🔺)2.030s (~)0.234s301.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.353s (-6.8% 🟢)7.876s (~)0.536s (+132.4% 🔺)8.902s (+3.4%)2.550s71.00x
▲ VercelNitro6.526s (-1.8%)8.158s (+3.1%)0.330s (+49.2% 🔺)8.975s (+4.3%)2.448s71.03x
▲ VercelNext.js (Turbopack)6.780s (-1.5%)8.291s (+3.3%)0.629s (+158.3% 🔺)9.487s (+8.5% 🔺)2.707s71.07x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.776s (+1.9%)1.029s (-1.8%)0.000s (-33.3% 🟢)1.061s (~)0.286s571.00x
🐘 PostgresNitro0.868s (+9.4% 🔺)1.079s (+3.0%)0.000s (-100.0% 🟢)1.122s (+5.8% 🔺)0.254s541.12x
🐘 PostgresNext.js (Turbopack)1.025s (+2.7%)1.469s (+2.8%)0.000s (+Infinity% 🔺)1.486s (+3.5%)0.461s411.32x
💻 LocalNitro1.427s (+3.1%)2.014s (~)0.000s (-11.1% 🟢)2.016s (~)0.589s301.84x
💻 LocalNext.js (Turbopack)1.476s (-0.6%)2.013s (~)0.000s (+133.3% 🔺)2.016s (~)0.540s301.90x
💻 LocalExpress1.492s (+2.1%)2.014s (~)0.000s (-65.2% 🟢)2.016s (~)0.524s301.92x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.152s (-28.2% 🟢)4.709s (-17.2% 🟢)0.001s (-25.9% 🟢)5.242s (-16.1% 🟢)2.090s121.00x
▲ VercelExpress3.292s (-31.2% 🟢)4.944s (-10.0% 🟢)0.000s (+81.8% 🔺)5.485s (-10.9% 🟢)2.193s111.04x
▲ VercelNext.js (Turbopack)3.597s (-0.7%)5.203s (+9.6% 🔺)0.000s (NaN%)5.709s (+10.4% 🔺)2.112s121.14x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

fan-out fan-in 10 streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.522s (-8.1% 🟢)2.063s (-5.1% 🟢)0.000s (+93.1% 🔺)2.093s (-4.8%)0.570s291.00x
🐘 PostgresNitro1.659s (+2.3%)2.258s (+5.5% 🔺)0.000s (+3.7%)2.273s (+5.6% 🔺)0.614s271.09x
🐘 PostgresNext.js (Turbopack)2.252s (+7.5% 🔺)2.729s (+2.8%)0.000s (+213.6% 🔺)2.776s (+4.3%)0.524s221.48x
💻 LocalNext.js (Turbopack)2.852s (-2.2%)3.470s (-1.5%)0.001s (+175.0% 🔺)3.473s (-1.6%)0.621s181.87x
💻 LocalNitro3.071s (+1.2%)3.674s (~)0.001s (-9.1% 🟢)3.677s (~)0.606s172.02x
💻 LocalExpress3.290s (+2.6%)4.026s (+1.5%)0.001s (+353.3% 🔺)4.030s (+1.6%)0.740s152.16x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.411s (-11.9% 🟢)7.163s (-5.1% 🟢)0.004s (+Infinity% 🔺)7.652s (-4.2%)2.241s81.00x
▲ VercelNext.js (Turbopack)6.145s (+25.8% 🔺)7.777s (+25.8% 🔺)0.000s (+Infinity% 🔺)8.258s (+25.1% 🔺)2.113s81.14x
▲ VercelExpress6.943s (+39.7% 🔺)8.421s (+43.0% 🔺)0.000s (NaN%)8.996s (+42.1% 🔺)2.053s81.28x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalExpress15/21
🐘 PostgresExpress17/21
▲ VercelNitro9/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres14/21
Next.js (Turbopack)🐘 Postgres14/21
Nitro🐘 Postgres15/21
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Redis + BullMQ: Community world (local development)
  • 🌐 Cloudflare: Community world (local development)
  • 🌐 MySQL: Community world (local development)
  • 🌐 Azure: Community world (local development)
  • 🌐 NATS JetStream: Community world (local development)
  • 🌐 Upstash: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production144202191661
✅ 💻 Local Development189502192114
✅ 📦 Local Production189502192114
✅ 🐘 Local Postgres188102332114
✅ 🪟 Windows15100151
✅ 📋 Other87901781057
Total8143010689211

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro125026
✅ example125026
✅ express125026
✅ fastify125026
✅ hono125026
✅ nextjs-turbopack14902
✅ nextjs-webpack14902
✅ nitro125026
✅ nuxt125026
✅ sveltekit14407
✅ vite125026
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable126025
✅ express-stable126025
✅ fastify-stable126025
✅ hono-stable126025
✅ nextjs-turbopack-canary132019
✅ nextjs-turbopack-stable-lazy-discovery-disabled15100
✅ nextjs-turbopack-stable-lazy-discovery-enabled15100
✅ nextjs-webpack-canary132019
✅ nextjs-webpack-stable-lazy-discovery-disabled15100
✅ nextjs-webpack-stable-lazy-discovery-enabled15100
✅ nitro-stable126025
✅ nuxt-stable126025
✅ sveltekit-stable14506
✅ vite-stable126025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable126025
✅ express-stable126025
✅ fastify-stable126025
✅ hono-stable126025
✅ nextjs-turbopack-canary132019
✅ nextjs-turbopack-stable-lazy-discovery-disabled15100
✅ nextjs-turbopack-stable-lazy-discovery-enabled15100
✅ nextjs-webpack-canary132019
✅ nextjs-webpack-stable-lazy-discovery-disabled15100
✅ nextjs-webpack-stable-lazy-discovery-enabled15100
✅ nitro-stable126025
✅ nuxt-stable126025
✅ sveltekit-stable14506
✅ vite-stable126025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable125026
✅ express-stable125026
✅ fastify-stable125026
✅ hono-stable125026
✅ nextjs-turbopack-canary131020
✅ nextjs-turbopack-stable-lazy-discovery-disabled15001
✅ nextjs-turbopack-stable-lazy-discovery-enabled15001
✅ nextjs-webpack-canary131020
✅ nextjs-webpack-stable-lazy-discovery-disabled15001
✅ nextjs-webpack-stable-lazy-discovery-enabled15001
✅ nitro-stable125026
✅ nuxt-stable125026
✅ sveltekit-stable14407
✅ vite-stable125026
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15100
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable126025
✅ e2e-local-dev-tanstack-start-126025
✅ e2e-local-postgres-nest-stable125026
✅ e2e-local-postgres-tanstack-start-125026
✅ e2e-local-prod-nest-stable126025
✅ e2e-local-prod-tanstack-start-126025
✅ e2e-vercel-prod-tanstack-start125026

📋 View full workflow run

@changeset-bot

changeset-botBot commented Jun 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 22c35aa

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

This PR includes changesets to release 22 packages
NameType
@workflow/coreMinor
workflowMinor
@workflow/world-vercelMinor
@workflow/utilsMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
@workflow/aiMajor
@workflow/errorsPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@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

@pranaygppranaygp 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.

Reviewed with a focus on attribute consistency, forwards compatibility, DX, and perf overhead. Overall this is solid: the linked-mode semantics are coherent with the other three PRs in the stack (baggage keys match what workflow-server#514 reads; the run-origin carrier semantics match the server's executionContext.traceCarrier-based span links, which are also pinned to run origin; world-vercel consumes deliveries via @vercel/queue.handleCallback, so vqs#181's consumer-side extraction is exactly what feeds linkToCurrentContext). Perf-wise the change is a net reduction when OTEL is active (linked mode skips a propagation.inject per re-enqueue) and stays a memoized no-op without an SDK. Ran the new test suites and typecheck locally — all green.

No blocking issues. Inline comments below: one behavioral edge around empty {} carriers in linked mode, a code-duplication suggestion, a DX nit on unrecognized WORKFLOW_TRACE_MODE values, two display-name edge cases, and a doc accuracy fix on span kinds.

Comment threadpackages/core/src/runtime.ts Outdated
// continuous mode the current (active) context is serialized so the
// trace keeps chaining.
const getNextTraceCarrier = (): Promise<Record<string, string>> =>
traceMode === 'linked' && traceContext

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.

traceContext here can be {} and still take the linked branch: start() always attaches a carrier, and serializeTraceCarrier() returns {} both when no OTEL SDK is registered at the origin and when OTEL is registered but start() runs outside any active span (background job, script). For such runs, linked mode forwards the empty object forever, while the undefined branch adaptively falls back to serializeTraceCarrier() (making the first instrumented invocation the de-facto run origin for future links).

Consider treating an empty carrier like an absent one — e.g. traceContext && Object.keys(traceContext).length > 0 — so both "no usable origin" shapes behave the same (same applies to the copy in step-handler.ts). Related side effect (pre-existing, but more visible now): workflow.trace.propagated is !!traceContext, so it reports true for {} even though there's nothing usable to link to.

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.

Fixed in 5b3ca9f. Added isUsableTraceCarrier() and normalized the incoming carrier at the top of both queue handlers, so {} counts as "no usable origin" everywhere the mode logic branches — linked mode falls back to serializing the current context (first instrumented invocation becomes the de-facto origin) instead of forwarding {} forever. Also took the related side effect: workflow.trace.propagated now reports whether a usable (non-empty) carrier arrived. Test added pinning traceCarrier: {} ≡ no carrier.

// so every future invocation links back to the same origin; in
// continuous mode the current (active) context is serialized so the
// trace keeps chaining.
const getNextTraceCarrier = (): Promise<Record<string, string>> =>

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.

This block — getNextTraceCarrier plus the origin-link dedup below (lines ~191–210) — is duplicated nearly verbatim from runtime.ts (~343–366). Since this encodes the core linked-mode invariants (forward the original carrier; dedup origin vs delivery link), consider extracting two small helpers into telemetry.ts, e.g. nextTraceCarrier(traceMode, traceContext) and buildInvocationSpanLinks(traceMode, traceContext), so the semantics can't drift between the workflow and step handlers.

Bonus if you do: resume-hook.ts (~186–193) has a hand-rolled version of carrier→link that lacks the isSpanContextValid guard your new linkToTraceCarrier has — it could reuse the helper too.

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.

Fixed in 5b3ca9f. Extracted both invariants into telemetry.ts as getNextTraceCarrier(traceMode, incomingCarrier) and buildInvocationSpanLinks(traceMode, incomingCarrier) (exact prior semantics, pinned by the existing trace-mode tests), now used by both runtime.ts and step-handler.ts. Took the bonus too: resume-hook.ts now uses linkToTraceCarrier and gains the isSpanContextValid guard it was missing.

Comment threadpackages/core/src/telemetry.ts Outdated
* Defaults to `'linked'`; any value other than `'continuous'` selects it.
*/
export function getWorkflowTraceMode(): WorkflowTraceMode {
return process.env.WORKFLOW_TRACE_MODE === 'continuous'

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.

Any unrecognized value silently selects linked — a typo like WORKFLOW_TRACE_MODE=continous changes trace topology with zero signal, and if a future SDK version adds a third mode, older SDKs will silently reinterpret it as linked. A one-time runtimeLogger.warn for non-empty unrecognized values would make misconfiguration debuggable and give forward compatibility a soft landing. (Resolving once into a module-level constant would also give you the warn-once behavior for free — the env var can't meaningfully change mid-process anyway.)

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.

Fixed in 5b3ca9f. Kept the dynamic per-call env read (the trace-mode tests flip WORKFLOW_TRACE_MODE per test, so a module-level constant would break them) and added a one-time runtimeLogger.warn per distinct unrecognized non-empty value, naming the value and the accepted ones before falling back to linked. Test asserts the warning fires exactly once for a continous typo.

Comment threadpackages/utils/src/parse-name.ts Outdated
if (!name.startsWith(`${tag}--`)) return null;
// The `//` separators became `--`, and within the function-name part any
// nested-function `/` became `-`. Function names are JS identifiers (no
// dashes), so the innermost name is the last dash-free segment.

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.

Two best-effort edges worth noting in this comment (or handling):

  1. $ is a valid JS identifier character and gets sanitized to -, so step//…//process$Order in sanitized form displays as Order — "no dashes" isn't strictly true for identifiers.
  2. Default exports diverge between the two input forms: parseName maps default/__default to the module short name, but this sanitized path returns the literal default. The same workflow can then show as workflow.start order (raw name in start()) but workflow.execute default (sanitized name in the queue handler). Mapping default to the preceding segment here would keep the two span names consistent.

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.

Fixed in 5b3ca9f. (2) is handled: shortNameFromSanitized now maps default/__default to the preceding module segment's short name, mirroring parseName, so default exports display consistently (e.g. order) in both workflow.start and workflow.execute — pinned by a test. (1) is documented as an accepted best-effort limitation in the comment ($ sanitizes to -, so process$Order displays as Order), with a test pinning the behavior.

| --- | --- | --- |
| `workflow.start <name>` | internal | `start()` is called in your application code |
| `workflow.execute <name>` | internal (root) | a queue delivery invokes the workflow — replay, orchestration, and inline steps run under it |
| `step.execute <name>` | internal | a step function executes |

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.

Kind is inaccurate for the queue-delivered case: step-handler.ts creates this span with SpanKind.CONSUMER (only inline steps executed within workflow.execute are internal), and in linked mode the queue-delivered step.execute span is also a new trace root, same as workflow.execute. Suggest something like: internal (inline) / consumer + root (queue-delivered).

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.

Fixed in 5b3ca9f. Table now reads workflow.executeconsumer (root) (it's CONSUMER as of this commit, see the other thread) and step.executeinternal (inline) / consumer + root (queue-delivered), per your suggested wording.

return trace(
`WORKFLOW_V2 ${workflowName}`,
{ links: spanLinks },
`workflow.execute ${workflowDisplayName(workflowName)}`,

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.

Pre-existing inconsistency, but this PR's v5 window is the cheapest moment to fix it: this queue-delivered span has default INTERNAL kind while the equivalent queue-delivered step.execute span uses CONSUMER. Messaging semconv would suggest CONSUMER here too — and it would pair nicely with the PRODUCER-kind vqs.send span being added on the other side in vercel/vqs#181. Fine as a follow-up, but if you want it, doing it inside the same beta avoids a second span-shape change.

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 5b3ca9f — agreed this beta is the cheapest window. The queue-delivered workflow.execute span now sets kind: CONSUMER via the same getSpanKind('CONSUMER') pattern step-handler uses (both modes), pairing with the PRODUCER vqs.send span in vercel/vqs#181. Added a SpanKind.CONSUMER assertion to the trace-mode test and a changeset bullet noting the internal→consumer kind change.

@pranaygppranaygp 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.

Pre-emptively approving — no blocking bugs, perf is clean (net reduction when OTEL is active, memoized no-op without it), and the cross-repo semantics line up with vqs-server#615 / workflow-server#514 / vqs#181.

@karthikscale3 please address the inline comments from my review (#2363 (review)) before merging — in particular:

  1. Empty {} carrier in linked mode (runtime.ts:344 + the step-handler.ts copy): runs started from uninstrumented contexts silently lose run-level correlation links; treat an empty carrier like an absent one.
  2. Silent fallback on unrecognized WORKFLOW_TRACE_MODE values (telemetry.ts:31): a typo flips trace topology with zero signal; add a warn-once.

The rest (dedup extraction, display-name edges, docs span-kind row, CONSUMER kind for workflow.execute) are nice-to-haves — fine in this PR or as follow-ups.

karthikscale3and others added 8 commits June 15, 2026 11:22
…per-invocation traces
- Add WORKFLOW_TRACE_MODE ('linked' default, 'continuous' legacy) to the
workflow and step queue handlers. In linked mode, WORKFLOW_V2/STEP spans
start a new trace root with span links to the incoming delivery context
and the run-origin context, and re-enqueued messages forward the
ORIGINAL run-origin trace carrier unchanged.
- world-vercel now explicitly injects W3C traceparent/tracestate/baggage
headers on outgoing workflow-server HTTP requests from inside the
client span (no-op without an OTEL SDK registered).
- New workflow.trace.mode span attribute; unit tests for both modes and
for header injection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Documents OTEL spans/attributes, linked trace mode and WORKFLOW_TRACE_MODE,
span links, context propagation, and the v4 behavior-change callout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WORKFLOW_V2/STEP prefixes with full machine names (workflow//./src/...//fn)
become workflow.execute / step.execute / workflow.start with the short
function name. New workflowDisplayName/stepDisplayName helpers in
@workflow/utils handle both raw and queue-sanitized name forms; full names
remain in the workflow.name/step.name attributes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ame edge cases, consumer span kind
- Treat an empty ({}) trace carrier as absent everywhere the trace-mode
logic branches, so linked mode falls back to a fresh origin instead of
forwarding a useless {} forever; workflow.trace.propagated now reports
whether a usable carrier arrived.
- Extract the duplicated linked-mode logic into shared telemetry helpers
getNextTraceCarrier() and buildInvocationSpanLinks(), used by both the
workflow and step queue handlers; resume-hook now uses
linkToTraceCarrier (gaining the isSpanContextValid guard).
- Warn once per distinct unrecognized WORKFLOW_TRACE_MODE value instead
of silently selecting linked.
- shortNameFromSanitized: map default/__default to the module short name
(mirroring parseName) and document the `$`-sanitization limitation.
- Queue-delivered workflow.execute spans now use the CONSUMER span kind,
matching queue-delivered step.execute spans; docs span table and
changeset updated accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

DiffPackageSupply Chain
Security
VulnerabilityQualityMaintenanceLicense
Addednpm/​@​opentelemetry/​context-async-hooks@​1.30.1741008896100

View full report

@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 926a5e7 (AI decision).

This is a deliberate telemetry-shape change that flips the default to the new linked trace mode, altering trace topology, per-run trace IDs, sampling semantics, and span names — a behavioral change explicitly scoped to the v5 beta major. The PR author explicitly requests no backport because applying it to GA v4 users mid-major would silently change their trace behavior, and the accompanying docs target only docs/content/docs/v5/, so it falls under "major/breaking changes intended for the next major release."

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

926a5e7c6a50c1e74f2e2cc37324caa0f6442d85

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@pranaygp
, '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); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' otel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces by karthikscale3 · Pull Request #2363 · vercel/workflow · GitHub
Skip to content

otel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces - #2363

Merged
karthikscale3 merged 8 commits into
mainfrom
karthik/otel-trace-correlation
Jun 15, 2026
Merged

otel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces#2363
karthikscale3 merged 8 commits into
mainfrom
karthik/otel-trace-correlation

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Problem: mega-traces

Today the workflow queue handlers restore the run-origin trace context from the message's traceCarrier and make it the parent of every WORKFLOW_V2 / STEP invocation span. Since each invocation re-serializes its own context onto the next queue message, a single workflow run becomes one giant trace — spanning hours of sleeps/retries and dozens of stitched-together function invocations. These mega-traces are slow to load, hard to read, and frequently broken in Datadog (span limits, late-arriving spans, partial flushes).

Separately, the world-vercel HTTP client creates a CLIENT span for every workflow-server request but never injects traceparent into the outgoing headers — propagation only happened if the customer's app happened to have undici auto-instrumentation, so workflow-server spans usually couldn't join the caller's trace.

Linked-trace mode (new default)

This PR introduces WORKFLOW_TRACE_MODE with two values:

  • linked (new default): each invocation's WORKFLOW_V2 <name> / STEP <name> span is created as a new trace root (SpanOptions.root: true) with span links to:

    • the incoming delivery context (the active span extracted from the queue delivery request — once the platform re-injects producer context on deliveries, this points at the enqueue site), and
    • the run-origin context from the message's traceCarrier (skipped when absent/invalid or identical to the delivery link).

    Re-enqueued messages forward the original run-origin traceCarrier unchanged, so every future invocation of the run links back to the same origin. Traces stay small and bounded per invocation, while links preserve full run-level correlation.

  • continuous: exactly the previous behavior — restored run-origin context parents the invocation span, with a link to the delivery context, and re-enqueues serialize the current context. Set WORKFLOW_TRACE_MODE=continuous to opt back in.

Both modes keep withWorkflowBaggage wrapping, all existing span attributes (including workflow.trace.propagated), and add a new workflow.trace.mode attribute recording the active mode.

Explicit traceparent injection on workflow-server calls

world-vercel's makeRequest now injects W3C context (traceparent, tracestate, baggage) into the outgoing request headers from inside the http <method> CLIENT span, via a new injectTraceContextIntoHeaders(headers) helper in world-vercel's lazy telemetry module. workflow-server can now reliably parent its spans to the SDK's client span regardless of the customer's instrumentation setup.

Queue sends (@vercel/queue) are intentionally untouched here — VQS treats message headers as allowlisted custom headers; HTTP-layer injection for queue sends is handled in the @vercel/queue client itself.

Behavioral changes to telemetry (please read)

The API is backward compatible, but the new linked default changes the shape of emitted traces in ways existing dashboards and queries can feel. Set WORKFLOW_TRACE_MODE=continuous to restore the previous shape exactly.

  1. A run no longer shares one trace ID. Previously, the trace of the request that called start() contained the entire workflow execution — every WORKFLOW_V2/STEP span across all invocations carried the run-origin trace ID. Now each invocation is its own root trace. Anything keyed on a shared per-run trace ID (saved trace queries, "open my request's trace and see the run" debugging flows, trace-ID joins) must switch to span links or the workflow.run.id attribute.
  2. Sampling semantics change. Parent-based samplers previously made one decision at start() that covered the whole run consistently. Each invocation root now samples independently — ratio samplers will produce partially-sampled runs, and the number of root spans/traces increases to one per invocation (relevant for trace-volume-based vendor billing and rate-limiting samplers).
  3. Parent/child topology changes.WORKFLOW_V2/STEP spans had a remote parent; they are now parentless roots. Queries filtering on parent relationships and service-map edges from the calling service to the workflow handler will change.
  4. Re-enqueue traceCarrier semantics change. Queue messages now forward the original run-origin carrier unchanged, rather than each invocation's current context. Custom worlds or tooling that introspect message carriers and assume "carrier = most recent invocation context" will observe different values.

Not changed: all existing span attributes and baggage keys, and the no-OTEL no-op behavior. One footnote: app-set baggage entries now also leave the process as a baggage HTTP request header on backend calls (they already left via traceCarrier in events).

Friendlier span names

Workflow/step span names previously used uppercase prefixes with full machine names (WORKFLOW_V2 workflow//./src/jobs/order//processOrder). They are now short and lowercase: workflow.execute processOrder, step.execute chargeCard, workflow.start processOrder. New workflowDisplayName/stepDisplayName helpers in @workflow/utils resolve both the raw machine name and the queue-sanitized form (workflow----src-jobs-order--processOrder) seen by queue handlers; unrecognized formats fall back to the raw string. The full machine name remains available in the workflow.name / step.name span attributes. This is also a span-name change for anyone querying WORKFLOW_V2/STEP names — same v5-beta reasoning as above.

Backward compatibility

  • No OTEL registered: everything no-ops exactly as before — @opentelemetry/api stays an optional peer dep, the default no-op propagator injects nothing, and no headers are added.
  • WORKFLOW_TRACE_MODE=continuous restores the prior trace shape bit-for-bit (parenting, links, and carrier chaining).
  • Servers ignore the new headers harmlessly:traceparent/tracestate/baggage are standard W3C headers; receivers without tracing simply drop them.

Testing

  • packages/core/src/runtime-trace-mode.test.ts: default is linked; linked creates a root span with links to both delivery + run-origin contexts; continuous preserves the legacy parented shape; linked forwards the original traceCarrier on re-enqueues while continuous serializes the current context. Uses a real in-memory OTEL SDK (BasicTracerProvider + InMemorySpanExporter + W3C propagator).
  • packages/world-vercel/src/trace-propagation.test.ts: traceparent lands on the outgoing request and matches the http GET client span; clean no-op without an active span context.
  • pnpm build, pnpm typecheck, full unit suites for packages/core (1124 passed) and packages/world-vercel (134 passed), Biome format/lint clean.

Backport policy

Do not backport to stable (v4). The linked default is a deliberate telemetry-shape change scoped to the v5 beta major — backporting it would change trace topology, per-run trace IDs, and sampling behavior for GA v4 users mid-major. v4 keeps its current behavior until users upgrade to v5; the platform side is fully tolerant of v4 SDKs.

Documentation

Adds docs/content/docs/v5/observability/tracing.mdx (linked from the Observability index): enabling OTEL, emitted spans and attributes, linked trace mode and span links, WORKFLOW_TRACE_MODE reference with a v4 behavior-change callout, and context-propagation/baggage notes. v4 docs intentionally untouched.

Rollout notes

Server-side support for storing and re-injecting trace context on queue deliveries ships separately on the platform. This PR is safe to merge and release independently: without the platform-side support, behavior is unchanged apart from the new (ignorable) W3C headers and the bounded linked-trace shape.

Follow-up: bump @vercel/queue in @workflow/world-vercel once a release with HTTP-layer trace-context injection is published, so queue sends carry trace headers as well.

🤖 Generated with Claude Code

@karthikscale3
karthikscale3 requested a review from a team as a code ownerJune 11, 2026 16:10
@vercel

vercelBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.041s (-8.4% 🟢)1.006s (~)0.965s101.00x
💻 LocalNitro0.045s (~)1.007s (~)0.961s101.12x
🐘 PostgresExpress0.061s (-3.3%)1.012s (~)0.951s101.50x
🐘 PostgresNitro0.061s (+3.9%)1.012s (~)0.951s101.51x
💻 LocalNext.js (Turbopack)0.062s (+3.7%)1.005s (~)0.943s101.54x
🐘 PostgresNext.js (Turbopack)0.069s (-1.4%)1.013s (~)0.944s101.70x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.308s (-6.5% 🟢)2.408s (+5.4% 🔺)2.099s101.00x
▲ VercelNitro0.331s (+31.6% 🔺)2.509s (+13.4% 🔺)2.179s101.07x
▲ VercelExpress0.423s (+34.2% 🔺)2.501s (-6.2% 🟢)2.077s101.37x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.089s (-1.3%)2.006s (~)0.917s101.00x
🐘 PostgresExpress1.109s (~)2.010s (~)0.901s101.02x
💻 LocalNitro1.110s (+1.7%)2.006s (~)0.896s101.02x
🐘 PostgresNitro1.117s (~)2.010s (~)0.894s101.02x
💻 LocalNext.js (Turbopack)1.140s (+1.0%)2.006s (~)0.866s101.05x
🐘 PostgresNext.js (Turbopack)1.161s (+1.9%)2.010s (~)0.849s101.07x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express1.680s (-9.9% 🟢)3.794s (+10.9% 🔺)2.114s101.00x
▲ VercelNext.js (Turbopack)1.765s (-17.8% 🟢)3.784s (+5.4% 🔺)2.019s101.05x
▲ VercelNitro1.800s (~)3.823s (+2.2%)2.023s101.07x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro10.556s (~)11.020s (~)0.464s31.00x
💻 LocalExpress10.566s (~)11.022s (~)0.456s31.00x
💻 LocalNitro10.574s (~)11.022s (~)0.448s31.00x
🐘 PostgresExpress10.576s (~)11.019s (~)0.443s31.00x
💻 LocalNext.js (Turbopack)10.794s (~)11.021s (~)0.227s31.02x
🐘 PostgresNext.js (Turbopack)10.946s (~)11.351s (~)0.405s31.04x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)14.431s (-14.2% 🟢)16.671s (-8.8% 🟢)2.240s21.00x
▲ VercelNitro14.546s (-2.8%)16.712s (-1.3%)2.166s21.01x
▲ VercelExpress14.781s (-7.0% 🟢)16.758s (-4.0%)1.977s21.02x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express13.761s (~)14.027s (~)0.266s51.00x
💻 LocalNitro13.782s (~)14.028s (~)0.246s51.00x
🐘 PostgresExpress13.786s (-0.5%)14.020s (~)0.233s51.00x
🐘 PostgresNitro13.862s (~)14.021s (~)0.159s51.01x
💻 LocalNext.js (Turbopack)14.321s (~)15.030s (~)0.709s41.04x
🐘 PostgresNext.js (Turbopack)14.461s (~)15.018s (~)0.557s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro23.321s (-20.2% 🟢)25.623s (-17.7% 🟢)2.302s31.00x
▲ VercelExpress23.347s (-15.1% 🟢)25.294s (-11.8% 🟢)1.947s31.00x
▲ VercelNext.js (Turbopack)23.574s (-18.5% 🟢)25.768s (-14.4% 🟢)2.194s31.01x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express12.399s (-0.9%)13.025s (~)0.626s71.00x
🐘 PostgresExpress12.452s (-0.5%)13.019s (~)0.567s71.00x
💻 LocalNitro12.585s (+1.0%)13.025s (~)0.440s71.01x
🐘 PostgresNitro12.914s (-1.3%)13.304s (~)0.390s71.04x
💻 LocalNext.js (Turbopack)13.639s (~)14.027s (-1.0%)0.388s71.10x
🐘 PostgresNext.js (Turbopack)14.158s (+1.3%)14.590s (~)0.432s71.14x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro32.772s (+2.6%)35.131s (+3.4%)2.359s31.00x
▲ VercelExpress34.356s (+4.2%)36.946s (+6.2% 🔺)2.590s31.05x
▲ VercelNext.js (Turbopack)34.608s (+7.4% 🔺)37.163s (+11.4% 🔺)2.555s31.06x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.196s (-1.7%)2.006s (~)0.810s151.00x
🐘 PostgresExpress1.217s (~)2.008s (~)0.791s151.02x
🐘 PostgresNitro1.241s (+1.2%)2.008s (~)0.768s151.04x
💻 LocalNitro1.241s (+2.4%)2.007s (~)0.765s151.04x
🐘 PostgresNext.js (Turbopack)1.291s (+1.6%)2.008s (~)0.717s151.08x
💻 LocalNext.js (Turbopack)1.374s (+6.9% 🔺)2.006s (~)0.632s151.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.381s (-6.9% 🟢)4.170s (+1.9%)1.789s81.00x
▲ VercelExpress2.418s (-49.2% 🟢)4.158s (-35.9% 🟢)1.740s81.02x
▲ VercelNext.js (Turbopack)2.438s (-53.9% 🟢)3.988s (-39.1% 🟢)1.550s81.02x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.413s (-6.1% 🟢)2.318s (~)0.905s131.00x
🐘 PostgresNitro1.415s (+2.9%)2.317s (-7.6% 🟢)0.902s131.00x
🐘 PostgresNext.js (Turbopack)1.655s (+6.0% 🔺)2.316s (+4.2%)0.661s131.17x
💻 LocalExpress1.721s (-6.7% 🟢)2.006s (-6.7% 🟢)0.284s151.22x
💻 LocalNext.js (Turbopack)1.820s (+12.1% 🔺)2.073s (+3.3%)0.254s151.29x
💻 LocalNitro2.079s (+16.6% 🔺)2.471s (+23.2% 🔺)0.392s131.47x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.682s (-11.9% 🟢)4.262s (-17.0% 🟢)1.580s81.00x
▲ VercelExpress3.635s (-3.3%)5.594s (+10.7% 🔺)1.959s71.36x
▲ VercelNext.js (Turbopack)4.256s (+1.4%)6.350s (+14.5% 🔺)2.094s51.59x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.544s (-7.3% 🟢)3.887s (-3.1%)2.343s81.00x
🐘 PostgresNitro1.657s (-7.4% 🟢)4.136s (+12.4% 🔺)2.478s81.07x
🐘 PostgresNext.js (Turbopack)3.528s (+5.1% 🔺)4.300s (+3.9%)0.772s72.29x
💻 LocalExpress4.700s (-15.7% 🟢)5.179s (-13.9% 🟢)0.479s63.04x
💻 LocalNext.js (Turbopack)5.238s (+12.7% 🔺)5.512s (+6.4% 🔺)0.274s63.39x
💻 LocalNitro6.891s (+34.2% 🔺)7.516s (+36.3% 🔺)0.625s44.46x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)4.729s (-24.8% 🟢)6.624s (-15.0% 🟢)1.895s51.00x
▲ VercelExpress5.058s (+11.2% 🔺)7.110s (+16.3% 🔺)2.052s51.07x
▲ VercelNitro5.934s (-13.6% 🟢)8.393s (-3.0%)2.459s41.25x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.212s (~)2.008s (~)0.796s151.00x
🐘 PostgresExpress1.219s (~)2.008s (-3.2%)0.789s151.01x
🐘 PostgresNext.js (Turbopack)1.326s (+2.8%)2.008s (~)0.682s151.09x
💻 LocalNext.js (Turbopack)1.392s (~)2.006s (~)0.614s151.15x
💻 LocalExpress1.577s (~)2.006s (~)0.430s151.30x
💻 LocalNitro1.635s (+2.6%)2.007s (~)0.372s151.35x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.345s (-15.9% 🟢)4.021s (-7.5% 🟢)1.676s81.00x
▲ VercelNext.js (Turbopack)2.375s (-3.8%)3.997s (+9.1% 🔺)1.623s81.01x
▲ VercelNitro3.013s (+19.4% 🔺)4.535s (+15.4% 🔺)1.522s71.28x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.364s (~)2.151s (-7.1% 🟢)0.788s141.00x
🐘 PostgresNitro1.404s (~)2.317s (~)0.913s131.03x
🐘 PostgresNext.js (Turbopack)1.557s (-4.2%)2.393s (+3.3%)0.836s131.14x
💻 LocalExpress2.014s (-13.4% 🟢)2.508s (-11.3% 🟢)0.494s121.48x
💻 LocalNext.js (Turbopack)2.020s (+1.0%)2.735s (+5.6% 🔺)0.715s111.48x
💻 LocalNitro2.227s (+2.2%)2.758s (+0.8%)0.531s121.63x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.771s (-3.9%)4.319s (~)1.548s81.00x
▲ VercelNext.js (Turbopack)2.915s (-1.8%)4.814s (+7.1% 🔺)1.900s71.05x
▲ VercelExpress3.336s (+2.4%)5.038s (+6.4% 🔺)1.702s61.20x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.772s (-4.9%)4.014s (-6.6% 🟢)2.241s81.00x
🐘 PostgresNitro1.886s (+17.2% 🔺)4.441s (+14.2% 🔺)2.556s71.06x
🐘 PostgresNext.js (Turbopack)3.388s (+3.7%)4.142s (-2.8%)0.754s81.91x
💻 LocalExpress5.290s (-24.9% 🟢)5.850s (-23.2% 🟢)0.560s62.99x
💻 LocalNext.js (Turbopack)6.189s (+38.2% 🔺)6.416s (+23.9% 🔺)0.226s53.49x
💻 LocalNitro6.932s (+19.4% 🔺)7.218s (+12.5% 🔺)0.286s53.91x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.744s (-28.8% 🟢)6.281s (-1.9%)2.537s51.00x
▲ VercelNext.js (Turbopack)3.946s (-18.4% 🟢)5.974s (-9.9% 🟢)2.028s61.05x
▲ VercelNitro4.515s (-3.1%)6.349s (+1.5%)1.834s51.21x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.586s (-2.8%)1.006s (-1.7%)0.420s601.00x
🐘 PostgresExpress0.593s (+2.9%)1.041s (+3.5%)0.448s581.01x
💻 LocalExpress0.615s (-3.4%)1.005s (-3.3%)0.390s601.05x
💻 LocalNitro0.670s (+4.6%)1.040s (+1.8%)0.370s581.14x
🐘 PostgresNext.js (Turbopack)0.835s (~)1.023s (~)0.189s591.42x
💻 LocalNext.js (Turbopack)0.870s (+2.0%)1.004s (~)0.135s601.48x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.493s (-24.9% 🟢)8.569s (-15.7% 🟢)2.076s81.00x
▲ VercelNitro6.528s (-27.6% 🟢)8.584s (-18.7% 🟢)2.056s71.01x
▲ VercelNext.js (Turbopack)6.529s (~)8.488s (+6.1% 🔺)1.959s81.01x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.388s (-5.6% 🟢)2.007s (-3.3%)0.619s451.00x
🐘 PostgresNitro1.407s (-3.4%)2.008s (-2.2%)0.601s451.01x
💻 LocalExpress1.514s (-1.9%)2.006s (~)0.491s451.09x
💻 LocalNitro1.646s (+8.0% 🔺)2.030s (+1.2%)0.383s451.19x
🐘 PostgresNext.js (Turbopack)1.991s (+2.6%)2.367s (+12.7% 🔺)0.375s391.43x
💻 LocalNext.js (Turbopack)2.174s (+3.6%)3.008s (+2.2%)0.835s301.57x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express15.578s (-29.1% 🟢)18.039s (-23.2% 🟢)2.461s51.00x
▲ VercelNitro16.348s (-9.2% 🟢)18.489s (-6.5% 🟢)2.141s51.05x
▲ VercelNext.js (Turbopack)16.650s (-9.7% 🟢)19.019s (-4.4%)2.369s51.07x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.822s (+1.1%)3.137s (~)0.315s391.00x
🐘 PostgresExpress2.869s (+3.9%)3.280s (+6.3% 🔺)0.410s371.02x
💻 LocalExpress3.265s (-0.8%)4.009s (~)0.744s301.16x
💻 LocalNitro3.436s (+4.4%)4.077s (+1.7%)0.640s301.22x
🐘 PostgresNext.js (Turbopack)3.936s (+2.7%)4.183s (+3.5%)0.247s291.39x
💻 LocalNext.js (Turbopack)4.327s (-3.0%)5.010s (-0.8%)0.683s241.53x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro29.059s (-8.3% 🟢)31.839s (-4.9%)2.780s41.00x
▲ VercelExpress30.029s (-9.5% 🟢)33.287s (-3.4%)3.258s41.03x
▲ VercelNext.js (Turbopack)32.694s (-1.4%)35.494s (+3.0%)2.800s41.13x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.256s (+1.6%)1.006s (~)0.750s601.00x
🐘 PostgresNitro0.256s (-1.4%)1.006s (-1.6%)0.750s601.00x
🐘 PostgresNext.js (Turbopack)0.302s (+1.2%)1.006s (~)0.704s601.18x
💻 LocalNitro0.431s (+1.5%)1.005s (~)0.574s601.68x
💻 LocalExpress0.435s (-1.5%)1.004s (~)0.569s601.70x
💻 LocalNext.js (Turbopack)0.521s (-12.1% 🟢)1.005s (-1.7%)0.484s602.04x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.115s (-15.5% 🟢)3.980s (-1.9%)1.866s161.00x
▲ VercelNext.js (Turbopack)2.203s (+7.0% 🔺)4.126s (+21.7% 🔺)1.922s151.04x
▲ VercelNitro2.523s (+18.9% 🔺)4.388s (+13.4% 🔺)1.865s141.19x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.415s (+3.7%)1.041s (~)0.627s871.00x
🐘 PostgresNitro0.424s (+0.5%)1.041s (-2.3%)0.617s871.02x
🐘 PostgresNext.js (Turbopack)0.577s (-7.4% 🟢)1.078s (-8.3% 🟢)0.501s841.39x
💻 LocalNitro2.071s (-7.6% 🟢)2.658s (-3.9%)0.586s345.00x
💻 LocalExpress2.325s (+13.4% 🔺)2.853s (+7.4% 🔺)0.528s325.61x
💻 LocalNext.js (Turbopack)2.611s (~)3.334s (-1.5%)0.723s286.30x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.672s (+1.7%)5.578s (+4.3%)1.907s171.00x
▲ VercelExpress3.683s (+11.8% 🔺)5.655s (+19.5% 🔺)1.972s161.00x
▲ VercelNitro3.775s (-16.7% 🟢)5.889s (-5.0% 🟢)2.114s161.03x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.785s (-0.8%)1.271s (-6.3% 🟢)0.486s951.00x
🐘 PostgresNitro0.844s (+3.8%)1.371s (-7.6% 🟢)0.527s881.08x
🐘 PostgresNext.js (Turbopack)2.677s (-6.4% 🟢)3.711s (+2.2%)1.034s333.41x
💻 LocalExpress9.827s (-3.2%)10.362s (-3.9%)0.535s1212.52x
💻 LocalNitro9.859s (+3.5%)10.363s (+1.7%)0.504s1212.56x
💻 LocalNext.js (Turbopack)10.346s (-5.7% 🟢)11.301s (-4.6%)0.956s1113.18x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.976s (-14.5% 🟢)8.118s (-6.4% 🟢)2.142s151.00x
▲ VercelExpress6.135s (-15.9% 🟢)8.528s (-3.9%)2.394s151.03x
▲ VercelNext.js (Turbopack)7.343s (-10.4% 🟢)9.606s (-1.9%)2.264s131.23x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.165s (~)2.004s (~)0.011s (-15.3% 🟢)2.017s (~)0.852s101.00x
💻 LocalNitro1.172s (~)2.005s (~)0.013s (+7.6% 🔺)2.021s (~)0.849s101.01x
🐘 PostgresExpress1.179s (~)2.001s (~)0.001s (~)2.011s (~)0.832s101.01x
🐘 PostgresNitro1.182s (+1.3%)1.997s (~)0.001s (-7.1% 🟢)2.010s (~)0.829s101.01x
💻 LocalNext.js (Turbopack)1.205s (~)2.003s (~)0.012s (+10.2% 🔺)2.019s (~)0.814s101.03x
🐘 PostgresNext.js (Turbopack)1.244s (+0.8%)2.001s (~)0.001s (+11.1% 🔺)2.011s (~)0.767s101.07x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.637s (+5.1% 🔺)3.726s (+10.3% 🔺)1.437s (+19.6% 🔺)5.659s (+13.9% 🔺)3.022s101.00x
▲ VercelNitro2.776s (+19.7% 🔺)4.150s (+24.5% 🔺)0.672s (-37.1% 🟢)5.469s (+13.2% 🔺)2.693s101.05x
▲ VercelExpress2.788s (+17.0% 🔺)4.107s (+28.5% 🔺)1.469s (+11.7% 🔺)6.069s (+23.3% 🔺)3.281s101.06x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.568s (-1.8%)2.006s (~)0.005s (+1.3%)2.028s (~)0.460s301.00x
💻 LocalNitro1.602s (~)2.010s (~)0.011s (-12.0% 🟢)2.024s (~)0.422s301.02x
💻 LocalExpress1.602s (~)2.009s (~)0.015s (+17.6% 🔺)2.027s (~)0.425s301.02x
🐘 PostgresNitro1.638s (+2.7%)2.002s (~)0.005s (-2.0%)2.027s (~)0.389s301.04x
💻 LocalNext.js (Turbopack)1.734s (+0.9%)2.008s (~)0.012s (-1.1%)2.024s (~)0.290s301.11x
🐘 PostgresNext.js (Turbopack)1.796s (+2.0%)2.009s (~)0.006s (+8.2% 🔺)2.030s (~)0.234s301.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.353s (-6.8% 🟢)7.876s (~)0.536s (+132.4% 🔺)8.902s (+3.4%)2.550s71.00x
▲ VercelNitro6.526s (-1.8%)8.158s (+3.1%)0.330s (+49.2% 🔺)8.975s (+4.3%)2.448s71.03x
▲ VercelNext.js (Turbopack)6.780s (-1.5%)8.291s (+3.3%)0.629s (+158.3% 🔺)9.487s (+8.5% 🔺)2.707s71.07x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.776s (+1.9%)1.029s (-1.8%)0.000s (-33.3% 🟢)1.061s (~)0.286s571.00x
🐘 PostgresNitro0.868s (+9.4% 🔺)1.079s (+3.0%)0.000s (-100.0% 🟢)1.122s (+5.8% 🔺)0.254s541.12x
🐘 PostgresNext.js (Turbopack)1.025s (+2.7%)1.469s (+2.8%)0.000s (+Infinity% 🔺)1.486s (+3.5%)0.461s411.32x
💻 LocalNitro1.427s (+3.1%)2.014s (~)0.000s (-11.1% 🟢)2.016s (~)0.589s301.84x
💻 LocalNext.js (Turbopack)1.476s (-0.6%)2.013s (~)0.000s (+133.3% 🔺)2.016s (~)0.540s301.90x
💻 LocalExpress1.492s (+2.1%)2.014s (~)0.000s (-65.2% 🟢)2.016s (~)0.524s301.92x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.152s (-28.2% 🟢)4.709s (-17.2% 🟢)0.001s (-25.9% 🟢)5.242s (-16.1% 🟢)2.090s121.00x
▲ VercelExpress3.292s (-31.2% 🟢)4.944s (-10.0% 🟢)0.000s (+81.8% 🔺)5.485s (-10.9% 🟢)2.193s111.04x
▲ VercelNext.js (Turbopack)3.597s (-0.7%)5.203s (+9.6% 🔺)0.000s (NaN%)5.709s (+10.4% 🔺)2.112s121.14x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

fan-out fan-in 10 streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.522s (-8.1% 🟢)2.063s (-5.1% 🟢)0.000s (+93.1% 🔺)2.093s (-4.8%)0.570s291.00x
🐘 PostgresNitro1.659s (+2.3%)2.258s (+5.5% 🔺)0.000s (+3.7%)2.273s (+5.6% 🔺)0.614s271.09x
🐘 PostgresNext.js (Turbopack)2.252s (+7.5% 🔺)2.729s (+2.8%)0.000s (+213.6% 🔺)2.776s (+4.3%)0.524s221.48x
💻 LocalNext.js (Turbopack)2.852s (-2.2%)3.470s (-1.5%)0.001s (+175.0% 🔺)3.473s (-1.6%)0.621s181.87x
💻 LocalNitro3.071s (+1.2%)3.674s (~)0.001s (-9.1% 🟢)3.677s (~)0.606s172.02x
💻 LocalExpress3.290s (+2.6%)4.026s (+1.5%)0.001s (+353.3% 🔺)4.030s (+1.6%)0.740s152.16x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.411s (-11.9% 🟢)7.163s (-5.1% 🟢)0.004s (+Infinity% 🔺)7.652s (-4.2%)2.241s81.00x
▲ VercelNext.js (Turbopack)6.145s (+25.8% 🔺)7.777s (+25.8% 🔺)0.000s (+Infinity% 🔺)8.258s (+25.1% 🔺)2.113s81.14x
▲ VercelExpress6.943s (+39.7% 🔺)8.421s (+43.0% 🔺)0.000s (NaN%)8.996s (+42.1% 🔺)2.053s81.28x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalExpress15/21
🐘 PostgresExpress17/21
▲ VercelNitro9/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres14/21
Next.js (Turbopack)🐘 Postgres14/21
Nitro🐘 Postgres15/21
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Redis + BullMQ: Community world (local development)
  • 🌐 Cloudflare: Community world (local development)
  • 🌐 MySQL: Community world (local development)
  • 🌐 Azure: Community world (local development)
  • 🌐 NATS JetStream: Community world (local development)
  • 🌐 Upstash: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production144202191661
✅ 💻 Local Development189502192114
✅ 📦 Local Production189502192114
✅ 🐘 Local Postgres188102332114
✅ 🪟 Windows15100151
✅ 📋 Other87901781057
Total8143010689211

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro125026
✅ example125026
✅ express125026
✅ fastify125026
✅ hono125026
✅ nextjs-turbopack14902
✅ nextjs-webpack14902
✅ nitro125026
✅ nuxt125026
✅ sveltekit14407
✅ vite125026
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable126025
✅ express-stable126025
✅ fastify-stable126025
✅ hono-stable126025
✅ nextjs-turbopack-canary132019
✅ nextjs-turbopack-stable-lazy-discovery-disabled15100
✅ nextjs-turbopack-stable-lazy-discovery-enabled15100
✅ nextjs-webpack-canary132019
✅ nextjs-webpack-stable-lazy-discovery-disabled15100
✅ nextjs-webpack-stable-lazy-discovery-enabled15100
✅ nitro-stable126025
✅ nuxt-stable126025
✅ sveltekit-stable14506
✅ vite-stable126025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable126025
✅ express-stable126025
✅ fastify-stable126025
✅ hono-stable126025
✅ nextjs-turbopack-canary132019
✅ nextjs-turbopack-stable-lazy-discovery-disabled15100
✅ nextjs-turbopack-stable-lazy-discovery-enabled15100
✅ nextjs-webpack-canary132019
✅ nextjs-webpack-stable-lazy-discovery-disabled15100
✅ nextjs-webpack-stable-lazy-discovery-enabled15100
✅ nitro-stable126025
✅ nuxt-stable126025
✅ sveltekit-stable14506
✅ vite-stable126025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable125026
✅ express-stable125026
✅ fastify-stable125026
✅ hono-stable125026
✅ nextjs-turbopack-canary131020
✅ nextjs-turbopack-stable-lazy-discovery-disabled15001
✅ nextjs-turbopack-stable-lazy-discovery-enabled15001
✅ nextjs-webpack-canary131020
✅ nextjs-webpack-stable-lazy-discovery-disabled15001
✅ nextjs-webpack-stable-lazy-discovery-enabled15001
✅ nitro-stable125026
✅ nuxt-stable125026
✅ sveltekit-stable14407
✅ vite-stable125026
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15100
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable126025
✅ e2e-local-dev-tanstack-start-126025
✅ e2e-local-postgres-nest-stable125026
✅ e2e-local-postgres-tanstack-start-125026
✅ e2e-local-prod-nest-stable126025
✅ e2e-local-prod-tanstack-start-126025
✅ e2e-vercel-prod-tanstack-start125026

📋 View full workflow run

@changeset-bot

changeset-botBot commented Jun 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 22c35aa

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

This PR includes changesets to release 22 packages
NameType
@workflow/coreMinor
workflowMinor
@workflow/world-vercelMinor
@workflow/utilsMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
@workflow/aiMajor
@workflow/errorsPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@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

@pranaygppranaygp 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.

Reviewed with a focus on attribute consistency, forwards compatibility, DX, and perf overhead. Overall this is solid: the linked-mode semantics are coherent with the other three PRs in the stack (baggage keys match what workflow-server#514 reads; the run-origin carrier semantics match the server's executionContext.traceCarrier-based span links, which are also pinned to run origin; world-vercel consumes deliveries via @vercel/queue.handleCallback, so vqs#181's consumer-side extraction is exactly what feeds linkToCurrentContext). Perf-wise the change is a net reduction when OTEL is active (linked mode skips a propagation.inject per re-enqueue) and stays a memoized no-op without an SDK. Ran the new test suites and typecheck locally — all green.

No blocking issues. Inline comments below: one behavioral edge around empty {} carriers in linked mode, a code-duplication suggestion, a DX nit on unrecognized WORKFLOW_TRACE_MODE values, two display-name edge cases, and a doc accuracy fix on span kinds.

Comment threadpackages/core/src/runtime.ts Outdated
// continuous mode the current (active) context is serialized so the
// trace keeps chaining.
const getNextTraceCarrier = (): Promise<Record<string, string>> =>
traceMode === 'linked' && traceContext

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.

traceContext here can be {} and still take the linked branch: start() always attaches a carrier, and serializeTraceCarrier() returns {} both when no OTEL SDK is registered at the origin and when OTEL is registered but start() runs outside any active span (background job, script). For such runs, linked mode forwards the empty object forever, while the undefined branch adaptively falls back to serializeTraceCarrier() (making the first instrumented invocation the de-facto run origin for future links).

Consider treating an empty carrier like an absent one — e.g. traceContext && Object.keys(traceContext).length > 0 — so both "no usable origin" shapes behave the same (same applies to the copy in step-handler.ts). Related side effect (pre-existing, but more visible now): workflow.trace.propagated is !!traceContext, so it reports true for {} even though there's nothing usable to link to.

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.

Fixed in 5b3ca9f. Added isUsableTraceCarrier() and normalized the incoming carrier at the top of both queue handlers, so {} counts as "no usable origin" everywhere the mode logic branches — linked mode falls back to serializing the current context (first instrumented invocation becomes the de-facto origin) instead of forwarding {} forever. Also took the related side effect: workflow.trace.propagated now reports whether a usable (non-empty) carrier arrived. Test added pinning traceCarrier: {} ≡ no carrier.

// so every future invocation links back to the same origin; in
// continuous mode the current (active) context is serialized so the
// trace keeps chaining.
const getNextTraceCarrier = (): Promise<Record<string, string>> =>

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.

This block — getNextTraceCarrier plus the origin-link dedup below (lines ~191–210) — is duplicated nearly verbatim from runtime.ts (~343–366). Since this encodes the core linked-mode invariants (forward the original carrier; dedup origin vs delivery link), consider extracting two small helpers into telemetry.ts, e.g. nextTraceCarrier(traceMode, traceContext) and buildInvocationSpanLinks(traceMode, traceContext), so the semantics can't drift between the workflow and step handlers.

Bonus if you do: resume-hook.ts (~186–193) has a hand-rolled version of carrier→link that lacks the isSpanContextValid guard your new linkToTraceCarrier has — it could reuse the helper too.

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.

Fixed in 5b3ca9f. Extracted both invariants into telemetry.ts as getNextTraceCarrier(traceMode, incomingCarrier) and buildInvocationSpanLinks(traceMode, incomingCarrier) (exact prior semantics, pinned by the existing trace-mode tests), now used by both runtime.ts and step-handler.ts. Took the bonus too: resume-hook.ts now uses linkToTraceCarrier and gains the isSpanContextValid guard it was missing.

Comment threadpackages/core/src/telemetry.ts Outdated
* Defaults to `'linked'`; any value other than `'continuous'` selects it.
*/
export function getWorkflowTraceMode(): WorkflowTraceMode {
return process.env.WORKFLOW_TRACE_MODE === 'continuous'

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.

Any unrecognized value silently selects linked — a typo like WORKFLOW_TRACE_MODE=continous changes trace topology with zero signal, and if a future SDK version adds a third mode, older SDKs will silently reinterpret it as linked. A one-time runtimeLogger.warn for non-empty unrecognized values would make misconfiguration debuggable and give forward compatibility a soft landing. (Resolving once into a module-level constant would also give you the warn-once behavior for free — the env var can't meaningfully change mid-process anyway.)

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.

Fixed in 5b3ca9f. Kept the dynamic per-call env read (the trace-mode tests flip WORKFLOW_TRACE_MODE per test, so a module-level constant would break them) and added a one-time runtimeLogger.warn per distinct unrecognized non-empty value, naming the value and the accepted ones before falling back to linked. Test asserts the warning fires exactly once for a continous typo.

Comment threadpackages/utils/src/parse-name.ts Outdated
if (!name.startsWith(`${tag}--`)) return null;
// The `//` separators became `--`, and within the function-name part any
// nested-function `/` became `-`. Function names are JS identifiers (no
// dashes), so the innermost name is the last dash-free segment.

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.

Two best-effort edges worth noting in this comment (or handling):

  1. $ is a valid JS identifier character and gets sanitized to -, so step//…//process$Order in sanitized form displays as Order — "no dashes" isn't strictly true for identifiers.
  2. Default exports diverge between the two input forms: parseName maps default/__default to the module short name, but this sanitized path returns the literal default. The same workflow can then show as workflow.start order (raw name in start()) but workflow.execute default (sanitized name in the queue handler). Mapping default to the preceding segment here would keep the two span names consistent.

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.

Fixed in 5b3ca9f. (2) is handled: shortNameFromSanitized now maps default/__default to the preceding module segment's short name, mirroring parseName, so default exports display consistently (e.g. order) in both workflow.start and workflow.execute — pinned by a test. (1) is documented as an accepted best-effort limitation in the comment ($ sanitizes to -, so process$Order displays as Order), with a test pinning the behavior.

| --- | --- | --- |
| `workflow.start <name>` | internal | `start()` is called in your application code |
| `workflow.execute <name>` | internal (root) | a queue delivery invokes the workflow — replay, orchestration, and inline steps run under it |
| `step.execute <name>` | internal | a step function executes |

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.

Kind is inaccurate for the queue-delivered case: step-handler.ts creates this span with SpanKind.CONSUMER (only inline steps executed within workflow.execute are internal), and in linked mode the queue-delivered step.execute span is also a new trace root, same as workflow.execute. Suggest something like: internal (inline) / consumer + root (queue-delivered).

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.

Fixed in 5b3ca9f. Table now reads workflow.executeconsumer (root) (it's CONSUMER as of this commit, see the other thread) and step.executeinternal (inline) / consumer + root (queue-delivered), per your suggested wording.

return trace(
`WORKFLOW_V2 ${workflowName}`,
{ links: spanLinks },
`workflow.execute ${workflowDisplayName(workflowName)}`,

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.

Pre-existing inconsistency, but this PR's v5 window is the cheapest moment to fix it: this queue-delivered span has default INTERNAL kind while the equivalent queue-delivered step.execute span uses CONSUMER. Messaging semconv would suggest CONSUMER here too — and it would pair nicely with the PRODUCER-kind vqs.send span being added on the other side in vercel/vqs#181. Fine as a follow-up, but if you want it, doing it inside the same beta avoids a second span-shape change.

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 5b3ca9f — agreed this beta is the cheapest window. The queue-delivered workflow.execute span now sets kind: CONSUMER via the same getSpanKind('CONSUMER') pattern step-handler uses (both modes), pairing with the PRODUCER vqs.send span in vercel/vqs#181. Added a SpanKind.CONSUMER assertion to the trace-mode test and a changeset bullet noting the internal→consumer kind change.

@pranaygppranaygp 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.

Pre-emptively approving — no blocking bugs, perf is clean (net reduction when OTEL is active, memoized no-op without it), and the cross-repo semantics line up with vqs-server#615 / workflow-server#514 / vqs#181.

@karthikscale3 please address the inline comments from my review (#2363 (review)) before merging — in particular:

  1. Empty {} carrier in linked mode (runtime.ts:344 + the step-handler.ts copy): runs started from uninstrumented contexts silently lose run-level correlation links; treat an empty carrier like an absent one.
  2. Silent fallback on unrecognized WORKFLOW_TRACE_MODE values (telemetry.ts:31): a typo flips trace topology with zero signal; add a warn-once.

The rest (dedup extraction, display-name edges, docs span-kind row, CONSUMER kind for workflow.execute) are nice-to-haves — fine in this PR or as follow-ups.

karthikscale3and others added 8 commits June 15, 2026 11:22
…per-invocation traces
- Add WORKFLOW_TRACE_MODE ('linked' default, 'continuous' legacy) to the
workflow and step queue handlers. In linked mode, WORKFLOW_V2/STEP spans
start a new trace root with span links to the incoming delivery context
and the run-origin context, and re-enqueued messages forward the
ORIGINAL run-origin trace carrier unchanged.
- world-vercel now explicitly injects W3C traceparent/tracestate/baggage
headers on outgoing workflow-server HTTP requests from inside the
client span (no-op without an OTEL SDK registered).
- New workflow.trace.mode span attribute; unit tests for both modes and
for header injection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Documents OTEL spans/attributes, linked trace mode and WORKFLOW_TRACE_MODE,
span links, context propagation, and the v4 behavior-change callout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WORKFLOW_V2/STEP prefixes with full machine names (workflow//./src/...//fn)
become workflow.execute / step.execute / workflow.start with the short
function name. New workflowDisplayName/stepDisplayName helpers in
@workflow/utils handle both raw and queue-sanitized name forms; full names
remain in the workflow.name/step.name attributes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ame edge cases, consumer span kind
- Treat an empty ({}) trace carrier as absent everywhere the trace-mode
logic branches, so linked mode falls back to a fresh origin instead of
forwarding a useless {} forever; workflow.trace.propagated now reports
whether a usable carrier arrived.
- Extract the duplicated linked-mode logic into shared telemetry helpers
getNextTraceCarrier() and buildInvocationSpanLinks(), used by both the
workflow and step queue handlers; resume-hook now uses
linkToTraceCarrier (gaining the isSpanContextValid guard).
- Warn once per distinct unrecognized WORKFLOW_TRACE_MODE value instead
of silently selecting linked.
- shortNameFromSanitized: map default/__default to the module short name
(mirroring parseName) and document the `$`-sanitization limitation.
- Queue-delivered workflow.execute spans now use the CONSUMER span kind,
matching queue-delivered step.execute spans; docs span table and
changeset updated accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

DiffPackageSupply Chain
Security
VulnerabilityQualityMaintenanceLicense
Addednpm/​@​opentelemetry/​context-async-hooks@​1.30.1741008896100

View full report

@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 926a5e7 (AI decision).

This is a deliberate telemetry-shape change that flips the default to the new linked trace mode, altering trace topology, per-run trace IDs, sampling semantics, and span names — a behavioral change explicitly scoped to the v5 beta major. The PR author explicitly requests no backport because applying it to GA v4 users mid-major would silently change their trace behavior, and the accompanying docs target only docs/content/docs/v5/, so it falls under "major/breaking changes intended for the next major release."

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

926a5e7c6a50c1e74f2e2cc37324caa0f6442d85

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

otel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces - #2363

Merged
karthikscale3 merged 8 commits into
mainfrom
karthik/otel-trace-correlation
Jun 15, 2026
Merged

otel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces#2363
karthikscale3 merged 8 commits into
mainfrom
karthik/otel-trace-correlation

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Problem: mega-traces

Today the workflow queue handlers restore the run-origin trace context from the message's traceCarrier and make it the parent of every WORKFLOW_V2 / STEP invocation span. Since each invocation re-serializes its own context onto the next queue message, a single workflow run becomes one giant trace — spanning hours of sleeps/retries and dozens of stitched-together function invocations. These mega-traces are slow to load, hard to read, and frequently broken in Datadog (span limits, late-arriving spans, partial flushes).

Separately, the world-vercel HTTP client creates a CLIENT span for every workflow-server request but never injects traceparent into the outgoing headers — propagation only happened if the customer's app happened to have undici auto-instrumentation, so workflow-server spans usually couldn't join the caller's trace.

Linked-trace mode (new default)

This PR introduces WORKFLOW_TRACE_MODE with two values:

  • linked (new default): each invocation's WORKFLOW_V2 <name> / STEP <name> span is created as a new trace root (SpanOptions.root: true) with span links to:

    • the incoming delivery context (the active span extracted from the queue delivery request — once the platform re-injects producer context on deliveries, this points at the enqueue site), and
    • the run-origin context from the message's traceCarrier (skipped when absent/invalid or identical to the delivery link).

    Re-enqueued messages forward the original run-origin traceCarrier unchanged, so every future invocation of the run links back to the same origin. Traces stay small and bounded per invocation, while links preserve full run-level correlation.

  • continuous: exactly the previous behavior — restored run-origin context parents the invocation span, with a link to the delivery context, and re-enqueues serialize the current context. Set WORKFLOW_TRACE_MODE=continuous to opt back in.

Both modes keep withWorkflowBaggage wrapping, all existing span attributes (including workflow.trace.propagated), and add a new workflow.trace.mode attribute recording the active mode.

Explicit traceparent injection on workflow-server calls

world-vercel's makeRequest now injects W3C context (traceparent, tracestate, baggage) into the outgoing request headers from inside the http <method> CLIENT span, via a new injectTraceContextIntoHeaders(headers) helper in world-vercel's lazy telemetry module. workflow-server can now reliably parent its spans to the SDK's client span regardless of the customer's instrumentation setup.

Queue sends (@vercel/queue) are intentionally untouched here — VQS treats message headers as allowlisted custom headers; HTTP-layer injection for queue sends is handled in the @vercel/queue client itself.

Behavioral changes to telemetry (please read)

The API is backward compatible, but the new linked default changes the shape of emitted traces in ways existing dashboards and queries can feel. Set WORKFLOW_TRACE_MODE=continuous to restore the previous shape exactly.

  1. A run no longer shares one trace ID. Previously, the trace of the request that called start() contained the entire workflow execution — every WORKFLOW_V2/STEP span across all invocations carried the run-origin trace ID. Now each invocation is its own root trace. Anything keyed on a shared per-run trace ID (saved trace queries, "open my request's trace and see the run" debugging flows, trace-ID joins) must switch to span links or the workflow.run.id attribute.
  2. Sampling semantics change. Parent-based samplers previously made one decision at start() that covered the whole run consistently. Each invocation root now samples independently — ratio samplers will produce partially-sampled runs, and the number of root spans/traces increases to one per invocation (relevant for trace-volume-based vendor billing and rate-limiting samplers).
  3. Parent/child topology changes.WORKFLOW_V2/STEP spans had a remote parent; they are now parentless roots. Queries filtering on parent relationships and service-map edges from the calling service to the workflow handler will change.
  4. Re-enqueue traceCarrier semantics change. Queue messages now forward the original run-origin carrier unchanged, rather than each invocation's current context. Custom worlds or tooling that introspect message carriers and assume "carrier = most recent invocation context" will observe different values.

Not changed: all existing span attributes and baggage keys, and the no-OTEL no-op behavior. One footnote: app-set baggage entries now also leave the process as a baggage HTTP request header on backend calls (they already left via traceCarrier in events).

Friendlier span names

Workflow/step span names previously used uppercase prefixes with full machine names (WORKFLOW_V2 workflow//./src/jobs/order//processOrder). They are now short and lowercase: workflow.execute processOrder, step.execute chargeCard, workflow.start processOrder. New workflowDisplayName/stepDisplayName helpers in @workflow/utils resolve both the raw machine name and the queue-sanitized form (workflow----src-jobs-order--processOrder) seen by queue handlers; unrecognized formats fall back to the raw string. The full machine name remains available in the workflow.name / step.name span attributes. This is also a span-name change for anyone querying WORKFLOW_V2/STEP names — same v5-beta reasoning as above.

Backward compatibility

  • No OTEL registered: everything no-ops exactly as before — @opentelemetry/api stays an optional peer dep, the default no-op propagator injects nothing, and no headers are added.
  • WORKFLOW_TRACE_MODE=continuous restores the prior trace shape bit-for-bit (parenting, links, and carrier chaining).
  • Servers ignore the new headers harmlessly:traceparent/tracestate/baggage are standard W3C headers; receivers without tracing simply drop them.

Testing

  • packages/core/src/runtime-trace-mode.test.ts: default is linked; linked creates a root span with links to both delivery + run-origin contexts; continuous preserves the legacy parented shape; linked forwards the original traceCarrier on re-enqueues while continuous serializes the current context. Uses a real in-memory OTEL SDK (BasicTracerProvider + InMemorySpanExporter + W3C propagator).
  • packages/world-vercel/src/trace-propagation.test.ts: traceparent lands on the outgoing request and matches the http GET client span; clean no-op without an active span context.
  • pnpm build, pnpm typecheck, full unit suites for packages/core (1124 passed) and packages/world-vercel (134 passed), Biome format/lint clean.

Backport policy

Do not backport to stable (v4). The linked default is a deliberate telemetry-shape change scoped to the v5 beta major — backporting it would change trace topology, per-run trace IDs, and sampling behavior for GA v4 users mid-major. v4 keeps its current behavior until users upgrade to v5; the platform side is fully tolerant of v4 SDKs.

Documentation

Adds docs/content/docs/v5/observability/tracing.mdx (linked from the Observability index): enabling OTEL, emitted spans and attributes, linked trace mode and span links, WORKFLOW_TRACE_MODE reference with a v4 behavior-change callout, and context-propagation/baggage notes. v4 docs intentionally untouched.

Rollout notes

Server-side support for storing and re-injecting trace context on queue deliveries ships separately on the platform. This PR is safe to merge and release independently: without the platform-side support, behavior is unchanged apart from the new (ignorable) W3C headers and the bounded linked-trace shape.

Follow-up: bump @vercel/queue in @workflow/world-vercel once a release with HTTP-layer trace-context injection is published, so queue sends carry trace headers as well.

🤖 Generated with Claude Code

@karthikscale3
karthikscale3 requested a review from a team as a code ownerJune 11, 2026 16:10
@vercel

vercelBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.041s (-8.4% 🟢)1.006s (~)0.965s101.00x
💻 LocalNitro0.045s (~)1.007s (~)0.961s101.12x
🐘 PostgresExpress0.061s (-3.3%)1.012s (~)0.951s101.50x
🐘 PostgresNitro0.061s (+3.9%)1.012s (~)0.951s101.51x
💻 LocalNext.js (Turbopack)0.062s (+3.7%)1.005s (~)0.943s101.54x
🐘 PostgresNext.js (Turbopack)0.069s (-1.4%)1.013s (~)0.944s101.70x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.308s (-6.5% 🟢)2.408s (+5.4% 🔺)2.099s101.00x
▲ VercelNitro0.331s (+31.6% 🔺)2.509s (+13.4% 🔺)2.179s101.07x
▲ VercelExpress0.423s (+34.2% 🔺)2.501s (-6.2% 🟢)2.077s101.37x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.089s (-1.3%)2.006s (~)0.917s101.00x
🐘 PostgresExpress1.109s (~)2.010s (~)0.901s101.02x
💻 LocalNitro1.110s (+1.7%)2.006s (~)0.896s101.02x
🐘 PostgresNitro1.117s (~)2.010s (~)0.894s101.02x
💻 LocalNext.js (Turbopack)1.140s (+1.0%)2.006s (~)0.866s101.05x
🐘 PostgresNext.js (Turbopack)1.161s (+1.9%)2.010s (~)0.849s101.07x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express1.680s (-9.9% 🟢)3.794s (+10.9% 🔺)2.114s101.00x
▲ VercelNext.js (Turbopack)1.765s (-17.8% 🟢)3.784s (+5.4% 🔺)2.019s101.05x
▲ VercelNitro1.800s (~)3.823s (+2.2%)2.023s101.07x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro10.556s (~)11.020s (~)0.464s31.00x
💻 LocalExpress10.566s (~)11.022s (~)0.456s31.00x
💻 LocalNitro10.574s (~)11.022s (~)0.448s31.00x
🐘 PostgresExpress10.576s (~)11.019s (~)0.443s31.00x
💻 LocalNext.js (Turbopack)10.794s (~)11.021s (~)0.227s31.02x
🐘 PostgresNext.js (Turbopack)10.946s (~)11.351s (~)0.405s31.04x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)14.431s (-14.2% 🟢)16.671s (-8.8% 🟢)2.240s21.00x
▲ VercelNitro14.546s (-2.8%)16.712s (-1.3%)2.166s21.01x
▲ VercelExpress14.781s (-7.0% 🟢)16.758s (-4.0%)1.977s21.02x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express13.761s (~)14.027s (~)0.266s51.00x
💻 LocalNitro13.782s (~)14.028s (~)0.246s51.00x
🐘 PostgresExpress13.786s (-0.5%)14.020s (~)0.233s51.00x
🐘 PostgresNitro13.862s (~)14.021s (~)0.159s51.01x
💻 LocalNext.js (Turbopack)14.321s (~)15.030s (~)0.709s41.04x
🐘 PostgresNext.js (Turbopack)14.461s (~)15.018s (~)0.557s41.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro23.321s (-20.2% 🟢)25.623s (-17.7% 🟢)2.302s31.00x
▲ VercelExpress23.347s (-15.1% 🟢)25.294s (-11.8% 🟢)1.947s31.00x
▲ VercelNext.js (Turbopack)23.574s (-18.5% 🟢)25.768s (-14.4% 🟢)2.194s31.01x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express12.399s (-0.9%)13.025s (~)0.626s71.00x
🐘 PostgresExpress12.452s (-0.5%)13.019s (~)0.567s71.00x
💻 LocalNitro12.585s (+1.0%)13.025s (~)0.440s71.01x
🐘 PostgresNitro12.914s (-1.3%)13.304s (~)0.390s71.04x
💻 LocalNext.js (Turbopack)13.639s (~)14.027s (-1.0%)0.388s71.10x
🐘 PostgresNext.js (Turbopack)14.158s (+1.3%)14.590s (~)0.432s71.14x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro32.772s (+2.6%)35.131s (+3.4%)2.359s31.00x
▲ VercelExpress34.356s (+4.2%)36.946s (+6.2% 🔺)2.590s31.05x
▲ VercelNext.js (Turbopack)34.608s (+7.4% 🔺)37.163s (+11.4% 🔺)2.555s31.06x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.196s (-1.7%)2.006s (~)0.810s151.00x
🐘 PostgresExpress1.217s (~)2.008s (~)0.791s151.02x
🐘 PostgresNitro1.241s (+1.2%)2.008s (~)0.768s151.04x
💻 LocalNitro1.241s (+2.4%)2.007s (~)0.765s151.04x
🐘 PostgresNext.js (Turbopack)1.291s (+1.6%)2.008s (~)0.717s151.08x
💻 LocalNext.js (Turbopack)1.374s (+6.9% 🔺)2.006s (~)0.632s151.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.381s (-6.9% 🟢)4.170s (+1.9%)1.789s81.00x
▲ VercelExpress2.418s (-49.2% 🟢)4.158s (-35.9% 🟢)1.740s81.02x
▲ VercelNext.js (Turbopack)2.438s (-53.9% 🟢)3.988s (-39.1% 🟢)1.550s81.02x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.413s (-6.1% 🟢)2.318s (~)0.905s131.00x
🐘 PostgresNitro1.415s (+2.9%)2.317s (-7.6% 🟢)0.902s131.00x
🐘 PostgresNext.js (Turbopack)1.655s (+6.0% 🔺)2.316s (+4.2%)0.661s131.17x
💻 LocalExpress1.721s (-6.7% 🟢)2.006s (-6.7% 🟢)0.284s151.22x
💻 LocalNext.js (Turbopack)1.820s (+12.1% 🔺)2.073s (+3.3%)0.254s151.29x
💻 LocalNitro2.079s (+16.6% 🔺)2.471s (+23.2% 🔺)0.392s131.47x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.682s (-11.9% 🟢)4.262s (-17.0% 🟢)1.580s81.00x
▲ VercelExpress3.635s (-3.3%)5.594s (+10.7% 🔺)1.959s71.36x
▲ VercelNext.js (Turbopack)4.256s (+1.4%)6.350s (+14.5% 🔺)2.094s51.59x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.544s (-7.3% 🟢)3.887s (-3.1%)2.343s81.00x
🐘 PostgresNitro1.657s (-7.4% 🟢)4.136s (+12.4% 🔺)2.478s81.07x
🐘 PostgresNext.js (Turbopack)3.528s (+5.1% 🔺)4.300s (+3.9%)0.772s72.29x
💻 LocalExpress4.700s (-15.7% 🟢)5.179s (-13.9% 🟢)0.479s63.04x
💻 LocalNext.js (Turbopack)5.238s (+12.7% 🔺)5.512s (+6.4% 🔺)0.274s63.39x
💻 LocalNitro6.891s (+34.2% 🔺)7.516s (+36.3% 🔺)0.625s44.46x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)4.729s (-24.8% 🟢)6.624s (-15.0% 🟢)1.895s51.00x
▲ VercelExpress5.058s (+11.2% 🔺)7.110s (+16.3% 🔺)2.052s51.07x
▲ VercelNitro5.934s (-13.6% 🟢)8.393s (-3.0%)2.459s41.25x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.212s (~)2.008s (~)0.796s151.00x
🐘 PostgresExpress1.219s (~)2.008s (-3.2%)0.789s151.01x
🐘 PostgresNext.js (Turbopack)1.326s (+2.8%)2.008s (~)0.682s151.09x
💻 LocalNext.js (Turbopack)1.392s (~)2.006s (~)0.614s151.15x
💻 LocalExpress1.577s (~)2.006s (~)0.430s151.30x
💻 LocalNitro1.635s (+2.6%)2.007s (~)0.372s151.35x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.345s (-15.9% 🟢)4.021s (-7.5% 🟢)1.676s81.00x
▲ VercelNext.js (Turbopack)2.375s (-3.8%)3.997s (+9.1% 🔺)1.623s81.01x
▲ VercelNitro3.013s (+19.4% 🔺)4.535s (+15.4% 🔺)1.522s71.28x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.364s (~)2.151s (-7.1% 🟢)0.788s141.00x
🐘 PostgresNitro1.404s (~)2.317s (~)0.913s131.03x
🐘 PostgresNext.js (Turbopack)1.557s (-4.2%)2.393s (+3.3%)0.836s131.14x
💻 LocalExpress2.014s (-13.4% 🟢)2.508s (-11.3% 🟢)0.494s121.48x
💻 LocalNext.js (Turbopack)2.020s (+1.0%)2.735s (+5.6% 🔺)0.715s111.48x
💻 LocalNitro2.227s (+2.2%)2.758s (+0.8%)0.531s121.63x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.771s (-3.9%)4.319s (~)1.548s81.00x
▲ VercelNext.js (Turbopack)2.915s (-1.8%)4.814s (+7.1% 🔺)1.900s71.05x
▲ VercelExpress3.336s (+2.4%)5.038s (+6.4% 🔺)1.702s61.20x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.772s (-4.9%)4.014s (-6.6% 🟢)2.241s81.00x
🐘 PostgresNitro1.886s (+17.2% 🔺)4.441s (+14.2% 🔺)2.556s71.06x
🐘 PostgresNext.js (Turbopack)3.388s (+3.7%)4.142s (-2.8%)0.754s81.91x
💻 LocalExpress5.290s (-24.9% 🟢)5.850s (-23.2% 🟢)0.560s62.99x
💻 LocalNext.js (Turbopack)6.189s (+38.2% 🔺)6.416s (+23.9% 🔺)0.226s53.49x
💻 LocalNitro6.932s (+19.4% 🔺)7.218s (+12.5% 🔺)0.286s53.91x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.744s (-28.8% 🟢)6.281s (-1.9%)2.537s51.00x
▲ VercelNext.js (Turbopack)3.946s (-18.4% 🟢)5.974s (-9.9% 🟢)2.028s61.05x
▲ VercelNitro4.515s (-3.1%)6.349s (+1.5%)1.834s51.21x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.586s (-2.8%)1.006s (-1.7%)0.420s601.00x
🐘 PostgresExpress0.593s (+2.9%)1.041s (+3.5%)0.448s581.01x
💻 LocalExpress0.615s (-3.4%)1.005s (-3.3%)0.390s601.05x
💻 LocalNitro0.670s (+4.6%)1.040s (+1.8%)0.370s581.14x
🐘 PostgresNext.js (Turbopack)0.835s (~)1.023s (~)0.189s591.42x
💻 LocalNext.js (Turbopack)0.870s (+2.0%)1.004s (~)0.135s601.48x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.493s (-24.9% 🟢)8.569s (-15.7% 🟢)2.076s81.00x
▲ VercelNitro6.528s (-27.6% 🟢)8.584s (-18.7% 🟢)2.056s71.01x
▲ VercelNext.js (Turbopack)6.529s (~)8.488s (+6.1% 🔺)1.959s81.01x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.388s (-5.6% 🟢)2.007s (-3.3%)0.619s451.00x
🐘 PostgresNitro1.407s (-3.4%)2.008s (-2.2%)0.601s451.01x
💻 LocalExpress1.514s (-1.9%)2.006s (~)0.491s451.09x
💻 LocalNitro1.646s (+8.0% 🔺)2.030s (+1.2%)0.383s451.19x
🐘 PostgresNext.js (Turbopack)1.991s (+2.6%)2.367s (+12.7% 🔺)0.375s391.43x
💻 LocalNext.js (Turbopack)2.174s (+3.6%)3.008s (+2.2%)0.835s301.57x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express15.578s (-29.1% 🟢)18.039s (-23.2% 🟢)2.461s51.00x
▲ VercelNitro16.348s (-9.2% 🟢)18.489s (-6.5% 🟢)2.141s51.05x
▲ VercelNext.js (Turbopack)16.650s (-9.7% 🟢)19.019s (-4.4%)2.369s51.07x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.822s (+1.1%)3.137s (~)0.315s391.00x
🐘 PostgresExpress2.869s (+3.9%)3.280s (+6.3% 🔺)0.410s371.02x
💻 LocalExpress3.265s (-0.8%)4.009s (~)0.744s301.16x
💻 LocalNitro3.436s (+4.4%)4.077s (+1.7%)0.640s301.22x
🐘 PostgresNext.js (Turbopack)3.936s (+2.7%)4.183s (+3.5%)0.247s291.39x
💻 LocalNext.js (Turbopack)4.327s (-3.0%)5.010s (-0.8%)0.683s241.53x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro29.059s (-8.3% 🟢)31.839s (-4.9%)2.780s41.00x
▲ VercelExpress30.029s (-9.5% 🟢)33.287s (-3.4%)3.258s41.03x
▲ VercelNext.js (Turbopack)32.694s (-1.4%)35.494s (+3.0%)2.800s41.13x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.256s (+1.6%)1.006s (~)0.750s601.00x
🐘 PostgresNitro0.256s (-1.4%)1.006s (-1.6%)0.750s601.00x
🐘 PostgresNext.js (Turbopack)0.302s (+1.2%)1.006s (~)0.704s601.18x
💻 LocalNitro0.431s (+1.5%)1.005s (~)0.574s601.68x
💻 LocalExpress0.435s (-1.5%)1.004s (~)0.569s601.70x
💻 LocalNext.js (Turbopack)0.521s (-12.1% 🟢)1.005s (-1.7%)0.484s602.04x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.115s (-15.5% 🟢)3.980s (-1.9%)1.866s161.00x
▲ VercelNext.js (Turbopack)2.203s (+7.0% 🔺)4.126s (+21.7% 🔺)1.922s151.04x
▲ VercelNitro2.523s (+18.9% 🔺)4.388s (+13.4% 🔺)1.865s141.19x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.415s (+3.7%)1.041s (~)0.627s871.00x
🐘 PostgresNitro0.424s (+0.5%)1.041s (-2.3%)0.617s871.02x
🐘 PostgresNext.js (Turbopack)0.577s (-7.4% 🟢)1.078s (-8.3% 🟢)0.501s841.39x
💻 LocalNitro2.071s (-7.6% 🟢)2.658s (-3.9%)0.586s345.00x
💻 LocalExpress2.325s (+13.4% 🔺)2.853s (+7.4% 🔺)0.528s325.61x
💻 LocalNext.js (Turbopack)2.611s (~)3.334s (-1.5%)0.723s286.30x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.672s (+1.7%)5.578s (+4.3%)1.907s171.00x
▲ VercelExpress3.683s (+11.8% 🔺)5.655s (+19.5% 🔺)1.972s161.00x
▲ VercelNitro3.775s (-16.7% 🟢)5.889s (-5.0% 🟢)2.114s161.03x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.785s (-0.8%)1.271s (-6.3% 🟢)0.486s951.00x
🐘 PostgresNitro0.844s (+3.8%)1.371s (-7.6% 🟢)0.527s881.08x
🐘 PostgresNext.js (Turbopack)2.677s (-6.4% 🟢)3.711s (+2.2%)1.034s333.41x
💻 LocalExpress9.827s (-3.2%)10.362s (-3.9%)0.535s1212.52x
💻 LocalNitro9.859s (+3.5%)10.363s (+1.7%)0.504s1212.56x
💻 LocalNext.js (Turbopack)10.346s (-5.7% 🟢)11.301s (-4.6%)0.956s1113.18x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.976s (-14.5% 🟢)8.118s (-6.4% 🟢)2.142s151.00x
▲ VercelExpress6.135s (-15.9% 🟢)8.528s (-3.9%)2.394s151.03x
▲ VercelNext.js (Turbopack)7.343s (-10.4% 🟢)9.606s (-1.9%)2.264s131.23x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.165s (~)2.004s (~)0.011s (-15.3% 🟢)2.017s (~)0.852s101.00x
💻 LocalNitro1.172s (~)2.005s (~)0.013s (+7.6% 🔺)2.021s (~)0.849s101.01x
🐘 PostgresExpress1.179s (~)2.001s (~)0.001s (~)2.011s (~)0.832s101.01x
🐘 PostgresNitro1.182s (+1.3%)1.997s (~)0.001s (-7.1% 🟢)2.010s (~)0.829s101.01x
💻 LocalNext.js (Turbopack)1.205s (~)2.003s (~)0.012s (+10.2% 🔺)2.019s (~)0.814s101.03x
🐘 PostgresNext.js (Turbopack)1.244s (+0.8%)2.001s (~)0.001s (+11.1% 🔺)2.011s (~)0.767s101.07x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.637s (+5.1% 🔺)3.726s (+10.3% 🔺)1.437s (+19.6% 🔺)5.659s (+13.9% 🔺)3.022s101.00x
▲ VercelNitro2.776s (+19.7% 🔺)4.150s (+24.5% 🔺)0.672s (-37.1% 🟢)5.469s (+13.2% 🔺)2.693s101.05x
▲ VercelExpress2.788s (+17.0% 🔺)4.107s (+28.5% 🔺)1.469s (+11.7% 🔺)6.069s (+23.3% 🔺)3.281s101.06x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.568s (-1.8%)2.006s (~)0.005s (+1.3%)2.028s (~)0.460s301.00x
💻 LocalNitro1.602s (~)2.010s (~)0.011s (-12.0% 🟢)2.024s (~)0.422s301.02x
💻 LocalExpress1.602s (~)2.009s (~)0.015s (+17.6% 🔺)2.027s (~)0.425s301.02x
🐘 PostgresNitro1.638s (+2.7%)2.002s (~)0.005s (-2.0%)2.027s (~)0.389s301.04x
💻 LocalNext.js (Turbopack)1.734s (+0.9%)2.008s (~)0.012s (-1.1%)2.024s (~)0.290s301.11x
🐘 PostgresNext.js (Turbopack)1.796s (+2.0%)2.009s (~)0.006s (+8.2% 🔺)2.030s (~)0.234s301.15x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.353s (-6.8% 🟢)7.876s (~)0.536s (+132.4% 🔺)8.902s (+3.4%)2.550s71.00x
▲ VercelNitro6.526s (-1.8%)8.158s (+3.1%)0.330s (+49.2% 🔺)8.975s (+4.3%)2.448s71.03x
▲ VercelNext.js (Turbopack)6.780s (-1.5%)8.291s (+3.3%)0.629s (+158.3% 🔺)9.487s (+8.5% 🔺)2.707s71.07x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.776s (+1.9%)1.029s (-1.8%)0.000s (-33.3% 🟢)1.061s (~)0.286s571.00x
🐘 PostgresNitro0.868s (+9.4% 🔺)1.079s (+3.0%)0.000s (-100.0% 🟢)1.122s (+5.8% 🔺)0.254s541.12x
🐘 PostgresNext.js (Turbopack)1.025s (+2.7%)1.469s (+2.8%)0.000s (+Infinity% 🔺)1.486s (+3.5%)0.461s411.32x
💻 LocalNitro1.427s (+3.1%)2.014s (~)0.000s (-11.1% 🟢)2.016s (~)0.589s301.84x
💻 LocalNext.js (Turbopack)1.476s (-0.6%)2.013s (~)0.000s (+133.3% 🔺)2.016s (~)0.540s301.90x
💻 LocalExpress1.492s (+2.1%)2.014s (~)0.000s (-65.2% 🟢)2.016s (~)0.524s301.92x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.152s (-28.2% 🟢)4.709s (-17.2% 🟢)0.001s (-25.9% 🟢)5.242s (-16.1% 🟢)2.090s121.00x
▲ VercelExpress3.292s (-31.2% 🟢)4.944s (-10.0% 🟢)0.000s (+81.8% 🔺)5.485s (-10.9% 🟢)2.193s111.04x
▲ VercelNext.js (Turbopack)3.597s (-0.7%)5.203s (+9.6% 🔺)0.000s (NaN%)5.709s (+10.4% 🔺)2.112s121.14x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

fan-out fan-in 10 streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.522s (-8.1% 🟢)2.063s (-5.1% 🟢)0.000s (+93.1% 🔺)2.093s (-4.8%)0.570s291.00x
🐘 PostgresNitro1.659s (+2.3%)2.258s (+5.5% 🔺)0.000s (+3.7%)2.273s (+5.6% 🔺)0.614s271.09x
🐘 PostgresNext.js (Turbopack)2.252s (+7.5% 🔺)2.729s (+2.8%)0.000s (+213.6% 🔺)2.776s (+4.3%)0.524s221.48x
💻 LocalNext.js (Turbopack)2.852s (-2.2%)3.470s (-1.5%)0.001s (+175.0% 🔺)3.473s (-1.6%)0.621s181.87x
💻 LocalNitro3.071s (+1.2%)3.674s (~)0.001s (-9.1% 🟢)3.677s (~)0.606s172.02x
💻 LocalExpress3.290s (+2.6%)4.026s (+1.5%)0.001s (+353.3% 🔺)4.030s (+1.6%)0.740s152.16x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro5.411s (-11.9% 🟢)7.163s (-5.1% 🟢)0.004s (+Infinity% 🔺)7.652s (-4.2%)2.241s81.00x
▲ VercelNext.js (Turbopack)6.145s (+25.8% 🔺)7.777s (+25.8% 🔺)0.000s (+Infinity% 🔺)8.258s (+25.1% 🔺)2.113s81.14x
▲ VercelExpress6.943s (+39.7% 🔺)8.421s (+43.0% 🔺)0.000s (NaN%)8.996s (+42.1% 🔺)2.053s81.28x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalExpress15/21
🐘 PostgresExpress17/21
▲ VercelNitro9/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres14/21
Next.js (Turbopack)🐘 Postgres14/21
Nitro🐘 Postgres15/21
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Redis + BullMQ: Community world (local development)
  • 🌐 Cloudflare: Community world (local development)
  • 🌐 MySQL: Community world (local development)
  • 🌐 Azure: Community world (local development)
  • 🌐 NATS JetStream: Community world (local development)
  • 🌐 Upstash: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production144202191661
✅ 💻 Local Development189502192114
✅ 📦 Local Production189502192114
✅ 🐘 Local Postgres188102332114
✅ 🪟 Windows15100151
✅ 📋 Other87901781057
Total8143010689211

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro125026
✅ example125026
✅ express125026
✅ fastify125026
✅ hono125026
✅ nextjs-turbopack14902
✅ nextjs-webpack14902
✅ nitro125026
✅ nuxt125026
✅ sveltekit14407
✅ vite125026
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable126025
✅ express-stable126025
✅ fastify-stable126025
✅ hono-stable126025
✅ nextjs-turbopack-canary132019
✅ nextjs-turbopack-stable-lazy-discovery-disabled15100
✅ nextjs-turbopack-stable-lazy-discovery-enabled15100
✅ nextjs-webpack-canary132019
✅ nextjs-webpack-stable-lazy-discovery-disabled15100
✅ nextjs-webpack-stable-lazy-discovery-enabled15100
✅ nitro-stable126025
✅ nuxt-stable126025
✅ sveltekit-stable14506
✅ vite-stable126025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable126025
✅ express-stable126025
✅ fastify-stable126025
✅ hono-stable126025
✅ nextjs-turbopack-canary132019
✅ nextjs-turbopack-stable-lazy-discovery-disabled15100
✅ nextjs-turbopack-stable-lazy-discovery-enabled15100
✅ nextjs-webpack-canary132019
✅ nextjs-webpack-stable-lazy-discovery-disabled15100
✅ nextjs-webpack-stable-lazy-discovery-enabled15100
✅ nitro-stable126025
✅ nuxt-stable126025
✅ sveltekit-stable14506
✅ vite-stable126025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable125026
✅ express-stable125026
✅ fastify-stable125026
✅ hono-stable125026
✅ nextjs-turbopack-canary131020
✅ nextjs-turbopack-stable-lazy-discovery-disabled15001
✅ nextjs-turbopack-stable-lazy-discovery-enabled15001
✅ nextjs-webpack-canary131020
✅ nextjs-webpack-stable-lazy-discovery-disabled15001
✅ nextjs-webpack-stable-lazy-discovery-enabled15001
✅ nitro-stable125026
✅ nuxt-stable125026
✅ sveltekit-stable14407
✅ vite-stable125026
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15100
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable126025
✅ e2e-local-dev-tanstack-start-126025
✅ e2e-local-postgres-nest-stable125026
✅ e2e-local-postgres-tanstack-start-125026
✅ e2e-local-prod-nest-stable126025
✅ e2e-local-prod-tanstack-start-126025
✅ e2e-vercel-prod-tanstack-start125026

📋 View full workflow run

@changeset-bot

changeset-botBot commented Jun 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 22c35aa

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

This PR includes changesets to release 22 packages
NameType
@workflow/coreMinor
workflowMinor
@workflow/world-vercelMinor
@workflow/utilsMinor
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/world-testingPatch
@workflow/aiMajor
@workflow/errorsPatch
@workflow/world-localPatch
@workflow/world-postgresPatch
@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

@pranaygppranaygp 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.

Reviewed with a focus on attribute consistency, forwards compatibility, DX, and perf overhead. Overall this is solid: the linked-mode semantics are coherent with the other three PRs in the stack (baggage keys match what workflow-server#514 reads; the run-origin carrier semantics match the server's executionContext.traceCarrier-based span links, which are also pinned to run origin; world-vercel consumes deliveries via @vercel/queue.handleCallback, so vqs#181's consumer-side extraction is exactly what feeds linkToCurrentContext). Perf-wise the change is a net reduction when OTEL is active (linked mode skips a propagation.inject per re-enqueue) and stays a memoized no-op without an SDK. Ran the new test suites and typecheck locally — all green.

No blocking issues. Inline comments below: one behavioral edge around empty {} carriers in linked mode, a code-duplication suggestion, a DX nit on unrecognized WORKFLOW_TRACE_MODE values, two display-name edge cases, and a doc accuracy fix on span kinds.

Comment threadpackages/core/src/runtime.ts Outdated
// continuous mode the current (active) context is serialized so the
// trace keeps chaining.
const getNextTraceCarrier = (): Promise<Record<string, string>> =>
traceMode === 'linked' && traceContext

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.

traceContext here can be {} and still take the linked branch: start() always attaches a carrier, and serializeTraceCarrier() returns {} both when no OTEL SDK is registered at the origin and when OTEL is registered but start() runs outside any active span (background job, script). For such runs, linked mode forwards the empty object forever, while the undefined branch adaptively falls back to serializeTraceCarrier() (making the first instrumented invocation the de-facto run origin for future links).

Consider treating an empty carrier like an absent one — e.g. traceContext && Object.keys(traceContext).length > 0 — so both "no usable origin" shapes behave the same (same applies to the copy in step-handler.ts). Related side effect (pre-existing, but more visible now): workflow.trace.propagated is !!traceContext, so it reports true for {} even though there's nothing usable to link to.

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.

Fixed in 5b3ca9f. Added isUsableTraceCarrier() and normalized the incoming carrier at the top of both queue handlers, so {} counts as "no usable origin" everywhere the mode logic branches — linked mode falls back to serializing the current context (first instrumented invocation becomes the de-facto origin) instead of forwarding {} forever. Also took the related side effect: workflow.trace.propagated now reports whether a usable (non-empty) carrier arrived. Test added pinning traceCarrier: {} ≡ no carrier.

// so every future invocation links back to the same origin; in
// continuous mode the current (active) context is serialized so the
// trace keeps chaining.
const getNextTraceCarrier = (): Promise<Record<string, string>> =>

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.

This block — getNextTraceCarrier plus the origin-link dedup below (lines ~191–210) — is duplicated nearly verbatim from runtime.ts (~343–366). Since this encodes the core linked-mode invariants (forward the original carrier; dedup origin vs delivery link), consider extracting two small helpers into telemetry.ts, e.g. nextTraceCarrier(traceMode, traceContext) and buildInvocationSpanLinks(traceMode, traceContext), so the semantics can't drift between the workflow and step handlers.

Bonus if you do: resume-hook.ts (~186–193) has a hand-rolled version of carrier→link that lacks the isSpanContextValid guard your new linkToTraceCarrier has — it could reuse the helper too.

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.

Fixed in 5b3ca9f. Extracted both invariants into telemetry.ts as getNextTraceCarrier(traceMode, incomingCarrier) and buildInvocationSpanLinks(traceMode, incomingCarrier) (exact prior semantics, pinned by the existing trace-mode tests), now used by both runtime.ts and step-handler.ts. Took the bonus too: resume-hook.ts now uses linkToTraceCarrier and gains the isSpanContextValid guard it was missing.

Comment threadpackages/core/src/telemetry.ts Outdated
* Defaults to `'linked'`; any value other than `'continuous'` selects it.
*/
export function getWorkflowTraceMode(): WorkflowTraceMode {
return process.env.WORKFLOW_TRACE_MODE === 'continuous'

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.

Any unrecognized value silently selects linked — a typo like WORKFLOW_TRACE_MODE=continous changes trace topology with zero signal, and if a future SDK version adds a third mode, older SDKs will silently reinterpret it as linked. A one-time runtimeLogger.warn for non-empty unrecognized values would make misconfiguration debuggable and give forward compatibility a soft landing. (Resolving once into a module-level constant would also give you the warn-once behavior for free — the env var can't meaningfully change mid-process anyway.)

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.

Fixed in 5b3ca9f. Kept the dynamic per-call env read (the trace-mode tests flip WORKFLOW_TRACE_MODE per test, so a module-level constant would break them) and added a one-time runtimeLogger.warn per distinct unrecognized non-empty value, naming the value and the accepted ones before falling back to linked. Test asserts the warning fires exactly once for a continous typo.

Comment threadpackages/utils/src/parse-name.ts Outdated
if (!name.startsWith(`${tag}--`)) return null;
// The `//` separators became `--`, and within the function-name part any
// nested-function `/` became `-`. Function names are JS identifiers (no
// dashes), so the innermost name is the last dash-free segment.

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.

Two best-effort edges worth noting in this comment (or handling):

  1. $ is a valid JS identifier character and gets sanitized to -, so step//…//process$Order in sanitized form displays as Order — "no dashes" isn't strictly true for identifiers.
  2. Default exports diverge between the two input forms: parseName maps default/__default to the module short name, but this sanitized path returns the literal default. The same workflow can then show as workflow.start order (raw name in start()) but workflow.execute default (sanitized name in the queue handler). Mapping default to the preceding segment here would keep the two span names consistent.

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.

Fixed in 5b3ca9f. (2) is handled: shortNameFromSanitized now maps default/__default to the preceding module segment's short name, mirroring parseName, so default exports display consistently (e.g. order) in both workflow.start and workflow.execute — pinned by a test. (1) is documented as an accepted best-effort limitation in the comment ($ sanitizes to -, so process$Order displays as Order), with a test pinning the behavior.

| --- | --- | --- |
| `workflow.start <name>` | internal | `start()` is called in your application code |
| `workflow.execute <name>` | internal (root) | a queue delivery invokes the workflow — replay, orchestration, and inline steps run under it |
| `step.execute <name>` | internal | a step function executes |

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.

Kind is inaccurate for the queue-delivered case: step-handler.ts creates this span with SpanKind.CONSUMER (only inline steps executed within workflow.execute are internal), and in linked mode the queue-delivered step.execute span is also a new trace root, same as workflow.execute. Suggest something like: internal (inline) / consumer + root (queue-delivered).

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.

Fixed in 5b3ca9f. Table now reads workflow.executeconsumer (root) (it's CONSUMER as of this commit, see the other thread) and step.executeinternal (inline) / consumer + root (queue-delivered), per your suggested wording.

return trace(
`WORKFLOW_V2 ${workflowName}`,
{ links: spanLinks },
`workflow.execute ${workflowDisplayName(workflowName)}`,

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.

Pre-existing inconsistency, but this PR's v5 window is the cheapest moment to fix it: this queue-delivered span has default INTERNAL kind while the equivalent queue-delivered step.execute span uses CONSUMER. Messaging semconv would suggest CONSUMER here too — and it would pair nicely with the PRODUCER-kind vqs.send span being added on the other side in vercel/vqs#181. Fine as a follow-up, but if you want it, doing it inside the same beta avoids a second span-shape change.

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 5b3ca9f — agreed this beta is the cheapest window. The queue-delivered workflow.execute span now sets kind: CONSUMER via the same getSpanKind('CONSUMER') pattern step-handler uses (both modes), pairing with the PRODUCER vqs.send span in vercel/vqs#181. Added a SpanKind.CONSUMER assertion to the trace-mode test and a changeset bullet noting the internal→consumer kind change.

@pranaygppranaygp 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.

Pre-emptively approving — no blocking bugs, perf is clean (net reduction when OTEL is active, memoized no-op without it), and the cross-repo semantics line up with vqs-server#615 / workflow-server#514 / vqs#181.

@karthikscale3 please address the inline comments from my review (#2363 (review)) before merging — in particular:

  1. Empty {} carrier in linked mode (runtime.ts:344 + the step-handler.ts copy): runs started from uninstrumented contexts silently lose run-level correlation links; treat an empty carrier like an absent one.
  2. Silent fallback on unrecognized WORKFLOW_TRACE_MODE values (telemetry.ts:31): a typo flips trace topology with zero signal; add a warn-once.

The rest (dedup extraction, display-name edges, docs span-kind row, CONSUMER kind for workflow.execute) are nice-to-haves — fine in this PR or as follow-ups.

karthikscale3and others added 8 commits June 15, 2026 11:22
…per-invocation traces
- Add WORKFLOW_TRACE_MODE ('linked' default, 'continuous' legacy) to the
workflow and step queue handlers. In linked mode, WORKFLOW_V2/STEP spans
start a new trace root with span links to the incoming delivery context
and the run-origin context, and re-enqueued messages forward the
ORIGINAL run-origin trace carrier unchanged.
- world-vercel now explicitly injects W3C traceparent/tracestate/baggage
headers on outgoing workflow-server HTTP requests from inside the
client span (no-op without an OTEL SDK registered).
- New workflow.trace.mode span attribute; unit tests for both modes and
for header injection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Documents OTEL spans/attributes, linked trace mode and WORKFLOW_TRACE_MODE,
span links, context propagation, and the v4 behavior-change callout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WORKFLOW_V2/STEP prefixes with full machine names (workflow//./src/...//fn)
become workflow.execute / step.execute / workflow.start with the short
function name. New workflowDisplayName/stepDisplayName helpers in
@workflow/utils handle both raw and queue-sanitized name forms; full names
remain in the workflow.name/step.name attributes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ame edge cases, consumer span kind
- Treat an empty ({}) trace carrier as absent everywhere the trace-mode
logic branches, so linked mode falls back to a fresh origin instead of
forwarding a useless {} forever; workflow.trace.propagated now reports
whether a usable carrier arrived.
- Extract the duplicated linked-mode logic into shared telemetry helpers
getNextTraceCarrier() and buildInvocationSpanLinks(), used by both the
workflow and step queue handlers; resume-hook now uses
linkToTraceCarrier (gaining the isSpanContextValid guard).
- Warn once per distinct unrecognized WORKFLOW_TRACE_MODE value instead
of silently selecting linked.
- shortNameFromSanitized: map default/__default to the module short name
(mirroring parseName) and document the `$`-sanitization limitation.
- Queue-delivered workflow.execute spans now use the CONSUMER span kind,
matching queue-delivered step.execute spans; docs span table and
changeset updated accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

DiffPackageSupply Chain
Security
VulnerabilityQualityMaintenanceLicense
Addednpm/​@​opentelemetry/​context-async-hooks@​1.30.1741008896100

View full report

@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 926a5e7 (AI decision).

This is a deliberate telemetry-shape change that flips the default to the new linked trace mode, altering trace topology, per-run trace IDs, sampling semantics, and span names — a behavioral change explicitly scoped to the v5 beta major. The PR author explicitly requests no backport because applying it to GA v4 users mid-major would silently change their trace behavior, and the accompanying docs target only docs/content/docs/v5/, so it falls under "major/breaking changes intended for the next major release."

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

926a5e7c6a50c1e74f2e2cc37324caa0f6442d85

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@pranaygp