polyc_runtime/propagation.rs
1//! W3C `traceparent` propagation across the connectrpc boundary.
2//!
3//! Pairs with [`crate::observability::init`] (which installs the global
4//! [`TraceContextPropagator`](opentelemetry_sdk::propagation::TraceContextPropagator)).
5//! Together they close the `OTel` cross-process story: a turn started on the
6//! control plane carries its trace context into the harness's `connect` span,
7//! so a single user request stitches into one trace tree across the
8//! control-plane → harness hop.
9//!
10//! # Direction
11//!
12//! - **Outgoing**: [`inject_current_span_into`] writes the current
13//! `tracing::Span`'s `OTel` context into an [`http::HeaderMap`] using the
14//! globally-installed propagator. Callers attach the headers to a
15//! `connectrpc::client::CallOptions` (per-call) or
16//! `connectrpc::client::ClientConfig` (per-client default).
17//! - **Incoming**: [`extract_parent_into_current_span`] reads `traceparent`
18//! (and any other propagator-registered fields) off a borrowed
19//! [`http::HeaderMap`] and re-parents the current `tracing::Span` so the
20//! server-side span shares the dialer's trace id.
21//!
22//! # Design
23//!
24//! The [`Injector`]/[`Extractor`] adapters are intentionally trivial — they
25//! exist because `opentelemetry`'s trait surface is carrier-agnostic and
26//! `http::HeaderMap` is not. Keeping them in `polyc-runtime` means the
27//! control plane, the harness, and any future server share one implementation;
28//! per-call sites only pick which side (inject vs extract) they need.
29
30use opentelemetry::Context;
31use opentelemetry::global;
32use opentelemetry::propagation::{Extractor, Injector};
33use tracing_opentelemetry::OpenTelemetrySpanExt as _;
34
35/// Adapter that injects propagator fields into an [`http::HeaderMap`].
36///
37/// Lets the global [`TextMapPropagator`](opentelemetry::propagation::TextMapPropagator)
38/// write `traceparent` (and any other fields the propagator registers) into
39/// an HTTP request's headers.
40///
41/// Invalid header names/values (the propagator should never produce them for
42/// W3C `traceparent`, but the contract is `String`) are silently dropped:
43/// shipping a partially-propagated request is strictly better than failing
44/// the request, and an absent `traceparent` just degrades to "new trace tree"
45/// on the receiving side.
46pub struct HttpHeaderInjector<'a>(pub &'a mut http::HeaderMap);
47
48impl Injector for HttpHeaderInjector<'_> {
49 fn set(&mut self, key: &str, value: String) {
50 if let (Ok(name), Ok(val)) = (
51 http::header::HeaderName::try_from(key),
52 http::header::HeaderValue::try_from(value),
53 ) {
54 self.0.insert(name, val);
55 }
56 }
57}
58
59/// Adapter that lets the global `OTel` propagator read `traceparent` (and any
60/// other fields) off an [`http::HeaderMap`].
61///
62/// Header values that aren't valid UTF-8 are skipped — W3C `traceparent` is
63/// ASCII by spec, so a non-string value means a misconfigured upstream, and
64/// "start a new trace tree" is the right fallback.
65pub struct HttpHeaderExtractor<'a>(pub &'a http::HeaderMap);
66
67impl Extractor for HttpHeaderExtractor<'_> {
68 fn get(&self, key: &str) -> Option<&str> {
69 self.0.get(key).and_then(|v| v.to_str().ok())
70 }
71
72 fn keys(&self) -> Vec<&str> {
73 self.0
74 .keys()
75 .map(http::header::HeaderName::as_str)
76 .collect()
77 }
78}
79
80/// Inject the current `tracing::Span`'s `OTel` context into `headers`.
81///
82/// Uses the propagator installed by [`crate::observability::init`]. If no
83/// propagator was installed (e.g. in a test that didn't run `init`), this is
84/// a no-op — the global getter returns a no-op propagator by default.
85pub fn inject_current_span_into(headers: &mut http::HeaderMap) {
86 let cx = tracing::Span::current().context();
87 inject_context_into(&cx, headers);
88}
89
90/// Inject a specific [`Context`] into `headers`. Lower-level than
91/// [`inject_current_span_into`]; prefer that for production call sites.
92pub fn inject_context_into(cx: &Context, headers: &mut http::HeaderMap) {
93 global::get_text_map_propagator(|propagator| {
94 propagator.inject_context(cx, &mut HttpHeaderInjector(headers));
95 });
96}
97
98/// Extract the parent `OTel` context from `headers` and attach it to the
99/// current `tracing::Span`, so server-side spans share the dialer's trace id.
100///
101/// A missing or malformed `traceparent` produces an empty context, which
102/// `set_parent` accepts as a no-op — the local span just stays a root span.
103///
104/// Calling this from a `#[tracing::instrument]`'d handler must happen
105/// **before** the first call to [`tracing::Span::context`] on the same span
106/// (or anything that triggers a span entry that consults the parent), per
107/// `tracing-opentelemetry`'s ordering contract. The two server-side
108/// `connect` handlers wire it first thing in the function body for exactly
109/// this reason.
110pub fn extract_parent_into_current_span(headers: &http::HeaderMap) {
111 let parent_cx = extract_context_from(headers);
112 // `set_parent` returns `Err` only if the span has already been entered /
113 // its context observed; for our use (called first thing in the handler)
114 // that's an internal invariant violation, so log and continue with the
115 // local root span rather than fail the request.
116 if let Err(err) = tracing::Span::current().set_parent(parent_cx) {
117 tracing::debug!(error = %err, "could not set parent trace context");
118 }
119}
120
121/// Extract a parent [`Context`] from `headers` using the globally-installed
122/// propagator. Lower-level than [`extract_parent_into_current_span`].
123#[must_use]
124pub fn extract_context_from(headers: &http::HeaderMap) -> Context {
125 global::get_text_map_propagator(|propagator| propagator.extract(&HttpHeaderExtractor(headers)))
126}
127
128#[cfg(test)]
129mod tests {
130 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
131
132 use super::*;
133 use opentelemetry::trace::{
134 SpanContext, SpanId, TraceContextExt, TraceFlags, TraceId, TraceState,
135 };
136 use opentelemetry_sdk::propagation::TraceContextPropagator;
137
138 /// Build a synthetic `OTel` context with a known trace + span id. We use
139 /// `Context::new().with_remote_span_context(...)` so the propagator's
140 /// `inject_context` finds a sampleable span context — this mirrors what
141 /// `tracing-opentelemetry` does internally.
142 fn ctx_with(trace_id: TraceId, span_id: SpanId) -> Context {
143 let span_ctx = SpanContext::new(
144 trace_id,
145 span_id,
146 TraceFlags::SAMPLED,
147 true, // remote
148 TraceState::default(),
149 );
150 Context::new().with_remote_span_context(span_ctx)
151 }
152
153 /// Install the W3C propagator locally for the duration of the test
154 /// process. `set_text_map_propagator` is process-global; multiple tests
155 /// installing the same propagator is harmless (idempotent replacement).
156 fn install_w3c() {
157 global::set_text_map_propagator(TraceContextPropagator::new());
158 }
159
160 #[test]
161 fn injector_extractor_round_trip_preserves_trace_id() {
162 install_w3c();
163 let trace_id = TraceId::from_hex("0af7651916cd43dd8448eb211c80319c").unwrap();
164 let span_id = SpanId::from_hex("b7ad6b7169203331").unwrap();
165 let cx = ctx_with(trace_id, span_id);
166
167 let mut headers = http::HeaderMap::new();
168 inject_context_into(&cx, &mut headers);
169
170 // The W3C propagator writes a `traceparent` header.
171 assert!(
172 headers.get("traceparent").is_some(),
173 "traceparent should be injected"
174 );
175
176 let extracted = extract_context_from(&headers);
177 let extracted_span = extracted.span();
178 let extracted_ctx = extracted_span.span_context();
179 assert_eq!(
180 extracted_ctx.trace_id(),
181 trace_id,
182 "trace id must round-trip"
183 );
184 assert_eq!(extracted_ctx.span_id(), span_id, "span id must round-trip");
185 }
186
187 #[test]
188 fn extract_from_empty_headers_yields_invalid_context() {
189 install_w3c();
190 let headers = http::HeaderMap::new();
191 let extracted = extract_context_from(&headers);
192 // No `traceparent` → the extracted span context isn't valid.
193 assert!(
194 !extracted.span().span_context().is_valid(),
195 "empty headers should yield an invalid (root) context"
196 );
197 }
198
199 #[test]
200 fn injector_silently_drops_invalid_header_names() {
201 // Direct unit test of the adapter: an invalid header name (e.g.
202 // containing whitespace) is dropped rather than panicking. This
203 // matches the production contract — better to ship a partial
204 // propagation than fail the request.
205 let mut map = http::HeaderMap::new();
206 let mut injector = HttpHeaderInjector(&mut map);
207 injector.set("invalid name with spaces", "v".to_owned());
208 injector.set("x-good", "ok".to_owned());
209 assert!(map.get("invalid name with spaces").is_none());
210 assert_eq!(map.get("x-good").unwrap(), "ok");
211 }
212
213 #[test]
214 fn extractor_keys_returns_header_names() {
215 let mut map = http::HeaderMap::new();
216 map.insert("traceparent", http::HeaderValue::from_static("x"));
217 map.insert("x-custom", http::HeaderValue::from_static("y"));
218 let extractor = HttpHeaderExtractor(&map);
219 let mut keys: Vec<&str> = extractor.keys();
220 keys.sort_unstable();
221 assert_eq!(keys, vec!["traceparent", "x-custom"]);
222 assert_eq!(extractor.get("traceparent"), Some("x"));
223 assert_eq!(extractor.get("missing"), None);
224 }
225}