Skip to main content

rustev_otel/
context.rs

1//! Trace context ingestion and the exchange join buffer (spec 028, 3.7):
2//! bounded tables, entries removed on delivery, on `abandon` or after their
3//! time to live by the sink's monotonic clock.
4
5use std::collections::{BTreeMap, VecDeque};
6use std::sync::Arc;
7
8use rustev_contract::remote::ExchangeRecord;
9
10/// A parsed W3C `traceparent` (version `00` layout; a later version is read
11/// by its first four fields, as the W3C specification requires).
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub struct TraceParent {
14    pub trace_id: [u8; 16],
15    pub parent_id: [u8; 8],
16    pub sampled: bool,
17}
18
19fn hex_bytes<const N: usize>(s: &str) -> Option<[u8; N]> {
20    if s.len() != N * 2
21        || !s
22            .bytes()
23            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
24    {
25        return None;
26    }
27    let mut out = [0u8; N];
28    for (i, o) in out.iter_mut().enumerate() {
29        *o = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?;
30    }
31    Some(out)
32}
33
34/// Parse a `traceparent`. `None` when it does not parse, has version
35/// `ff`, or has an all-zero trace or parent id (3.7.1).
36pub fn parse_traceparent(s: &str) -> Option<TraceParent> {
37    let mut parts = s.split('-');
38    let version = parts.next()?;
39    let trace = parts.next()?;
40    let parent = parts.next()?;
41    let flags = parts.next()?;
42    let rest: Vec<&str> = parts.collect();
43    let v: [u8; 1] = hex_bytes(version)?;
44    if v[0] == 0xff {
45        return None;
46    }
47    // Version 00 has exactly four fields; a later version may append more.
48    if v[0] == 0 && !rest.is_empty() {
49        return None;
50    }
51    let trace_id: [u8; 16] = hex_bytes(trace)?;
52    let parent_id: [u8; 8] = hex_bytes(parent)?;
53    let f: [u8; 1] = hex_bytes(flags)?;
54    if trace_id.iter().all(|b| *b == 0) || parent_id.iter().all(|b| *b == 0) {
55        return None;
56    }
57    Some(TraceParent {
58        trace_id,
59        parent_id,
60        sampled: f[0] & 1 == 1,
61    })
62}
63
64/// One registered decision (3.7.1).
65#[derive(Debug, Clone)]
66pub struct Context {
67    pub trace_id: [u8; 16],
68    pub root_span_id: [u8; 8],
69    /// The host span, when a valid parent was registered.
70    pub parent: Option<TraceParent>,
71    /// Carried unchanged when the parent is valid.
72    pub tracestate: Option<String>,
73    /// The wall-clock anchor read at `begin`, Unix nanoseconds.
74    pub anchor_unix_nanos: u64,
75    registered_ms: u64,
76}
77
78/// The bounded table of registered decisions.
79#[derive(Debug)]
80pub struct ContextTable {
81    max: usize,
82    ttl_ms: u64,
83    entries: BTreeMap<String, Context>,
84}
85
86impl ContextTable {
87    pub fn new(max: usize, ttl_ms: u64) -> Self {
88        ContextTable {
89            max,
90            ttl_ms,
91            entries: BTreeMap::new(),
92        }
93    }
94
95    /// Remove expired entries; how many.
96    pub fn expire(&mut self, now_ms: u64) -> u64 {
97        let ttl = self.ttl_ms;
98        let before = self.entries.len();
99        self.entries
100            .retain(|_, c| now_ms.saturating_sub(c.registered_ms) < ttl);
101        (before - self.entries.len()) as u64
102    }
103
104    /// Register; `false` when the table is full.
105    #[allow(clippy::too_many_arguments)]
106    pub fn insert(
107        &mut self,
108        decision_id: &str,
109        trace_id: [u8; 16],
110        root_span_id: [u8; 8],
111        parent: Option<TraceParent>,
112        tracestate: Option<String>,
113        anchor_unix_nanos: u64,
114        now_ms: u64,
115    ) -> bool {
116        if !self.entries.contains_key(decision_id) && self.entries.len() >= self.max {
117            return false;
118        }
119        self.entries.insert(
120            decision_id.to_string(),
121            Context {
122                trace_id,
123                root_span_id,
124                parent,
125                tracestate: if parent.is_some() { tracestate } else { None },
126                anchor_unix_nanos,
127                registered_ms: now_ms,
128            },
129        );
130        true
131    }
132
133    pub fn take(&mut self, decision_id: &str) -> Option<Context> {
134        self.entries.remove(decision_id)
135    }
136
137    pub fn len(&self) -> usize {
138        self.entries.len()
139    }
140
141    pub fn is_empty(&self) -> bool {
142        self.entries.is_empty()
143    }
144}
145
146/// Attempt ids remembered as exported, at least (the buffer's own bound when
147/// larger): a record for one of them is `exchange_unjoined` at once.
148pub const EXPORTED_MEMORY: usize = 4096;
149
150/// Exchange records waiting for their decision (3.7.3), keyed by attempt
151/// id, and a bounded memory of attempts already exported so a late record
152/// is counted at once.
153#[derive(Debug)]
154pub struct JoinBuffer {
155    max: usize,
156    ttl_ms: u64,
157    records: BTreeMap<String, (Arc<ExchangeRecord>, u64)>,
158    exported: VecDeque<String>,
159    exported_set: BTreeMap<String, ()>,
160}
161
162/// What `JoinBuffer::insert` did with a record.
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum Joined {
165    /// Waiting for its decision.
166    Buffered,
167    /// Its decision was already exported, or the buffer was full: counted
168    /// as `exchange_unjoined`.
169    Unjoined,
170}
171
172impl JoinBuffer {
173    pub fn new(max: usize, ttl_ms: u64) -> Self {
174        JoinBuffer {
175            max,
176            ttl_ms,
177            records: BTreeMap::new(),
178            exported: VecDeque::new(),
179            exported_set: BTreeMap::new(),
180        }
181    }
182
183    /// Remove expired records; how many records (not attempts).
184    pub fn expire(&mut self, now_ms: u64) -> u64 {
185        let ttl = self.ttl_ms;
186        let mut gone = std::collections::BTreeSet::new();
187        self.records.retain(|_, (x, at)| {
188            let keep = now_ms.saturating_sub(*at) < ttl;
189            if !keep {
190                gone.insert(Arc::as_ptr(x) as usize);
191            }
192            keep
193        });
194        gone.len() as u64
195    }
196
197    fn distinct(&self) -> usize {
198        let mut s = std::collections::BTreeSet::new();
199        for (x, _) in self.records.values() {
200            s.insert(Arc::as_ptr(x) as usize);
201        }
202        s.len()
203    }
204
205    pub fn insert(&mut self, x: ExchangeRecord, now_ms: u64) -> Joined {
206        if x.members
207            .iter()
208            .any(|m| self.exported_set.contains_key(&m.attempt_id))
209            || x.members.is_empty()
210            || self.distinct() >= self.max
211        {
212            return Joined::Unjoined;
213        }
214        let x = Arc::new(x);
215        for m in &x.members {
216            self.records
217                .insert(m.attempt_id.clone(), (x.clone(), now_ms));
218        }
219        Joined::Buffered
220    }
221
222    /// Take the records of `attempt_ids`, and remember them as exported.
223    pub fn take(&mut self, attempt_ids: &[&str]) -> BTreeMap<String, Arc<ExchangeRecord>> {
224        let mut out = BTreeMap::new();
225        for id in attempt_ids {
226            if let Some((x, _)) = self.records.remove(*id) {
227                out.insert(id.to_string(), x);
228            }
229            if self.exported_set.insert(id.to_string(), ()).is_none() {
230                self.exported.push_back(id.to_string());
231                while self.exported.len() > self.max.max(EXPORTED_MEMORY) {
232                    if let Some(old) = self.exported.pop_front() {
233                        self.exported_set.remove(&old);
234                    }
235                }
236            }
237        }
238        out
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn traceparent_parsing_follows_w3c() {
248        let ok = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
249        let p = parse_traceparent(ok).unwrap();
250        assert!(p.sampled);
251        assert_eq!(
252            p.parent_id,
253            [0xb7, 0xad, 0x6b, 0x71, 0x69, 0x20, 0x33, 0x31]
254        );
255        for bad in [
256            "",
257            "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331",
258            "ff-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
259            "00-00000000000000000000000000000000-b7ad6b7169203331-01",
260            "00-0af7651916cd43dd8448eb211c80319c-0000000000000000-01",
261            "00-0AF7651916CD43DD8448EB211C80319C-b7ad6b7169203331-01",
262            "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01-x",
263            "0-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
264        ] {
265            assert!(parse_traceparent(bad).is_none(), "{bad}");
266        }
267        // A later version may carry more fields.
268        assert!(
269            parse_traceparent("01-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-00-x")
270                .is_some_and(|p| !p.sampled)
271        );
272    }
273}