Skip to main content

foundations_sentry/
hook.rs

1//! Sentry hook implementation for tracking sentry events and rate-limiting them.
2
3use crate::SentrySettings;
4use foundations::ratelimit::StaticQuantaClock;
5use governor::{Quota, RateLimiter};
6use std::borrow::Cow;
7use std::num::NonZeroU32;
8use std::sync::Arc;
9
10type Fingerprint = Cow<'static, str>;
11
12/// Clean up keys in the sentry rate limiter once every 20 minutes
13/// (3 times per hour), with no burst allowed.
14const SENTRY_LIMITER_CLEANUP_QUOTA: Quota =
15    Quota::per_hour(NonZeroU32::new(3).unwrap()).allow_burst(NonZeroU32::new(1).unwrap());
16
17/// Install the sentry hook on the provided client options.
18///
19/// This installs a `before_send` hook that increments `sentry_events_total`
20/// and performs rate limiting, if configured. If a previous `before_send`
21/// hook exists, it will be called after rate limiting has been applied.
22/// Only unfiltered events are counted.
23///
24/// See the [`foundations-sentry`](crate) crate-level docs for more information.
25pub fn install_hook_with_settings(
26    options: &mut sentry_core::ClientOptions,
27    settings: &SentrySettings,
28) {
29    let rate_limiter = settings.max_events_per_second.map(|rl| {
30        RateLimiter::dashmap_with_clock(Quota::per_second(rl), StaticQuantaClock::default())
31    });
32
33    let previous = options.before_send.take();
34
35    options.before_send = Some(Arc::new(move |mut event| {
36        if let Some(limiter) = &rate_limiter {
37            foundations::ratelimit!(SENTRY_LIMITER_CLEANUP_QUOTA; limiter.retain_recent());
38
39            let fp = extract_fingerprint(&event);
40            if limiter.check_key(&fp).is_err() {
41                return None;
42            }
43        }
44
45        if let Some(prev) = &previous {
46            event = prev(event)?;
47        }
48
49        super::metrics::sentry::events_total(event.level).inc();
50
51        Some(event)
52    }));
53}
54
55/// Derive a fingerprint for a sentry event to perform rate limiting.
56///
57/// We check for the following event attributes, in order:
58/// 1. Explicit fingerprint (if set and not defaulted)
59/// 2. Event message
60/// 3. First exception value/type
61/// 4. Fallback: event level name
62fn extract_fingerprint(event: &sentry_core::protocol::Event<'static>) -> Fingerprint {
63    use sentry_core::Level;
64
65    // Try the explicitly-specified fingerprint first, but only if its not defaulted
66    let explicit_fp = &event.fingerprint;
67    if !explicit_fp.is_empty() && !is_sentry_default_fingerprint(explicit_fp) {
68        if let [fp] = explicit_fp.as_ref() {
69            // Just clone if the explicit fingerprint is a single element
70            return fp.clone();
71        }
72        return explicit_fp.join("::").into();
73    }
74
75    // Try the event message, if there is one
76    if let Some(msg) = &event.message {
77        return msg.clone().into();
78    }
79
80    // Try the first attached exception, if there is one
81    if let Some(exc) = event.exception.first() {
82        if let Some(val) = &exc.value {
83            return val.clone().into();
84        }
85        if !exc.ty.is_empty() {
86            return exc.ty.clone().into();
87        }
88    }
89
90    // Finally, fall back to the event level
91    Cow::Borrowed(match event.level {
92        Level::Debug => "level::debug",
93        Level::Info => "level::info",
94        Level::Warning => "level::warning",
95        Level::Error => "level::error",
96        Level::Fatal => "level::fatal",
97    })
98}
99
100// Adapted from https://github.com/getsentry/sentry-rust/blob/0.47.0/sentry-types/src/protocol/v7.rs#L1619
101fn is_sentry_default_fingerprint(fp: &[Cow<'_, str>]) -> bool {
102    if let [fp] = fp {
103        return matches!(fp.as_ref(), "{{ default }}" | "{{default}}");
104    }
105    false
106}