Skip to main content

modkit/http/
otel.rs

1//! OpenTelemetry trace context helpers for HTTP headers
2//!
3//! Provides W3C Trace Context propagation with optional OpenTelemetry integration.
4//! - With `otel` feature: Uses proper OTEL propagators for distributed tracing
5//! - Without `otel` feature: No-op implementations (graceful degradation)
6
7use http::HeaderMap;
8
9/// W3C Trace Context header name
10pub const TRACEPARENT: &str = "traceparent";
11
12// ========================================
13// Shared helpers (available in all builds)
14// ========================================
15
16/// Extract traceparent header value from HTTP headers
17#[must_use]
18pub fn get_traceparent(headers: &HeaderMap) -> Option<&str> {
19    headers.get(TRACEPARENT)?.to_str().ok()
20}
21
22/// Parse trace ID from W3C traceparent header (format: "00-{trace_id}-{span_id}-{flags}")
23#[must_use]
24pub fn parse_trace_id(traceparent: &str) -> Option<String> {
25    let parts: Vec<&str> = traceparent.split('-').collect();
26    if parts.len() >= 4 && parts[0] == "00" {
27        Some(parts[1].to_owned())
28    } else {
29        None
30    }
31}
32
33// ========================================
34// OpenTelemetry integration (feature-gated)
35// ========================================
36
37#[cfg(feature = "otel")]
38mod imp {
39    use super::{get_traceparent, parse_trace_id};
40    use http::{HeaderMap, HeaderName, HeaderValue};
41    use opentelemetry::{
42        Context, global,
43        propagation::{Extractor, Injector},
44    };
45    use tracing::Span;
46    use tracing_opentelemetry::OpenTelemetrySpanExt;
47
48    /// Adapter for extracting W3C Trace Context from HTTP headers
49    struct HeadersExtractor<'a>(&'a HeaderMap);
50
51    impl Extractor for HeadersExtractor<'_> {
52        fn get(&self, key: &str) -> Option<&str> {
53            self.0.get(key).and_then(|v| v.to_str().ok())
54        }
55
56        fn keys(&self) -> Vec<&str> {
57            self.0.keys().map(http::HeaderName::as_str).collect()
58        }
59    }
60
61    /// Adapter for injecting W3C Trace Context into HTTP headers
62    struct HeadersInjector<'a>(&'a mut HeaderMap);
63
64    impl Injector for HeadersInjector<'_> {
65        fn set(&mut self, key: &str, value: String) {
66            if let Ok(name) = HeaderName::from_bytes(key.as_bytes())
67                && let Ok(val) = HeaderValue::from_str(&value)
68            {
69                self.0.insert(name, val);
70            }
71        }
72    }
73
74    /// Inject current OpenTelemetry context into HTTP headers.
75    /// Uses the global propagator to inject W3C Trace Context.
76    pub fn inject_current_span(headers: &mut HeaderMap) {
77        let cx = Context::current();
78        global::get_text_map_propagator(|propagator| {
79            propagator.inject_context(&cx, &mut HeadersInjector(headers));
80        });
81    }
82
83    /// Set span parent from W3C Trace Context headers.
84    /// Extracts the trace context and sets it as the parent of the given span.
85    pub fn set_parent_from_headers(span: &Span, headers: &HeaderMap) {
86        // Extract parent context using OTEL propagator
87        let parent_cx = global::get_text_map_propagator(|propagator| {
88            propagator.extract(&HeadersExtractor(headers))
89        });
90
91        // Set as parent of current span
92        let _ = span.set_parent(parent_cx);
93
94        // Also record trace IDs for log correlation
95        if let Some(traceparent) = get_traceparent(headers)
96            && let Some(trace_id) = parse_trace_id(traceparent)
97        {
98            span.record("trace_id", &trace_id);
99            span.record("parent.trace_id", &trace_id);
100        }
101    }
102}
103
104#[cfg(not(feature = "otel"))]
105mod imp {
106    use super::{get_traceparent, parse_trace_id};
107    use http::HeaderMap;
108    use tracing::Span;
109
110    /// No-op: OpenTelemetry is disabled
111    pub fn inject_current_span(_headers: &mut HeaderMap) {
112        // No-op when OTEL is disabled
113    }
114
115    /// No-op: OpenTelemetry is disabled
116    /// Records trace IDs if present in headers for log correlation only.
117    pub fn set_parent_from_headers(span: &Span, headers: &HeaderMap) {
118        // Without OTEL, just record trace IDs for log correlation if present
119        if let Some(traceparent) = get_traceparent(headers)
120            && let Some(trace_id) = parse_trace_id(traceparent)
121        {
122            span.record("trace_id", &trace_id);
123            span.record("parent.trace_id", &trace_id);
124        }
125    }
126}
127
128// ========================================
129// Public API
130// ========================================
131
132pub use imp::{inject_current_span, set_parent_from_headers};
133
134#[cfg(test)]
135#[cfg_attr(coverage_nightly, coverage(off))]
136mod tests {
137    use super::*;
138    use tracing::info_span;
139
140    #[test]
141    fn test_get_traceparent_none() {
142        let headers = HeaderMap::new();
143        assert!(get_traceparent(&headers).is_none());
144    }
145
146    #[test]
147    fn test_get_traceparent_ok() {
148        let mut headers = HeaderMap::new();
149        headers.insert(
150            TRACEPARENT,
151            "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
152                .parse()
153                .unwrap(),
154        );
155
156        let tp = get_traceparent(&headers);
157        assert!(tp.is_some());
158        assert_eq!(
159            tp.unwrap(),
160            "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
161        );
162    }
163
164    #[test]
165    fn test_parse_trace_id_ok() {
166        let traceparent = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
167        let trace_id = parse_trace_id(traceparent);
168        assert_eq!(
169            trace_id,
170            Some("4bf92f3577b34da6a3ce929d0e0e4736".to_owned())
171        );
172    }
173
174    #[test]
175    fn test_parse_trace_id_invalid() {
176        assert!(parse_trace_id("invalid").is_none());
177        assert!(parse_trace_id("").is_none());
178    }
179
180    #[test]
181    #[cfg(not(feature = "otel"))]
182    fn test_inject_current_span_noop() {
183        let mut headers = HeaderMap::new();
184        inject_current_span(&mut headers);
185        // Should be no-op, no headers added
186        assert!(headers.is_empty());
187    }
188
189    #[test]
190    #[cfg(feature = "otel")]
191    fn test_inject_current_span_no_panic() {
192        use opentelemetry::global;
193        use opentelemetry_sdk::propagation::TraceContextPropagator;
194
195        global::set_text_map_propagator(TraceContextPropagator::new());
196
197        let mut headers = http::HeaderMap::new();
198        let _span = tracing::info_span!("test").entered();
199        // Without full OTEL setup, this may not inject anything, but shouldn't panic
200        inject_current_span(&mut headers);
201    }
202
203    #[test]
204    fn test_set_parent_from_headers_no_panic() {
205        let mut headers = HeaderMap::new();
206        headers.insert(
207            TRACEPARENT,
208            "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
209                .parse()
210                .unwrap(),
211        );
212
213        let span = info_span!(
214            "test",
215            trace_id = tracing::field::Empty,
216            parent.trace_id = tracing::field::Empty
217        );
218
219        // Should not panic in either mode
220        set_parent_from_headers(&span, &headers);
221    }
222}