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`]: the user, tags, and breadcrumbs attached to events.
//!
//! A scope is a snapshot of ambient context. The [`crate::hub`] keeps a
//! per-thread stack of them plus one process-wide base scope, so
//! `set_user`/`set_tag`/`add_breadcrumb` made during one unit of work
//! (a request, a job, a `with_scope` block) don't bleed into the next.

use std::collections::{BTreeMap, VecDeque};

use crate::event::{Breadcrumb, User};

/// Default breadcrumb ring-buffer cap. The backend keeps 50; we keep a little
/// more locally so a `before_send` filter has room to work, and trim to the
/// configured `max_breadcrumbs` when an event is built.
pub const DEFAULT_MAX_BREADCRUMBS: usize = 100;

/// Ambient context layered onto events: the current user, string tags, and a
/// bounded breadcrumb trail. Cheap to clone (that's how the hub forks scopes).
#[derive(Debug, Clone)]
pub struct Scope {
    user: Option<User>,
    tags: BTreeMap<String, String>,
    breadcrumbs: VecDeque<Breadcrumb>,
    max_breadcrumbs: usize,
}

impl Default for Scope {
    fn default() -> Self {
        Scope {
            user: None,
            tags: BTreeMap::new(),
            breadcrumbs: VecDeque::new(),
            max_breadcrumbs: DEFAULT_MAX_BREADCRUMBS,
        }
    }
}

impl Scope {
    pub fn new() -> Self {
        Self::default()
    }

    /// Set the user this scope's events are attributed to. Passing an empty
    /// user clears it, so `configure_scope(|s| s.set_user(User::new()))` is a
    /// no-op rather than attaching a blank block.
    pub fn set_user(&mut self, user: User) -> &mut Self {
        self.user = if user.is_empty() { None } else { Some(user) };
        self
    }

    pub fn clear_user(&mut self) -> &mut Self {
        self.user = None;
        self
    }

    pub fn user(&self) -> Option<&User> {
        self.user.as_ref()
    }

    /// Set a single tag. Keys and values are strings; the backend filters and
    /// groups on these.
    pub fn set_tag(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
        self.tags.insert(key.into(), value.into());
        self
    }

    pub fn remove_tag(&mut self, key: &str) -> &mut Self {
        self.tags.remove(key);
        self
    }

    pub fn clear_tags(&mut self) -> &mut Self {
        self.tags.clear();
        self
    }

    pub fn tags(&self) -> &BTreeMap<String, String> {
        &self.tags
    }

    /// Append a breadcrumb, evicting the oldest if the ring is full.
    pub fn add_breadcrumb(&mut self, crumb: Breadcrumb) -> &mut Self {
        self.breadcrumbs.push_back(crumb);
        while self.breadcrumbs.len() > self.max_breadcrumbs {
            self.breadcrumbs.pop_front();
        }
        self
    }

    pub fn clear_breadcrumbs(&mut self) -> &mut Self {
        self.breadcrumbs.clear();
        self
    }

    /// Set the local ring-buffer cap, trimming immediately if it shrank.
    pub fn set_max_breadcrumbs(&mut self, max: usize) -> &mut Self {
        self.max_breadcrumbs = max;
        while self.breadcrumbs.len() > self.max_breadcrumbs {
            self.breadcrumbs.pop_front();
        }
        self
    }

    /// The most recent `limit` breadcrumbs, oldest-first. Used when building an
    /// event to honour the configured cap regardless of the local ring size.
    pub fn breadcrumbs(&self, limit: usize) -> Vec<Breadcrumb> {
        let n = self.breadcrumbs.len();
        let start = n.saturating_sub(limit);
        self.breadcrumbs.iter().skip(start).cloned().collect()
    }

    /// Apply this scope's user/tags/breadcrumbs onto an event being built. The
    /// event's own already-set user/tags win over the scope's, so per-call
    /// overrides take precedence.
    pub fn apply_to(&self, event: &mut crate::event::Event, breadcrumb_limit: usize) {
        if event.user.is_none() {
            if let Some(u) = &self.user {
                event.user = Some(u.clone());
            }
        }
        // Scope tags fill in; per-call tags already on the event are kept.
        for (k, v) in &self.tags {
            event.tags.entry(k.clone()).or_insert_with(|| v.clone());
        }
        if event.breadcrumbs.is_empty() && !self.breadcrumbs.is_empty() {
            event.breadcrumbs = self.breadcrumbs(breadcrumb_limit);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::event::{Breadcrumb, Event, User};
    use crate::level::Level;

    #[test]
    fn breadcrumbs_are_bounded() {
        let mut s = Scope::new();
        s.set_max_breadcrumbs(3);
        for i in 0..10 {
            s.add_breadcrumb(Breadcrumb::new("test", format!("crumb {i}")));
        }
        let crumbs = s.breadcrumbs(100);
        assert_eq!(crumbs.len(), 3);
        assert_eq!(crumbs[0].message, "crumb 7");
        assert_eq!(crumbs[2].message, "crumb 9");
    }

    #[test]
    fn empty_user_clears() {
        let mut s = Scope::new();
        s.set_user(User::new().email("a@b.c"));
        assert!(s.user().is_some());
        s.set_user(User::new());
        assert!(s.user().is_none());
    }

    #[test]
    fn apply_does_not_override_per_call_values() {
        let mut s = Scope::new();
        s.set_user(User::new().id("scope-user"));
        s.set_tag("region", "us");
        let mut e = Event::new(Level::Error, "x");
        e.user = Some(User::new().id("call-user"));
        e.tags.insert("region".into(), "eu".into());
        s.apply_to(&mut e, 100);
        assert_eq!(e.user.unwrap().id.unwrap(), "call-user");
        assert_eq!(e.tags["region"], "eu");
    }

    #[test]
    fn apply_fills_missing_values() {
        let mut s = Scope::new();
        s.set_user(User::new().id("scope-user"));
        s.set_tag("region", "us");
        s.add_breadcrumb(Breadcrumb::new("nav", "home"));
        let mut e = Event::new(Level::Error, "x");
        s.apply_to(&mut e, 100);
        assert_eq!(e.user.unwrap().id.unwrap(), "scope-user");
        assert_eq!(e.tags["region"], "us");
        assert_eq!(e.breadcrumbs.len(), 1);
    }
}