Skip to content

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

Open
karthikscale3 wants to merge 8 commits into
mainfrom
karthik/otel-trace-correlation
Open

otel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces#2363
karthikscale3 wants to merge 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

…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>
@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 12, 2026 12:44am
example-nextjs-workflow-webpack Ready Ready Preview, Comment Jun 12, 2026 12:44am
example-workflow Ready Ready Preview, Comment Jun 12, 2026 12:44am
workbench-astro-workflow Ready Ready Preview, Comment Jun 12, 2026 12:44am
workbench-express-workflow Ready Ready Preview, Comment Jun 12, 2026 12:44am
workbench-fastify-workflow Ready Ready Preview, Comment Jun 12, 2026 12:44am
workbench-hono-workflow Ready Ready Preview, Comment Jun 12, 2026 12:44am
workbench-nitro-workflow Ready Ready Preview, Comment Jun 12, 2026 12:44am
workbench-nuxt-workflow Ready Ready Preview, Comment Jun 12, 2026 12:44am
workbench-sveltekit-workflow Ready Ready Preview, Comment Jun 12, 2026 12:44am
workbench-tanstack-start-workflow Ready Ready Preview, Comment Jun 12, 2026 12:44am
workbench-vite-workflow Ready Ready Preview, Comment Jun 12, 2026 12:44am
workflow-docs Ready Ready Preview, Comment, Open in v0 Jun 12, 2026 12:44am
workflow-swc-playground Ready Ready Preview, Comment Jun 12, 2026 12:44am
workflow-tarballs Ready Ready Preview, Comment Jun 12, 2026 12:44am
workflow-web Ready Ready Preview, Comment Jun 12, 2026 12:44am

@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 🥇 Nitro 0.043s (-2.7%) 1.007s (~) 0.964s 10 1.00x
💻 Local Express 0.044s (+4.3%) 1.006s (~) 0.962s 10 1.01x
🐘 Postgres Express 0.059s (-1.8%) 1.012s (~) 0.953s 10 1.36x
🐘 Postgres Nitro 0.064s (+13.1% 🔺) 1.014s (~) 0.950s 10 1.47x
💻 Local Next.js (Turbopack) 0.065s (+19.6% 🔺) 1.006s (~) 0.941s 10 1.49x
🐘 Postgres Next.js (Turbopack) 0.070s (-2.1%) 1.013s (~) 0.943s 10 1.61x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 0.260s (-27.5% 🟢) 2.106s (-11.5% 🟢) 1.846s 10 1.00x
▲ Vercel Next.js (Turbopack) 0.316s (-5.1% 🟢) 2.478s (+24.2% 🔺) 2.163s 10 1.21x
▲ Vercel Express 0.407s (+35.3% 🔺) 2.369s (+5.2% 🔺) 1.962s 10 1.56x

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

workflow with 1 step

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 1.104s (~) 2.007s (~) 0.902s 10 1.00x
🐘 Postgres Express 1.109s (~) 2.010s (~) 0.901s 10 1.00x
🐘 Postgres Nitro 1.111s (~) 2.010s (~) 0.899s 10 1.01x
💻 Local Express 1.113s (+1.9%) 2.006s (~) 0.893s 10 1.01x
💻 Local Next.js (Turbopack) 1.136s (+2.0%) 2.007s (~) 0.871s 10 1.03x
🐘 Postgres Next.js (Turbopack) 1.141s (~) 2.011s (~) 0.869s 10 1.03x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 1.716s (-7.4% 🟢) 3.805s (-6.9% 🟢) 2.089s 10 1.00x
▲ Vercel Express 1.716s (-1.5%) 3.350s (-7.9% 🟢) 1.633s 10 1.00x
▲ Vercel Next.js (Turbopack) 1.744s (+2.7%) 3.763s (+15.2% 🔺) 2.019s 10 1.02x

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

workflow with 10 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 10.539s (-1.1%) 11.023s (~) 0.484s 3 1.00x
🐘 Postgres Nitro 10.553s (~) 11.021s (~) 0.468s 3 1.00x
🐘 Postgres Express 10.574s (~) 11.020s (~) 0.445s 3 1.00x
💻 Local Express 10.615s (+0.9%) 11.023s (~) 0.407s 3 1.01x
💻 Local Next.js (Turbopack) 10.812s (+1.6%) 11.023s (~) 0.211s 3 1.03x
🐘 Postgres Next.js (Turbopack) 10.865s (~) 11.023s (~) 0.159s 3 1.03x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 13.356s (-7.4% 🟢) 15.083s (-2.5%) 1.727s 2 1.00x
▲ Vercel Nitro 13.945s (-1.8%) 16.494s (+4.7%) 2.549s 2 1.04x
▲ Vercel Express 14.123s (+2.0%) 15.843s (-1.1%) 1.720s 2 1.06x

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

workflow with 25 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 13.757s (-0.6%) 14.027s (~) 0.270s 5 1.00x
💻 Local Express 13.786s (~) 14.027s (~) 0.241s 5 1.00x
🐘 Postgres Express 13.829s (~) 14.020s (~) 0.191s 5 1.01x
🐘 Postgres Nitro 13.907s (+1.3%) 14.221s (+1.4%) 0.313s 5 1.01x
💻 Local Next.js (Turbopack) 14.363s (+2.3%) 15.028s (+2.7%) 0.665s 4 1.04x
🐘 Postgres Next.js (Turbopack) 14.528s (+0.7%) 15.021s (~) 0.493s 4 1.06x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 21.995s (-1.9%) 24.425s (~) 2.430s 3 1.00x
▲ Vercel Nitro 22.157s (-0.8%) 24.189s (-1.9%) 2.032s 3 1.01x
▲ Vercel Express 23.657s (+10.6% 🔺) 25.342s (+9.3% 🔺) 1.685s 3 1.08x

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

workflow with 50 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 12.463s (~) 13.022s (~) 0.560s 7 1.00x
💻 Local Nitro 12.513s (-2.0%) 13.024s (~) 0.511s 7 1.00x
💻 Local Express 12.585s (~) 13.024s (~) 0.439s 7 1.01x
🐘 Postgres Nitro 12.751s (+2.9%) 13.023s (~) 0.273s 7 1.02x
💻 Local Next.js (Turbopack) 13.698s (+5.0%) 14.027s (+3.2%) 0.329s 7 1.10x
🐘 Postgres Next.js (Turbopack) 13.947s (+1.3%) 14.306s (+2.0%) 0.359s 7 1.12x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 35.175s (+0.9%) 36.875s (+1.3%) 1.701s 3 1.00x
▲ Vercel Nitro 36.615s (-15.4% 🟢) 39.099s (-14.3% 🟢) 2.484s 3 1.04x
▲ Vercel Next.js (Turbopack) 36.996s (+9.4% 🔺) 38.907s (+11.5% 🔺) 1.911s 3 1.05x

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

Promise.all with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.193s (+0.8%) 2.007s (~) 0.814s 15 1.00x
🐘 Postgres Nitro 1.202s (~) 2.007s (~) 0.805s 15 1.01x
💻 Local Nitro 1.242s (+3.7%) 2.006s (~) 0.765s 15 1.04x
💻 Local Express 1.260s (+1.9%) 2.006s (~) 0.746s 15 1.06x
🐘 Postgres Next.js (Turbopack) 1.275s (+2.1%) 2.008s (~) 0.734s 15 1.07x
💻 Local Next.js (Turbopack) 1.304s (+5.6% 🔺) 2.007s (~) 0.702s 15 1.09x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.716s (-26.2% 🟢) 4.414s (-13.6% 🟢) 1.698s 7 1.00x
▲ Vercel Nitro 2.825s (-21.0% 🟢) 4.569s (-16.0% 🟢) 1.744s 7 1.04x
▲ Vercel Next.js (Turbopack) 2.912s (+8.0% 🔺) 4.659s (+13.3% 🔺) 1.747s 7 1.07x

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

Promise.all with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.249s (~) 2.008s (~) 0.759s 15 1.00x
🐘 Postgres Nitro 1.269s (-0.9%) 2.009s (~) 0.741s 15 1.02x
🐘 Postgres Next.js (Turbopack) 1.394s (~) 2.007s (~) 0.613s 15 1.12x
💻 Local Express 1.661s (-3.2%) 2.006s (~) 0.345s 15 1.33x
💻 Local Next.js (Turbopack) 1.810s (+1.9%) 2.007s (-3.1%) 0.197s 15 1.45x
💻 Local Nitro 1.856s (-3.6%) 2.222s (-4.1%) 0.365s 14 1.49x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 4.544s (+15.0% 🔺) 6.587s (+19.6% 🔺) 2.043s 5 1.00x
▲ Vercel Nitro 4.994s (+10.5% 🔺) 7.269s (+12.7% 🔺) 2.275s 5 1.10x
▲ Vercel Express 5.048s (+14.5% 🔺) 6.828s (-0.7%) 1.781s 5 1.11x

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

Promise.all with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.389s (-1.2%) 2.008s (~) 0.619s 15 1.00x
🐘 Postgres Express 1.414s (~) 2.009s (~) 0.595s 15 1.02x
🐘 Postgres Next.js (Turbopack) 1.706s (-2.5%) 2.154s (-14.1% 🟢) 0.449s 14 1.23x
💻 Local Express 4.408s (-4.0%) 5.012s (-3.2%) 0.604s 7 3.17x
💻 Local Next.js (Turbopack) 5.767s (+23.2% 🔺) 6.216s (+24.0% 🔺) 0.448s 5 4.15x
💻 Local Nitro 6.073s (+9.9% 🔺) 6.414s (+3.2%) 0.341s 5 4.37x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 6.948s (+17.2% 🔺) 8.807s (+17.4% 🔺) 1.859s 4 1.00x
▲ Vercel Nitro 7.979s (+29.7% 🔺) 10.572s (+34.6% 🔺) 2.593s 3 1.15x
▲ Vercel Express 66.690s (+1122.9% 🔺) 68.246s (+856.0% 🔺) 1.556s 5 9.60x

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

Promise.race with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.182s (-1.9%) 2.009s (~) 0.827s 15 1.00x
🐘 Postgres Nitro 1.210s (+2.8%) 2.009s (~) 0.799s 15 1.02x
🐘 Postgres Next.js (Turbopack) 1.244s (-2.5%) 2.009s (~) 0.766s 15 1.05x
💻 Local Next.js (Turbopack) 1.395s (-1.0%) 2.006s (~) 0.612s 15 1.18x
💻 Local Express 1.535s (~) 2.007s (~) 0.471s 15 1.30x
💻 Local Nitro 1.592s (-1.3%) 2.007s (~) 0.414s 15 1.35x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.645s (+4.4%) 4.880s (+22.4% 🔺) 2.235s 7 1.00x
▲ Vercel Express 2.698s (-17.0% 🟢) 4.383s (-13.0% 🟢) 1.685s 7 1.02x
▲ Vercel Next.js (Turbopack) 3.196s (-10.3% 🟢) 5.086s (+1.6%) 1.890s 6 1.21x

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

Promise.race with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.258s (~) 2.008s (~) 0.750s 15 1.00x
🐘 Postgres Nitro 1.260s (+0.8%) 2.008s (~) 0.747s 15 1.00x
🐘 Postgres Next.js (Turbopack) 1.383s (-0.6%) 2.007s (~) 0.624s 15 1.10x
💻 Local Express 1.694s (-18.9% 🟢) 2.007s (-20.0% 🟢) 0.314s 15 1.35x
💻 Local Next.js (Turbopack) 2.005s (-0.9%) 2.314s (-6.2% 🟢) 0.309s 13 1.59x
💻 Local Nitro 2.042s (-16.6% 🟢) 2.592s (-14.0% 🟢) 0.549s 12 1.62x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 3.593s (-8.6% 🟢) 5.347s (+1.6%) 1.754s 6 1.00x
▲ Vercel Nitro 4.213s (+17.9% 🔺) 5.930s (+15.5% 🔺) 1.717s 6 1.17x
▲ Vercel Next.js (Turbopack) 4.397s (+10.1% 🔺) 6.182s (+10.3% 🔺) 1.784s 5 1.22x

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

Promise.race with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.376s (~) 2.009s (~) 0.632s 15 1.00x
🐘 Postgres Nitro 1.399s (+1.7%) 2.009s (~) 0.609s 15 1.02x
🐘 Postgres Next.js (Turbopack) 1.682s (~) 2.316s (+11.5% 🔺) 0.635s 13 1.22x
💻 Local Express 5.203s (-4.9%) 5.680s (-5.6% 🟢) 0.477s 6 3.78x
💻 Local Nitro 5.755s (-6.2% 🟢) 6.216s (-6.1% 🟢) 0.461s 5 4.18x
💻 Local Next.js (Turbopack) 6.435s (+24.1% 🔺) 6.816s (+16.6% 🔺) 0.381s 5 4.67x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 5.597s (-18.7% 🟢) 7.656s (-13.4% 🟢) 2.059s 4 1.00x
▲ Vercel Next.js (Turbopack) 5.740s (-11.2% 🟢) 7.926s (-2.4%) 2.186s 4 1.03x
▲ Vercel Nitro 5.847s (+4.9%) 8.219s (+8.5% 🔺) 2.372s 4 1.04x

🔍 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 🥇 Express 0.565s (+1.5%) 1.007s (~) 0.442s 60 1.00x
🐘 Postgres Nitro 0.578s (+5.6% 🔺) 1.006s (~) 0.428s 60 1.02x
💻 Local Nitro 0.603s (-6.6% 🟢) 1.005s (~) 0.402s 60 1.07x
💻 Local Express 0.622s (+2.3%) 1.005s (-1.7%) 0.382s 60 1.10x
🐘 Postgres Next.js (Turbopack) 0.831s (~) 1.041s (+1.8%) 0.210s 58 1.47x
💻 Local Next.js (Turbopack) 0.874s (+23.9% 🔺) 1.021s (+1.7%) 0.148s 59 1.55x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 5.900s (+2.8%) 7.815s (+2.0%) 1.915s 8 1.00x
▲ Vercel Express 6.129s (-9.6% 🟢) 7.890s (-5.6% 🟢) 1.761s 8 1.04x
▲ Vercel Next.js (Turbopack) 6.914s (+18.3% 🔺) 8.711s (+22.8% 🔺) 1.796s 7 1.17x

🔍 Observability: Nitro | Express | 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.309s (-3.4%) 2.007s (-1.1%) 0.698s 45 1.00x
🐘 Postgres Nitro 1.433s (+4.5%) 2.031s (~) 0.598s 45 1.09x
💻 Local Express 1.509s (-2.0%) 2.006s (-1.1%) 0.497s 45 1.15x
💻 Local Nitro 1.529s (-4.6%) 2.007s (~) 0.477s 45 1.17x
🐘 Postgres Next.js (Turbopack) 1.960s (+0.6%) 2.202s (+3.7%) 0.243s 41 1.50x
💻 Local Next.js (Turbopack) 2.103s (+19.9% 🔺) 3.008s (+46.6% 🔺) 0.905s 30 1.61x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 13.709s (-8.9% 🟢) 15.060s (-11.9% 🟢) 1.351s 7 1.00x
▲ Vercel Nitro 14.963s (-2.8%) 17.420s (~) 2.457s 6 1.09x
▲ Vercel Next.js (Turbopack) 16.388s (+5.3% 🔺) 18.605s (+9.1% 🔺) 2.216s 5 1.20x

🔍 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 🥇 Express 2.647s (+2.4%) 3.059s (+1.7%) 0.412s 40 1.00x
🐘 Postgres Nitro 2.839s (+3.5%) 3.168s (+1.0%) 0.329s 38 1.07x
💻 Local Express 3.244s (-0.9%) 4.009s (~) 0.766s 30 1.23x
💻 Local Nitro 3.312s (-3.7%) 4.009s (~) 0.697s 30 1.25x
🐘 Postgres Next.js (Turbopack) 3.821s (-1.3%) 4.010s (-1.6%) 0.188s 30 1.44x
💻 Local Next.js (Turbopack) 4.393s (+21.2% 🔺) 5.010s (+25.0% 🔺) 0.617s 24 1.66x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 25.744s (-2.5%) 27.392s (-3.8%) 1.648s 5 1.00x
▲ Vercel Nitro 33.234s (+16.3% 🔺) 36.168s (+17.9% 🔺) 2.934s 4 1.29x
▲ Vercel Next.js (Turbopack) 37.471s (+20.5% 🔺) 40.170s (+23.6% 🔺) 2.699s 3 1.46x

🔍 Observability: Express | Nitro | 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.214s (+3.1%) 1.006s (~) 0.792s 60 1.00x
🐘 Postgres Nitro 0.236s (+6.2% 🔺) 1.006s (~) 0.770s 60 1.11x
🐘 Postgres Next.js (Turbopack) 0.275s (-1.5%) 1.006s (~) 0.731s 60 1.29x
💻 Local Nitro 0.399s (-11.7% 🟢) 1.005s (~) 0.606s 60 1.87x
💻 Local Express 0.428s (+0.7%) 1.004s (~) 0.576s 60 2.00x
💻 Local Next.js (Turbopack) 0.557s (-4.4%) 1.005s (-1.7%) 0.447s 60 2.61x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.448s (-31.2% 🟢) 3.884s (-27.8% 🟢) 1.436s 16 1.00x
▲ Vercel Nitro 2.492s (-4.8%) 4.231s (-5.7% 🟢) 1.739s 15 1.02x
▲ Vercel Next.js (Turbopack) 3.307s (+24.1% 🔺) 5.199s (+29.7% 🔺) 1.892s 12 1.35x

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

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.342s (+1.4%) 1.006s (~) 0.664s 90 1.00x
🐘 Postgres Nitro 0.366s (+8.5% 🔺) 1.007s (~) 0.641s 90 1.07x
🐘 Postgres Next.js (Turbopack) 0.487s (~) 1.007s (~) 0.520s 90 1.42x
💻 Local Express 2.029s (-7.1% 🟢) 2.655s (-5.9% 🟢) 0.626s 34 5.93x
💻 Local Nitro 2.074s (-7.0% 🟢) 2.686s (-6.9% 🟢) 0.612s 34 6.06x
💻 Local Next.js (Turbopack) 2.493s (+10.2% 🔺) 3.114s (+3.5%) 0.621s 29 7.28x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 5.299s (+4.2%) 6.988s (-2.8%) 1.689s 13 1.00x
▲ Vercel Express 5.745s (-5.2% 🟢) 7.116s (-8.4% 🟢) 1.371s 13 1.08x
▲ Vercel Next.js (Turbopack) 7.097s (+2.1%) 9.025s (+4.7%) 1.928s 10 1.34x

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

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.651s (-2.4%) 1.006s (~) 0.355s 120 1.00x
🐘 Postgres Nitro 0.689s (+2.0%) 1.006s (~) 0.317s 120 1.06x
🐘 Postgres Next.js (Turbopack) 0.967s (-1.1%) 1.521s (-1.6%) 0.554s 80 1.49x
💻 Local Express 9.005s (-9.1% 🟢) 9.566s (-8.4% 🟢) 0.561s 13 13.84x
💻 Local Nitro 9.900s (-4.7%) 10.444s (-4.5%) 0.544s 12 15.21x
💻 Local Next.js (Turbopack) 10.458s (+1.5%) 11.122s (-1.6%) 0.664s 11 16.07x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 18.925s (-8.2% 🟢) 21.148s (-6.8% 🟢) 2.223s 6 1.00x
▲ Vercel Express 19.882s (+16.6% 🔺) 21.586s (+13.5% 🔺) 1.704s 6 1.05x
▲ Vercel Next.js (Turbopack) 92.422s (+368.8% 🔺) 94.567s (+344.4% 🔺) 2.144s 4 4.88x

🔍 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.164s (~) 2.005s (~) 0.010s (-4.8%) 2.017s (~) 0.854s 10 1.00x
💻 Local Nitro 1.170s (-0.5%) 2.006s (~) 0.012s (-5.6% 🟢) 2.019s (~) 0.849s 10 1.01x
🐘 Postgres Express 1.174s (+0.6%) 1.996s (~) 0.001s (+10.0% 🔺) 2.011s (~) 0.837s 10 1.01x
🐘 Postgres Nitro 1.187s (+2.4%) 2.000s (~) 0.001s (+9.1% 🔺) 2.010s (~) 0.823s 10 1.02x
💻 Local Next.js (Turbopack) 1.215s (+3.4%) 2.004s (~) 0.012s (+22.2% 🔺) 2.020s (~) 0.805s 10 1.04x
🐘 Postgres Next.js (Turbopack) 1.227s (~) 2.001s (~) 0.001s (-33.3% 🟢) 2.011s (~) 0.784s 10 1.05x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.342s (+1.4%) 3.253s (-4.6%) 1.112s (-4.6%) 4.861s (-3.3%) 2.519s 10 1.00x
▲ Vercel Next.js (Turbopack) 2.423s (-7.8% 🟢) 3.492s (-2.3%) 1.306s (+5.3% 🔺) 5.331s (+2.4%) 2.908s 10 1.03x
▲ Vercel Nitro 2.527s (+16.3% 🔺) 3.768s (+8.2% 🔺) 0.782s (-32.8% 🟢) 5.056s (-1.7%) 2.529s 10 1.08x

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

stream pipeline with 5 transform steps (1MB)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.571s (~) 2.002s (~) 0.005s (+3.8%) 2.027s (~) 0.455s 30 1.00x
💻 Local Express 1.573s (-1.3%) 2.009s (~) 0.013s (+3.6%) 2.024s (~) 0.452s 30 1.00x
💻 Local Nitro 1.591s (-3.0%) 2.010s (~) 0.013s (+5.5% 🔺) 2.025s (~) 0.434s 30 1.01x
🐘 Postgres Nitro 1.616s (+1.6%) 2.004s (~) 0.005s (-8.5% 🟢) 2.028s (~) 0.413s 30 1.03x
💻 Local Next.js (Turbopack) 1.739s (+6.4% 🔺) 2.010s (~) 0.013s (+3.3%) 2.026s (~) 0.288s 30 1.11x
🐘 Postgres Next.js (Turbopack) 1.754s (~) 2.010s (~) 0.005s (+3.3%) 2.028s (~) 0.273s 30 1.12x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 6.142s (+1.3%) 7.350s (+4.1%) 0.320s (+58.0% 🔺) 8.239s (+7.9% 🔺) 2.097s 8 1.00x
▲ Vercel Nitro 6.182s (+1.8%) 7.687s (+2.9%) 0.218s (-29.2% 🟢) 8.445s (+1.7%) 2.263s 8 1.01x
▲ Vercel Express 6.315s (-13.1% 🟢) 7.752s (-8.6% 🟢) 0.259s (-1.8%) 8.490s (-8.4% 🟢) 2.174s 8 1.03x

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

10 parallel streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.703s (-2.1%) 1.029s (~) 0.000s (~) 1.047s (~) 0.344s 58 1.00x
🐘 Postgres Nitro 0.706s (~) 1.050s (-1.8%) 0.000s (-1.8%) 1.060s (-1.8%) 0.354s 57 1.00x
🐘 Postgres Next.js (Turbopack) 0.846s (+0.7%) 1.133s (+5.7% 🔺) 0.000s (+Infinity% 🔺) 1.140s (+5.7% 🔺) 0.294s 53 1.20x
💻 Local Express 1.376s (-6.2% 🟢) 2.011s (~) 0.000s (-22.2% 🟢) 2.014s (~) 0.638s 30 1.96x
💻 Local Nitro 1.389s (-3.9%) 2.014s (~) 0.000s (-23.1% 🟢) 2.016s (~) 0.627s 30 1.98x
💻 Local Next.js (Turbopack) 1.495s (+3.7%) 2.013s (~) 0.000s (~) 2.016s (~) 0.521s 30 2.13x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 3.313s (-4.6%) 4.664s (-4.6%) 0.000s (-100.0% 🟢) 5.200s (-3.2%) 1.887s 12 1.00x
▲ Vercel Next.js (Turbopack) 3.687s (-1.7%) 5.181s (+12.4% 🔺) 0.000s (-100.0% 🟢) 5.761s (+14.4% 🔺) 2.074s 11 1.11x
▲ Vercel Express 3.753s (+9.6% 🔺) 4.861s (+1.0%) 0.000s (NaN%) 5.401s (+2.3%) 1.648s 12 1.13x

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

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.415s (+5.0% 🔺) 2.102s (~) 0.000s (+Infinity% 🔺) 2.130s (+0.6%) 0.714s 29 1.00x
🐘 Postgres Nitro 1.477s (+5.4% 🔺) 2.102s (-1.8%) 0.000s (-100.0% 🟢) 2.132s (-1.0%) 0.655s 29 1.04x
🐘 Postgres Next.js (Turbopack) 1.673s (+0.5%) 2.224s (+2.0%) 0.000s (NaN%) 2.233s (+1.4%) 0.560s 27 1.18x
💻 Local Next.js (Turbopack) 2.954s (-0.7%) 3.472s (-3.8%) 0.000s (-52.8% 🟢) 3.475s (-3.9%) 0.521s 18 2.09x
💻 Local Express 3.166s (-4.4%) 3.900s (~) 0.001s (-25.0% 🟢) 3.903s (~) 0.737s 16 2.24x
💻 Local Nitro 3.166s (-4.9%) 3.902s (-3.2%) 0.001s (~) 3.905s (-3.1%) 0.739s 16 2.24x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 4.913s (+0.5%) 6.214s (-0.5%) 0.008s (+296.8% 🔺) 6.708s (~) 1.795s 9 1.00x
▲ Vercel Nitro 5.335s (+8.8% 🔺) 6.651s (+4.4%) 0.000s (NaN%) 7.208s (+5.1% 🔺) 1.872s 9 1.09x
▲ Vercel Next.js (Turbopack) 5.615s (+6.5% 🔺) 7.260s (+13.3% 🔺) 0.000s (+Infinity% 🔺) 7.850s (+14.6% 🔺) 2.235s 8 1.14x

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

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World 🥇 Fastest Framework Wins
💻 Local Express 11/21
🐘 Postgres Express 19/21
▲ Vercel Express 9/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework 🥇 Fastest World Wins
Express 🐘 Postgres 18/21
Next.js (Turbopack) 🐘 Postgres 14/21
Nitro 🐘 Postgres 14/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

Some tests failed

Summary

Passed Failed Skipped Total
❌ ▲ Vercel Production 1265 1 219 1485
✅ 💻 Local Development 1671 0 219 1890
✅ 📦 Local Production 1671 0 219 1890
✅ 🐘 Local Postgres 1671 0 219 1890
✅ 🪟 Windows 135 0 0 135
✅ 📋 Other 769 0 176 945
Total 7182 1 1052 8235

❌ Failed Tests

▲ Vercel Production (1 failed)

astro (1 failed):

  • outputStreamWorkflow - getTailIndex and getChunks getTailIndex returns correct index after stream completes

Details by Category

❌ ▲ Vercel Production
App Passed Failed Skipped
❌ astro 108 1 26
✅ example 109 0 26
✅ express 109 0 26
✅ fastify 109 0 26
✅ hono 109 0 26
✅ nextjs-turbopack 133 0 2
✅ nextjs-webpack 133 0 2
✅ nitro 109 0 26
✅ nuxt 109 0 26
✅ sveltekit 128 0 7
✅ vite 109 0 26
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 110 0 25
✅ express-stable 110 0 25
✅ fastify-stable 110 0 25
✅ hono-stable 110 0 25
✅ nextjs-turbopack-canary 116 0 19
✅ nextjs-turbopack-stable-lazy-discovery-disabled 135 0 0
✅ nextjs-turbopack-stable-lazy-discovery-enabled 135 0 0
✅ nextjs-webpack-canary 116 0 19
✅ nextjs-webpack-stable-lazy-discovery-disabled 135 0 0
✅ nextjs-webpack-stable-lazy-discovery-enabled 135 0 0
✅ nitro-stable 110 0 25
✅ nuxt-stable 110 0 25
✅ sveltekit-stable 129 0 6
✅ vite-stable 110 0 25
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 110 0 25
✅ express-stable 110 0 25
✅ fastify-stable 110 0 25
✅ hono-stable 110 0 25
✅ nextjs-turbopack-canary 116 0 19
✅ nextjs-turbopack-stable-lazy-discovery-disabled 135 0 0
✅ nextjs-turbopack-stable-lazy-discovery-enabled 135 0 0
✅ nextjs-webpack-canary 116 0 19
✅ nextjs-webpack-stable-lazy-discovery-disabled 135 0 0
✅ nextjs-webpack-stable-lazy-discovery-enabled 135 0 0
✅ nitro-stable 110 0 25
✅ nuxt-stable 110 0 25
✅ sveltekit-stable 129 0 6
✅ vite-stable 110 0 25
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 110 0 25
✅ express-stable 110 0 25
✅ fastify-stable 110 0 25
✅ hono-stable 110 0 25
✅ nextjs-turbopack-canary 116 0 19
✅ nextjs-turbopack-stable-lazy-discovery-disabled 135 0 0
✅ nextjs-turbopack-stable-lazy-discovery-enabled 135 0 0
✅ nextjs-webpack-canary 116 0 19
✅ nextjs-webpack-stable-lazy-discovery-disabled 135 0 0
✅ nextjs-webpack-stable-lazy-discovery-enabled 135 0 0
✅ nitro-stable 110 0 25
✅ nuxt-stable 110 0 25
✅ sveltekit-stable 129 0 6
✅ vite-stable 110 0 25
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 135 0 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 110 0 25
✅ e2e-local-dev-tanstack-start- 110 0 25
✅ e2e-local-postgres-nest-stable 110 0 25
✅ e2e-local-postgres-tanstack-start- 110 0 25
✅ e2e-local-prod-nest-stable 110 0 25
✅ e2e-local-prod-tanstack-start- 110 0 25
✅ e2e-vercel-prod-tanstack-start 109 0 26

📋 View full workflow run


Some E2E test jobs failed:

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

Check the workflow run for details.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jun 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5b3ca9f

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

karthikscale3 and others added 2 commits June 11, 2026 11:52
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>

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

…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>
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