Skip to main content

forge_ops_tracker/
breadcrumb_buffer.rs

1// A bounded, in-order trail of what happened right before an error: SQL queries, outbound calls,
2// anything added by hand via `add_breadcrumb`. This crate has no web framework integration at all
3// (unlike sdks/go's net/http/Gin middleware, or gems/forge_ops_tracker's Rack middleware), so
4// there's no automatic source and no request boundary this module could hook a "start a fresh
5// trail" step into on its own; a host app using this crate inside its own request handler is
6// expected to call `clear_breadcrumbs()` itself at the start of each one, the same honest
7// division of responsibility `set_user` in lib.rs already documents for the identical reason.
8//
9// A plain `thread_local!`, the same choice CURRENT_USER in lib.rs already made and for the
10// identical reason (see that thread-local's own doc comment): the right fit for this crate's
11// synchronous, thread-per-request-shaped design, not for an async runtime, where a single OS
12// thread can interleave multiple unrelated tasks. Unlike CURRENT_USER, there's no `Option` wrapper
13// here: a trail is always "on," just possibly empty, so `add_breadcrumb`/`current_breadcrumbs` can
14// stay simple rather than needing to lazily create one first.
15
16use std::cell::RefCell;
17use std::collections::HashMap;
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use crate::pii_scrubber::Value;
21
22/// One entry in a trail. Matches the wire shape gems/forge_ops_tracker's own BreadcrumbBuffer#add
23/// already builds, and the same four keys Api::V1::EventsController permits.
24#[derive(Clone, Debug, PartialEq)]
25pub struct Breadcrumb {
26    pub category: String,
27    pub message: String,
28    pub level: String,
29    pub timestamp: String,
30    pub data: HashMap<String, Value>,
31}
32
33thread_local! {
34    static BREADCRUMBS: RefCell<Vec<Breadcrumb>> = const { RefCell::new(Vec::new()) };
35}
36
37/// Records one entry into the current thread's trail: a query, an outbound call, or anything
38/// worth remembering right up to the moment something actually goes wrong. `category` defaults to
39/// `"custom"` and `level` to `"info"` when passed an empty string, the same zero-value-means-
40/// default convention this crate's own `Configuration` uses for its env-seeded fields.
41///
42/// A no-op, not an error, when `track_breadcrumbs` is `false`: the same "the call site never has
43/// to check first" posture every other independently-gated mechanism in this crate already has.
44pub fn add_breadcrumb(
45    track_breadcrumbs: bool,
46    max_breadcrumbs: usize,
47    message: &str,
48    category: &str,
49    level: &str,
50    data: HashMap<String, Value>,
51) {
52    if !track_breadcrumbs || max_breadcrumbs == 0 {
53        return;
54    }
55
56    let category = if category.is_empty() {
57        "custom"
58    } else {
59        category
60    };
61    let level = if level.is_empty() { "info" } else { level };
62
63    BREADCRUMBS.with(|trail| {
64        let mut trail = trail.borrow_mut();
65        trail.push(Breadcrumb {
66            category: category.to_string(),
67            message: message.to_string(),
68            level: level.to_string(),
69            timestamp: format_now(),
70            data,
71        });
72        if trail.len() > max_breadcrumbs {
73            let overflow = trail.len() - max_breadcrumbs;
74            trail.drain(0..overflow);
75        }
76    });
77}
78
79/// Clears the current thread's trail: call this yourself at the start of each request, the same
80/// place a host app would already be calling `set_user` (or clearing it) from, since this crate
81/// has no middleware of its own to do it automatically. Without this, a synchronous, thread-
82/// pool-based server (actix-web's own worker threads, for instance) would otherwise let one
83/// request's trail bleed into the next one handled on the same reused thread.
84pub fn clear_breadcrumbs() {
85    BREADCRUMBS.with(|trail| trail.borrow_mut().clear());
86}
87
88/// Reads back a copy of the current thread's trail: `Reporter`'s own counterpart to
89/// `add_breadcrumb` above, called automatically by `capture_error`/`capture_error_with_class`/the
90/// panic hook, the same way `current_user()` already is.
91pub fn current_breadcrumbs() -> Vec<Breadcrumb> {
92    BREADCRUMBS.with(|trail| trail.borrow().clone())
93}
94
95fn format_now() -> String {
96    let now = SystemTime::now()
97        .duration_since(UNIX_EPOCH)
98        .unwrap_or_default();
99    crate::event_builder::format_unix_timestamp(now.as_secs())
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    // Each test below clears the thread-local trail first: cargo test runs tests in a thread
107    // pool by default, and a pool thread genuinely can be reused across separate #[test]
108    // functions, the same "the same OS thread can end up serving totally unrelated work" reason
109    // clear_breadcrumbs() itself exists in the first place; without this, some other test's
110    // leftover entries could leak into this one's own assertions depending purely on scheduling.
111    fn reset() {
112        clear_breadcrumbs();
113    }
114
115    #[test]
116    fn add_breadcrumb_preserves_order_and_defaults() {
117        reset();
118        add_breadcrumb(true, 30, "first", "", "", HashMap::new());
119        add_breadcrumb(
120            true,
121            30,
122            "second",
123            "custom",
124            "warning",
125            HashMap::from([("n".to_string(), Value::Number(1.0))]),
126        );
127
128        let trail = current_breadcrumbs();
129        assert_eq!(trail.len(), 2);
130        assert_eq!(trail[0].message, "first");
131        assert_eq!(trail[0].category, "custom");
132        assert_eq!(trail[0].level, "info");
133        assert!(trail[0].data.is_empty());
134        assert_eq!(trail[1].message, "second");
135        assert_eq!(trail[1].level, "warning");
136        assert_eq!(trail[1].data.get("n"), Some(&Value::Number(1.0)));
137        assert!(!trail[0].timestamp.is_empty());
138        reset();
139    }
140
141    #[test]
142    fn add_breadcrumb_drops_oldest_once_over_max_size() {
143        reset();
144        add_breadcrumb(true, 2, "one", "", "", HashMap::new());
145        add_breadcrumb(true, 2, "two", "", "", HashMap::new());
146        add_breadcrumb(true, 2, "three", "", "", HashMap::new());
147
148        let trail = current_breadcrumbs();
149        assert_eq!(trail.len(), 2);
150        assert_eq!(trail[0].message, "two");
151        assert_eq!(trail[1].message, "three");
152        reset();
153    }
154
155    #[test]
156    fn add_breadcrumb_is_a_no_op_when_tracking_is_off() {
157        reset();
158        add_breadcrumb(false, 30, "hello", "", "", HashMap::new());
159
160        assert!(current_breadcrumbs().is_empty());
161        reset();
162    }
163
164    #[test]
165    fn add_breadcrumb_is_a_no_op_when_max_size_is_zero() {
166        reset();
167        add_breadcrumb(true, 0, "hello", "", "", HashMap::new());
168
169        assert!(current_breadcrumbs().is_empty());
170        reset();
171    }
172
173    #[test]
174    fn clear_breadcrumbs_empties_the_current_thread_trail() {
175        reset();
176        add_breadcrumb(true, 30, "hello", "", "", HashMap::new());
177        assert_eq!(current_breadcrumbs().len(), 1);
178
179        clear_breadcrumbs();
180
181        assert!(current_breadcrumbs().is_empty());
182    }
183}