Skip to main content

agentd/obs/
trace.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! W3C Trace Context propagation.
3//!
4//! **Propagation is default-on and dependency-free** — a `trace_id` shared
5//! across the whole agent tree (ingested from an upstream `traceparent` or
6//! minted from the run id), threaded into every process's log lines, the spawn
7//! payload, and outbound MCP `_meta`. So a single run — supervisor + every
8//! subagent + every tool call — is one correlatable, auditable trace, with no
9//! collector required. Span *export* (OTLP) is the only otel-gated part.
10//!
11//! `traceparent` = `00-<32-hex trace-id>-<16-hex span-id>-<2-hex flags>`.
12
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::time::{SystemTime, UNIX_EPOCH};
15
16/// A W3C trace context for one process: the shared trace id, this process's
17/// span id, and the sampling flags.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct TraceContext {
20    pub trace_id: String,
21    pub span_id: String,
22    pub flags: String,
23}
24
25impl TraceContext {
26    /// The `traceparent` header/`_meta` value to emit on outbound calls.
27    pub fn traceparent(&self) -> String {
28        format!("00-{}-{}-{}", self.trace_id, self.span_id, self.flags)
29    }
30}
31
32/// An outbound `traceparent` for a given trace id with a fresh child span —
33/// for stamping onto MCP `_meta` / the LLM header so downstream services see
34/// this agent as their parent (W3C-correct).
35pub fn outbound_traceparent(trace_id: &str) -> String {
36    format!("00-{}-{}-01", trace_id, new_span_id())
37}
38
39/// Resolve the trace context for a run: continue an upstream `traceparent` if a
40/// valid one is supplied (same trace id, fresh span), else mint a fresh trace
41/// deterministically from the run id (so retries of a run share a trace id).
42pub fn resolve(run_id: &str, incoming: Option<&str>) -> TraceContext {
43    match incoming.and_then(parse) {
44        Some(up) => TraceContext {
45            trace_id: up.trace_id,
46            span_id: new_span_id(),
47            flags: up.flags,
48        },
49        None => TraceContext {
50            trace_id: trace_id_from(run_id),
51            span_id: new_span_id(),
52            flags: "01".into(),
53        },
54    }
55}
56
57/// Parse a `traceparent`. Returns `None` for any malformed or all-zero
58/// (invalid per spec) value.
59pub fn parse(s: &str) -> Option<TraceContext> {
60    let p: Vec<&str> = s.trim().split('-').collect();
61    if p.len() != 4 {
62        return None;
63    }
64    let (ver, tid, sid, flags) = (p[0], p[1], p[2], p[3]);
65    if ver.len() != 2 || tid.len() != 32 || sid.len() != 16 || flags.len() != 2 {
66        return None;
67    }
68    if ![ver, tid, sid, flags].iter().all(|s| is_hex(s)) {
69        return None;
70    }
71    if tid.bytes().all(|b| b == b'0') || sid.bytes().all(|b| b == b'0') {
72        return None; // all-zero ids are invalid
73    }
74    Some(TraceContext {
75        trace_id: tid.to_string(),
76        span_id: sid.to_string(),
77        flags: flags.to_string(),
78    })
79}
80
81fn is_hex(s: &str) -> bool {
82    !s.is_empty() && s.bytes().all(|b| b.is_ascii_hexdigit())
83}
84
85/// FNV-1a 64-bit — a tiny non-crypto hash (a trace/span id needs uniqueness,
86/// not unpredictability; this keeps the dependency budget at zero).
87fn fnv1a(data: &[u8], seed: u64) -> u64 {
88    let mut h = seed;
89    for &b in data {
90        h ^= b as u64;
91        h = h.wrapping_mul(0x0000_0100_0000_01b3);
92    }
93    h
94}
95
96/// A 16-byte (32-hex) trace id derived deterministically from the run id, so
97/// the same `--run-id` always maps to the same trace.
98fn trace_id_from(run_id: &str) -> String {
99    let a = fnv1a(run_id.as_bytes(), 0xcbf2_9ce4_8422_2325) | 1; // avoid all-zero
100    let b = fnv1a(run_id.as_bytes(), 0x8422_2325_cbf2_9ce4);
101    format!("{a:016x}{b:016x}")
102}
103
104/// A fresh 8-byte (16-hex) span id, unique per call (time ⊕ pid ⊕ counter).
105/// Public so the `otel` span export can mint child span ids under the run trace.
106pub fn new_span_id() -> String {
107    static COUNTER: AtomicU64 = AtomicU64::new(0);
108    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
109    let nanos = SystemTime::now()
110        .duration_since(UNIX_EPOCH)
111        .map(|d| d.as_nanos() as u64)
112        .unwrap_or(0);
113    let seed = 0xcbf2_9ce4_8422_2325 ^ (std::process::id() as u64) ^ n;
114    let mixed = fnv1a(&nanos.to_le_bytes(), seed) | 1; // avoid all-zero
115    format!("{mixed:016x}")
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn parse_valid_traceparent() {
124        let tc = parse("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01").unwrap();
125        assert_eq!(tc.trace_id, "4bf92f3577b34da6a3ce929d0e0e4736");
126        assert_eq!(tc.span_id, "00f067aa0ba902b7");
127        assert_eq!(tc.flags, "01");
128        assert_eq!(
129            tc.traceparent(),
130            "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
131        );
132    }
133
134    #[test]
135    fn parse_rejects_malformed() {
136        assert!(parse("garbage").is_none());
137        assert!(parse("00-short-00f067aa0ba902b7-01").is_none());
138        assert!(parse("00-zzzz2f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01").is_none());
139        // all-zero trace id is invalid
140        assert!(parse("00-00000000000000000000000000000000-00f067aa0ba902b7-01").is_none());
141    }
142
143    #[test]
144    fn resolve_continues_upstream_trace() {
145        let up = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
146        let tc = resolve("run-1", Some(up));
147        assert_eq!(tc.trace_id, "4bf92f3577b34da6a3ce929d0e0e4736"); // same trace
148        assert_ne!(tc.span_id, "00f067aa0ba902b7"); // fresh span
149        assert_eq!(tc.flags, "01");
150    }
151
152    #[test]
153    fn resolve_mints_deterministically_from_run_id() {
154        let a = resolve("run-abc", None);
155        let b = resolve("run-abc", None);
156        assert_eq!(a.trace_id, b.trace_id); // same run id → same trace id
157        assert_ne!(a.span_id, b.span_id); // but distinct spans
158        assert_eq!(a.trace_id.len(), 32);
159        let c = resolve("run-xyz", None);
160        assert_ne!(a.trace_id, c.trace_id); // different run id → different trace
161    }
162
163    #[test]
164    fn resolve_mints_on_invalid_incoming() {
165        let tc = resolve("run-1", Some("not-a-traceparent"));
166        assert_eq!(tc.trace_id.len(), 32);
167        assert_eq!(tc.flags, "01");
168    }
169}