Skip to main content

obs_core/scope/
mod.rs

1//! `obs::scope!` and `obs::context!` runtime support — task-local /
2//! thread-local stacks of `ScopeFrame`s, the tail-on-error ring buffer,
3//! and the auto-fill machinery used by `EventSchema::project`.
4//!
5//! Spec 13 §§ 2 + 3, spec 11 § 4.1 (pipeline order steps 3 + 5).
6
7mod builder;
8mod frame;
9mod guard;
10
11use std::cell::RefCell;
12
13use obs_proto::obs::v1::ObsEnvelope;
14
15pub(crate) use self::guard::finish_scope_frame;
16pub use self::{
17    builder::ScopeFrameBuilder,
18    frame::{ScopeField, ScopeFrame, ScopeKind},
19    guard::ScopeGuard,
20};
21
22thread_local! {
23    static THREAD_STACK: RefCell<Vec<ScopeFrame>> = const { RefCell::new(Vec::new()) };
24}
25
26tokio::task_local! {
27    static TASK_STACK: RefCell<Vec<ScopeFrame>>;
28}
29
30/// Push a frame onto the active scope stack. Returns a numerical depth
31/// hint the RAII guard uses to validate LIFO order at drop.
32pub(crate) fn push_frame(frame: ScopeFrame) -> usize {
33    if let Ok(depth) = TASK_STACK.try_with(|cell| {
34        let mut v = cell.borrow_mut();
35        v.push(frame.clone());
36        v.len()
37    }) {
38        return depth;
39    }
40    THREAD_STACK.with(|cell| {
41        let mut v = cell.borrow_mut();
42        v.push(frame);
43        v.len()
44    })
45}
46
47/// Pop the active scope's top frame, returning it for the RAII guard
48/// to inspect (`seen_error` decides whether the tail buffer flushes).
49pub(crate) fn pop_frame() -> Option<ScopeFrame> {
50    if let Ok(frame) = TASK_STACK.try_with(|cell| cell.borrow_mut().pop()) {
51        return frame;
52    }
53    THREAD_STACK.with(|cell| cell.borrow_mut().pop())
54}
55
56/// Public push helper for external integrations that own their own
57/// pop (e.g. the tracing bridge pushes on `on_enter` and pops on
58/// `on_exit`). External callers MUST pair every call to
59/// [`push_frame_pub`] with exactly one call to [`pop_frame_pub`] in
60/// LIFO order — using [`ScopeFrameBuilder`] + the returned
61/// [`ScopeGuard`] is preferred when the lifetime is RAII-shaped.
62/// Spec 94 D7-3.
63pub fn push_frame_pub(frame: ScopeFrame) {
64    let _ = push_frame(frame);
65}
66
67/// Public pop helper. See [`push_frame_pub`] for ownership rules.
68pub fn pop_frame_pub() -> Option<ScopeFrame> {
69    pop_frame()
70}
71
72/// Visit every active scope frame innermost-first. Used by
73/// `auto_fill_envelope` and by `obs::SpanTrace`.
74pub fn with_frames_innermost_first<F, R>(f: F) -> R
75where
76    F: FnOnce(&[ScopeFrame]) -> R,
77{
78    // The closure is FnOnce so we cannot reuse it across both
79    // task-local and thread-local probes. Snapshot the active stack
80    // into a single Vec and hand it to the user.
81    let snapshot = collect_active_stack();
82    f(snapshot.as_slice())
83}
84
85fn collect_active_stack() -> Vec<ScopeFrame> {
86    if let Ok(v) = TASK_STACK.try_with(|cell| cell.borrow().clone()) {
87        return v;
88    }
89    THREAD_STACK.with(|cell| cell.borrow().clone())
90}
91
92/// Walk active scopes innermost-first and inject any declared fields
93/// the envelope is missing. Mirrors spec 13 § 2.1: only `None`-equivalent
94/// envelope slots inherit; explicit values pass through untouched.
95pub fn auto_fill_envelope(env: &mut ObsEnvelope) {
96    let frames = collect_active_stack();
97    for frame in frames.iter().rev() {
98        for field in frame.fields() {
99            match field {
100                ScopeField::TraceId(v) if env.trace_id.is_empty() => {
101                    env.trace_id.clone_from(v);
102                }
103                ScopeField::SpanId(v) if env.span_id.is_empty() => {
104                    env.span_id.clone_from(v);
105                }
106                ScopeField::ParentSpanId(v) if env.parent_span_id.is_empty() => {
107                    env.parent_span_id.clone_from(v);
108                }
109                ScopeField::Label(k, v) if !env.labels.contains_key(*k) => {
110                    env.labels.insert((*k).to_string(), v.clone());
111                }
112                _ => {}
113            }
114        }
115    }
116}
117
118/// Inbound `traceparent.sampled` decision from the outermost (oldest)
119/// scope frame, when set. Spec 13 § 6.
120#[must_use]
121pub fn inbound_traceparent_sampled() -> Option<bool> {
122    let frames = collect_active_stack();
123    frames.iter().find_map(|f| f.traceparent_sampled())
124}
125
126/// Read the active scope's correlation pair as
127/// `Some((trace_id, span_id))` when both have been pushed, otherwise
128/// `None`. Walks the stack innermost-first so the *deepest* set value
129/// wins — matches the auto-fill ordering in `auto_fill_envelope`.
130///
131/// This is the symmetrical *read* surface to `ScopeFrameBuilder` (D7-3,
132/// which writes frames). Spec 95 D8-2: outbound HTTP middleware,
133/// OTLP exporters, and any other generic code that needs to inherit
134/// the active trace context calls this function.
135#[must_use]
136pub fn active_correlation() -> Option<(String, String)> {
137    let frames = collect_active_stack();
138    let mut trace_id: Option<String> = None;
139    let mut span_id: Option<String> = None;
140    for frame in frames.iter().rev() {
141        for field in frame.fields() {
142            match field {
143                ScopeField::TraceId(v) if trace_id.is_none() => trace_id = Some(v.clone()),
144                ScopeField::SpanId(v) if span_id.is_none() => span_id = Some(v.clone()),
145                _ => {}
146            }
147        }
148        if trace_id.is_some() && span_id.is_some() {
149            break;
150        }
151    }
152    match (trace_id, span_id) {
153        (Some(t), Some(s)) => Some((t, s)),
154        _ => None,
155    }
156}
157
158/// Inbound sampled decision exposed under the same naming as
159/// [`active_correlation`] so callers can grab both with one symmetrical
160/// import. Equivalent to [`inbound_traceparent_sampled`]. Spec 95 D8-2.
161#[must_use]
162pub fn active_sampled() -> Option<bool> {
163    inbound_traceparent_sampled()
164}
165
166/// Push an envelope onto the innermost active scope's tail buffer (if
167/// the scope is a `Scope`, not a `Context`). No-op when no frame is
168/// active or the active frame is `Context`. Spec 13 § 6.
169pub fn push_tail_buffer(env: &ObsEnvelope) {
170    if let Ok(()) = TASK_STACK.try_with(|cell| push_to_top(cell.borrow_mut().last_mut(), env)) {
171        return;
172    }
173    THREAD_STACK.with(|cell| push_to_top(cell.borrow_mut().last_mut(), env));
174}
175
176/// Mark every active scope frame as having seen an error so the
177/// outermost scope's drop will trigger the tail-on-error flush.
178/// Spec 13 § 6.
179pub fn mark_error_on_active_scopes() {
180    if let Ok(()) = TASK_STACK.try_with(|cell| {
181        for f in cell.borrow_mut().iter_mut() {
182            f.mark_error();
183        }
184    }) {
185        return;
186    }
187    THREAD_STACK.with(|cell| {
188        for f in cell.borrow_mut().iter_mut() {
189            f.mark_error();
190        }
191    });
192}
193
194/// Run `fut` under a fresh task-local scope stack so spawned tasks do
195/// not see the parent's stack. Used by callers that want a clean
196/// child task with no inherited frames.
197#[allow(dead_code)]
198pub(crate) async fn scope_task<F, R>(fut: F) -> R
199where
200    F: std::future::Future<Output = R>,
201{
202    TASK_STACK.scope(RefCell::new(Vec::new()), fut).await
203}
204
205fn push_to_top(top: Option<&mut ScopeFrame>, env: &ObsEnvelope) {
206    if let Some(frame) = top {
207        frame.push_tail(env.clone());
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use obs_proto::obs::v1::Severity;
214
215    use super::*;
216
217    fn make_frame(fields: Vec<ScopeField>, kind: ScopeKind) -> ScopeFrame {
218        ScopeFrame::new(fields, kind, 64)
219    }
220
221    #[test]
222    fn test_should_inject_label_when_envelope_missing() {
223        let frame = make_frame(
224            vec![ScopeField::Label("tenant", "alpha".to_string())],
225            ScopeKind::Scope,
226        );
227        let _depth = push_frame(frame);
228        let mut env = ObsEnvelope::default();
229        auto_fill_envelope(&mut env);
230        assert_eq!(env.labels.get("tenant"), Some(&"alpha".to_string()));
231        let _ = pop_frame();
232    }
233
234    #[test]
235    fn test_should_not_overwrite_explicit_label() {
236        let frame = make_frame(
237            vec![ScopeField::Label("tenant", "alpha".to_string())],
238            ScopeKind::Scope,
239        );
240        let _depth = push_frame(frame);
241        let mut env = ObsEnvelope::default();
242        env.labels.insert("tenant".to_string(), "beta".to_string());
243        auto_fill_envelope(&mut env);
244        assert_eq!(env.labels.get("tenant"), Some(&"beta".to_string()));
245        let _ = pop_frame();
246    }
247
248    #[test]
249    fn test_should_inject_trace_id() {
250        let frame = make_frame(
251            vec![ScopeField::TraceId("abc".to_string())],
252            ScopeKind::Scope,
253        );
254        let _depth = push_frame(frame);
255        let mut env = ObsEnvelope::default();
256        auto_fill_envelope(&mut env);
257        assert_eq!(env.trace_id, "abc");
258        let _ = pop_frame();
259    }
260
261    #[test]
262    fn test_should_return_active_correlation_when_present() {
263        let frame = make_frame(
264            vec![
265                ScopeField::TraceId("trc".to_string()),
266                ScopeField::SpanId("spn".to_string()),
267            ],
268            ScopeKind::Scope,
269        );
270        let _ = push_frame(frame);
271        assert_eq!(
272            active_correlation(),
273            Some(("trc".to_string(), "spn".to_string())),
274        );
275        let _ = pop_frame();
276    }
277
278    #[test]
279    fn test_should_return_none_when_no_correlation() {
280        // Each test runs on its own thread; no scope active.
281        assert_eq!(active_correlation(), None);
282    }
283
284    #[test]
285    fn test_should_walk_stack_for_correlation() {
286        let outer = make_frame(
287            vec![ScopeField::TraceId("outer-trc".to_string())],
288            ScopeKind::Scope,
289        );
290        let inner = make_frame(
291            vec![ScopeField::SpanId("inner-spn".to_string())],
292            ScopeKind::Scope,
293        );
294        let _ = push_frame(outer);
295        let _ = push_frame(inner);
296        // Walking innermost-first: span_id from inner, trace_id from outer.
297        assert_eq!(
298            active_correlation(),
299            Some(("outer-trc".to_string(), "inner-spn".to_string())),
300        );
301        let _ = pop_frame();
302        let _ = pop_frame();
303    }
304
305    #[test]
306    fn test_should_push_tail_buffer_only_for_scope_kind() {
307        let frame = make_frame(vec![], ScopeKind::Context);
308        let _ = push_frame(frame);
309        let env = ObsEnvelope {
310            full_name: "test.v1.X".to_string(),
311            sev: ::buffa::EnumValue::Known(obs_proto::obs::v1::Severity::SEVERITY_DEBUG),
312            ..Default::default()
313        };
314        push_tail_buffer(&env);
315        let frame = pop_frame().unwrap();
316        // Context kind should not buffer.
317        assert!(frame.tail_snapshot().is_empty());
318        let _ = Severity::Debug;
319    }
320}