Skip to main content

toolkit_trace_context/
lib.rs

1//! W3C Trace Context propagation shared by CF/Gears crates.
2//!
3//! The core toolkit (`cf-gears-toolkit`) and the HTTP crate
4//! (`cf-gears-toolkit-http`) both need to validate an inbound W3C `traceparent`,
5//! seed a span's parent from it, and inject the active context on egress.
6//! Historically each kept its own copy, which drifted; this crate is the one
7//! place both depend on so the gateway and the gears agree on what a valid
8//! `traceparent` is, on which ids a request span carries, and on how context is
9//! propagated.
10//!
11//! - [`set_parent_from_headers`] — inbound continuation. With the `otel` feature
12//!   it continues the caller's trace via the process-global text-map
13//!   propagator; without it it still records `trace_id` / `parent.trace_id` on
14//!   the span for log correlation, but the span is a fresh root.
15//! - [`inject_current_span`] — outbound propagation. With `otel` it injects the
16//!   active context as `traceparent`; without it, a no-op.
17//! - [`current_trace_id`] / [`current_trace_ids`] — read the ids of the active
18//!   `OTel` span (what the log-correlation formatter and error path stamp).
19//! - [`extract_trace_id`] — the wire-id resolver: active span, else a validated
20//!   inbound `traceparent`.
21#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
22
23use http::HeaderMap;
24use std::future::Future;
25
26/// The `(trace_id, span_id)` of the currently active `OTel` span, if valid.
27///
28/// Returns the raw `Copy` id types rather than `String`s: both render as
29/// lowercase hex (32 and 16 chars) via `Display`, matching the `traceparent`
30/// wire form and [`parse_trace_id`], so a log formatter can write them straight
31/// into its output with no intermediate allocation.
32///
33/// Reads `OTel`'s own context directly rather than via
34/// `OpenTelemetrySpanExt::context()`, which re-enters the subscriber and yields
35/// nothing from inside `on_event`. `OpenTelemetryLayer` attaches the context on
36/// span entry (`context_activation`, on by default), so it is already current
37/// by the time a formatter runs.
38///
39/// Only a log-correlation formatter needs *both* ids; the per-error path uses
40/// [`current_trace_id`] instead, which reads the `trace_id` alone.
41#[cfg(feature = "otel")]
42#[must_use]
43pub fn current_trace_ids() -> Option<(opentelemetry::trace::TraceId, opentelemetry::trace::SpanId)>
44{
45    use opentelemetry::trace::TraceContextExt as _;
46
47    let context = opentelemetry::Context::current();
48    let span = context.span();
49    let span_context = span.span_context();
50    span_context
51        .is_valid()
52        .then(|| (span_context.trace_id(), span_context.span_id()))
53}
54
55/// The `trace_id` of the currently active `OTel` span, if valid.
56///
57/// Reads the `trace_id` directly rather than via [`current_trace_ids`], to avoid
58/// allocating the discarded `span_id` on the per-error-response path.
59#[cfg(feature = "otel")]
60#[must_use]
61pub fn current_trace_id() -> Option<String> {
62    use opentelemetry::trace::TraceContextExt as _;
63
64    let context = opentelemetry::Context::current();
65    let span = context.span();
66    let span_context = span.span_context();
67    span_context
68        .is_valid()
69        .then(|| span_context.trace_id().to_string())
70}
71
72/// No `OTel` compiled in: there is never a span context to read.
73#[cfg(not(feature = "otel"))]
74#[must_use]
75pub fn current_trace_id() -> Option<String> {
76    None
77}
78
79/// Resolve the wire `trace_id`: live `OTel` span context (the same source a
80/// log-correlation formatter uses) → incoming W3C `traceparent` (validated by
81/// [`parse_trace_id`]).
82///
83/// The live span is preferred so the wire id is *always* the one the caller's
84/// own log lines carry: on a successful continuation the two are equal, and on a
85/// fresh root (no propagator, or `set_parent` failed) the span's id is what the
86/// logs show — reading the header first would ship an id the logs don't carry.
87/// The header is used only when no span is active (no `otel`, or outside any
88/// request span), and an invalid `traceparent` is treated as absent.
89///
90/// It does NOT fall back to `x-request-id` or a span handle: those are different
91/// identifiers that resolve to nothing under `trace_id` in a trace backend.
92/// Absent both, `trace_id` is left absent rather than filled with a stand-in.
93#[must_use]
94pub fn extract_trace_id(headers: &HeaderMap) -> Option<String> {
95    current_trace_id().or_else(|| get_traceparent(headers).and_then(parse_trace_id))
96}
97
98/// W3C Trace Context header name.
99pub const TRACEPARENT: &str = "traceparent";
100
101/// Extract the raw `traceparent` header value from HTTP headers.
102#[must_use]
103pub fn get_traceparent(headers: &HeaderMap) -> Option<&str> {
104    headers.get(TRACEPARENT)?.to_str().ok()
105}
106
107/// Parse the trace-id from a W3C `traceparent` header
108/// (format: `00-{trace_id}-{span_id}-{flags}`).
109///
110/// Returns the trace-id only when the whole header is a valid W3C version-`00`
111/// traceparent: exactly four fields, a non-zero 32-hex trace-id, a non-zero
112/// 16-hex parent-id, and 2-hex flags. Any deviation yields `None`, so a
113/// caller never propagates unvalidated input as if it were a trace-id.
114#[must_use]
115pub fn parse_trace_id(traceparent: &str) -> Option<String> {
116    let parts: Vec<&str> = traceparent.split('-').collect();
117    if parts.len() == 4
118        && parts[0] == "00"
119        && is_valid_trace_id(parts[1])
120        && is_valid_parent_id(parts[2])
121        && is_valid_flags(parts[3])
122    {
123        Some(parts[1].to_owned())
124    } else {
125        None
126    }
127}
128
129/// `len` lowercase hex digits (`0-9`, `a-f`).
130fn is_lowercase_hex(s: &str, len: usize) -> bool {
131    s.len() == len
132        && s.bytes()
133            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
134}
135
136/// A valid W3C trace-id: 32 lowercase hex digits, not all zeroes.
137fn is_valid_trace_id(s: &str) -> bool {
138    is_lowercase_hex(s, 32) && s.bytes().any(|b| b != b'0')
139}
140
141/// A valid W3C parent-id (span-id): 16 lowercase hex digits, not all zeroes.
142fn is_valid_parent_id(s: &str) -> bool {
143    is_lowercase_hex(s, 16) && s.bytes().any(|b| b != b'0')
144}
145
146/// Valid W3C trace-flags: exactly 2 lowercase hex digits.
147fn is_valid_flags(s: &str) -> bool {
148    is_lowercase_hex(s, 2)
149}
150
151/// OpenTelemetry-backed propagation: continues and injects W3C Trace Context
152/// through the process-global text-map propagator.
153#[cfg(feature = "otel")]
154mod imp {
155    use super::parse_trace_id;
156    use http::{HeaderMap, HeaderName, HeaderValue};
157    use opentelemetry::{
158        Context, global,
159        propagation::{Extractor, Injector},
160    };
161    use tracing::Span;
162    use tracing_opentelemetry::OpenTelemetrySpanExt as _;
163
164    /// Adapter for extracting W3C Trace Context from HTTP headers.
165    struct HeadersExtractor<'a>(&'a HeaderMap);
166
167    impl Extractor for HeadersExtractor<'_> {
168        fn get(&self, key: &str) -> Option<&str> {
169            self.0.get(key).and_then(|v| v.to_str().ok())
170        }
171
172        fn keys(&self) -> Vec<&str> {
173            self.0.keys().map(http::HeaderName::as_str).collect()
174        }
175    }
176
177    /// Adapter for injecting W3C Trace Context into HTTP headers.
178    struct HeadersInjector<'a>(&'a mut HeaderMap);
179
180    impl Injector for HeadersInjector<'_> {
181        fn set(&mut self, key: &str, value: String) {
182            if let Ok(name) = HeaderName::from_bytes(key.as_bytes())
183                && let Ok(val) = HeaderValue::from_str(&value)
184            {
185                self.0.insert(name, val);
186            }
187        }
188    }
189
190    /// Seed `span`'s parent with the W3C trace context carried by `headers` (via
191    /// the process-global text-map propagator), so the span continues the
192    /// caller's trace rather than starting a fresh root, and record
193    /// `trace_id` / `parent.trace_id` on the span for log correlation.
194    ///
195    /// A no-op (fresh root) when no `traceparent` is present or no propagator is
196    /// installed. The `set_parent` error (e.g. `LayerNotFound`) is intentionally
197    /// discarded: it only means no continuation happened, and the recorded ids
198    /// below still carry the wire trace-id.
199    pub fn set_parent_from_headers(span: &Span, headers: &HeaderMap) {
200        let parent_cx = global::get_text_map_propagator(|propagator| {
201            propagator.extract(&HeadersExtractor(headers))
202        });
203        _ = span.set_parent(parent_cx);
204
205        if let Some(traceparent) = super::get_traceparent(headers)
206            && let Some(trace_id) = parse_trace_id(traceparent)
207        {
208            span.record("trace_id", &trace_id);
209            span.record("parent.trace_id", &trace_id);
210        }
211    }
212
213    /// Inject the current OpenTelemetry context into `headers` as `traceparent`,
214    /// so an outbound request continues this process's active trace.
215    pub fn inject_current_span(headers: &mut HeaderMap) {
216        let cx = Context::current();
217        global::get_text_map_propagator(|propagator| {
218            propagator.inject_context(&cx, &mut HeadersInjector(headers));
219        });
220    }
221}
222
223/// Fallback propagation without OpenTelemetry: records the inbound trace id
224/// for log correlation; injection is a no-op.
225#[cfg(not(feature = "otel"))]
226mod imp {
227    use super::parse_trace_id;
228    use http::HeaderMap;
229    use tracing::Span;
230
231    /// No OpenTelemetry: cannot continue the caller's trace, but still record
232    /// `trace_id` / `parent.trace_id` from the header so text/JSON logs carry
233    /// the wire id.
234    pub fn set_parent_from_headers(span: &Span, headers: &HeaderMap) {
235        if let Some(traceparent) = super::get_traceparent(headers)
236            && let Some(trace_id) = parse_trace_id(traceparent)
237        {
238            span.record("trace_id", &trace_id);
239            span.record("parent.trace_id", &trace_id);
240        }
241    }
242
243    /// No-op: OpenTelemetry is disabled, so there is no active context to inject.
244    pub fn inject_current_span(_headers: &mut HeaderMap) {}
245}
246
247pub use imp::{inject_current_span, set_parent_from_headers};
248
249/// `x-request-id` header name — the edge-minted, chain-scoped correlation id
250/// the gateway echoes on responses and gears record on their request spans.
251///
252/// Unlike `trace_id` (a new span per hop), the request id is static for the
253/// whole request chain. Inbound middleware installs it via [`with_request_id`];
254/// `toolkit-http` forwards it on outbound requests via [`inject_request_id`].
255pub const X_REQUEST_ID: &str = "x-request-id";
256
257tokio::task_local! {
258    /// Chain-scoped `x-request-id` for the in-flight request this task serves.
259    static REQUEST_ID: Option<String>
260}
261
262/// Extract the inbound `x-request-id` header value, if present.
263#[must_use]
264pub fn extract_request_id(headers: &HeaderMap) -> Option<String> {
265    headers
266        .get(X_REQUEST_ID)
267        .and_then(|v| v.to_str().ok())
268        .map(str::to_owned)
269}
270
271/// The `x-request-id` installed by [`with_request_id`], if any.
272///
273/// Returns `None` outside the task that `with_request_id` wraps — see its
274/// docs for the `tokio::spawn` / `spawn_blocking` / streamed-body limitation.
275#[must_use]
276pub fn current_request_id() -> Option<String> {
277    REQUEST_ID.try_with(Clone::clone).ok().flatten()
278}
279
280/// Run `f` with `id` installed as [`current_request_id`], so `toolkit-http`
281/// requests made while serving this request carry the same `x-request-id`
282/// downstream. `None` installs an empty scope: nothing propagates.
283///
284/// `id` is a tokio task-local, so it is not inherited across `tokio::spawn` /
285/// `spawn_blocking` or into a streamed body polled after `f` resolves — such
286/// work is uncorrelated unless it captures `current_request_id` and re-installs
287/// it via `with_request_id` (as `tracing::Span` needs `.in_current_span()`).
288pub async fn with_request_id<F: Future>(id: Option<String>, f: F) -> F::Output {
289    REQUEST_ID.scope(id, f).await
290}
291
292/// Copy [`current_request_id`] into `headers` unless the caller already set
293/// `x-request-id`. A no-op outside a request scope — nothing is minted.
294///
295/// Must run in the caller's task, not in a `Buffer`-ed layer: the buffer's
296/// worker task sees no task-locals (it forwards only `tracing::Span`).
297pub fn inject_request_id(headers: &mut HeaderMap) {
298    if headers.contains_key(X_REQUEST_ID) {
299        return;
300    }
301    if let Some(id) = current_request_id()
302        && let Ok(value) = id.parse()
303    {
304        headers.insert(X_REQUEST_ID, value);
305    }
306}
307
308#[cfg(test)]
309#[cfg_attr(coverage_nightly, coverage(off))]
310mod tests {
311    use super::*;
312    use http::HeaderMap;
313    use tracing::info_span;
314
315    const TID: &str = "4bf92f3577b34da6a3ce929d0e0e4736";
316
317    #[test]
318    fn get_traceparent_none_when_absent() {
319        assert!(get_traceparent(&HeaderMap::new()).is_none());
320    }
321
322    #[test]
323    fn get_traceparent_returns_the_raw_value() {
324        let mut headers = HeaderMap::new();
325        headers.insert(
326            TRACEPARENT,
327            format!("00-{TID}-00f067aa0ba902b7-01")
328                .parse()
329                .expect("valid header"),
330        );
331        assert_eq!(
332            get_traceparent(&headers),
333            Some(format!("00-{TID}-00f067aa0ba902b7-01").as_str())
334        );
335    }
336
337    #[test]
338    fn parses_a_valid_version_00_header() {
339        assert_eq!(
340            parse_trace_id(&format!("00-{TID}-00f067aa0ba902b7-01")).as_deref(),
341            Some(TID)
342        );
343    }
344
345    #[test]
346    fn rejects_obviously_invalid_headers() {
347        assert!(parse_trace_id("invalid").is_none());
348        assert!(parse_trace_id("").is_none());
349        assert!(parse_trace_id("00---").is_none());
350        assert!(parse_trace_id("00-not-hex-at-all-x").is_none());
351    }
352
353    #[test]
354    fn rejects_a_parseable_but_invalid_segment() {
355        assert!(
356            parse_trace_id("00-00000000000000000000000000000000-00f067aa0ba902b7-01").is_none(),
357            "all-zero trace id is invalid per W3C"
358        );
359        assert!(
360            parse_trace_id("00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01").is_none(),
361            "uppercase hex is not the lowercase wire form"
362        );
363    }
364
365    #[test]
366    fn validates_the_whole_version_00_header() {
367        assert!(
368            parse_trace_id(&format!("00-{TID}-00f067aa0ba902b7-01-extra")).is_none(),
369            "a version-00 header must have exactly four fields"
370        );
371        assert!(
372            parse_trace_id(&format!("00-{TID}-00f067aa0ba902-01")).is_none(),
373            "short parent-id must be rejected"
374        );
375        assert!(
376            parse_trace_id(&format!("00-{TID}-zzzzzzzzzzzzzzzz-01")).is_none(),
377            "non-hex parent-id must be rejected"
378        );
379        assert!(
380            parse_trace_id(&format!("00-{TID}-0000000000000000-01")).is_none(),
381            "all-zero parent-id is invalid per W3C"
382        );
383        assert!(
384            parse_trace_id(&format!("00-{TID}-00f067aa0ba902b7-1")).is_none(),
385            "single-digit flags must be rejected"
386        );
387        assert!(
388            parse_trace_id(&format!("00-{TID}-00f067aa0ba902b7-zz")).is_none(),
389            "non-hex flags must be rejected"
390        );
391    }
392
393    /// The request-id carrier is visible only inside `with_request_id`'s scope
394    /// and never leaks to the surrounding task.
395    #[tokio::test]
396    async fn request_id_is_visible_only_inside_the_scope() {
397        assert!(current_request_id().is_none());
398        let id = with_request_id(Some("req-1".to_owned()), async { current_request_id() }).await;
399        assert_eq!(id.as_deref(), Some("req-1"));
400        assert!(current_request_id().is_none());
401    }
402
403    /// `inject_request_id` stamps the scoped id, leaves a caller-set header
404    /// alone, and does nothing outside a request scope.
405    #[tokio::test]
406    async fn inject_request_id_scoped_explicit_and_absent() {
407        let mut headers = HeaderMap::new();
408        inject_request_id(&mut headers);
409        assert!(
410            headers.is_empty(),
411            "outside a request scope no id is minted"
412        );
413
414        with_request_id(Some("req-9".to_owned()), async {
415            let mut scoped = HeaderMap::new();
416            inject_request_id(&mut scoped);
417            assert_eq!(scoped.get(X_REQUEST_ID).unwrap(), "req-9");
418
419            let mut explicit = HeaderMap::new();
420            explicit.insert(X_REQUEST_ID, "explicit".parse().expect("header"));
421            inject_request_id(&mut explicit);
422            assert_eq!(
423                explicit.get(X_REQUEST_ID).unwrap(),
424                "explicit",
425                "a caller-set header is never overwritten"
426            );
427        })
428        .await;
429    }
430
431    /// `extract_request_id` lifts the raw header value and treats a missing or
432    /// non-UTF8 value as absent.
433    #[test]
434    fn extract_request_id_reads_the_header() {
435        let mut headers = HeaderMap::new();
436        assert!(extract_request_id(&headers).is_none());
437        headers.insert(X_REQUEST_ID, "req-7".parse().expect("header"));
438        assert_eq!(extract_request_id(&headers).as_deref(), Some("req-7"));
439    }
440
441    /// `set_parent_from_headers` must not panic in either feature mode, and it
442    /// records the ids onto a span that declares them.
443    #[test]
444    fn set_parent_from_headers_records_ids_without_panicking() {
445        let mut headers = HeaderMap::new();
446        headers.insert(
447            TRACEPARENT,
448            format!("00-{TID}-00f067aa0ba902b7-01")
449                .parse()
450                .expect("valid header"),
451        );
452        let span = info_span!(
453            "test",
454            trace_id = tracing::field::Empty,
455            parent.trace_id = tracing::field::Empty
456        );
457        set_parent_from_headers(&span, &headers);
458    }
459
460    /// With `otel`, injecting the active context must not panic even when no
461    /// full tracer pipeline is installed.
462    #[test]
463    #[cfg(feature = "otel")]
464    fn inject_current_span_does_not_panic() {
465        use opentelemetry::global;
466        use opentelemetry_sdk::propagation::TraceContextPropagator;
467
468        global::set_text_map_propagator(TraceContextPropagator::new());
469        let mut headers = HeaderMap::new();
470        let _span = info_span!("test").entered();
471        inject_current_span(&mut headers);
472    }
473
474    /// Without `otel`, injection is a no-op: no headers are added.
475    #[test]
476    #[cfg(not(feature = "otel"))]
477    fn inject_current_span_is_a_noop_without_otel() {
478        let mut headers = HeaderMap::new();
479        inject_current_span(&mut headers);
480        assert!(headers.is_empty());
481    }
482}
483
484// Split cfgs (not `all(test, feature = "otel")`) so clippy still sees the
485// `#[cfg(test)]` and allows `expect`/`unwrap` in these tests. The whole module
486// needs a live OTel span, so it is also gated on `otel`.
487#[cfg(test)]
488#[cfg(feature = "otel")]
489#[cfg_attr(coverage_nightly, coverage(off))]
490mod resolver_tests {
491    use super::{current_trace_id, extract_trace_id};
492    use http::HeaderMap;
493    use opentelemetry::trace::TracerProvider as _;
494    use tracing_subscriber::layer::SubscriberExt as _;
495
496    /// A trace-id a caller might send that is deliberately *not* the one an
497    /// active span carries, so a test can tell which of the two won.
498    const OTHER_TRACE: &str = "0af7651916cd43dd8448eb211c80319c";
499
500    fn header(value: &str) -> HeaderMap {
501        let mut headers = HeaderMap::new();
502        headers.insert("traceparent", value.parse().expect("header value"));
503        headers
504    }
505
506    fn valid_header_from_another_trace() -> HeaderMap {
507        header(&format!("00-{OTHER_TRACE}-b7ad6b7169203331-01"))
508    }
509
510    /// Run `f` inside an active `OTel` span wired like production, so
511    /// `current_trace_id()` resolves to a real span id.
512    fn in_active_span<R>(f: impl FnOnce() -> R) -> R {
513        let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder().build();
514        let tracer = provider.tracer("trace-context-test");
515        let subscriber =
516            tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer));
517        tracing::subscriber::with_default(subscriber, || tracing::info_span!("unit").in_scope(f))
518    }
519
520    /// A valid header from a *different* trace does not override an active span:
521    /// the wire id must be the span's, the one the caller's own logs carry.
522    #[test]
523    fn active_span_wins_over_a_valid_header_from_another_trace() {
524        in_active_span(|| {
525            let span_id = current_trace_id().expect("an active span has a trace id");
526            let resolved = extract_trace_id(&valid_header_from_another_trace());
527            assert_eq!(
528                resolved.as_deref(),
529                Some(span_id.as_str()),
530                "the live span's id, not the header's"
531            );
532            assert_ne!(
533                resolved.as_deref(),
534                Some(OTHER_TRACE),
535                "the header carried a different trace and must not win"
536            );
537        });
538    }
539
540    /// An invalid header inside an active span falls through to the span id
541    /// (never to `None`): the fallthrough from a rejected header to the live
542    /// span is exercised here, not just the no-span `None` case.
543    #[test]
544    fn invalid_header_inside_a_span_yields_the_span_id() {
545        in_active_span(|| {
546            let span_id = current_trace_id().expect("an active span has a trace id");
547            assert_eq!(
548                extract_trace_id(&header("not-a-valid-traceparent")).as_deref(),
549                Some(span_id.as_str())
550            );
551        });
552    }
553
554    /// With no span active, the header is the only source: a valid one parses, an
555    /// invalid one and an absent one both yield `None` (never the raw header).
556    #[test]
557    fn header_is_used_only_when_no_span_is_active() {
558        assert_eq!(
559            extract_trace_id(&valid_header_from_another_trace()).as_deref(),
560            Some(OTHER_TRACE)
561        );
562        assert!(extract_trace_id(&header("not-a-valid-traceparent")).is_none());
563        assert!(extract_trace_id(&HeaderMap::new()).is_none());
564    }
565}