feat: propagate W3C trace context across the QStash boundary - #41
Open
djb-gt wants to merge 1 commit into
Open
Conversation
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.
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. |
Author
|
Re-opening. Filing a companion issue with the problem statement so maintainers can choose between issue-first discussion or direct PR review. |
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
workflow-pycurrently does no OpenTelemetry context propagation. When a step submits the next step via QStash, notraceparentflows 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:
opentelemetry-instrumentation-celeryopentelemetry-instrumentation-kafka-pythonopentelemetry-instrumentation-botocoreopentelemetry-instrumentation-pikaI 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-traceparentfrom outside the library via an OTel httpxrequest_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, plustracestatewhen present) is written asUpstash-Forward-traceparent(and-tracestate). QStash strips theUpstash-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
POSTto QStash.OpenTelemetry is a soft dependency
_inject_otel_contextsoft-importsopentelemetry.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 atDEBUG— 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)Both sync (
upstash_workflow.context.auto_executor) and async (upstash_workflow.asyncio.context.auto_executor) paths reuse_get_headersdirectly fromworkflow_requests.py, so a single inject point covers everything.Test plan
pytest -q— 13 pass (10 existing + 3 new)traceparentis injected when a span is active, (b) nothing is injected when no span is active, (c) the helper is a no-op whenopentelemetryis unavailableruff check— cleanmypy upstash_workflow/workflow_requests.py— cleanNotes / follow-ups (out of scope for this PR)
context.run("<name>", fn)in astart_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.upstash/workflow-js. If you accept this, I'd be happy to mirror it there.🤖 Generated with Claude Code