errsight 0.1.1

Rust client for ErrSight error tracking — captures panics, errors, and log/tracing events and ships them to the ErrSight API from a background thread.
Documentation
//! Scope management: a process-wide base scope plus a per-thread scope stack.
//!
//! Reading/writing "the current scope" means: the top of this thread's stack
//! if a [`with_scope`] block is active, otherwise the shared base scope. New
//! stack frames are forked from the current effective scope, so a block sees
//! everything set above it but its own mutations are discarded on exit. This is
//! the mechanism that stops a long-lived worker thread from leaking one
//! request's user/tags into the next.
//!
//! Note on async: scopes are keyed to OS threads. A future that moves across
//! tokio worker threads will not carry its `with_scope` frame with it. For
//! async request handlers, set context with [`crate::configure_scope`] inside
//! the same poll, or attach per-call `user`/`tags` at capture time.

use std::cell::RefCell;
use std::sync::{LazyLock, Mutex};

use crate::scope::Scope;

/// Process-wide base scope. Mutated by `configure_scope` when no `with_scope`
/// block is active; read as the fallback when a thread has no stack frame.
static BASE_SCOPE: LazyLock<Mutex<Scope>> = LazyLock::new(|| Mutex::new(Scope::new()));

thread_local! {
    /// This thread's stack of scope frames pushed by `with_scope`.
    static SCOPE_STACK: RefCell<Vec<Scope>> = const { RefCell::new(Vec::new()) };
}

/// A snapshot clone of the current effective scope (top of stack, else base).
/// Used when building an event so capture reads a consistent view without
/// holding any lock across `before_send`.
pub fn current_scope_snapshot() -> Scope {
    SCOPE_STACK.with(|stack| {
        if let Some(top) = stack.borrow().last() {
            return top.clone();
        }
        // No active frame — fall back to the base scope. If the lock is
        // poisoned (a panic while mutating the base scope), recover the inner
        // value rather than panicking on the capture hot path.
        match BASE_SCOPE.lock() {
            Ok(g) => g.clone(),
            Err(poisoned) => poisoned.into_inner().clone(),
        }
    })
}

/// Run `f` against the current effective scope, mutating in place. If a
/// `with_scope` frame is active it's mutated; otherwise the base scope is.
pub fn configure_scope<F: FnOnce(&mut Scope)>(f: F) {
    // `f` is `FnOnce`, so it must be consumed exactly once. Decide which target
    // to apply it to up front, then call it in a single place.
    SCOPE_STACK.with(|stack| {
        let has_frame = !stack.borrow().is_empty();
        if has_frame {
            let mut frames = stack.borrow_mut();
            f(frames.last_mut().expect("checked non-empty"));
        } else {
            match BASE_SCOPE.lock() {
                Ok(mut g) => f(&mut g),
                Err(poisoned) => f(&mut poisoned.into_inner()),
            }
        }
    });
}

/// RAII guard that pops the scope frame it pushed, even if the body panics.
struct ScopeGuard;

impl Drop for ScopeGuard {
    fn drop(&mut self) {
        SCOPE_STACK.with(|stack| {
            stack.borrow_mut().pop();
        });
    }
}

/// Push a fresh scope frame (forked from the current effective scope), run
/// `body`, then pop — even on panic. Mutations made inside via
/// `configure_scope` apply only to the duration of the block.
pub fn with_scope<R>(body: impl FnOnce() -> R) -> R {
    let forked = current_scope_snapshot();
    SCOPE_STACK.with(|stack| stack.borrow_mut().push(forked));
    let _guard = ScopeGuard;
    body()
}

/// Test-only: drop this thread's stack and reset the base scope so each test
/// starts clean regardless of execution order.
#[cfg(test)]
pub fn reset_for_test() {
    SCOPE_STACK.with(|stack| stack.borrow_mut().clear());
    if let Ok(mut g) = BASE_SCOPE.lock() {
        *g = Scope::new();
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::event::User;

    #[test]
    fn base_scope_is_visible_without_frame() {
        reset_for_test();
        configure_scope(|s| {
            s.set_tag("region", "us");
        });
        let snap = current_scope_snapshot();
        assert_eq!(snap.tags()["region"], "us");
    }

    #[test]
    fn with_scope_isolates_mutations() {
        reset_for_test();
        configure_scope(|s| {
            s.set_tag("base", "1");
        });
        with_scope(|| {
            configure_scope(|s| {
                s.set_tag("inner", "2");
                s.set_user(User::new().id("u"));
            });
            let snap = current_scope_snapshot();
            assert_eq!(snap.tags()["base"], "1", "inherits base");
            assert_eq!(snap.tags()["inner"], "2");
            assert!(snap.user().is_some());
        });
        // Frame popped: inner tag and user gone, base remains.
        let snap = current_scope_snapshot();
        assert_eq!(snap.tags()["base"], "1");
        assert!(snap.tags().get("inner").is_none());
        assert!(snap.user().is_none());
    }

    #[test]
    fn scope_frame_pops_on_panic() {
        reset_for_test();
        let result = std::panic::catch_unwind(|| {
            with_scope(|| {
                configure_scope(|s| {
                    s.set_tag("leak", "x");
                });
                panic!("boom");
            });
        });
        assert!(result.is_err());
        // The frame must have been popped despite the panic.
        let snap = current_scope_snapshot();
        assert!(
            snap.tags().get("leak").is_none(),
            "frame leaked after panic"
        );
    }
}