Skip to main content

atomr_core/actor/
metadata.rs

1//! Per-message [`Metadata`] — trace context + baggage that propagates across
2//! actor hops without polluting domain message types (FR-10).
3//!
4//! End-to-end tracing needs causal context (W3C TraceContext, plus arbitrary
5//! baggage) to ride along every `tell`/`ask`, through mailboxes, and across
6//! stream / `JoinSet` boundaries. Wrapping every domain message in a
7//! `Traced<M>` by hand is invasive and easy to drop at a boundary. Instead the
8//! envelope carries an extensible [`Metadata`] map that the runtime threads
9//! automatically, and a [`MessageInterceptor`] (installed via
10//! [`Props::with_interceptor`](super::Props::with_interceptor)) opens a span on
11//! the way in and injects child context on the way out.
12
13use std::any::Any;
14use std::collections::BTreeMap;
15
16/// Extensible message metadata: W3C-style trace context plus a small string
17/// baggage map. Empty by default and cheap to clone (the baggage map does not
18/// allocate until the first insert).
19#[derive(Debug, Clone, Default, PartialEq, Eq)]
20pub struct Metadata {
21    trace_id: Option<String>,
22    span_id: Option<String>,
23    baggage: BTreeMap<String, String>,
24}
25
26impl Metadata {
27    /// An empty metadata map.
28    pub fn new() -> Self {
29        Self::default()
30    }
31
32    /// Construct with a trace + span id.
33    pub fn with_trace(trace_id: impl Into<String>, span_id: impl Into<String>) -> Self {
34        Self { trace_id: Some(trace_id.into()), span_id: Some(span_id.into()), baggage: BTreeMap::new() }
35    }
36
37    /// The W3C trace id, if set.
38    pub fn trace_id(&self) -> Option<&str> {
39        self.trace_id.as_deref()
40    }
41
42    /// The current span id, if set.
43    pub fn span_id(&self) -> Option<&str> {
44        self.span_id.as_deref()
45    }
46
47    /// Set the trace id.
48    pub fn set_trace_id(&mut self, trace_id: impl Into<String>) {
49        self.trace_id = Some(trace_id.into());
50    }
51
52    /// Set the span id.
53    pub fn set_span_id(&mut self, span_id: impl Into<String>) {
54        self.span_id = Some(span_id.into());
55    }
56
57    /// Insert a baggage key/value.
58    pub fn set_baggage(&mut self, key: impl Into<String>, value: impl Into<String>) {
59        self.baggage.insert(key.into(), value.into());
60    }
61
62    /// Read a baggage value.
63    pub fn baggage(&self, key: &str) -> Option<&str> {
64        self.baggage.get(key).map(String::as_str)
65    }
66
67    /// Iterate baggage entries (e.g. to serialize on a remote hop).
68    pub fn baggage_iter(&self) -> impl Iterator<Item = (&str, &str)> {
69        self.baggage.iter().map(|(k, v)| (k.as_str(), v.as_str()))
70    }
71
72    /// True if no trace context and no baggage are present.
73    pub fn is_empty(&self) -> bool {
74        self.trace_id.is_none() && self.span_id.is_none() && self.baggage.is_empty()
75    }
76}
77
78/// RAII guard returned by [`MessageInterceptor::before_handle`]. Held for the
79/// duration of `handle` and dropped afterward, so an implementation can keep a
80/// `tracing::span::Entered` (or any other scope guard) alive across the call.
81#[must_use = "the span guard must be held for the duration of message handling"]
82pub struct SpanGuard(#[allow(dead_code)] Option<Box<dyn Any + Send>>);
83
84impl SpanGuard {
85    /// A no-op guard.
86    pub fn none() -> Self {
87        SpanGuard(None)
88    }
89
90    /// Wrap an arbitrary scope guard so it lives for the handle duration.
91    pub fn holding(guard: impl Any + Send) -> Self {
92        SpanGuard(Some(Box::new(guard)))
93    }
94}
95
96impl Default for SpanGuard {
97    fn default() -> Self {
98        SpanGuard::none()
99    }
100}
101
102/// A Props-level hook that observes message handling for cross-cutting concerns
103/// such as distributed tracing. Default methods are no-ops, so an interceptor
104/// only overrides what it needs.
105///
106/// The default [`TraceContextInterceptor`]-style behaviour (linking parent →
107/// child spans) is provided by `atomr-telemetry` (FR-11); core only defines the
108/// hook so domain message types stay clean.
109pub trait MessageInterceptor: Send + Sync {
110    /// Called immediately before [`Actor::handle`](super::Actor::handle) with
111    /// the incoming message's metadata. The returned [`SpanGuard`] is dropped
112    /// once handling completes.
113    fn before_handle(&self, meta: &Metadata) -> SpanGuard {
114        let _ = meta;
115        SpanGuard::none()
116    }
117
118    /// Derive the metadata to attach to messages sent *while* handling the
119    /// current message. Defaults to propagating the parent context unchanged.
120    fn outgoing(&self, parent: &Metadata) -> Metadata {
121        parent.clone()
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn empty_by_default() {
131        let m = Metadata::new();
132        assert!(m.is_empty());
133        assert_eq!(m.trace_id(), None);
134    }
135
136    #[test]
137    fn carries_trace_and_baggage() {
138        let mut m = Metadata::with_trace("trace-1", "span-1");
139        m.set_baggage("tenant", "acme");
140        assert_eq!(m.trace_id(), Some("trace-1"));
141        assert_eq!(m.span_id(), Some("span-1"));
142        assert_eq!(m.baggage("tenant"), Some("acme"));
143        assert!(!m.is_empty());
144    }
145
146    struct Noop;
147    impl MessageInterceptor for Noop {}
148
149    #[test]
150    fn default_interceptor_propagates() {
151        let i = Noop;
152        let mut m = Metadata::with_trace("t", "s");
153        m.set_baggage("k", "v");
154        let child = i.outgoing(&m);
155        assert_eq!(child, m);
156        let _guard = i.before_handle(&m);
157    }
158}