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
//! The wire model: [`Event`] and the structures it carries.
//!
//! Field names and shapes here are the SDK's half of the ingestion contract
//! documented by the ErrSight backend (`POST /api/v1/events`). The backend
//! truncates and clamps generously, but matching its expectations up front
//! keeps events from being silently trimmed:
//!
//! - `message`        — string (backend truncates to 10,000 chars)
//! - `level`          — one of debug/info/warning/error/fatal
//! - `backtrace`      — newline-joined string (kept alongside structured frames)
//! - `occurred_at`    — ISO-8601; backend clamps to [-7d, +1h]
//! - `environment`    — string
//! - `release`        — string (truncated to 120)
//! - `user`           — {id,email,username,ip_address}
//! - `tags`           — string→string map (max 30)
//! - `breadcrumbs`    — array (max 50)
//! - `metadata`       — free-form object; the backend reads `exception_class`,
//!   `exception_frames`, and `exception_causes` for grouping and stack display
//! - `fingerprint`    — string or array; overrides server-side grouping
//! - `ingestion_id`   — client-generated UUID for idempotent retries

use serde::Serialize;
use serde_json::{Map, Value};

use crate::level::Level;

/// A single error/log event, serialized to the JSON the backend ingests.
///
/// Construct via [`Event::new`] and the builder-style setters, or let the
/// `capture_*` functions assemble one for you.
#[derive(Debug, Clone, Serialize)]
pub struct Event {
    /// Client-generated UUID, set by [`Event::new`]. The backend dedups on it,
    /// so it must be unique per logical event — reusing one id across distinct
    /// events collapses them into one server-side. Leave the generated value
    /// alone unless you have a specific idempotency scheme.
    pub ingestion_id: String,

    pub level: Level,

    pub message: String,

    /// Newline-joined stack string. Optional; structured frames in
    /// `metadata["exception_frames"]` are what drive the rich UI, but the
    /// string is the universal fallback.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub backtrace: Option<String>,

    pub environment: String,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub release: Option<String>,

    /// ISO-8601 / RFC-3339 timestamp with millisecond precision, set to "now"
    /// by [`Event::new`]. If you overwrite it, keep it RFC-3339 — the backend
    /// clamps anything it parses to a sane window and replaces what it can't
    /// parse with the receive time.
    pub occurred_at: String,

    #[serde(skip_serializing_if = "Map::is_empty")]
    pub metadata: Map<String, Value>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub user: Option<User>,

    #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub tags: std::collections::BTreeMap<String, String>,

    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub breadcrumbs: Vec<Breadcrumb>,

    /// Grouping override. `None` lets the backend fingerprint server-side.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub fingerprint: Option<Vec<String>>,
}

impl Event {
    /// A minimal event with a fresh `ingestion_id`, `occurred_at = now`, and
    /// the given level/message. Environment defaults to `"production"` and is
    /// normally overwritten from config when the event is captured.
    pub fn new(level: Level, message: impl Into<String>) -> Self {
        Event {
            ingestion_id: crate::util::new_uuid(),
            level,
            message: message.into(),
            backtrace: None,
            environment: "production".to_string(),
            release: None,
            occurred_at: crate::util::now_iso8601(),
            metadata: Map::new(),
            user: None,
            tags: std::collections::BTreeMap::new(),
            breadcrumbs: Vec::new(),
            fingerprint: None,
        }
    }

    /// Insert a key into `metadata`. Used by capture helpers to attach
    /// `exception_class` / `exception_frames` / `exception_causes`, and
    /// available to callers for arbitrary structured context.
    pub fn set_metadata(&mut self, key: impl Into<String>, value: impl Into<Value>) -> &mut Self {
        self.metadata.insert(key.into(), value.into());
        self
    }

    /// Override grouping with one or more fingerprint parts.
    pub fn set_fingerprint<I, S>(&mut self, parts: I) -> &mut Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        let v: Vec<String> = parts.into_iter().map(Into::into).collect();
        self.fingerprint = if v.is_empty() { None } else { Some(v) };
        self
    }
}

/// The signed-in user an event is attributed to. All fields optional; the
/// backend derives a display identifier from `id` → `email` → `username`.
#[derive(Debug, Clone, Default, Serialize)]
pub struct User {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub email: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub username: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ip_address: Option<String>,
}

impl User {
    pub fn new() -> Self {
        Self::default()
    }
    pub fn id(mut self, v: impl Into<String>) -> Self {
        self.id = Some(v.into());
        self
    }
    pub fn email(mut self, v: impl Into<String>) -> Self {
        self.email = Some(v.into());
        self
    }
    pub fn username(mut self, v: impl Into<String>) -> Self {
        self.username = Some(v.into());
        self
    }
    pub fn ip_address(mut self, v: impl Into<String>) -> Self {
        self.ip_address = Some(v.into());
        self
    }

    /// True when no field is set — used to avoid attaching an empty user block.
    pub fn is_empty(&self) -> bool {
        self.id.is_none()
            && self.email.is_none()
            && self.username.is_none()
            && self.ip_address.is_none()
    }
}

/// A timestamped activity record leading up to an event. The backend keeps the
/// most recent 50.
#[derive(Debug, Clone, Serialize)]
pub struct Breadcrumb {
    pub timestamp: String,
    pub category: String,
    pub level: Level,
    pub message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub data: Option<Map<String, Value>>,
}

impl Breadcrumb {
    /// A breadcrumb stamped with the current time. Category groups crumbs in
    /// the UI (`"http"`, `"db"`, `"navigation"`, …); message is the human line.
    pub fn new(category: impl Into<String>, message: impl Into<String>) -> Self {
        Breadcrumb {
            timestamp: crate::util::now_iso8601(),
            category: category.into(),
            level: Level::Info,
            message: message.into(),
            data: None,
        }
    }

    pub fn level(mut self, level: Level) -> Self {
        self.level = level;
        self
    }

    /// Attach a key of structured data. Backend keeps up to 20 keys per crumb.
    pub fn data(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
        self.data
            .get_or_insert_with(Map::new)
            .insert(key.into(), value.into());
        self
    }
}

/// One resolved stack frame. Shipped inside `metadata["exception_frames"]`.
/// `in_app` frames (your code, not dependencies) are the ones the UI
/// highlights and shows source context for.
#[derive(Debug, Clone, Serialize)]
pub struct Frame {
    /// Path as it should display — relative to the project root when known,
    /// else the absolute path.
    pub filename: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub abs_path: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub lineno: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub colno: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub function: Option<String>,
    pub in_app: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pre_context: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub context_line: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub post_context: Option<Vec<String>>,
}

/// An entry in an error's `source()` / cause chain. The backend surfaces these
/// on the issue detail page as "caused by".
#[derive(Debug, Clone, Serialize)]
pub struct Cause {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub class: Option<String>,
    pub message: String,
}

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

    #[test]
    fn event_serializes_expected_fields() {
        let mut e = Event::new(Level::Error, "boom");
        e.environment = "test".into();
        e.set_metadata("exception_class", "std::io::Error");
        let v: Value = serde_json::to_value(&e).unwrap();
        assert_eq!(v["level"], "error");
        assert_eq!(v["message"], "boom");
        assert_eq!(v["environment"], "test");
        assert_eq!(v["metadata"]["exception_class"], "std::io::Error");
        // Empty collections are omitted, not sent as [] / {}.
        assert!(v.get("tags").is_none());
        assert!(v.get("breadcrumbs").is_none());
        assert!(v.get("backtrace").is_none());
        assert!(v.get("fingerprint").is_none());
        // ingestion_id is always present.
        assert!(v["ingestion_id"].as_str().unwrap().len() >= 32);
    }

    #[test]
    fn user_is_empty_detection() {
        assert!(User::new().is_empty());
        assert!(!User::new().email("a@b.c").is_empty());
    }

    #[test]
    fn fingerprint_empty_is_none() {
        let mut e = Event::new(Level::Info, "x");
        e.set_fingerprint(Vec::<String>::new());
        assert!(e.fingerprint.is_none());
        e.set_fingerprint(["a", "b"]);
        assert_eq!(e.fingerprint.as_ref().unwrap().len(), 2);
    }
}