agentguard_core/observability/
span.rs

1//! W3C Trace Context propagation for agentguard requests.
2//!
3//! See <https://www.w3.org/TR/trace-context/>. A `TraceContext` can be parsed
4//! from the standard `traceparent` HTTP header and serialized back to one.
5//! Carried on [`crate::request::AgentRequest`] and threaded through every
6//! authorization decision's audit record.
7
8use rand::RngCore;
9use serde::{Deserialize, Serialize};
10use std::fmt;
11use std::str::FromStr;
12
13use crate::error::{Error, Result};
14
15/// Length in bytes of a W3C trace-id (16 bytes = 32 hex chars).
16pub const TRACE_ID_LEN: usize = 16;
17/// Length in bytes of a W3C parent-id / span-id (8 bytes = 16 hex chars).
18pub const SPAN_ID_LEN: usize = 8;
19
20/// 128-bit trace identifier, hex-encoded to 32 chars when displayed.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
22#[serde(try_from = "String", into = "String")]
23pub struct TraceId(pub [u8; TRACE_ID_LEN]);
24
25/// 64-bit span identifier, hex-encoded to 16 chars.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(try_from = "String", into = "String")]
28pub struct SpanId(pub [u8; SPAN_ID_LEN]);
29
30impl TraceId {
31    pub fn new(bytes: [u8; TRACE_ID_LEN]) -> Self {
32        Self(bytes)
33    }
34
35    /// Generate a random trace id from the OS CSPRNG.
36    pub fn random() -> Self {
37        let mut bytes = [0u8; TRACE_ID_LEN];
38        rand::rngs::OsRng.fill_bytes(&mut bytes);
39        Self(bytes)
40    }
41
42    pub fn as_hex(&self) -> String {
43        hex::encode(self.0)
44    }
45}
46
47impl fmt::Display for TraceId {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        f.write_str(&self.as_hex())
50    }
51}
52
53impl FromStr for TraceId {
54    type Err = Error;
55    fn from_str(s: &str) -> Result<Self> {
56        let bytes = hex::decode(s).map_err(|e| Error::Other(format!("trace_id hex: {}", e)))?;
57        if bytes.len() != TRACE_ID_LEN {
58            return Err(Error::Other(format!(
59                "trace_id must be {} bytes, got {}",
60                TRACE_ID_LEN,
61                bytes.len()
62            )));
63        }
64        let mut arr = [0u8; TRACE_ID_LEN];
65        arr.copy_from_slice(&bytes);
66        Ok(Self(arr))
67    }
68}
69
70impl From<TraceId> for String {
71    fn from(t: TraceId) -> Self {
72        t.as_hex()
73    }
74}
75
76impl TryFrom<String> for TraceId {
77    type Error = Error;
78    fn try_from(s: String) -> Result<Self> {
79        s.parse()
80    }
81}
82
83impl SpanId {
84    pub fn new(bytes: [u8; SPAN_ID_LEN]) -> Self {
85        Self(bytes)
86    }
87
88    pub fn random() -> Self {
89        let mut bytes = [0u8; SPAN_ID_LEN];
90        rand::rngs::OsRng.fill_bytes(&mut bytes);
91        Self(bytes)
92    }
93
94    pub fn as_hex(&self) -> String {
95        hex::encode(self.0)
96    }
97}
98
99impl fmt::Display for SpanId {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        f.write_str(&self.as_hex())
102    }
103}
104
105impl FromStr for SpanId {
106    type Err = Error;
107    fn from_str(s: &str) -> Result<Self> {
108        let bytes = hex::decode(s).map_err(|e| Error::Other(format!("span_id hex: {}", e)))?;
109        if bytes.len() != SPAN_ID_LEN {
110            return Err(Error::Other(format!(
111                "span_id must be {} bytes, got {}",
112                SPAN_ID_LEN,
113                bytes.len()
114            )));
115        }
116        let mut arr = [0u8; SPAN_ID_LEN];
117        arr.copy_from_slice(&bytes);
118        Ok(Self(arr))
119    }
120}
121
122impl From<SpanId> for String {
123    fn from(s: SpanId) -> Self {
124        s.as_hex()
125    }
126}
127
128impl TryFrom<String> for SpanId {
129    type Error = Error;
130    fn try_from(s: String) -> Result<Self> {
131        s.parse()
132    }
133}
134
135/// W3C Trace Context, parsed from the `traceparent` header.
136///
137/// Format: `00-<trace-id-hex>-<parent-id-hex>-<flags-hex>`
138/// We currently ignore `tracestate` (kept for future propagation).
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
140pub struct TraceContext {
141    pub trace_id: TraceId,
142    pub span_id: SpanId,
143    /// Trace flags. Bit 0 = sampled.
144    pub flags: u8,
145}
146
147impl TraceContext {
148    /// Build a new trace context with the given trace + span IDs. Flags
149    /// default to `0x01` (sampled).
150    pub fn new(trace_id: TraceId, span_id: SpanId) -> Self {
151        Self {
152            trace_id,
153            span_id,
154            flags: 0x01,
155        }
156    }
157
158    /// Generate a fresh trace context (new trace, new root span).
159    pub fn fresh() -> Self {
160        Self::new(TraceId::random(), SpanId::random())
161    }
162
163    /// Return a new context with the same trace_id but a fresh span_id.
164    /// Use this to record a child operation within the same trace.
165    pub fn child(&self) -> Self {
166        Self {
167            trace_id: self.trace_id,
168            span_id: SpanId::random(),
169            flags: self.flags,
170        }
171    }
172}
173
174impl fmt::Display for TraceContext {
175    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176        write!(
177            f,
178            "00-{}-{}-{:02x}",
179            self.trace_id, self.span_id, self.flags
180        )
181    }
182}
183
184impl FromStr for TraceContext {
185    type Err = Error;
186    fn from_str(s: &str) -> Result<Self> {
187        // W3C: "VERSION-TRACE_ID-PARENT_ID-FLAGS"
188        let parts: Vec<&str> = s.split('-').collect();
189        if parts.len() != 4 {
190            return Err(Error::Other(format!(
191                "traceparent must have 4 dash-separated parts, got {}",
192                parts.len()
193            )));
194        }
195        let version = parts[0];
196        // W3C Trace Context §3.2.2.5: implementations MUST accept any
197        // future 00-XX version as long as the leading 2 chars are
198        // "00". Reject only versions that don't start with "00" (which
199        // would mean a non-zero leading byte, indicating a different
200        // format).
201        if !version.starts_with("00") {
202            return Err(Error::Other(format!(
203                "unsupported traceparent version: {}",
204                version
205            )));
206        }
207        let trace_id: TraceId = parts[1].parse()?;
208        let span_id: SpanId = parts[2].parse()?;
209        let flags = u8::from_str_radix(parts[3], 16)
210            .map_err(|e| Error::Other(format!("flags hex: {}", e)))?;
211        Ok(Self {
212            trace_id,
213            span_id,
214            flags,
215        })
216    }
217}
218
219impl Serialize for TraceContext {
220    fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
221        s.serialize_str(&self.to_string())
222    }
223}
224
225impl<'de> Deserialize<'de> for TraceContext {
226    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
227        let s = String::deserialize(d)?;
228        s.parse().map_err(serde::de::Error::custom)
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn traceparent_roundtrip() {
238        let tc = TraceContext::fresh();
239        let s = tc.to_string();
240        let parsed: TraceContext = s.parse().unwrap();
241        assert_eq!(tc, parsed);
242    }
243
244    #[test]
245    fn trace_id_must_be_16_bytes() {
246        assert!("abcd".parse::<TraceId>().is_err());
247        let bytes = [1u8; 16];
248        let hex = hex::encode(bytes);
249        let t: TraceId = hex.parse().unwrap();
250        assert_eq!(t.0, bytes);
251    }
252
253    #[test]
254    fn span_id_must_be_8_bytes() {
255        let bytes = [1u8; 8];
256        let hex = hex::encode(bytes);
257        let s: SpanId = hex.parse().unwrap();
258        assert_eq!(s.0, bytes);
259    }
260
261    #[test]
262    fn traceparent_format() {
263        let trace_id = TraceId::new([0xab; 16]);
264        let span_id = SpanId::new([0xcd; 8]);
265        let tc = TraceContext::new(trace_id, span_id);
266        assert_eq!(
267            tc.to_string(),
268            "00-abababababababababababababababab-cdcdcdcdcdcdcdcd-01"
269        );
270    }
271
272    #[test]
273    fn traceparent_rejects_wrong_version() {
274        // W3C traceparent version "01" is reserved; we accept only "00".
275        let bad = "01-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
276        assert!(bad.parse::<TraceContext>().is_err());
277    }
278
279    #[test]
280    fn traceparent_rejects_wrong_segment_count() {
281        let bad = "00-aaaa-bbbb-cccc-dddd";
282        assert!(bad.parse::<TraceContext>().is_err());
283    }
284
285    #[test]
286    fn traceparent_rejects_non_hex() {
287        let bad = "00-zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz-b7ad6b7169203331-01";
288        assert!(bad.parse::<TraceContext>().is_err());
289    }
290
291    #[test]
292    fn random_trace_id_is_unique_99pct_of_the_time() {
293        // Collision check: 1000 random IDs should have no duplicates.
294        // 128-bit space → 2^-128 collision probability per pair, so 1000
295        // pairs gives effectively zero chance. We use a small threshold to
296        // avoid flakiness on degenerate PRNGs.
297        use std::collections::HashSet;
298        let mut set = HashSet::new();
299        for _ in 0..1000 {
300            set.insert(TraceId::random().0);
301        }
302        assert_eq!(set.len(), 1000, "TraceId::random had collisions");
303    }
304
305    #[test]
306    fn random_span_id_is_unique_99pct_of_the_time() {
307        use std::collections::HashSet;
308        let mut set = HashSet::new();
309        for _ in 0..1000 {
310            set.insert(SpanId::random().0);
311        }
312        assert_eq!(set.len(), 1000, "SpanId::random had collisions");
313    }
314
315    #[cfg(test)]
316    mod proptests {
317        use super::*;
318        use proptest::prelude::*;
319
320        proptest! {
321            /// Parsing a printed trace context round-trips through
322            /// `Display` → `FromStr`.
323            #[test]
324            fn round_trip(seed in any::<u8>()) {
325                let trace_id = TraceId::new([seed; 16]);
326                let span_id = SpanId::new([seed.wrapping_mul(2); 8]);
327                let tc = TraceContext::new(trace_id, span_id);
328                let printed = tc.to_string();
329                let parsed: TraceContext = printed.parse().unwrap();
330                prop_assert_eq!(tc.trace_id, parsed.trace_id);
331                prop_assert_eq!(tc.span_id, parsed.span_id);
332                prop_assert_eq!(tc.flags, parsed.flags);
333            }
334        }
335    }
336}