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

@karthikscale3 karthikscale3 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 owner June 11, 2026 16:10
@vercel

vercel Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

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

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

@github-actions

github-actions Bot 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

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 0.041s (-8.4% 🟢) 1.006s (~) 0.965s 10 1.00x
💻 Local Nitro 0.045s (~) 1.007s (~) 0.961s 10 1.12x
🐘 Postgres Express 0.061s (-3.3%) 1.012s (~) 0.951s 10 1.50x
🐘 Postgres Nitro 0.061s (+3.9%) 1.012s (~) 0.951s 10 1.51x
💻 Local Next.js (Turbopack) 0.062s (+3.7%) 1.005s (~) 0.943s 10 1.54x
🐘 Postgres Next.js (Turbopack) 0.069s (-1.4%) 1.013s (~) 0.944s 10 1.70x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 0.308s (-6.5% 🟢) 2.408s (+5.4% 🔺) 2.099s 10 1.00x
▲ Vercel Nitro 0.331s (+31.6% 🔺) 2.509s (+13.4% 🔺) 2.179s 10 1.07x
▲ Vercel Express 0.423s (+34.2% 🔺) 2.501s (-6.2% 🟢) 2.077s 10 1.37x

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

workflow with 1 step

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 1.089s (-1.3%) 2.006s (~) 0.917s 10 1.00x
🐘 Postgres Express 1.109s (~) 2.010s (~) 0.901s 10 1.02x
💻 Local Nitro 1.110s (+1.7%) 2.006s (~) 0.896s 10 1.02x
🐘 Postgres Nitro 1.117s (~) 2.010s (~) 0.894s 10 1.02x
💻 Local Next.js (Turbopack) 1.140s (+1.0%) 2.006s (~) 0.866s 10 1.05x
🐘 Postgres Next.js (Turbopack) 1.161s (+1.9%) 2.010s (~) 0.849s 10 1.07x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 1.680s (-9.9% 🟢) 3.794s (+10.9% 🔺) 2.114s 10 1.00x
▲ Vercel Next.js (Turbopack) 1.765s (-17.8% 🟢) 3.784s (+5.4% 🔺) 2.019s 10 1.05x
▲ Vercel Nitro 1.800s (~) 3.823s (+2.2%) 2.023s 10 1.07x

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

workflow with 10 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 10.556s (~) 11.020s (~) 0.464s 3 1.00x
💻 Local Express 10.566s (~) 11.022s (~) 0.456s 3 1.00x
💻 Local Nitro 10.574s (~) 11.022s (~) 0.448s 3 1.00x
🐘 Postgres Express 10.576s (~) 11.019s (~) 0.443s 3 1.00x
💻 Local Next.js (Turbopack) 10.794s (~) 11.021s (~) 0.227s 3 1.02x
🐘 Postgres Next.js (Turbopack) 10.946s (~) 11.351s (~) 0.405s 3 1.04x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 14.431s (-14.2% 🟢) 16.671s (-8.8% 🟢) 2.240s 2 1.00x
▲ Vercel Nitro 14.546s (-2.8%) 16.712s (-1.3%) 2.166s 2 1.01x
▲ Vercel Express 14.781s (-7.0% 🟢) 16.758s (-4.0%) 1.977s 2 1.02x

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

workflow with 25 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 13.761s (~) 14.027s (~) 0.266s 5 1.00x
💻 Local Nitro 13.782s (~) 14.028s (~) 0.246s 5 1.00x
🐘 Postgres Express 13.786s (-0.5%) 14.020s (~) 0.233s 5 1.00x
🐘 Postgres Nitro 13.862s (~) 14.021s (~) 0.159s 5 1.01x
💻 Local Next.js (Turbopack) 14.321s (~) 15.030s (~) 0.709s 4 1.04x
🐘 Postgres Next.js (Turbopack) 14.461s (~) 15.018s (~) 0.557s 4 1.05x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 23.321s (-20.2% 🟢) 25.623s (-17.7% 🟢) 2.302s 3 1.00x
▲ Vercel Express 23.347s (-15.1% 🟢) 25.294s (-11.8% 🟢) 1.947s 3 1.00x
▲ Vercel Next.js (Turbopack) 23.574s (-18.5% 🟢) 25.768s (-14.4% 🟢) 2.194s 3 1.01x

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

workflow with 50 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 12.399s (-0.9%) 13.025s (~) 0.626s 7 1.00x
🐘 Postgres Express 12.452s (-0.5%) 13.019s (~) 0.567s 7 1.00x
💻 Local Nitro 12.585s (+1.0%) 13.025s (~) 0.440s 7 1.01x
🐘 Postgres Nitro 12.914s (-1.3%) 13.304s (~) 0.390s 7 1.04x
💻 Local Next.js (Turbopack) 13.639s (~) 14.027s (-1.0%) 0.388s 7 1.10x
🐘 Postgres Next.js (Turbopack) 14.158s (+1.3%) 14.590s (~) 0.432s 7 1.14x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 32.772s (+2.6%) 35.131s (+3.4%) 2.359s 3 1.00x
▲ Vercel Express 34.356s (+4.2%) 36.946s (+6.2% 🔺) 2.590s 3 1.05x
▲ Vercel Next.js (Turbopack) 34.608s (+7.4% 🔺) 37.163s (+11.4% 🔺) 2.555s 3 1.06x

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

Promise.all with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 1.196s (-1.7%) 2.006s (~) 0.810s 15 1.00x
🐘 Postgres Express 1.217s (~) 2.008s (~) 0.791s 15 1.02x
🐘 Postgres Nitro 1.241s (+1.2%) 2.008s (~) 0.768s 15 1.04x
💻 Local Nitro 1.241s (+2.4%) 2.007s (~) 0.765s 15 1.04x
🐘 Postgres Next.js (Turbopack) 1.291s (+1.6%) 2.008s (~) 0.717s 15 1.08x
💻 Local Next.js (Turbopack) 1.374s (+6.9% 🔺) 2.006s (~) 0.632s 15 1.15x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.381s (-6.9% 🟢) 4.170s (+1.9%) 1.789s 8 1.00x
▲ Vercel Express 2.418s (-49.2% 🟢) 4.158s (-35.9% 🟢) 1.740s 8 1.02x
▲ Vercel Next.js (Turbopack) 2.438s (-53.9% 🟢) 3.988s (-39.1% 🟢) 1.550s 8 1.02x

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

Promise.all with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.413s (-6.1% 🟢) 2.318s (~) 0.905s 13 1.00x
🐘 Postgres Nitro 1.415s (+2.9%) 2.317s (-7.6% 🟢) 0.902s 13 1.00x
🐘 Postgres Next.js (Turbopack) 1.655s (+6.0% 🔺) 2.316s (+4.2%) 0.661s 13 1.17x
💻 Local Express 1.721s (-6.7% 🟢) 2.006s (-6.7% 🟢) 0.284s 15 1.22x
💻 Local Next.js (Turbopack) 1.820s (+12.1% 🔺) 2.073s (+3.3%) 0.254s 15 1.29x
💻 Local Nitro 2.079s (+16.6% 🔺) 2.471s (+23.2% 🔺) 0.392s 13 1.47x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.682s (-11.9% 🟢) 4.262s (-17.0% 🟢) 1.580s 8 1.00x
▲ Vercel Express 3.635s (-3.3%) 5.594s (+10.7% 🔺) 1.959s 7 1.36x
▲ Vercel Next.js (Turbopack) 4.256s (+1.4%) 6.350s (+14.5% 🔺) 2.094s 5 1.59x

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

Promise.all with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.544s (-7.3% 🟢) 3.887s (-3.1%) 2.343s 8 1.00x
🐘 Postgres Nitro 1.657s (-7.4% 🟢) 4.136s (+12.4% 🔺) 2.478s 8 1.07x
🐘 Postgres Next.js (Turbopack) 3.528s (+5.1% 🔺) 4.300s (+3.9%) 0.772s 7 2.29x
💻 Local Express 4.700s (-15.7% 🟢) 5.179s (-13.9% 🟢) 0.479s 6 3.04x
💻 Local Next.js (Turbopack) 5.238s (+12.7% 🔺) 5.512s (+6.4% 🔺) 0.274s 6 3.39x
💻 Local Nitro 6.891s (+34.2% 🔺) 7.516s (+36.3% 🔺) 0.625s 4 4.46x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 4.729s (-24.8% 🟢) 6.624s (-15.0% 🟢) 1.895s 5 1.00x
▲ Vercel Express 5.058s (+11.2% 🔺) 7.110s (+16.3% 🔺) 2.052s 5 1.07x
▲ Vercel Nitro 5.934s (-13.6% 🟢) 8.393s (-3.0%) 2.459s 4 1.25x

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

Promise.race with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.212s (~) 2.008s (~) 0.796s 15 1.00x
🐘 Postgres Express 1.219s (~) 2.008s (-3.2%) 0.789s 15 1.01x
🐘 Postgres Next.js (Turbopack) 1.326s (+2.8%) 2.008s (~) 0.682s 15 1.09x
💻 Local Next.js (Turbopack) 1.392s (~) 2.006s (~) 0.614s 15 1.15x
💻 Local Express 1.577s (~) 2.006s (~) 0.430s 15 1.30x
💻 Local Nitro 1.635s (+2.6%) 2.007s (~) 0.372s 15 1.35x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.345s (-15.9% 🟢) 4.021s (-7.5% 🟢) 1.676s 8 1.00x
▲ Vercel Next.js (Turbopack) 2.375s (-3.8%) 3.997s (+9.1% 🔺) 1.623s 8 1.01x
▲ Vercel Nitro 3.013s (+19.4% 🔺) 4.535s (+15.4% 🔺) 1.522s 7 1.28x

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

Promise.race with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.364s (~) 2.151s (-7.1% 🟢) 0.788s 14 1.00x
🐘 Postgres Nitro 1.404s (~) 2.317s (~) 0.913s 13 1.03x
🐘 Postgres Next.js (Turbopack) 1.557s (-4.2%) 2.393s (+3.3%) 0.836s 13 1.14x
💻 Local Express 2.014s (-13.4% 🟢) 2.508s (-11.3% 🟢) 0.494s 12 1.48x
💻 Local Next.js (Turbopack) 2.020s (+1.0%) 2.735s (+5.6% 🔺) 0.715s 11 1.48x
💻 Local Nitro 2.227s (+2.2%) 2.758s (+0.8%) 0.531s 12 1.63x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.771s (-3.9%) 4.319s (~) 1.548s 8 1.00x
▲ Vercel Next.js (Turbopack) 2.915s (-1.8%) 4.814s (+7.1% 🔺) 1.900s 7 1.05x
▲ Vercel Express 3.336s (+2.4%) 5.038s (+6.4% 🔺) 1.702s 6 1.20x

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

Promise.race with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.772s (-4.9%) 4.014s (-6.6% 🟢) 2.241s 8 1.00x
🐘 Postgres Nitro 1.886s (+17.2% 🔺) 4.441s (+14.2% 🔺) 2.556s 7 1.06x
🐘 Postgres Next.js (Turbopack) 3.388s (+3.7%) 4.142s (-2.8%) 0.754s 8 1.91x
💻 Local Express 5.290s (-24.9% 🟢) 5.850s (-23.2% 🟢) 0.560s 6 2.99x
💻 Local Next.js (Turbopack) 6.189s (+38.2% 🔺) 6.416s (+23.9% 🔺) 0.226s 5 3.49x
💻 Local Nitro 6.932s (+19.4% 🔺) 7.218s (+12.5% 🔺) 0.286s 5 3.91x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 3.744s (-28.8% 🟢) 6.281s (-1.9%) 2.537s 5 1.00x
▲ Vercel Next.js (Turbopack) 3.946s (-18.4% 🟢) 5.974s (-9.9% 🟢) 2.028s 6 1.05x
▲ Vercel Nitro 4.515s (-3.1%) 6.349s (+1.5%) 1.834s 5 1.21x

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

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 0.586s (-2.8%) 1.006s (-1.7%) 0.420s 60 1.00x
🐘 Postgres Express 0.593s (+2.9%) 1.041s (+3.5%) 0.448s 58 1.01x
💻 Local Express 0.615s (-3.4%) 1.005s (-3.3%) 0.390s 60 1.05x
💻 Local Nitro 0.670s (+4.6%) 1.040s (+1.8%) 0.370s 58 1.14x
🐘 Postgres Next.js (Turbopack) 0.835s (~) 1.023s (~) 0.189s 59 1.42x
💻 Local Next.js (Turbopack) 0.870s (+2.0%) 1.004s (~) 0.135s 60 1.48x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 6.493s (-24.9% 🟢) 8.569s (-15.7% 🟢) 2.076s 8 1.00x
▲ Vercel Nitro 6.528s (-27.6% 🟢) 8.584s (-18.7% 🟢) 2.056s 7 1.01x
▲ Vercel Next.js (Turbopack) 6.529s (~) 8.488s (+6.1% 🔺) 1.959s 8 1.01x

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

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.388s (-5.6% 🟢) 2.007s (-3.3%) 0.619s 45 1.00x
🐘 Postgres Nitro 1.407s (-3.4%) 2.008s (-2.2%) 0.601s 45 1.01x
💻 Local Express 1.514s (-1.9%) 2.006s (~) 0.491s 45 1.09x
💻 Local Nitro 1.646s (+8.0% 🔺) 2.030s (+1.2%) 0.383s 45 1.19x
🐘 Postgres Next.js (Turbopack) 1.991s (+2.6%) 2.367s (+12.7% 🔺) 0.375s 39 1.43x
💻 Local Next.js (Turbopack) 2.174s (+3.6%) 3.008s (+2.2%) 0.835s 30 1.57x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 15.578s (-29.1% 🟢) 18.039s (-23.2% 🟢) 2.461s 5 1.00x
▲ Vercel Nitro 16.348s (-9.2% 🟢) 18.489s (-6.5% 🟢) 2.141s 5 1.05x
▲ Vercel Next.js (Turbopack) 16.650s (-9.7% 🟢) 19.019s (-4.4%) 2.369s 5 1.07x

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

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 2.822s (+1.1%) 3.137s (~) 0.315s 39 1.00x
🐘 Postgres Express 2.869s (+3.9%) 3.280s (+6.3% 🔺) 0.410s 37 1.02x
💻 Local Express 3.265s (-0.8%) 4.009s (~) 0.744s 30 1.16x
💻 Local Nitro 3.436s (+4.4%) 4.077s (+1.7%) 0.640s 30 1.22x
🐘 Postgres Next.js (Turbopack) 3.936s (+2.7%) 4.183s (+3.5%) 0.247s 29 1.39x
💻 Local Next.js (Turbopack) 4.327s (-3.0%) 5.010s (-0.8%) 0.683s 24 1.53x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 29.059s (-8.3% 🟢) 31.839s (-4.9%) 2.780s 4 1.00x
▲ Vercel Express 30.029s (-9.5% 🟢) 33.287s (-3.4%) 3.258s 4 1.03x
▲ Vercel Next.js (Turbopack) 32.694s (-1.4%) 35.494s (+3.0%) 2.800s 4 1.13x

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

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.256s (+1.6%) 1.006s (~) 0.750s 60 1.00x
🐘 Postgres Nitro 0.256s (-1.4%) 1.006s (-1.6%) 0.750s 60 1.00x
🐘 Postgres Next.js (Turbopack) 0.302s (+1.2%) 1.006s (~) 0.704s 60 1.18x
💻 Local Nitro 0.431s (+1.5%) 1.005s (~) 0.574s 60 1.68x
💻 Local Express 0.435s (-1.5%) 1.004s (~) 0.569s 60 1.70x
💻 Local Next.js (Turbopack) 0.521s (-12.1% 🟢) 1.005s (-1.7%) 0.484s 60 2.04x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.115s (-15.5% 🟢) 3.980s (-1.9%) 1.866s 16 1.00x
▲ Vercel Next.js (Turbopack) 2.203s (+7.0% 🔺) 4.126s (+21.7% 🔺) 1.922s 15 1.04x
▲ Vercel Nitro 2.523s (+18.9% 🔺) 4.388s (+13.4% 🔺) 1.865s 14 1.19x

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

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.415s (+3.7%) 1.041s (~) 0.627s 87 1.00x
🐘 Postgres Nitro 0.424s (+0.5%) 1.041s (-2.3%) 0.617s 87 1.02x
🐘 Postgres Next.js (Turbopack) 0.577s (-7.4% 🟢) 1.078s (-8.3% 🟢) 0.501s 84 1.39x
💻 Local Nitro 2.071s (-7.6% 🟢) 2.658s (-3.9%) 0.586s 34 5.00x
💻 Local Express 2.325s (+13.4% 🔺) 2.853s (+7.4% 🔺) 0.528s 32 5.61x
💻 Local Next.js (Turbopack) 2.611s (~) 3.334s (-1.5%) 0.723s 28 6.30x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 3.672s (+1.7%) 5.578s (+4.3%) 1.907s 17 1.00x
▲ Vercel Express 3.683s (+11.8% 🔺) 5.655s (+19.5% 🔺) 1.972s 16 1.00x
▲ Vercel Nitro 3.775s (-16.7% 🟢) 5.889s (-5.0% 🟢) 2.114s 16 1.03x

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

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.785s (-0.8%) 1.271s (-6.3% 🟢) 0.486s 95 1.00x
🐘 Postgres Nitro 0.844s (+3.8%) 1.371s (-7.6% 🟢) 0.527s 88 1.08x
🐘 Postgres Next.js (Turbopack) 2.677s (-6.4% 🟢) 3.711s (+2.2%) 1.034s 33 3.41x
💻 Local Express 9.827s (-3.2%) 10.362s (-3.9%) 0.535s 12 12.52x
💻 Local Nitro 9.859s (+3.5%) 10.363s (+1.7%) 0.504s 12 12.56x
💻 Local Next.js (Turbopack) 10.346s (-5.7% 🟢) 11.301s (-4.6%) 0.956s 11 13.18x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 5.976s (-14.5% 🟢) 8.118s (-6.4% 🟢) 2.142s 15 1.00x
▲ Vercel Express 6.135s (-15.9% 🟢) 8.528s (-3.9%) 2.394s 15 1.03x
▲ Vercel Next.js (Turbopack) 7.343s (-10.4% 🟢) 9.606s (-1.9%) 2.264s 13 1.23x

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

Stream Benchmarks (includes TTFB metrics)
workflow with stream

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 1.165s (~) 2.004s (~) 0.011s (-15.3% 🟢) 2.017s (~) 0.852s 10 1.00x
💻 Local Nitro 1.172s (~) 2.005s (~) 0.013s (+7.6% 🔺) 2.021s (~) 0.849s 10 1.01x
🐘 Postgres Express 1.179s (~) 2.001s (~) 0.001s (~) 2.011s (~) 0.832s 10 1.01x
🐘 Postgres Nitro 1.182s (+1.3%) 1.997s (~) 0.001s (-7.1% 🟢) 2.010s (~) 0.829s 10 1.01x
💻 Local Next.js (Turbopack) 1.205s (~) 2.003s (~) 0.012s (+10.2% 🔺) 2.019s (~) 0.814s 10 1.03x
🐘 Postgres Next.js (Turbopack) 1.244s (+0.8%) 2.001s (~) 0.001s (+11.1% 🔺) 2.011s (~) 0.767s 10 1.07x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 2.637s (+5.1% 🔺) 3.726s (+10.3% 🔺) 1.437s (+19.6% 🔺) 5.659s (+13.9% 🔺) 3.022s 10 1.00x
▲ Vercel Nitro 2.776s (+19.7% 🔺) 4.150s (+24.5% 🔺) 0.672s (-37.1% 🟢) 5.469s (+13.2% 🔺) 2.693s 10 1.05x
▲ Vercel Express 2.788s (+17.0% 🔺) 4.107s (+28.5% 🔺) 1.469s (+11.7% 🔺) 6.069s (+23.3% 🔺) 3.281s 10 1.06x

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

stream pipeline with 5 transform steps (1MB)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.568s (-1.8%) 2.006s (~) 0.005s (+1.3%) 2.028s (~) 0.460s 30 1.00x
💻 Local Nitro 1.602s (~) 2.010s (~) 0.011s (-12.0% 🟢) 2.024s (~) 0.422s 30 1.02x
💻 Local Express 1.602s (~) 2.009s (~) 0.015s (+17.6% 🔺) 2.027s (~) 0.425s 30 1.02x
🐘 Postgres Nitro 1.638s (+2.7%) 2.002s (~) 0.005s (-2.0%) 2.027s (~) 0.389s 30 1.04x
💻 Local Next.js (Turbopack) 1.734s (+0.9%) 2.008s (~) 0.012s (-1.1%) 2.024s (~) 0.290s 30 1.11x
🐘 Postgres Next.js (Turbopack) 1.796s (+2.0%) 2.009s (~) 0.006s (+8.2% 🔺) 2.030s (~) 0.234s 30 1.15x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 6.353s (-6.8% 🟢) 7.876s (~) 0.536s (+132.4% 🔺) 8.902s (+3.4%) 2.550s 7 1.00x
▲ Vercel Nitro 6.526s (-1.8%) 8.158s (+3.1%) 0.330s (+49.2% 🔺) 8.975s (+4.3%) 2.448s 7 1.03x
▲ Vercel Next.js (Turbopack) 6.780s (-1.5%) 8.291s (+3.3%) 0.629s (+158.3% 🔺) 9.487s (+8.5% 🔺) 2.707s 7 1.07x

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

10 parallel streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.776s (+1.9%) 1.029s (-1.8%) 0.000s (-33.3% 🟢) 1.061s (~) 0.286s 57 1.00x
🐘 Postgres Nitro 0.868s (+9.4% 🔺) 1.079s (+3.0%) 0.000s (-100.0% 🟢) 1.122s (+5.8% 🔺) 0.254s 54 1.12x
🐘 Postgres Next.js (Turbopack) 1.025s (+2.7%) 1.469s (+2.8%) 0.000s (+Infinity% 🔺) 1.486s (+3.5%) 0.461s 41 1.32x
💻 Local Nitro 1.427s (+3.1%) 2.014s (~) 0.000s (-11.1% 🟢) 2.016s (~) 0.589s 30 1.84x
💻 Local Next.js (Turbopack) 1.476s (-0.6%) 2.013s (~) 0.000s (+133.3% 🔺) 2.016s (~) 0.540s 30 1.90x
💻 Local Express 1.492s (+2.1%) 2.014s (~) 0.000s (-65.2% 🟢) 2.016s (~) 0.524s 30 1.92x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 3.152s (-28.2% 🟢) 4.709s (-17.2% 🟢) 0.001s (-25.9% 🟢) 5.242s (-16.1% 🟢) 2.090s 12 1.00x
▲ Vercel Express 3.292s (-31.2% 🟢) 4.944s (-10.0% 🟢) 0.000s (+81.8% 🔺) 5.485s (-10.9% 🟢) 2.193s 11 1.04x
▲ Vercel Next.js (Turbopack) 3.597s (-0.7%) 5.203s (+9.6% 🔺) 0.000s (NaN%) 5.709s (+10.4% 🔺) 2.112s 12 1.14x

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

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

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.522s (-8.1% 🟢) 2.063s (-5.1% 🟢) 0.000s (+93.1% 🔺) 2.093s (-4.8%) 0.570s 29 1.00x
🐘 Postgres Nitro 1.659s (+2.3%) 2.258s (+5.5% 🔺) 0.000s (+3.7%) 2.273s (+5.6% 🔺) 0.614s 27 1.09x
🐘 Postgres Next.js (Turbopack) 2.252s (+7.5% 🔺) 2.729s (+2.8%) 0.000s (+213.6% 🔺) 2.776s (+4.3%) 0.524s 22 1.48x
💻 Local Next.js (Turbopack) 2.852s (-2.2%) 3.470s (-1.5%) 0.001s (+175.0% 🔺) 3.473s (-1.6%) 0.621s 18 1.87x
💻 Local Nitro 3.071s (+1.2%) 3.674s (~) 0.001s (-9.1% 🟢) 3.677s (~) 0.606s 17 2.02x
💻 Local Express 3.290s (+2.6%) 4.026s (+1.5%) 0.001s (+353.3% 🔺) 4.030s (+1.6%) 0.740s 15 2.16x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 5.411s (-11.9% 🟢) 7.163s (-5.1% 🟢) 0.004s (+Infinity% 🔺) 7.652s (-4.2%) 2.241s 8 1.00x
▲ Vercel Next.js (Turbopack) 6.145s (+25.8% 🔺) 7.777s (+25.8% 🔺) 0.000s (+Infinity% 🔺) 8.258s (+25.1% 🔺) 2.113s 8 1.14x
▲ Vercel Express 6.943s (+39.7% 🔺) 8.421s (+43.0% 🔺) 0.000s (NaN%) 8.996s (+42.1% 🔺) 2.053s 8 1.28x

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

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World 🥇 Fastest Framework Wins
💻 Local Express 15/21
🐘 Postgres Express 17/21
▲ Vercel Nitro 9/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework 🥇 Fastest World Wins
Express 🐘 Postgres 14/21
Next.js (Turbopack) 🐘 Postgres 14/21
Nitro 🐘 Postgres 15/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-actions Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

Passed Failed Skipped Total
✅ ▲ Vercel Production 1442 0 219 1661
✅ 💻 Local Development 1895 0 219 2114
✅ 📦 Local Production 1895 0 219 2114
✅ 🐘 Local Postgres 1881 0 233 2114
✅ 🪟 Windows 151 0 0 151
✅ 📋 Other 879 0 178 1057
Total 8143 0 1068 9211

Details by Category

✅ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 125 0 26
✅ example 125 0 26
✅ express 125 0 26
✅ fastify 125 0 26
✅ hono 125 0 26
✅ nextjs-turbopack 149 0 2
✅ nextjs-webpack 149 0 2
✅ nitro 125 0 26
✅ nuxt 125 0 26
✅ sveltekit 144 0 7
✅ vite 125 0 26
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 126 0 25
✅ express-stable 126 0 25
✅ fastify-stable 126 0 25
✅ hono-stable 126 0 25
✅ nextjs-turbopack-canary 132 0 19
✅ nextjs-turbopack-stable-lazy-discovery-disabled 151 0 0
✅ nextjs-turbopack-stable-lazy-discovery-enabled 151 0 0
✅ nextjs-webpack-canary 132 0 19
✅ nextjs-webpack-stable-lazy-discovery-disabled 151 0 0
✅ nextjs-webpack-stable-lazy-discovery-enabled 151 0 0
✅ nitro-stable 126 0 25
✅ nuxt-stable 126 0 25
✅ sveltekit-stable 145 0 6
✅ vite-stable 126 0 25
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 126 0 25
✅ express-stable 126 0 25
✅ fastify-stable 126 0 25
✅ hono-stable 126 0 25
✅ nextjs-turbopack-canary 132 0 19
✅ nextjs-turbopack-stable-lazy-discovery-disabled 151 0 0
✅ nextjs-turbopack-stable-lazy-discovery-enabled 151 0 0
✅ nextjs-webpack-canary 132 0 19
✅ nextjs-webpack-stable-lazy-discovery-disabled 151 0 0
✅ nextjs-webpack-stable-lazy-discovery-enabled 151 0 0
✅ nitro-stable 126 0 25
✅ nuxt-stable 126 0 25
✅ sveltekit-stable 145 0 6
✅ vite-stable 126 0 25
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 125 0 26
✅ express-stable 125 0 26
✅ fastify-stable 125 0 26
✅ hono-stable 125 0 26
✅ nextjs-turbopack-canary 131 0 20
✅ nextjs-turbopack-stable-lazy-discovery-disabled 150 0 1
✅ nextjs-turbopack-stable-lazy-discovery-enabled 150 0 1
✅ nextjs-webpack-canary 131 0 20
✅ nextjs-webpack-stable-lazy-discovery-disabled 150 0 1
✅ nextjs-webpack-stable-lazy-discovery-enabled 150 0 1
✅ nitro-stable 125 0 26
✅ nuxt-stable 125 0 26
✅ sveltekit-stable 144 0 7
✅ vite-stable 125 0 26
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 151 0 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 126 0 25
✅ e2e-local-dev-tanstack-start- 126 0 25
✅ e2e-local-postgres-nest-stable 125 0 26
✅ e2e-local-postgres-tanstack-start- 125 0 26
✅ e2e-local-prod-nest-stable 126 0 25
✅ e2e-local-prod-tanstack-start- 126 0 25
✅ e2e-vercel-prod-tanstack-start 125 0 26

📋 View full workflow run

@changeset-bot

changeset-bot Bot 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
Name Type
@workflow/core Minor
workflow Minor
@workflow/world-vercel Minor
@workflow/utils Minor
@workflow/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
@workflow/world-testing Patch
@workflow/ai Major
@workflow/errors Patch
@workflow/world-local Patch
@workflow/world-postgres Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch
@workflow/nuxt Patch

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

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

@pranaygp pranaygp 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 thread packages/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
Contributor Author

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
Contributor Author

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 thread packages/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
Contributor Author

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 thread packages/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
Contributor Author

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
Contributor Author

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
Contributor Author

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.

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

karthikscale3 and 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.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
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 free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants