feat: link a queued run back to the request that queued it (#14616)
* feat: link a queued run back to the request that queued it
A background run arrived at the APM as an orphan. Measured before the change, on a real
run through POST /api/v2/workflows with mode=background:
flow.execute trace_id = 1291125b0d1eaf83ea0508d451d160a9 (its own trace)
parent = None
links = []
So the request that enqueued the job and the run that resulted were two unrelated traces.
An operator looking at a slow background run could not reach the request that caused it,
and an operator looking at a slow request could not see the work it queued.
The flow span already links back to a finished parent (graph/base.py), but only while that
parent is reachable in process memory, which covers a run outliving its request inside one
process because contextvars copy into an asyncio task. A job written to the database and
read back later has no context to inherit, so that branch never fires.
Now the trace context travels on the job row. Injected once at enqueue as a W3C traceparent,
extracted by whoever picks the job up, attached to the flow span as a link.
Link rather than parent: the run starts after the request finished, and parenting a span to
an ancestor that already ended renders as a child beginning after its parent, which is the
defect the existing branch already works around. traceparent rather than a bare trace id:
the bare id loses the sampled flag, so a linked run could be dropped by a sampler that kept
the request it came from.
Written once at enqueue and never rewritten. The alternative, chaining each pass to the one
before it, would need a write into job_metadata at every suspend, and that blob is saved back
whole -- the runner already carries a comment about a heartbeat racing a stamp and clobbering
the request id. Passes are instead grouped by the existing run_id, which on the background
path IS the job id, and ordered by their timestamps.
The link rides a contextvar bound next to execution_protocol, because the graph that opens
the span sits several layers below the runner that reads the job row and two of those layers
hand the run to a fresh task. That is the same mechanism, and the same reason, as protocol.
It only applies when no span is current. An in-process parent is better evidence than a
stamped one, so a run genuinely nested inside a live request keeps its real parent.
Absence stays absent throughout: nothing traced at enqueue writes no carrier, and a missing,
unparseable or invalid carrier yields no link and an ordinary root span. The all-zero trace
id is covered explicitly, because it parses as well formed and is what an invalid context
serialises to, so a naive reader would return a link to a trace that does not exist.
Verified rather than assumed: removing the injection turns the end-to-end link test red with
links [], and it passes again on restore. A synchronous run is asserted to have no link at
all, which is what would break first if the ambient link escaped its binding.
13 carrier tests, 3 end-to-end. 173 backend telemetry and 294 v2 API tests pass.
Pre-existing and unrelated: two catalog-policy tests in lfx tests/unit/graph fail on this
base branch with none of these changes applied, verified by reverting both source files.
* fix(test): attach to the existing tracer provider instead of installing over it
CI failed on Python 3.14 group 5 only, with this file's own guard firing:
AssertionError: another test installed a tracer provider first;
these assertions would be vacuous
The guard was right. set_tracer_provider is first-write-wins, so when xdist scheduled another
provider-installing module into the same worker, this module's provider was ignored and its
exporter would have been fed by nothing. Passing on the other Python versions was scheduling
luck, not correctness.
Attaching a processor to whichever provider is already there removes the ordering dependency
entirely. The tests still cannot pass vacuously, because each asserts its own spans arrived
before asserting anything about their links.
Reproduced locally by running this file alongside test_build_flow_span.py, which installs a
provider of its own: 9 passed after the change, and the same pairing is what CI hit.
* test: assert the sampling flag survives the carrier
Raised in review, and it lands on the claim the design rests on. The reason the carrier is a
W3C traceparent rather than a bare trace id is that a bare id drops the sampling decision, so
a linked run could be discarded by a sampler that kept the request it came from -- leaving an
operator with a request pointing at a run that was never exported.
That argument had no test. The trace id round-trip passed whether or not the flag survived.
Now asserted on its own. The assertion is not trivially true: the originating span is sampled
here, and the carrier it produces ends in -03, so a regression that dropped the flag would
turn this red rather than compare two zeroes.
* fix(tests): shut the attached span processor down on teardown
A tracer provider has no removal API. Clearing the exporter empties the list
but leaves the processor registered, so it kept appending every later span in
the worker to an exporter nothing reads. Probed: after clear() a later span
still lands, after shutdown() it does not.
Move the teardown into a finally block so it also runs when a test fails.
* fix: prefer the job row's carrier over a stale span in the worker's context
The ended-parent branch was checked first, so whatever ended span a worker
task happened to be holding captured the run and the carrier was never read.
A worker started from a request inherits that request's context permanently,
so every later run it served linked back to that first request. That is the
fabricated relationship this work argues is worse than an orphan, and it
renders in the APM as real.
Reproduced before fixing, in test_queued_link_precedence.py: the flow span
linked to the stale trace rather than the originating one. Red before, green
after. A genuinely live parent still wins over both and stays a real parent,
asserted as the control.
Also from review:
- do not read the job row when OpenTelemetry is absent; the row was read,
parsed and discarded, costing a SELECT per run and per resume for a link
nothing could render
- pass job_id on the buffered background path, which passed only run_id and
so read no carrier at all
- annotate the two carrier helpers as Link | None rather than Any G
Gabriel Luiz Freitas Almeida committed
c9bcd8fbc3ceb76130b280c655cc621c2d47322d
Parent: 0aefaa2
Committed by GitHub <noreply@github.com>
on 8/20/2026, 1:48:55 PM