Skip to main content

uptrakit_wire/
trace_context.rs

1//! Distributed tracing context propagated through wire protocol messages.
2//!
3//! Carries W3C-compatible trace and span identifiers for correlating messages
4//! across service boundaries. When `tracing-opentelemetry` is wired in later,
5//! [`current_trace_context`] will extract IDs from the current span context;
6//! until then it generates random trace IDs.
7
8use std::fmt;
9
10use serde::{Deserialize, Serialize};
11
12use crate::limits::{
13    MAX_SPAN_ID_LEN, MAX_TRACE_ID_LEN, WireValidate, WireValidationError, check_opt_string_len,
14    check_string_len,
15};
16
17/// Distributed tracing context for correlating messages across service boundaries.
18///
19/// ## Wire format
20///
21/// ```json
22/// {"trace_id":"0123456789abcdef0123456789abcdef","span_id":"0123456789abcdef"}
23/// ```
24///
25/// - `trace_id`: 32 lowercase hex characters (128-bit identifier)
26/// - `span_id`: 16 lowercase hex characters (64-bit identifier), omitted when absent
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
29pub struct TraceContext {
30    /// 128-bit trace identifier encoded as 32 lowercase hex characters.
31    pub trace_id: String,
32    /// 64-bit span identifier encoded as 16 lowercase hex characters.
33    /// `None` when no parent span is active.
34    #[serde(skip_serializing_if = "Option::is_none", default)]
35    pub span_id: Option<String>,
36}
37
38impl TraceContext {
39    /// Generate a new trace context with a random trace ID and no span ID.
40    ///
41    /// The trace ID is a 128-bit random value formatted as 32 lowercase hex
42    /// characters (UUID v4 without hyphens).
43    pub fn generate() -> Self {
44        Self {
45            trace_id: uuid::Uuid::new_v4().simple().to_string(),
46            span_id: None,
47        }
48    }
49}
50
51impl Default for TraceContext {
52    fn default() -> Self {
53        Self::generate()
54    }
55}
56
57impl fmt::Display for TraceContext {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        match &self.span_id {
60            Some(span) => write!(f, "{}:{}", self.trace_id, span),
61            None => f.write_str(&self.trace_id),
62        }
63    }
64}
65
66impl WireValidate for TraceContext {
67    fn wire_validate(&self) -> Result<(), WireValidationError> {
68        check_string_len(&self.trace_id, MAX_TRACE_ID_LEN, "trace_context.trace_id")?;
69        check_opt_string_len(&self.span_id, MAX_SPAN_ID_LEN, "trace_context.span_id")?;
70        Ok(())
71    }
72}
73
74/// Returns the trace context for the current execution context.
75///
76/// Currently generates a new random trace context. When `tracing-opentelemetry`
77/// is wired in, this function will extract trace/span IDs from the current span
78/// context, making all existing propagation plumbing light up automatically.
79pub fn current_trace_context() -> TraceContext {
80    TraceContext::generate()
81}
82
83#[cfg(test)]
84mod tests {
85    #![expect(
86        clippy::assertions_on_result_states,
87        reason = "test assertions — is_ok/is_err provides readable failure messages"
88    )]
89    use super::*;
90
91    #[test]
92    fn generate_produces_valid_trace_id() {
93        let ctx = TraceContext::generate();
94        assert_eq!(ctx.trace_id.len(), 32, "trace_id must be 32 hex chars");
95        assert!(
96            ctx.trace_id.chars().all(|c| c.is_ascii_hexdigit()),
97            "trace_id must contain only hex characters"
98        );
99        assert!(ctx.span_id.is_none(), "generated context has no span_id");
100    }
101
102    #[test]
103    fn generate_produces_unique_ids() {
104        let a = TraceContext::generate();
105        let b = TraceContext::generate();
106        assert_ne!(a.trace_id, b.trace_id, "two generated IDs must differ");
107    }
108
109    #[test]
110    fn display_without_span_id() {
111        let ctx = TraceContext {
112            trace_id: "0123456789abcdef0123456789abcdef".to_string(),
113            span_id: None,
114        };
115        assert_eq!(ctx.to_string(), "0123456789abcdef0123456789abcdef");
116    }
117
118    #[test]
119    fn display_with_span_id() {
120        let ctx = TraceContext {
121            trace_id: "0123456789abcdef0123456789abcdef".to_string(),
122            span_id: Some("fedcba9876543210".to_string()),
123        };
124        assert_eq!(
125            ctx.to_string(),
126            "0123456789abcdef0123456789abcdef:fedcba9876543210"
127        );
128    }
129
130    #[test]
131    fn serde_roundtrip_with_span_id() {
132        let ctx = TraceContext {
133            trace_id: "0123456789abcdef0123456789abcdef".to_string(),
134            span_id: Some("fedcba9876543210".to_string()),
135        };
136        let json = serde_json::to_string(&ctx).unwrap();
137        assert!(json.contains("span_id"));
138        let deserialized: TraceContext = serde_json::from_str(&json).unwrap();
139        assert_eq!(deserialized, ctx);
140    }
141
142    #[test]
143    fn serde_roundtrip_without_span_id() {
144        let ctx = TraceContext {
145            trace_id: "0123456789abcdef0123456789abcdef".to_string(),
146            span_id: None,
147        };
148        let json = serde_json::to_string(&ctx).unwrap();
149        assert!(!json.contains("span_id"), "None span_id must be omitted");
150        let deserialized: TraceContext = serde_json::from_str(&json).unwrap();
151        assert_eq!(deserialized, ctx);
152    }
153
154    #[test]
155    fn deserialize_missing_span_id() {
156        let json = r#"{"trace_id":"0123456789abcdef0123456789abcdef"}"#;
157        let ctx: TraceContext = serde_json::from_str(json).unwrap();
158        assert_eq!(ctx.trace_id, "0123456789abcdef0123456789abcdef");
159        assert!(ctx.span_id.is_none());
160    }
161
162    #[test]
163    fn wire_validate_valid() {
164        let ctx = TraceContext {
165            trace_id: "0123456789abcdef0123456789abcdef".to_string(),
166            span_id: Some("fedcba9876543210".to_string()),
167        };
168        assert!(ctx.wire_validate().is_ok());
169    }
170
171    #[test]
172    fn wire_validate_trace_id_too_long() {
173        let ctx = TraceContext {
174            trace_id: "a".repeat(33),
175            span_id: None,
176        };
177        let err = ctx.wire_validate().unwrap_err();
178        assert_eq!(err.field, "trace_context.trace_id");
179    }
180
181    #[test]
182    fn wire_validate_span_id_too_long() {
183        let ctx = TraceContext {
184            trace_id: "0123456789abcdef0123456789abcdef".to_string(),
185            span_id: Some("a".repeat(17)),
186        };
187        let err = ctx.wire_validate().unwrap_err();
188        assert_eq!(err.field, "trace_context.span_id");
189    }
190
191    #[test]
192    fn current_trace_context_generates_valid() {
193        let ctx = current_trace_context();
194        assert!(ctx.wire_validate().is_ok());
195    }
196}