krabka_trace_context/carrier.rs
1//! A serialisable W3C trace context that fits inside an existing payload.
2
3use std::{
4 ops::Range,
5 str::{FromStr as _, from_utf8},
6};
7
8use opentelemetry::trace::{SpanContext, SpanId, TraceFlags, TraceId, TraceState};
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11use tracing_opentelemetry::OpenTelemetrySpanExt as _;
12
13use crate::propagation::{TRACEPARENT, TRACESTATE, current_trace_headers, set_remote_parent};
14
15/// Byte length of a version-`00` `traceparent`: `00-` + 32 + `-` + 16 + `-` + 2.
16const TRACEPARENT_LEN: usize = 55;
17/// Byte offsets of the three `-` separators in a version-`00` `traceparent`.
18const TRACE_ID_RANGE: Range<usize> = 3..35;
19const SPAN_ID_RANGE: Range<usize> = 36..52;
20const FLAGS_RANGE: Range<usize> = 53..55;
21
22/// W3C caps `tracestate` at 512 bytes and 32 list members. Krabka drops a
23/// larger value and does not forward it, so a client cannot use `tracestate`
24/// as free storage on every span Krabka emits.
25const MAX_TRACESTATE_BYTES: usize = 512;
26const MAX_TRACESTATE_MEMBERS: usize = 32;
27
28/// Why Krabka rejected a client-supplied W3C trace context.
29///
30/// No variant holds the input that failed. An attacker controls a rejected
31/// `traceparent`, and a message that keeps it verbatim would put that string
32/// into the log field that shows the error.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
34#[non_exhaustive]
35pub enum TraceContextError {
36 /// The `traceparent` is not exactly 55 bytes long.
37 #[error("traceparent must be {TRACEPARENT_LEN} bytes, got {0}")]
38 Length(usize),
39
40 /// Krabka reads only version `00` of the W3C traceparent format.
41 #[error("unsupported traceparent version")]
42 UnsupportedVersion,
43
44 /// A separator was missing or a field held a non-lower-hex byte.
45 #[error("malformed traceparent")]
46 Malformed,
47
48 /// The W3C specification defines an all-zero trace-id as invalid.
49 #[error("traceparent carries an all-zero trace-id")]
50 ZeroTraceId,
51
52 /// The W3C specification defines an all-zero parent span-id as invalid.
53 #[error("traceparent carries an all-zero span-id")]
54 ZeroSpanId,
55}
56
57/// A W3C trace context in transit, sized to fit in a request payload.
58///
59/// Both fields are `Option` because most calls happen with no active span: no
60/// sampling, or OTLP switched off. The carrier is then two `None`s that
61/// serialise to nothing. `#[serde(default, skip_serializing_if = …)]` makes
62/// that free on the wire. It is a payload-size optimisation for the common
63/// empty case, **not** a compatibility shim for older encodings. Krabka keeps
64/// no such shims: see `CLAUDE.md`.
65///
66/// This type does not derive `PartialEq`, and that is deliberate. RPC envelopes
67/// hold a carrier next to a request, and their equality must stay a pure
68/// function of the request payload. A derived `PartialEq` would silently make
69/// two identical requests compare unequal because they were traced differently.
70#[derive(Debug, Clone, Default, Serialize, Deserialize)]
71pub struct TraceCarrier {
72 /// The validated, re-rendered W3C `traceparent`.
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub traceparent: Option<String>,
75
76 /// The validated, re-rendered W3C `tracestate`, when the peer sent one
77 /// small enough to forward.
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub tracestate: Option<String>,
80}
81
82impl TraceCarrier {
83 /// Capture the currently active span's trace context.
84 ///
85 /// The carrier is empty when there is no active span, when the span is not
86 /// sampled, or when OTLP is disabled. It is always safe to call on a hot
87 /// path.
88 #[must_use]
89 pub fn capture_current() -> Self {
90 let headers = current_trace_headers();
91 let find = |name: &str| {
92 headers
93 .iter()
94 .find(|(key, _)| key == name)
95 .map(|(_, value)| value.as_str())
96 };
97 let Some(traceparent) = find(TRACEPARENT) else {
98 return Self::default();
99 };
100 Self::from_w3c(traceparent, find(TRACESTATE)).unwrap_or_default()
101 }
102
103 /// Build a carrier from a peer-supplied `traceparent` and `tracestate`.
104 ///
105 /// This function validates both inputs. `traceparent` must match
106 /// `^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$` with a non-zero trace-id
107 /// and a non-zero span-id. The function *drops* the `tracestate`, and does
108 /// not return an error, when it is more than 512 bytes, when it has more
109 /// than 32 list members, or when it is not a well-formed list. The
110 /// `traceparent` still applies, because a lost vendor state is better than
111 /// a lost trace.
112 ///
113 /// This function stores neither input verbatim. It parses both, then
114 /// re-renders them from the [`SpanContext`] and the [`TraceState`], so a
115 /// hostile value cannot get into a span attribute or a log line.
116 ///
117 /// # Errors
118 ///
119 /// Returns [`TraceContextError`] when `traceparent` fails validation. A
120 /// caller on an ingress path should discard the error and not show it: a
121 /// bad trace header must never fail the request that carried it.
122 pub fn from_w3c(
123 traceparent: &str,
124 tracestate: Option<&str>,
125 ) -> Result<Self, TraceContextError> {
126 let span_context = parse_traceparent(traceparent)?;
127 Ok(Self {
128 traceparent: Some(render_traceparent(&span_context)),
129 tracestate: tracestate.and_then(sanitize_tracestate),
130 })
131 }
132
133 /// Build a carrier from the headers on a Kafka record.
134 ///
135 /// Each header is a key plus a raw byte value. This function does the same
136 /// validation as [`TraceCarrier::from_w3c`]. But a non-UTF-8, absent, or
137 /// invalid value gives an empty carrier and not an error, because a
138 /// consumer that cannot read the producer's trace context must still apply
139 /// the record.
140 #[must_use]
141 pub fn from_headers<'a, I, V>(headers: I) -> Self
142 where
143 I: IntoIterator<Item = (&'a str, V)>,
144 V: AsRef<[u8]>,
145 {
146 let mut traceparent = None;
147 let mut tracestate = None;
148 for (key, value) in headers {
149 let target = if key.eq_ignore_ascii_case(TRACEPARENT) {
150 &mut traceparent
151 } else if key.eq_ignore_ascii_case(TRACESTATE) {
152 &mut tracestate
153 } else {
154 continue;
155 };
156 if let Ok(text) = from_utf8(value.as_ref()) {
157 *target = Some(text.to_owned());
158 }
159 }
160 let Some(traceparent) = traceparent else {
161 return Self::default();
162 };
163 Self::from_w3c(&traceparent, tracestate.as_deref()).unwrap_or_default()
164 }
165
166 /// `true` when there is no trace context to propagate.
167 #[must_use]
168 pub fn is_empty(&self) -> bool {
169 self.traceparent.is_none()
170 }
171
172 /// Make `span` a child of the carried context.
173 ///
174 /// Work on this side of the boundary then joins the caller's trace. This
175 /// method does nothing when the carrier is empty.
176 pub fn apply_to(&self, span: &tracing::Span) {
177 if self.is_empty() {
178 return;
179 }
180 set_remote_parent(span, self.headers());
181 }
182
183 /// Attach the carried context to `span` as an OpenTelemetry **link**.
184 ///
185 /// The carried context becomes a link, not the parent of `span`. This
186 /// method does nothing when the carrier is empty.
187 ///
188 /// A link is the correct relation when many nodes do the linked work, or
189 /// when the work runs long after the operation that started it. Every
190 /// follower applies a WAL record, and recovery replays that record hours
191 /// after the commit. A parent relation would stretch one trace across the
192 /// retention period, and would force export of every apply of every
193 /// sampled write.
194 pub fn link_into(&self, span: &tracing::Span) {
195 let Some(span_context) = self.span_context() else {
196 return;
197 };
198 span.add_link(span_context);
199 }
200
201 /// The remote [`SpanContext`] this carrier describes.
202 ///
203 /// `None` when the carrier is empty, or when it holds a value that does
204 /// not parse.
205 #[must_use]
206 pub fn span_context(&self) -> Option<SpanContext> {
207 let parsed = parse_traceparent(self.traceparent.as_deref()?).ok()?;
208 let state = self
209 .tracestate
210 .as_deref()
211 .and_then(|value| TraceState::from_str(value).ok())
212 .unwrap_or(TraceState::NONE);
213 Some(SpanContext::new(
214 parsed.trace_id(),
215 parsed.span_id(),
216 parsed.trace_flags(),
217 true,
218 state,
219 ))
220 }
221
222 /// The carrier as `(key, value)` header pairs for a Kafka record.
223 ///
224 /// The iterator yields nothing when the carrier is empty.
225 pub fn headers(&self) -> impl Iterator<Item = (&str, &[u8])> + '_ {
226 let traceparent = self.traceparent.as_deref();
227 // `tracestate` alone carries no trace, so it is never emitted without
228 // the `traceparent` it qualifies.
229 let tracestate = traceparent.and(self.tracestate.as_deref());
230 traceparent
231 .map(|value| (TRACEPARENT, value.as_bytes()))
232 .into_iter()
233 .chain(tracestate.map(|value| (TRACESTATE, value.as_bytes())))
234 }
235}
236
237/// Validate a version-`00` W3C `traceparent`.
238///
239/// Returns the `traceparent` as a remote [`SpanContext`] with no vendor state.
240pub fn parse_traceparent(value: &str) -> Result<SpanContext, TraceContextError> {
241 let bytes = value.as_bytes();
242 if bytes.len() != TRACEPARENT_LEN {
243 return Err(TraceContextError::Length(bytes.len()));
244 }
245 // The length check above puts every range below in bounds.
246 let field = |range: Range<usize>| bytes.get(range).ok_or(TraceContextError::Malformed);
247 if field(0..2)? != b"00" {
248 return Err(TraceContextError::UnsupportedVersion);
249 }
250 if [2, 35, 52]
251 .into_iter()
252 .any(|separator| bytes.get(separator) != Some(&b'-'))
253 {
254 return Err(TraceContextError::Malformed);
255 }
256
257 let trace_id = field(TRACE_ID_RANGE)?;
258 let span_id = field(SPAN_ID_RANGE)?;
259 let flags = field(FLAGS_RANGE)?;
260 if !is_lower_hex(trace_id) || !is_lower_hex(span_id) || !is_lower_hex(flags) {
261 return Err(TraceContextError::Malformed);
262 }
263 if trace_id.iter().all(|byte| *byte == b'0') {
264 return Err(TraceContextError::ZeroTraceId);
265 }
266 if span_id.iter().all(|byte| *byte == b'0') {
267 return Err(TraceContextError::ZeroSpanId);
268 }
269
270 // Every byte is validated lower-hex of the exact expected width, so the
271 // three conversions below cannot fail.
272 let text = |range: Range<usize>| value.get(range).ok_or(TraceContextError::Malformed);
273 let trace_id =
274 TraceId::from_hex(text(TRACE_ID_RANGE)?).map_err(|_| TraceContextError::Malformed)?;
275 let span_id =
276 SpanId::from_hex(text(SPAN_ID_RANGE)?).map_err(|_| TraceContextError::Malformed)?;
277 let flags =
278 u8::from_str_radix(text(FLAGS_RANGE)?, 16).map_err(|_| TraceContextError::Malformed)?;
279
280 Ok(SpanContext::new(
281 trace_id,
282 span_id,
283 TraceFlags::new(flags),
284 true,
285 TraceState::NONE,
286 ))
287}
288
289fn is_lower_hex(bytes: &[u8]) -> bool {
290 bytes
291 .iter()
292 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
293}
294
295fn render_traceparent(span_context: &SpanContext) -> String {
296 format!(
297 "00-{}-{}-{:02x}",
298 span_context.trace_id(),
299 span_context.span_id(),
300 span_context.trace_flags().to_u8()
301 )
302}
303
304/// Re-render a peer's `tracestate`.
305///
306/// This function drops the `tracestate` when it is too large, or when it is
307/// not a well-formed W3C list.
308fn sanitize_tracestate(value: &str) -> Option<String> {
309 if value.is_empty() || value.len() > MAX_TRACESTATE_BYTES {
310 return None;
311 }
312 if value.split(',').count() > MAX_TRACESTATE_MEMBERS {
313 return None;
314 }
315 let state = TraceState::from_str(value).ok()?;
316 let header = state.header();
317 (!header.is_empty()).then_some(header)
318}