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