grafos_observe/
trace.rs

1//! Distributed trace context types for W3C traceparent propagation.
2//!
3//! All types are `no_std` compatible. The [`TraceContext`] struct carries a
4//! 16-byte trace ID, 8-byte span ID, and 1-byte flags field conforming to
5//! the W3C Trace Context specification.
6//!
7//! # Binary encoding
8//!
9//! ```text
10//! [version: 1B] [trace_id: 16B] [span_id: 8B] [flags: 1B] [reserved: 6B]
11//! ```
12//!
13//! Total: 32 bytes (fits in a single cache line, suitable for shared-memory
14//! RPC header extension).
15//!
16//! # W3C string format
17//!
18//! ```text
19//! 00-{trace_id:032x}-{span_id:016x}-{flags:02x}
20//! ```
21
22use core::fmt;
23
24/// 16-byte trace identifier, unique per trace.
25#[derive(Clone, Copy, PartialEq, Eq, Hash)]
26pub struct TraceId(pub u128);
27
28impl TraceId {
29    /// The zero (invalid) trace ID.
30    pub const INVALID: TraceId = TraceId(0);
31
32    /// Whether this trace ID is the zero/invalid value.
33    pub fn is_valid(&self) -> bool {
34        self.0 != 0
35    }
36}
37
38impl fmt::Debug for TraceId {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        write!(f, "TraceId({:032x})", self.0)
41    }
42}
43
44impl fmt::Display for TraceId {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        write!(f, "{:032x}", self.0)
47    }
48}
49
50impl fmt::LowerHex for TraceId {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        fmt::LowerHex::fmt(&self.0, f)
53    }
54}
55
56/// 8-byte span identifier, unique per span within a trace.
57#[derive(Clone, Copy, PartialEq, Eq, Hash)]
58pub struct SpanId(pub u64);
59
60impl SpanId {
61    /// The zero (invalid) span ID.
62    pub const INVALID: SpanId = SpanId(0);
63
64    /// Whether this span ID is the zero/invalid value.
65    pub fn is_valid(&self) -> bool {
66        self.0 != 0
67    }
68}
69
70impl fmt::Debug for SpanId {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        write!(f, "SpanId({:016x})", self.0)
73    }
74}
75
76impl fmt::Display for SpanId {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        write!(f, "{:016x}", self.0)
79    }
80}
81
82impl fmt::LowerHex for SpanId {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        fmt::LowerHex::fmt(&self.0, f)
85    }
86}
87
88/// W3C trace context in binary form.
89///
90/// Carries the identifiers needed to correlate spans across service boundaries.
91/// The `flags` field uses bit 0 as the "sampled" flag (W3C traceparent spec).
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub struct TraceContext {
94    /// Trace-wide identifier (shared by all spans in the trace).
95    pub trace_id: TraceId,
96    /// Span identifier for the current span.
97    pub span_id: SpanId,
98    /// Parent span identifier (zero if this is the root span).
99    pub parent_span_id: SpanId,
100    /// W3C trace flags. Bit 0 = sampled.
101    pub flags: u8,
102}
103
104/// W3C traceparent version byte.
105const W3C_VERSION: u8 = 0x00;
106
107/// Flag bit: trace is sampled.
108pub const FLAG_SAMPLED: u8 = 0x01;
109
110/// Binary encoding size.
111pub const TRACE_CONTEXT_BYTES: usize = 32;
112
113impl TraceContext {
114    /// Create a new root trace context with the given random source.
115    ///
116    /// Generates fresh trace_id and span_id from the provided random bytes.
117    /// `random_bytes` must be at least 24 bytes (16 for trace_id + 8 for span_id).
118    pub fn new_root(random_bytes: &[u8; 24]) -> Self {
119        let trace_id = u128::from_be_bytes(random_bytes[0..16].try_into().unwrap());
120        let span_id = u64::from_be_bytes(random_bytes[16..24].try_into().unwrap());
121        TraceContext {
122            trace_id: TraceId(trace_id),
123            span_id: SpanId(span_id),
124            parent_span_id: SpanId::INVALID,
125            flags: FLAG_SAMPLED,
126        }
127    }
128
129    /// Create a child context within the same trace.
130    ///
131    /// The child inherits `trace_id` and `flags`, gets a new `span_id` from
132    /// the provided random bytes, and records the current `span_id` as its parent.
133    pub fn child(&self, random_span_bytes: &[u8; 8]) -> Self {
134        let new_span_id = u64::from_be_bytes(*random_span_bytes);
135        TraceContext {
136            trace_id: self.trace_id,
137            span_id: SpanId(new_span_id),
138            parent_span_id: self.span_id,
139            flags: self.flags,
140        }
141    }
142
143    /// Whether the sampled flag is set.
144    pub fn is_sampled(&self) -> bool {
145        self.flags & FLAG_SAMPLED != 0
146    }
147
148    /// Set or clear the sampled flag.
149    pub fn set_sampled(&mut self, sampled: bool) {
150        if sampled {
151            self.flags |= FLAG_SAMPLED;
152        } else {
153            self.flags &= !FLAG_SAMPLED;
154        }
155    }
156
157    /// Encode to a 32-byte binary representation.
158    ///
159    /// Layout: `[version:1][trace_id:16][span_id:8][flags:1][reserved:6]`
160    pub fn encode(&self) -> [u8; TRACE_CONTEXT_BYTES] {
161        let mut buf = [0u8; TRACE_CONTEXT_BYTES];
162        buf[0] = W3C_VERSION;
163        buf[1..17].copy_from_slice(&self.trace_id.0.to_be_bytes());
164        buf[17..25].copy_from_slice(&self.span_id.0.to_be_bytes());
165        buf[25] = self.flags;
166        // bytes 26..32 reserved (zeros)
167        buf
168    }
169
170    /// Decode from a 32-byte binary representation.
171    pub fn decode(buf: &[u8; TRACE_CONTEXT_BYTES]) -> Result<Self, TraceContextError> {
172        if buf[0] != W3C_VERSION {
173            return Err(TraceContextError::UnsupportedVersion(buf[0]));
174        }
175        let trace_id = u128::from_be_bytes(buf[1..17].try_into().unwrap());
176        let span_id = u64::from_be_bytes(buf[17..25].try_into().unwrap());
177        let flags = buf[25];
178
179        Ok(TraceContext {
180            trace_id: TraceId(trace_id),
181            span_id: SpanId(span_id),
182            parent_span_id: SpanId::INVALID, // not stored in wire format
183            flags,
184        })
185    }
186
187    /// Format as a W3C traceparent string: `00-{trace_id}-{span_id}-{flags}`.
188    ///
189    /// Returns a fixed-length 55-character string.
190    pub fn to_w3c_string(&self) -> alloc::string::String {
191        alloc::format!(
192            "{:02x}-{:032x}-{:016x}-{:02x}",
193            W3C_VERSION,
194            self.trace_id.0,
195            self.span_id.0,
196            self.flags,
197        )
198    }
199
200    /// Parse a W3C traceparent string.
201    ///
202    /// Expected format: `{version:2}-{trace_id:32}-{span_id:16}-{flags:2}`
203    /// (55 characters total).
204    pub fn from_w3c_string(s: &str) -> Result<Self, TraceContextError> {
205        if s.len() != 55 {
206            return Err(TraceContextError::InvalidFormat);
207        }
208        let parts: alloc::vec::Vec<&str> = s.split('-').collect();
209        if parts.len() != 4 {
210            return Err(TraceContextError::InvalidFormat);
211        }
212        if parts[0].len() != 2
213            || parts[1].len() != 32
214            || parts[2].len() != 16
215            || parts[3].len() != 2
216        {
217            return Err(TraceContextError::InvalidFormat);
218        }
219        let version =
220            u8::from_str_radix(parts[0], 16).map_err(|_| TraceContextError::InvalidFormat)?;
221        if version != W3C_VERSION {
222            return Err(TraceContextError::UnsupportedVersion(version));
223        }
224        let trace_id =
225            u128::from_str_radix(parts[1], 16).map_err(|_| TraceContextError::InvalidFormat)?;
226        let span_id =
227            u64::from_str_radix(parts[2], 16).map_err(|_| TraceContextError::InvalidFormat)?;
228        let flags =
229            u8::from_str_radix(parts[3], 16).map_err(|_| TraceContextError::InvalidFormat)?;
230
231        Ok(TraceContext {
232            trace_id: TraceId(trace_id),
233            span_id: SpanId(span_id),
234            parent_span_id: SpanId::INVALID,
235            flags,
236        })
237    }
238
239    /// Check if this context has all-zero IDs (i.e. no trace context was set).
240    pub fn is_empty(&self) -> bool {
241        !self.trace_id.is_valid() && !self.span_id.is_valid()
242    }
243}
244
245impl Default for TraceContext {
246    fn default() -> Self {
247        TraceContext {
248            trace_id: TraceId::INVALID,
249            span_id: SpanId::INVALID,
250            parent_span_id: SpanId::INVALID,
251            flags: 0,
252        }
253    }
254}
255
256impl fmt::Display for TraceContext {
257    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258        write!(f, "{}", self.to_w3c_string())
259    }
260}
261
262/// Errors from trace context encoding/decoding.
263#[derive(Debug, Clone, PartialEq, Eq)]
264pub enum TraceContextError {
265    /// The version byte is not supported.
266    UnsupportedVersion(u8),
267    /// The string format is invalid.
268    InvalidFormat,
269}
270
271impl fmt::Display for TraceContextError {
272    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
273        match self {
274            TraceContextError::UnsupportedVersion(v) => {
275                write!(f, "unsupported traceparent version: {v:#04x}")
276            }
277            TraceContextError::InvalidFormat => write!(f, "invalid traceparent format"),
278        }
279    }
280}
281
282// ---------------------------------------------------------------------------
283// slice 224 — bridge between runtime TraceContext (this module) and the
284// v1.1 §4.3 graph-layer EdgeTraceContext in grafos-core.
285//
286// The two types coexist by design (see grafos_core::trace module docs):
287// - This `TraceContext` has u8 flags matching the W3C traceparent wire
288//   format the observability runtime decodes.
289// - `EdgeTraceContext` has u32 flags per v1.1 §4.3 to carry the wider
290//   schema-level flag space on every edge.
291//
292// The bridge gives callers two typed directions:
293//   - `From<TraceContext> for EdgeTraceContext`: lossless u8 → u32
294//     widening. Always succeeds.
295//   - `TryFrom<EdgeTraceContext> for TraceContext`: u32 → u8 narrowing
296//     that fails closed when high bits are set (per v1.1 §3.3 "no
297//     silent best-effort loss" and the project CLAUDE.md global rule
298//     against fallbacks circumventing correct system behavior).
299//
300// Orphan-rule note: both impls live here because grafos-core cannot
301// see grafos_observe::TraceContext (would be cyclic), and From/TryFrom
302// must be defined in the crate that owns either the source or target.
303// `EdgeTraceContext` is from grafos-core; `TraceContext` is from this
304// crate — so this crate is the right home.
305// ---------------------------------------------------------------------------
306
307impl From<TraceContext> for grafos_core::EdgeTraceContext {
308    fn from(ctx: TraceContext) -> Self {
309        grafos_core::EdgeTraceContext {
310            trace_id: ctx.trace_id.0,
311            span_id: ctx.span_id.0,
312            parent_span_id: ctx.parent_span_id.0,
313            // u8 → u32: lossless widening. High bits stay zero.
314            flags: ctx.flags as u32,
315        }
316    }
317}
318
319/// Error returned when narrowing an [`grafos_core::EdgeTraceContext`]
320/// to a runtime [`TraceContext`] would discard set high bits in the
321/// `flags` field. The full u32 value is preserved on the error so
322/// callers can log/audit what was rejected.
323#[derive(Clone, Copy, Debug, PartialEq, Eq)]
324pub struct EdgeTraceContextToObserveError {
325    /// The original u32 flags value that did not fit in the
326    /// runtime's u8 flags slot.
327    pub flags: u32,
328}
329
330impl fmt::Display for EdgeTraceContextToObserveError {
331    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
332        write!(
333            f,
334            "EdgeTraceContext → TraceContext: flags 0x{:08x} has high bits set; runtime TraceContext is W3C-shaped with u8 flags",
335            self.flags
336        )
337    }
338}
339
340#[cfg(feature = "std")]
341impl std::error::Error for EdgeTraceContextToObserveError {}
342
343impl TryFrom<grafos_core::EdgeTraceContext> for TraceContext {
344    type Error = EdgeTraceContextToObserveError;
345
346    fn try_from(ctx: grafos_core::EdgeTraceContext) -> Result<Self, Self::Error> {
347        // u32 → u8: fail closed if any high bit is set so the bridge
348        // never silently drops semantics the schema is allowed to
349        // carry but the runtime wire format isn't.
350        if ctx.flags > u8::MAX as u32 {
351            return Err(EdgeTraceContextToObserveError { flags: ctx.flags });
352        }
353        Ok(TraceContext {
354            trace_id: TraceId(ctx.trace_id),
355            span_id: SpanId(ctx.span_id),
356            parent_span_id: SpanId(ctx.parent_span_id),
357            flags: ctx.flags as u8,
358        })
359    }
360}
361
362// ---------------------------------------------------------------------------
363// Thread-local current trace context (requires std)
364// ---------------------------------------------------------------------------
365
366#[cfg(feature = "std")]
367mod thread_local_ctx {
368    use super::TraceContext;
369    use std::cell::RefCell;
370
371    thread_local! {
372        static CURRENT: RefCell<TraceContext> = RefCell::new(TraceContext::default());
373    }
374
375    impl TraceContext {
376        /// Get the current thread-local trace context.
377        pub fn current() -> TraceContext {
378            CURRENT.with(|c| *c.borrow())
379        }
380
381        /// Set the current thread-local trace context.
382        pub fn set_current(ctx: TraceContext) {
383            CURRENT.with(|c| *c.borrow_mut() = ctx);
384        }
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    fn test_random_bytes() -> [u8; 24] {
393        // Deterministic test bytes
394        let mut bytes = [0u8; 24];
395        for (i, b) in bytes.iter_mut().enumerate() {
396            *b = (i as u8).wrapping_add(0x10);
397        }
398        bytes
399    }
400
401    #[test]
402    fn new_root_creates_valid_context() {
403        let ctx = TraceContext::new_root(&test_random_bytes());
404        assert!(ctx.trace_id.is_valid());
405        assert!(ctx.span_id.is_valid());
406        assert_eq!(ctx.parent_span_id, SpanId::INVALID);
407        assert!(ctx.is_sampled());
408    }
409
410    #[test]
411    fn child_inherits_trace_id() {
412        let root = TraceContext::new_root(&test_random_bytes());
413        let child_bytes: [u8; 8] = [0xAA; 8];
414        let child = root.child(&child_bytes);
415
416        assert_eq!(child.trace_id, root.trace_id);
417        assert_ne!(child.span_id, root.span_id);
418        assert_eq!(child.parent_span_id, root.span_id);
419        assert_eq!(child.flags, root.flags);
420    }
421
422    #[test]
423    fn encode_decode_roundtrip() {
424        let ctx = TraceContext::new_root(&test_random_bytes());
425        let encoded = ctx.encode();
426        let decoded = TraceContext::decode(&encoded).unwrap();
427
428        assert_eq!(decoded.trace_id, ctx.trace_id);
429        assert_eq!(decoded.span_id, ctx.span_id);
430        assert_eq!(decoded.flags, ctx.flags);
431    }
432
433    #[test]
434    fn w3c_string_roundtrip() {
435        let ctx = TraceContext::new_root(&test_random_bytes());
436        let s = ctx.to_w3c_string();
437
438        assert_eq!(s.len(), 55);
439        assert!(s.starts_with("00-"));
440
441        let parsed = TraceContext::from_w3c_string(&s).unwrap();
442        assert_eq!(parsed.trace_id, ctx.trace_id);
443        assert_eq!(parsed.span_id, ctx.span_id);
444        assert_eq!(parsed.flags, ctx.flags);
445    }
446
447    #[test]
448    fn w3c_string_format() {
449        let ctx = TraceContext {
450            trace_id: TraceId(0x0102030405060708090a0b0c0d0e0f10),
451            span_id: SpanId(0x1112131415161718),
452            parent_span_id: SpanId::INVALID,
453            flags: 0x01,
454        };
455        let s = ctx.to_w3c_string();
456        assert_eq!(s, "00-0102030405060708090a0b0c0d0e0f10-1112131415161718-01");
457    }
458
459    #[test]
460    fn from_w3c_string_rejects_bad_version() {
461        let s = "01-0102030405060708090a0b0c0d0e0f10-1112131415161718-01";
462        let err = TraceContext::from_w3c_string(s).unwrap_err();
463        assert_eq!(err, TraceContextError::UnsupportedVersion(0x01));
464    }
465
466    #[test]
467    fn from_w3c_string_rejects_bad_length() {
468        assert_eq!(
469            TraceContext::from_w3c_string("too-short"),
470            Err(TraceContextError::InvalidFormat)
471        );
472    }
473
474    #[test]
475    fn sampled_flag_manipulation() {
476        let mut ctx = TraceContext::default();
477        assert!(!ctx.is_sampled());
478        ctx.set_sampled(true);
479        assert!(ctx.is_sampled());
480        ctx.set_sampled(false);
481        assert!(!ctx.is_sampled());
482    }
483
484    #[test]
485    fn default_is_empty() {
486        let ctx = TraceContext::default();
487        assert!(ctx.is_empty());
488    }
489
490    #[test]
491    fn decode_rejects_bad_version() {
492        let mut buf = [0u8; 32];
493        buf[0] = 0xFF;
494        assert_eq!(
495            TraceContext::decode(&buf),
496            Err(TraceContextError::UnsupportedVersion(0xFF))
497        );
498    }
499
500    #[cfg(feature = "std")]
501    #[test]
502    fn thread_local_current_context() {
503        // Save and restore to avoid polluting other tests
504        let saved = TraceContext::current();
505
506        let ctx = TraceContext::new_root(&test_random_bytes());
507        TraceContext::set_current(ctx);
508        let got = TraceContext::current();
509        assert_eq!(got.trace_id, ctx.trace_id);
510        assert_eq!(got.span_id, ctx.span_id);
511
512        TraceContext::set_current(saved);
513    }
514
515    #[test]
516    fn display_uses_w3c_format() {
517        let ctx = TraceContext::new_root(&test_random_bytes());
518        let display = alloc::format!("{ctx}");
519        let w3c = ctx.to_w3c_string();
520        assert_eq!(display, w3c);
521    }
522
523    #[test]
524    fn trace_id_display() {
525        let tid = TraceId(0x0102030405060708090a0b0c0d0e0f10);
526        assert_eq!(alloc::format!("{tid}"), "0102030405060708090a0b0c0d0e0f10");
527    }
528
529    #[test]
530    fn span_id_display() {
531        let sid = SpanId(0x1112131415161718);
532        assert_eq!(alloc::format!("{sid}"), "1112131415161718");
533    }
534
535    // -----------------------------------------------------------------
536    // slice 224 — EdgeTraceContext ↔ TraceContext bridge pin tests.
537    //
538    // The runtime `TraceContext` has u8 flags (W3C wire) while the
539    // graph-layer `grafos_core::EdgeTraceContext` has u32 flags (v1.1
540    // §4.3 schema). The bridge fails closed on the narrowing direction
541    // when high bits are set, and is lossless on the widening
542    // direction.
543    // -----------------------------------------------------------------
544
545    #[test]
546    fn from_trace_context_widens_flags_losslessly() {
547        let ctx = TraceContext {
548            trace_id: TraceId(0xdeadbeef_cafe_babe_0123_4567_89ab_cdef),
549            span_id: SpanId(0x1234_5678_9abc_def0),
550            parent_span_id: SpanId(0xfeed_face_dead_beef),
551            flags: FLAG_SAMPLED,
552        };
553        let edge: grafos_core::EdgeTraceContext = ctx.into();
554        assert_eq!(edge.trace_id, ctx.trace_id.0);
555        assert_eq!(edge.span_id, ctx.span_id.0);
556        assert_eq!(edge.parent_span_id, ctx.parent_span_id.0);
557        assert_eq!(edge.flags, FLAG_SAMPLED as u32);
558        // High bits are zero — u8 → u32 widening can never set them.
559        assert_eq!(edge.flags & 0xffff_ff00, 0);
560    }
561
562    #[test]
563    fn from_trace_context_preserves_zero_flags() {
564        let ctx = TraceContext::default();
565        let edge: grafos_core::EdgeTraceContext = ctx.into();
566        assert_eq!(edge.flags, 0);
567        assert!(edge.is_zero());
568    }
569
570    #[test]
571    fn from_trace_context_preserves_max_u8_flags() {
572        let ctx = TraceContext {
573            trace_id: TraceId(1),
574            span_id: SpanId(2),
575            parent_span_id: SpanId(3),
576            flags: u8::MAX,
577        };
578        let edge: grafos_core::EdgeTraceContext = ctx.into();
579        assert_eq!(edge.flags, u8::MAX as u32);
580        assert_eq!(edge.flags, 0x0000_00ff);
581    }
582
583    #[test]
584    fn try_from_edge_trace_context_narrows_u8_range_losslessly() {
585        let edge = grafos_core::EdgeTraceContext {
586            trace_id: 0xa5a5_a5a5_a5a5_a5a5_a5a5_a5a5_a5a5_a5a5,
587            span_id: 0x0123_4567_89ab_cdef,
588            parent_span_id: 0xfedc_ba98_7654_3210,
589            flags: grafos_core::EDGE_TRACE_FLAG_SAMPLED,
590        };
591        let ctx = TraceContext::try_from(edge).expect("u8-fitting flags must narrow");
592        assert_eq!(ctx.trace_id.0, edge.trace_id);
593        assert_eq!(ctx.span_id.0, edge.span_id);
594        assert_eq!(ctx.parent_span_id.0, edge.parent_span_id);
595        assert_eq!(ctx.flags, FLAG_SAMPLED);
596    }
597
598    #[test]
599    fn try_from_edge_trace_context_fails_closed_on_high_bits() {
600        // High bit (32) set — would silently drop in a u32→u8 cast.
601        // The bridge must reject this rather than narrowing to 0.
602        let edge = grafos_core::EdgeTraceContext {
603            flags: 0x8000_0000,
604            ..grafos_core::EdgeTraceContext::default()
605        };
606        let err = TraceContext::try_from(edge).expect_err("high bits must fail closed");
607        assert_eq!(err, EdgeTraceContextToObserveError { flags: 0x8000_0000 });
608    }
609
610    #[test]
611    fn try_from_edge_trace_context_fails_closed_on_mid_bits() {
612        // Bit 8 set — just above u8::MAX. This is the canonical
613        // "silently truncates to 0" case the bridge protects against.
614        let edge = grafos_core::EdgeTraceContext {
615            flags: 0x0000_0100,
616            ..grafos_core::EdgeTraceContext::default()
617        };
618        let err = TraceContext::try_from(edge).expect_err("bit 8 must fail closed");
619        assert_eq!(err.flags, 0x0000_0100);
620    }
621
622    #[test]
623    fn try_from_edge_trace_context_at_u8_max_boundary() {
624        // 0xff fits exactly; 0x100 does not. Pin the boundary.
625        let at_max = grafos_core::EdgeTraceContext {
626            flags: 0xff,
627            ..grafos_core::EdgeTraceContext::default()
628        };
629        let just_over = grafos_core::EdgeTraceContext {
630            flags: 0x100,
631            ..grafos_core::EdgeTraceContext::default()
632        };
633        assert!(TraceContext::try_from(at_max).is_ok());
634        assert!(TraceContext::try_from(just_over).is_err());
635    }
636
637    #[test]
638    fn bridge_round_trip_is_identity_in_u8_range() {
639        // Any TraceContext can round-trip TC → EdgeTraceContext → TC
640        // because the widening direction is lossless and the narrowing
641        // direction succeeds for any u32 value that fits in u8 (which
642        // every u8 value does by construction).
643        let original = TraceContext {
644            trace_id: TraceId(0x1111_2222_3333_4444_5555_6666_7777_8888),
645            span_id: SpanId(0x9999_aaaa_bbbb_cccc),
646            parent_span_id: SpanId(0xdddd_eeee_ffff_0000),
647            flags: 0x42,
648        };
649        let edge: grafos_core::EdgeTraceContext = original.into();
650        let round: TraceContext =
651            TraceContext::try_from(edge).expect("u8-sourced round-trip must succeed");
652        assert_eq!(round.trace_id, original.trace_id);
653        assert_eq!(round.span_id, original.span_id);
654        assert_eq!(round.parent_span_id, original.parent_span_id);
655        assert_eq!(round.flags, original.flags);
656    }
657
658    #[test]
659    fn bridge_error_display_is_human_readable() {
660        let err = EdgeTraceContextToObserveError { flags: 0xdead_beef };
661        let s = alloc::format!("{err}");
662        assert!(
663            s.contains("0xdeadbeef"),
664            "error must surface the rejected flags value: {s}"
665        );
666        assert!(
667            s.contains("u8") || s.contains("W3C"),
668            "error must explain why narrowing failed: {s}"
669        );
670    }
671}