Skip to main content

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}