Skip to content

feat: propagate W3C trace context across the QStash boundary - #41

Open
djb-gt wants to merge 1 commit into
upstash:masterfrom
djb-gt:feat/otel-context-propagation
Open

feat: propagate W3C trace context across the QStash boundary#41
djb-gt wants to merge 1 commit into
upstash:masterfrom
djb-gt:feat/otel-context-propagation

Conversation

@djb-gt

@djb-gt djb-gt commented May 15, 2026

Copy link
Copy Markdown

Problem

workflow-py currently does no OpenTelemetry context propagation. When a step submits the next step via QStash, no traceparent flows with it, so QStash invokes the next step handler with a fresh (root) trace context. Multi-step workflows therefore appear as N disjoint root traces in any tracing UI (Tempo, Jaeger, Datadog, etc.) — one per step invocation — instead of one trace per logical workflow run.

This breaks the standard observability pattern that every other major queue ecosystem already supports:

Library Native OTel context propagation
Celery opentelemetry-instrumentation-celery
Kafka opentelemetry-instrumentation-kafka-python
AWS SQS opentelemetry-instrumentation-botocore
RabbitMQ opentelemetry-instrumentation-pika
Inngest Built in
QStash / upstash-workflow Not implemented ← this PR

I hit this in production while wiring Grafana Cloud OTel into our data-engine syncs. Each 3-step QStash workflow surfaced as 3 separate single-span "traces", which is essentially unusable for debugging a multi-step run. As a short-term workaround I'm injecting Upstash-Forward-traceparent from outside the library via an OTel httpx request_hook, but the principled fix belongs here — every workflow-py user hits the same gap.

Fix

A single inject point in _get_headers — the chokepoint every QStash publish in the library already routes through (initial trigger, step submissions, third-party-call steps). The current OpenTelemetry context (traceparent, plus tracestate when present) is written as Upstash-Forward-traceparent (and -tracestate). QStash strips the Upstash-Forward- prefix and delivers the header to the destination, where the receiver's HTTP instrumentor (FastAPI / Flask / Django / Starlette / whatever) extracts it as the parent context.

After this PR, multi-step workflows surface as one trace per run in tracing UIs, with each step appearing as a child span of the previous step's outbound POST to QStash.

OpenTelemetry is a soft dependency

_inject_otel_context soft-imports opentelemetry.propagate.inject. When the package is not installed (the common case for users not running OTel) the helper is a pure no-op and the library carries zero runtime cost. Failures inside the helper are caught and logged at DEBUG — a misconfigured tracing setup must never break a workflow.

Diff scope

  • upstash_workflow/workflow_requests.py: +37 lines (one helper function, one call site)
  • tests/test_otel_propagation.py: +111 lines (three tests)
  • Zero changes to public API, dependencies, or behaviour for non-OTel users.

Both sync (upstash_workflow.context.auto_executor) and async (upstash_workflow.asyncio.context.auto_executor) paths reuse _get_headers directly from workflow_requests.py, so a single inject point covers everything.

Test plan

  • pytest -q — 13 pass (10 existing + 3 new)
    • new tests verify (a) traceparent is injected when a span is active, (b) nothing is injected when no span is active, (c) the helper is a no-op when opentelemetry is unavailable
  • ruff check — clean
  • mypy upstash_workflow/workflow_requests.py — clean
  • Local end-to-end test in a real consumer (FastAPI + ngrok + QStash, one 3-step workflow): one unified trace landed in Grafana Cloud Tempo, vs three disjoint root traces before.

Notes / follow-ups (out of scope for this PR)

  • An optional companion improvement would be wrapping each context.run("<name>", fn) in a start_as_current_span(f"workflow.step.{name}") so steps get named INTERNAL spans without users needing to decorate. Happy to do this in a separate PR if you're interested; kept it out here to minimise scope.
  • Same gap exists in upstash/workflow-js. If you accept this, I'd be happy to mirror it there.

🤖 Generated with Claude Code

Workflow-py currently does no OpenTelemetry context propagation: when a
step submits the next step via QStash, no traceparent flows with it, so
QStash invokes the next step handler with a fresh (root) trace context.
Multi-step workflows therefore appear as N disjoint root traces in any
tracing UI (Tempo, Jaeger, Datadog, etc.) instead of one trace per
logical run.

This adds a single inject point in '_get_headers' — the chokepoint every
QStash publish in the library already routes through — that writes the
current 'traceparent' (and 'tracestate', when present) as
'Upstash-Forward-traceparent' (and -tracestate). QStash strips the
'Upstash-Forward-' prefix and delivers the header to the destination,
where the receiver's HTTP instrumentor (FastAPI/Flask/etc.) extracts it
as the parent context. Multi-step workflows now surface as a single
trace per run.

OpenTelemetry is a SOFT dependency. '_inject_otel_context' soft-imports
the package; when it isn't installed (the common case for users not
running OTel) the helper is a pure no-op. Failures inside the helper are
swallowed at DEBUG level — a misconfigured tracing setup must never
break a workflow.

Test coverage: three new tests verify (a) traceparent is injected when a
span is active, (b) nothing is injected when no span is active, and (c)
the helper is a no-op when the opentelemetry package is unavailable.
All existing tests continue to pass.

Precedent: every other major queue ecosystem already does this —
opentelemetry-instrumentation-celery, -kafka-python, -botocore (SQS),
-pika (RabbitMQ), Inngest. QStash/workflow has been the outlier.
@djb-gt

djb-gt commented May 15, 2026

Copy link
Copy Markdown
Author

Closing — opened this without explicit sign-off from my org to file upstream. The diff is preserved in the fork branch (djb-gt/workflow-py:feat/otel-context-propagation) in case anyone wants to pick it up later.

@djb-gt djb-gt closed this May 15, 2026
@djb-gt

djb-gt commented May 15, 2026

Copy link
Copy Markdown
Author

Re-opening. Filing a companion issue with the problem statement so maintainers can choose between issue-first discussion or direct PR review.

@djb-gt

djb-gt commented May 15, 2026

Copy link
Copy Markdown
Author

Companion issue with the problem statement and rationale: #42. Maintainers can pick the workflow that suits — discuss the gap on the issue first, or review the diff here directly. Happy to revise either path based on feedback.

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.

1 participant