tensor_wasm_api/trace_propagation.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! W3C Trace Context propagation helpers.
5//!
6//! This module is the single point of contact between the inbound HTTP
7//! request's `traceparent` / `tracestate` headers and the local `tracing`
8//! span tree. It exists so the wiring lives in exactly one place — the
9//! tower middleware in [`crate::middleware`] is the only consumer at the
10//! moment, but the same helpers are usable from integration tests and from
11//! any future surface that wants to participate in distributed tracing
12//! (e.g. a CLI shim that calls back into the gateway, a benchmark harness
13//! that captures the resulting trace IDs, etc.).
14//!
15//! The actual span-creation logic still lives in
16//! [`crate::middleware::trace_layer_with_propagation`]; this file owns:
17//!
18//! * the [`HeaderMapExtractor`] adapter that lets the OpenTelemetry
19//! `TextMapPropagator` API read from `axum::http::HeaderMap`;
20//! * [`install_w3c_propagator`], an idempotent one-shot installer for the
21//! global [`opentelemetry_sdk::propagation::TraceContextPropagator`] so
22//! middleware extraction is not a silent no-op;
23//! * [`extract_parent_context`], a thin helper around the global
24//! propagator that the middleware uses to attach a parent to its span;
25//! * [`current_trace_id`], a helper for emitting the active trace id on
26//! responses (`x-trace-id` header) and in audit records.
27//!
28//! ## Why install the propagator from the API crate
29//!
30//! `tensor-wasm-core`'s [`telemetry`](tensor_wasm_core::telemetry) module
31//! initialises the global tracer provider but its OTel deps are
32//! feature-gated under `otlp`. The gateway crate, by contrast, needs the
33//! propagator on every deployment regardless of whether the operator has
34//! enabled OTLP export — without it the middleware sees an empty context
35//! and `traceparent` from the client is silently dropped. So we install
36//! the propagator here, unconditionally, at router build time. It's a
37//! single global write guarded by a [`Once`].
38
39use std::sync::Once;
40
41#[allow(unused_imports)] // referenced indirectly via the global propagator registration
42use opentelemetry::propagation::TextMapPropagator;
43use opentelemetry_sdk::propagation::TraceContextPropagator;
44
45/// HTTP header name used by the gateway to surface the request's resolved
46/// trace id on every response. Lowercased per HTTP/2 norms; axum
47/// normalises outgoing headers regardless, but using the canonical
48/// lowercase spelling here matches the rest of the codebase.
49///
50/// The value is the 32-character lowercase hex string the OpenTelemetry
51/// SDK assigns to the request's trace, the same one operators paste into
52/// Jaeger / Tempo / Honeycomb to find the trace. Operators correlating
53/// logs with a captured trace id should query this header — see
54/// `docs/runbooks/trace-id.md`.
55pub const HEADER_TRACE_ID: &str = "x-trace-id";
56
57/// Guards [`install_w3c_propagator`]. A separate global from any used in
58/// `tensor_wasm_core::telemetry` so the API crate can stand on its own
59/// without forcing operators to enable the `otlp` feature.
60static PROPAGATOR_INIT: Once = Once::new();
61
62/// Install the W3C Trace Context propagator as the global text-map
63/// propagator if no other propagator has been installed yet.
64///
65/// Idempotent: subsequent calls are no-ops. Safe to call from
66/// [`crate::server::build_router`] on every router construction — the
67/// `Once` guarantees the install happens exactly once per process.
68///
69/// Without this call the global propagator defaults to OpenTelemetry's
70/// `NoopTextMapPropagator`, which always extracts an empty context and
71/// therefore silently drops the inbound `traceparent` header. We make
72/// the failure mode "the propagator is installed but the SDK is not"
73/// (degraded to a no-op exporter) rather than "the propagator is
74/// missing" (which is a much harder bug to spot at runtime).
75pub fn install_w3c_propagator() {
76 PROPAGATOR_INIT.call_once(|| {
77 opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new());
78 });
79}
80
81/// Adapter that lets the OpenTelemetry `TextMapPropagator` read from an
82/// `axum`/`http` [`HeaderMap`](axum::http::HeaderMap).
83///
84/// The propagator API is generic over an
85/// [`opentelemetry::propagation::Extractor`], which expects
86/// `get(&str) -> Option<&str>` and `keys() -> Vec<&str>` semantics. We
87/// provide the smallest possible bridge so we do not need the
88/// `opentelemetry-http` crate as an additional dependency.
89pub struct HeaderMapExtractor<'a>(pub &'a axum::http::HeaderMap);
90
91impl<'a> opentelemetry::propagation::Extractor for HeaderMapExtractor<'a> {
92 fn get(&self, key: &str) -> Option<&str> {
93 self.0.get(key).and_then(|v| v.to_str().ok())
94 }
95
96 fn keys(&self) -> Vec<&str> {
97 self.0.keys().map(|k| k.as_str()).collect()
98 }
99}
100
101/// Extract a parent [`opentelemetry::Context`] from the supplied headers
102/// via whichever global propagator has been installed (typically
103/// [`TraceContextPropagator`] after [`install_w3c_propagator`] has run).
104///
105/// Returns an empty context if no propagator is installed or if the
106/// headers do not carry a recognised trace-context entry; the caller can
107/// pass that empty context to `set_parent` safely — the resulting span
108/// becomes a new local root, which is the documented degradation
109/// behaviour.
110pub fn extract_parent_context(headers: &axum::http::HeaderMap) -> opentelemetry::Context {
111 opentelemetry::global::get_text_map_propagator(|propagator| {
112 propagator.extract(&HeaderMapExtractor(headers))
113 })
114}
115
116/// Return the 32-character lowercase hex representation of the active
117/// span's trace id, if any.
118///
119/// The value is read from `tracing::Span::current()` via the
120/// [`tracing_opentelemetry::OpenTelemetrySpanExt`] bridge, which only
121/// resolves to a non-zero id when a `tracing_opentelemetry` layer is
122/// active in the subscriber. Returns `None` when no layer is installed
123/// (the common case in unit tests that do not initialise telemetry) or
124/// when the active span has no associated OTel context.
125///
126/// The "invalid" all-zero trace id (`00000000000000000000000000000000`)
127/// returned by `SpanContext::INVALID` is also mapped to `None` so callers
128/// can branch on `Option` instead of pattern-matching on the sentinel.
129pub fn current_trace_id() -> Option<String> {
130 use opentelemetry::trace::TraceContextExt;
131 use tracing_opentelemetry::OpenTelemetrySpanExt;
132
133 let span = tracing::Span::current();
134 let cx = span.context();
135 let trace_id = cx.span().span_context().trace_id();
136 let hex = trace_id.to_string();
137 // `TraceId::INVALID` renders as 32 zeros — surfacing that to callers
138 // would defeat the point of the helper.
139 if hex.chars().all(|c| c == '0') {
140 None
141 } else {
142 Some(hex)
143 }
144}
145
146/// Middleware that stamps `x-trace-id: <hex>` on every response when the
147/// active span has a resolved OTel trace id.
148///
149/// Called via [`axum::middleware::from_fn`] from
150/// [`crate::server::build_router_with_audit`]. Sits inside
151/// [`crate::middleware::trace_layer_with_propagation`] so the active
152/// span at header-injection time is the one the tracing layer just
153/// created (with its parent already wired from `traceparent`).
154///
155/// Absent trace id => header omitted entirely; we never emit
156/// `x-trace-id: ` with an empty value, as that surfaces as a confusing
157/// "trace context unavailable" signal to operators reading captures.
158pub async fn inject_trace_id_header(
159 req: axum::extract::Request,
160 next: axum::middleware::Next,
161) -> axum::response::Response {
162 let mut response = next.run(req).await;
163 if let Some(hex) = current_trace_id() {
164 if let Ok(value) = axum::http::HeaderValue::from_str(&hex) {
165 response.headers_mut().insert(HEADER_TRACE_ID, value);
166 }
167 }
168 response
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use axum::http::HeaderMap;
175 use opentelemetry::propagation::Extractor;
176
177 #[test]
178 fn header_map_extractor_returns_value() {
179 let mut headers = HeaderMap::new();
180 headers.insert("traceparent", "abc".parse().unwrap());
181 let ex = HeaderMapExtractor(&headers);
182 assert_eq!(ex.get("traceparent"), Some("abc"));
183 assert!(ex.keys().contains(&"traceparent"));
184 }
185
186 #[test]
187 fn header_map_extractor_missing_returns_none() {
188 let headers = HeaderMap::new();
189 let ex = HeaderMapExtractor(&headers);
190 assert_eq!(ex.get("traceparent"), None);
191 }
192
193 #[test]
194 fn install_w3c_propagator_is_idempotent() {
195 // First call may or may not actually install (another test or the
196 // server bin may have got there first), but neither call panics
197 // and both must return cleanly.
198 install_w3c_propagator();
199 install_w3c_propagator();
200 }
201
202 #[test]
203 fn extract_parent_context_with_no_trace_header_is_safe() {
204 install_w3c_propagator();
205 let headers = HeaderMap::new();
206 // Must not panic; returned context may be empty.
207 let _ = extract_parent_context(&headers);
208 }
209
210 #[test]
211 fn extract_parent_context_with_valid_traceparent_returns_non_empty() {
212 use opentelemetry::trace::TraceContextExt;
213
214 install_w3c_propagator();
215 let mut headers = HeaderMap::new();
216 // Sample W3C traceparent: version=00, trace_id, parent_id, sampled.
217 headers.insert(
218 "traceparent",
219 "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"
220 .parse()
221 .unwrap(),
222 );
223 let cx = extract_parent_context(&headers);
224 let trace_id_hex = cx.span().span_context().trace_id().to_string();
225 assert_eq!(trace_id_hex, "0af7651916cd43dd8448eb211c80319c");
226 }
227
228 #[test]
229 fn current_trace_id_is_none_without_subscriber() {
230 // No tracing-opentelemetry layer installed in this unit test, so
231 // the helper must report None rather than the all-zero sentinel.
232 // (Other tests in the workspace may have set up a subscriber, but
233 // the global SpanContext for the current — no-op — span is still
234 // invalid. The check below is robust to either outcome: we only
235 // require that the helper does not surface the sentinel.)
236 match current_trace_id() {
237 None => {}
238 Some(hex) => assert!(
239 !hex.chars().all(|c| c == '0'),
240 "current_trace_id leaked the all-zero sentinel",
241 ),
242 }
243 }
244}