rfc: 0039 title: Inbound trace-context propagation — SERVER spans continue the caller’s trace status: accepted author: Jens Holdgaard Pedersen jens@holdgaard.org drafting-assistance: Claude created: 2026-07-25 supersedes: — superseded-by: —
RFC 0039 — Inbound trace-context propagation
Status:
accepted(2026-08-25, maintainer sign-off). Terminal — the completed-backlog batch flip. Where a thesis-gate applies it stands passing indocs/benchmarks.md§7; elsewherevalidatedis vacuous for this surface (RFC 0008 precedent).Status:
green— all six §5 scenarios have asserting tests, landed over four slices: the query arm and the propagator install (#627), the ingest spawn boundary on both OTLP transports (#628), the/mcpSERVER span with the tool spans nesting locally beneath it (#629), and the sampling regime (this slice). Two §3 design decisions were withdrawn during implementation and amended in place rather than silently followed — gRPC extraction moved out of the tower auth layer into the receiving handler (§3.3/§3.4), and the MCP arm gained a span, which §2’s original “no new spans” promise had ruled out.
1. Summary
Ourios’s request-scoped SERVER spans (RFC 0038) are currently created as trace
roots: they never read the incoming W3C traceparent/tracestate, so a
caller’s trace stops at the Ourios boundary. This RFC installs a global
TraceContextPropagator and, at each ingress, extracts the caller’s
opentelemetry::Context from the request carrier (HTTP headers / gRPC metadata)
and attaches it as the current context around the span-producing future
(FutureExt::with_context), so the root span inherits it as parent — not via
set_parent, which fails on an already-entered #[tracing::instrument] span.
The observable result: the ingest logs, POST /v1/query, and MCP tool spans join
the caller’s distributed trace instead of starting a disconnected one, and a
parentbased sampler honours the caller’s sampling decision. No new signal, no
schema change — this completes the traces pillar that RFC 0038 established.
2. Motivation
The point of a SERVER-kind span is to be the server half of a client’s
request: linked to the caller’s CLIENT/PRODUCER span through propagated
context, so an operator can follow one trace from the application that emitted a
log, through the OTLP exporter, into Ourios’s ingest path — or from a query
client into Ourios’s querier. RFC 0038 built the spans but not the propagation,
so today every Ourios span is a root. For a telemetry backend that sits inside
someone else’s distributed system, that is the most consequential remaining gap
in the traces signal: correlation-within-Ourios works (RFC 0038), but
correlation-across-the-boundary does not.
This is deliberately a small, bounded change at the ingress layer. It touches the traces pillar (hence an RFC), and for the ingest and query arms it adds no new spans, no new attributes of consequence, and no on-disk change — it only sets the parent of spans that already exist.
Amendment (slice 3). The paragraph above originally promised “no new spans” for every arm. That does not survive contact with the MCP arm: the only spans
/mcphad were theexecute_tool <tool>spans, which areINTERNAL— a kind the tracing API defines as an operation “as opposed to an operations [sic] with remote parents or children” (the grammar slip is upstream’s; quoted verbatim). Parenting them straight to the caller, as §7 originally deferred, would contradict their own kind, and MCPtools/callis JSON-RPC over HTTP, where both conventions require the inbound server span’s kind to beSERVER. So the MCP arm adds one span — aSERVERspan for the inbound/mcprequest — with the tool spans nesting locally beneath it. No on-disk change; §3.3 bullet D and §3.5 carry the details, and this closes §7’s second open question.
3. Proposed design
3.1 The one global: a W3C propagator
Install the W3C Trace Context propagator once, in ourios-telemetry’s init(),
alongside the existing provider installation:
#![allow(unused)]
fn main() {
opentelemetry::global::set_text_map_propagator(
opentelemetry_sdk::propagation::TraceContextPropagator::new(),
);
}
This is unconditional (cheap, stateless) and independent of whether the traces
pipeline is enabled — extraction is a no-op when no exporter is installed, and
installing the propagator regardless keeps the ingress code uniform. baggage
propagation is out of scope (§7).
3.2 The ingress map
Four ingress categories open a span on the request path — seven
span-producing functions in all, since the MCP category is three tool functions
plus the /mcp server span slice 3 adds (§3.3 bullet D). The count is stated so
test coverage (RFC0039.1/.3/.6) omits no site. The carrier — where the incoming
traceparent lives — is not always co-located with the span:
Sites are named by file and function, deliberately without line numbers — those rot on every touch of the surrounding code and have already been corrected twice in review.
| # | Span | Site | Carrier & where it is reachable |
|---|---|---|---|
| A | ingest logs (gRPC) | span in IngestPipeline::ingest_bound (pipeline.rs); entry LogsReceiver::export (grpc.rs) | tonic MetadataMap, on the request in export itself (§3.4) |
| B | ingest logs (HTTP) | span in ingest_bound; entry handle_logs (http.rs) | axum HeaderMap, a handle_logs extractor |
| C | POST /v1/query | handle_query_traced, behind the handle_query wrapper (querier.rs) | axum HeaderMap, a handle_query extractor |
| D1 | {method} /mcp (SERVER) | mcp_server_span_traced, behind the mcp_server_span layer (mcp.rs) | axum HeaderMap, in the layer — the remote carrier for the whole MCP category |
| D2 | execute_tool <tool> (×3, INTERNAL) | the three _traced fns (mcp.rs), each via a thin #[tool] delegate | not the remote carrier: McpTraceContext in parts.extensions, the D1 span’s own context (§3.3 bullet D) |
The mechanism is uniform (§3.3): extract the caller’s opentelemetry::Context
and make it the current context around the span-producing future, so the
span — a tracing root — inherits it as its OTel parent.
3.3 The mechanism: attach the context, do not set_parent
OpenTelemetrySpanExt::set_parent must be called before the span is entered.
On an already-entered span — which every #[tracing::instrument] span is, for
its whole body — it returns SetParentError::AlreadyStarted and the parent is
silently not set. So propagation cannot set_parent from inside an
instrumented fn. Instead it makes the extracted context current before the
span is built; tracing-opentelemetry then parents a root span to
Context::current(). The idiom is
opentelemetry::trace::FutureExt::with_context(future, cx) — run the
span-producing future under the extracted context. One contract, every site:
- Query (C): an un-instrumented
handle_querywrapper extractscxfrom the requestHeaderMapand awaits the instrumentedhandle_query_tracedunder it —handle_query_traced(..).with_context(cx).await. No tower layer: an earlier revision of this bullet proposed a sharedPropagationLayer, but neither ingest arm can use one (their span is born past atokio::spawn, below), which would leave the query arm as its sole beneficiary — see §4. - gRPC ingest (A): extraction happens in
LogsService::exportitself, from the request’s tonicMetadataMap(§3.4) — not in the tower auth layer, as an earlier revision of this section proposed. Extraction belongs with the handler that receives the call, which is both what OpenTelemetry prescribes for a service receiving upstream calls and what makes it testable: the RFC0039.3 harness drivesexportdirectly (no tower stack), so a layer-extracted context would leave the extraction itself uncovered. See the amendment note below. - The
tokio::spawnboundary (A/B): theingest logsspan is born insideingest_bound, after the spawn inexport/handle_logs, which ambient context does not cross. So the handler extractscxfrom its own carrier before the spawn, moves it into the spawned closure, and attaches it to the whole spawned block —async move { ingest_bound(...).await }.with_context(cx)— rather than toingest_bound’s future alone. Wrapping the block is deliberate: it holds whether#[tracing::instrument]mints its span at call time or on first poll, so the span cannot be created outsidecx. Noingest_boundsignature change, noset_parent.
Amendment (slice 2). §3.3’s gRPC bullet and §3.4 originally routed extraction through the tower auth layer into a request extension. That was withdrawn during implementation for two reasons: it contradicted §6, whose RFC0039.3 test calls
exportdirectly and so would have exercised only the spawn hand-off and never the extraction; and it put a propagation concern inside a layer named for authentication. The OTel guidance is explicit that a service receiving upstream calls extracts in the receiving handler (“the one context on the wire becomes the parent of the new span the library creates”), and the OTel Demo’s own C++ gRPC service does exactly this with aGrpcServerCarrieroverclient_metadata(). The cost of the correction is the ~15-lineMetadataExtractorthat §3.4 had hoped to avoid.
-
MCP (D): two spans, because one cannot honestly do both jobs. A
mcp_server_spanlayer, outermost on the/mcprouter so it also covers an auth rejection, extracts the caller’scxfrom the request headers and opens aSERVERspan under it (D1) — named{method} /mcpviaotel.name, since/mcpserves POST, GET and DELETE and the macro’s staticnamecannot vary. That span then publishes its own context asMcpTraceContextin the request extensions, and each un-instrumented#[tool]delegate reads it back and runsself.<tool>_traced(...).with_context(parent).await(D2).Two details are load-bearing. The delegates attach the server span’s context, not the extracted remote one: that is what makes the tool spans children of a local span, keeping them legitimately
INTERNAL. And the channel is the request extensions rather than ambient context, because rmcp dispatches the tool on atokio::spawned task — verified, not assumed: with the hand-off removed the tool span lands in a freshly minted trace. The extensions are known to survive that hop becauseAuthBindingalready travels the same route.
This is the same discipline RFC 0038.3 uses to carry work across tokio::spawn,
applied here to the parent context — and it is one uniform contract, resolving
the earlier draft’s split between an explicit parameter and a request extension.
3.4 The extractor shim
opentelemetry::propagation::Extractor is a two-method trait (get, keys).
Two adapters are needed, both in receiver/propagation.rs:
HeaderExtractor<'a>(&'a http::HeaderMap)— the axum-side ingresses (OTLP/HTTP, the query API, the MCP tools), whose carrier is anhttp::HeaderMap.MetadataExtractor<'a>(&'a tonic::metadata::MetadataMap)— the OTLP/gRPC ingress. gRPC metadata is HTTP/2 headers, but tonic models it asHeaderMap<MetadataValue>, nothttp::HeaderMap, and exposes no cheap&HeaderMapview, so it needs its own carrier.keys()offers only the ascii half: a binary (-bin) key can never resolve as a text-map entry.
Both resolve through the propagator installed in §3.1, e.g.
global::get_text_map_propagator(|p| p.extract(&HeaderExtractor(headers))).
opentelemetry-http ships an equivalent HeaderExtractor; the local pair avoids
that dependency and keeps both carriers described in one place.
3.5 Dependency promotion (the one production-surface change)
The ingress code needs opentelemetry types in production
(Context, propagation::Extractor, trace::FutureExt::with_context,
global::get_text_map_propagator), but opentelemetry is a production
dependency of ourios-ingester/ourios-server today only with the
metrics feature. This RFC adds the trace feature to that existing
dependency in both crates. The propagator install
(opentelemetry_sdk::propagation::TraceContextPropagator, §3.1) stays in
ourios-telemetry, which already depends on opentelemetry_sdk.
For the ingest and query arms that is the whole production-surface cost — one
added feature flag on a crate already depended on — because the parenting rides
the current-context bridge the tracing-opentelemetry layer already provides,
with no set_parent call.
Amendment (slice 3). This section originally concluded that “
tracing-opentelemetryis not needed in the ingress crates at all”. True of the ingest and query arms; not of MCP. The/mcpSERVER span must hand its own context to the tool handlers across rmcp’s dispatch spawn (§3.3 bullet D), and reading a tracing span’s OTel context requiresOpenTelemetrySpanExt::context(). Soourios-servergainstracing-opentelemetryas a production dependency — at the same 0.33 pinourios-telemetryalready uses, so no new version enters the tree. The alternative was building the/mcpspan through the raw OTel API, which hands back aContextdirectly and needs no new dependency, but would have made it the one Ourios span that is not atracingspan (no log correlation, and a pattern break —CLAUDE.md§5.4).
3.6 Sampling interplay
With a parent context attached, the SDK’s default parentbased_always_on
sampler (RFC 0038 §3.4, resolved from OTEL_TRACES_SAMPLER) honours the
caller’s sampled flag: a caller who sampled the trace propagates sampled=1 and
Ourios records/exports its spans within that trace; a caller who did not
propagates sampled=0 and Ourios’s spans are dropped, keeping the trace
consistent end-to-end. This is desirable and is the reason to prefer a
parentbased sampler as the default — it is what makes propagation meaningful.
A request with no incoming context falls back to the root sampling rule
unchanged (backward-compatible).
3.7 Traces disabled
with_context merely attaches an opentelemetry::Context for the duration of a
future; it has no fallible surface and no Result to handle (contrast the
set_parent design, which returned SetParentError — one reason to prefer the
attach idiom). When traces are disabled the span carries no OTel layer, the
attached context is inert, and nothing is exported — a no-op, not an error. No
unwrap/expect is introduced (CLAUDE.md §6.1).
4. Alternatives considered
Do nothing (status quo — roots). Correlation within Ourios works; the cost is that no operator can follow a trace across the Ourios boundary. For a telemetry backend this is precisely the interesting join, so the gap is not acceptable long-term.
Extract at the shared ingest_bound span only, via ambient context. Fails:
the carrier does not reach ingest_bound (its signature has no request), and
tokio::spawn severs ambient context (§3.3). Extraction must happen in the
handler.
A single set_parent call inside each instrumented fn. The obvious first
design, and what an earlier draft proposed — but it does not work:
OpenTelemetrySpanExt::set_parent returns AlreadyStarted on an entered span,
and every #[tracing::instrument] span is entered for its body, so the parent is
silently dropped (§3.3). The attach-the-context idiom (with_context) is the
correct primitive and is what §3.3 adopts.
A shared PropagationLayer instead of per-handler extraction. Attractive on
paper — one layer for every axum-side ingress — but it buys less than it looks.
Neither ingest arm can use it: their span is born past a tokio::spawn, so they
need the explicit with_context hand-off regardless (§3.3), and a layer that
extracted for them would only duplicate what the handler must do anyway. That
leaves the query arm as the sole beneficiary of a whole tower layer, for the two
lines it already spends extracting directly. Per-handler extraction also keeps
each site’s carrier visible at the site, which is where OTel puts it.
Adopt opentelemetry-http’s HeaderExtractor as a dependency. Reasonable,
but it is one more crate for a ~10-line shim; the RFC inlines the extractor. If
a tonic-metadata extractor is later needed, revisit.
5. Acceptance criteria
Scenario RFC0039.1 — a SERVER span continues an incoming trace. Given the traces pipeline enabled and the global
TraceContextPropagatorinstalled, When aPOST /v1/queryrequest and an OTLPExport(both gRPC and HTTP) each arrive carrying a valid W3Ctraceparentfor traceTspanS, Then the resultingPOST /v1/queryandingest logsspans each havetrace_id == Tand parent span id== S(they are children of the caller’s span, not roots).
Scenario RFC0039.2 — no incoming context is a fresh root, unchanged. Given the same setup, When a request arrives with no
traceparent, Then the span is a fresh root with a newly mintedtrace_idand no parent — identical to pre-RFC behaviour, and no error is raised.
Scenario RFC0039.3 — the extracted context survives the ingest spawn. Given the gRPC and HTTP OTLP receivers, whose
ingest logsspan is created inside atokio::spawnedingest_bound, When a batch arrives carryingtraceparentfor traceT, Then theingest logsspan (and itscommit walchild) resolve totrace_id == T— proving the parent context was extracted before the spawn and applied to the post-spawn span (the RFC 0038.3 boundary, for the parent context this time).
Scenario RFC0039.4 — the caller’s sampling decision is honoured. Given the default
parentbasedsampler, When a request carriestraceparentwith the sampled flag unset (-00), and separately with it set (-01), Then the unset case produces no exported span (the trace was not sampled upstream), and the set case exports the span within traceT— the parent decision governs, end to end.
Scenario RFC0039.5 — a malformed carrier is treated as absent. Given the propagator, When a request carries a syntactically invalid
traceparent, Then extraction yields an empty context, the span becomes a fresh root (as RFC0039.2), and no panic or request error occurs.
Scenario RFC0039.6 — the MCP tool call joins the caller’s trace, correctly shaped. Given an MCP
tools/callover/mcpcarryingtraceparentfor traceTspanS, When the tool executes, Then a{method} /mcpspan of kindSERVERresolves totrace_id == Twith parent span id== S; and theexecute_tool <tool>span resolves totrace_id == Twith its parent being that local server span — retaining kindINTERNAL, which the spec reserves for operations without remote parents. So an agent driving Ourios’s tools sees the whole exchange inside its own trace, without either span misrepresenting its kind.
6. Testing strategy
Mapped to CLAUDE.md §6.2:
- RFC0039.1 / .2 / .5 / .6 — integration tests in
ourios-server/ourios-ingesterusing the RFC 0038 scoped-InMemorySpanExporterharness: drivehandle_query,handle_logs, and (global-tracer binary, per RFC0038.1 MCP arm) an MCPtools/call, each with an injectedtraceparentheader, then assertSpanData.span_context.trace_id()/.parent_span_id(). The no-context and malformed-context cases assert a fresh, valid root and no error. - RFC0039.3 —
rfc0039_3_ingest_propagation.rs(its own global-tracer binary, per RFC0028.2): callLogsReceiver::exportand the HTTP router directly with atraceparent, and assert theingest logs+commit walspans carry the injectedtrace_id. It gets a binary of its own rather than extendingrfc0038_3_spawn_boundary.rs— a process holds one tracer install, and that file owns the no-inbound-context case, so extending it would have meant editing a passing test’s assertions (CLAUDE.md§6.2). - RFC0039.6 —
rfc0039_6_mcp_propagation.rs(likewise its own binary): handshake without atraceparent, then onetools/callwith one, so a single run covers both the propagated and the root case. Asserts theSERVERkind and remote parent of the/mcpspan, and that theexecute_toolspan isINTERNALwith that server span as its local parent. - RFC0039.4 — a sampler test: with
OTEL_TRACES_SAMPLER=parentbased_always_on(default), inject-00vs-01traceparents and assert exported-span presence. The parent-based resolution itself is upstream SDK behaviour; the test covers Ourios’s wiring (that the extracted context reaches the sampler). - The extractor shims get a unit test (round-trip a
traceparentthrough aHeaderMapand back to aSpanContext).
7. Open questions
- Resolved (slice 2).
FutureExt::with_contextdoes re-attach the extracted context inside the spawnedingest_boundtask, on both transports —rfc0039_3_ingest_propagation.rspasses, and fails with each arm’s span in a freshly minted trace when the two handler changes are reverted. Site A’s carrier is the tonicMetadataMap, read inexport(§3.4 amendment); no request extension and noingest_boundsignature change are involved. - Resolved (slice 3): yes, the dedicated
/mcpSERVER span is warranted — and required. This question framed an INTERNAL span with a remote parent as “valid but slightly unusual”. It is not valid: the tracing API definesINTERNALas an operation “as opposed to an operations [sic] with remote parents or children”, and the concepts doc as one that “does not cross a process boundary” (both quoted verbatim — the grammar slip is upstream’s). Meanwhiletools/callis JSON-RPC over HTTP, and both the RPC and HTTP conventions state the inbound server span’s kind MUST beSERVER. So/mcpgains a SERVER span (§3.3 bullet D), the tool spans nest locally under it, and both kinds stay honest. Consequences recorded as amendments in §2 (the “no new spans” promise) and §3.5 (thetracing-opentelemetrydependency). -
tracestateandbaggage:tracestaterides along withTraceContextautomatically;baggagepropagation is explicitly out of scope here. - Response-side injection (Ourios as a client to object storage / a downstream) is a separate concern — not in this RFC (inbound only).
8. References
- RFC 0038 (self-tracing) — the spans this RFC gives parents to; §3.3 (the
tokio::spawnboundary), §3.4 (the sampler), RFC0038.3 (spawn-boundary test harness), RFC0038.7 (rmcp=offloop-guard). CLAUDE.md§6.3 (observability of ourselves), §2 (the traces pillar via RFC 0038), §6.1 (nounwrap/expectin non-test code).- W3C Trace Context — https://www.w3.org/TR/trace-context/.
- OpenTelemetry — context propagation;
FutureExt::with_context(the attach idiom this RFC uses; noteOpenTelemetrySpanExt::set_parentreturnsAlreadyStartedon an entered span, which is why it is not used). - OpenTelemetry span kinds —
SpanKind(the normative definition ofINTERNALthat §7’s second question turned on), and the RPC / HTTP server-span conventions, both of which state the kind MUST beSERVER. - Pinned:
opentelemetry0.32.0,opentelemetry_sdk0.32.1,tracing-opentelemetry0.33.0.