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