Skip to main content

devboy_core/
sentry_integration.rs

1//! Sentry error reporting integration.
2//!
3//! Provides optional Sentry integration for devboy-tools.
4//! Enabled via `DEVBOY_SENTRY_DSN` environment variable or `[sentry]` config section.
5//!
6//! # Priority
7//!
8//! Environment variables take precedence over config file values:
9//! - `DEVBOY_SENTRY_DSN` → `sentry.dsn`
10//! - `DEVBOY_SENTRY_ENVIRONMENT` → `sentry.environment`
11//! - `DEVBOY_SENTRY_SAMPLE_RATE` → `sentry.sample_rate`
12//! - `DEVBOY_SENTRY_TRACES_SAMPLE_RATE` → `sentry.traces_sample_rate`
13
14use crate::config::SentryConfig;
15
16/// Sensitive header/field names to scrub from Sentry events.
17const SENSITIVE_KEYS: &[&str] = &[
18    "authorization",
19    "x-gitlab-token",
20    "x-api-key",
21    "cookie",
22    "private-token",
23    "token",
24    "api_key",
25    "apikey",
26    "secret",
27    "password",
28    "private_key",
29];
30
31/// Initialize Sentry error reporting.
32///
33/// Returns `Some(guard)` if Sentry was initialized and enabled, `None` otherwise.
34/// The guard **must** be kept alive for the entire process lifetime —
35/// dropping it flushes pending events and shuts down the Sentry client.
36///
37/// # Arguments
38///
39/// * `config` - Optional Sentry config from config.toml
40/// * `release` - Full release string (e.g., "devboy-tools@0.16.0+abc1234")
41pub fn init_sentry(
42    config: Option<&SentryConfig>,
43    release: &str,
44) -> Option<sentry::ClientInitGuard> {
45    let default_config = SentryConfig::default();
46    let config = config.unwrap_or(&default_config);
47
48    // DSN: env var overrides config, trim whitespace
49    let dsn = std::env::var("DEVBOY_SENTRY_DSN")
50        .ok()
51        .map(|s| s.trim().to_string())
52        .filter(|s| !s.is_empty())
53        .or_else(|| config.dsn.as_ref().map(|s| s.trim().to_string()))
54        .filter(|s| !s.is_empty())?;
55
56    // Validate DSN is parseable (don't log the full DSN — it may contain credentials).
57    // Use eprintln! instead of tracing::warn! because this runs before the tracing
58    // subscriber is initialized, so tracing events would be silently dropped.
59    let parsed_dsn = match dsn.parse::<sentry::types::Dsn>() {
60        Ok(d) => Some(d),
61        Err(e) => {
62            eprintln!("[devboy] Invalid Sentry DSN: {e}. Sentry will be disabled.");
63            return None;
64        }
65    };
66
67    // Environment: env var overrides config
68    let environment = std::env::var("DEVBOY_SENTRY_ENVIRONMENT")
69        .ok()
70        .map(|s| s.trim().to_string())
71        .filter(|s| !s.is_empty())
72        .or_else(|| config.environment.clone());
73
74    // Sample rate: env var overrides config, default 1.0, clamped to 0.0–1.0
75    let sample_rate = std::env::var("DEVBOY_SENTRY_SAMPLE_RATE")
76        .ok()
77        .and_then(|s| s.trim().parse::<f32>().ok())
78        .or(config.sample_rate)
79        .unwrap_or(1.0)
80        .clamp(0.0, 1.0);
81
82    // Traces sample rate: env var overrides config, default 0.0, clamped to 0.0–1.0
83    let traces_sample_rate = std::env::var("DEVBOY_SENTRY_TRACES_SAMPLE_RATE")
84        .ok()
85        .and_then(|s| s.trim().parse::<f32>().ok())
86        .or(config.traces_sample_rate)
87        .unwrap_or(0.0)
88        .clamp(0.0, 1.0);
89
90    let guard = sentry::init(sentry::ClientOptions {
91        dsn: parsed_dsn,
92        release: Some(release.to_string().into()),
93        environment: environment.map(Into::into),
94        sample_rate,
95        traces_sample_rate,
96        before_send: Some(std::sync::Arc::new(scrub_sensitive_data)),
97        before_breadcrumb: Some(std::sync::Arc::new(scrub_breadcrumb)),
98        ..Default::default()
99    });
100
101    if guard.is_enabled() {
102        // Use eprintln! because tracing subscriber is not yet initialized at this point.
103        eprintln!("[devboy] Sentry error reporting enabled");
104    }
105
106    Some(guard)
107}
108
109/// Scrub sensitive data from Sentry events before sending.
110fn scrub_sensitive_data(
111    mut event: sentry::protocol::Event<'static>,
112) -> Option<sentry::protocol::Event<'static>> {
113    // Scrub event message (e.g., from capture_message with error strings)
114    if let Some(ref mut message) = event.message {
115        *message = scrub_url_credentials(message);
116    }
117
118    // Scrub exception values (error messages may contain URLs with tokens)
119    for exception in &mut event.exception.values {
120        if let Some(ref mut value) = exception.value {
121            *value = scrub_url_credentials(value);
122        }
123    }
124
125    // Scrub request headers, URL and query string
126    if let Some(ref mut request) = event.request {
127        scrub_map(&mut request.headers);
128        if let Some(ref url) = request.url {
129            let scrubbed = scrub_url_credentials(url.as_str());
130            if let Ok(new_url) = scrubbed.parse() {
131                request.url = Some(new_url);
132            }
133        }
134        if let Some(ref mut query) = request.query_string {
135            *query = scrub_url_credentials(query);
136        }
137    }
138
139    // Scrub extra context for sensitive keys
140    let keys_to_scrub: Vec<String> = event
141        .extra
142        .keys()
143        .filter(|k| is_sensitive_key(k))
144        .cloned()
145        .collect();
146    for key in keys_to_scrub {
147        event.extra.insert(
148            key,
149            sentry::protocol::Value::String("[Filtered]".to_string()),
150        );
151    }
152
153    Some(event)
154}
155
156/// Scrub sensitive data from breadcrumbs before attaching to events.
157///
158/// Breadcrumbs are created from `tracing` info/warn logs and may contain
159/// URLs with embedded credentials or query tokens (e.g., proxy URLs).
160fn scrub_breadcrumb(
161    mut breadcrumb: sentry::protocol::Breadcrumb,
162) -> Option<sentry::protocol::Breadcrumb> {
163    if let Some(ref mut message) = breadcrumb.message {
164        *message = scrub_url_credentials(message);
165    }
166    // Scrub breadcrumb data values
167    for value in breadcrumb.data.values_mut() {
168        if let sentry::protocol::Value::String(s) = value {
169            *s = scrub_url_credentials(s);
170        }
171    }
172    Some(breadcrumb)
173}
174
175/// Scrub credentials from URLs in a string.
176///
177/// Replaces `user:password@host` patterns and sensitive query parameters
178/// (token, key, secret, password) with `[Filtered]`.
179///
180/// Uses ASCII-only case-insensitive matching to avoid index misalignment
181/// issues with `to_lowercase()` on non-ASCII input.
182fn scrub_url_credentials(input: &str) -> String {
183    let mut result = input.to_string();
184    // Scrub userinfo in URLs: https://user:pass@host → https://[Filtered]@host
185    if let Some(start) = result.find("://") {
186        let after_scheme = start + 3;
187        if let Some(at_pos) = result[after_scheme..].find('@') {
188            let abs_at = after_scheme + at_pos;
189            // Only scrub if there's a colon before @ (looks like user:pass)
190            if result[after_scheme..abs_at].contains(':') {
191                result = format!("{}[Filtered]{}", &result[..after_scheme], &result[abs_at..]);
192            }
193        }
194    }
195    // Scrub sensitive query params: ?token=xxx&key=yyy
196    // Uses ASCII-only case-insensitive byte search (safe for non-ASCII strings).
197    for param in &["token", "key", "secret", "password", "api_key", "apikey"] {
198        let pat = format!("{param}=");
199        let pat_bytes = pat.as_bytes();
200        let mut search_from = 0;
201        while search_from + pat_bytes.len() <= result.len() {
202            let haystack = &result.as_bytes()[search_from..];
203            let found = haystack
204                .windows(pat_bytes.len())
205                .position(|w| w.eq_ignore_ascii_case(pat_bytes));
206            let Some(rel_pos) = found else { break };
207            let pos = search_from + rel_pos;
208            let value_start = pos + pat.len();
209            let value_end = result[value_start..]
210                .find(['&', '#', ' '])
211                .map(|i| value_start + i)
212                .unwrap_or(result.len());
213            // Preserve original case of the param name from the input
214            let original_param = &result[pos..value_start];
215            let replacement = format!(
216                "{}{}[Filtered]{}",
217                &result[..pos],
218                original_param,
219                &result[value_end..]
220            );
221            search_from = pos + original_param.len() + "[Filtered]".len();
222            result = replacement;
223        }
224    }
225    result
226}
227
228/// Check if a key name looks like it contains sensitive data.
229fn is_sensitive_key(key: &str) -> bool {
230    let lower = key.to_lowercase();
231    SENSITIVE_KEYS.iter().any(|&k| lower.contains(k))
232}
233
234/// Scrub sensitive values from a header map.
235fn scrub_map(map: &mut std::collections::BTreeMap<String, String>) {
236    let keys_to_scrub: Vec<String> = map
237        .keys()
238        .filter(|k| is_sensitive_key(k))
239        .cloned()
240        .collect();
241    for key in keys_to_scrub {
242        map.insert(key, "[Filtered]".to_string());
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn test_is_sensitive_key() {
252        assert!(is_sensitive_key("Authorization"));
253        assert!(is_sensitive_key("x-gitlab-token"));
254        assert!(is_sensitive_key("X-API-KEY"));
255        assert!(is_sensitive_key("my_secret_field"));
256        assert!(is_sensitive_key("PRIVATE-TOKEN"));
257        assert!(!is_sensitive_key("content-type"));
258        assert!(!is_sensitive_key("user-agent"));
259        assert!(!is_sensitive_key("tool_name"));
260    }
261
262    #[test]
263    fn test_scrub_url_credentials() {
264        // Userinfo in URL
265        assert_eq!(
266            scrub_url_credentials("https://user:pass@sentry.io/123"),
267            "https://[Filtered]@sentry.io/123"
268        );
269        // Query param token
270        assert_eq!(
271            scrub_url_credentials("https://host.com/api?token=secret123&foo=bar"),
272            "https://host.com/api?token=[Filtered]&foo=bar"
273        );
274        // Multiple sensitive params
275        assert_eq!(
276            scrub_url_credentials("https://host.com?key=abc&password=xyz"),
277            "https://host.com?key=[Filtered]&password=[Filtered]"
278        );
279        // No credentials — unchanged
280        assert_eq!(
281            scrub_url_credentials("https://host.com/path?page=1"),
282            "https://host.com/path?page=1"
283        );
284        // Plain text without URL — unchanged
285        assert_eq!(
286            scrub_url_credentials("Connected to proxy at host:8080"),
287            "Connected to proxy at host:8080"
288        );
289        // Case-insensitive param matching (TOKEN=, Token=)
290        assert_eq!(
291            scrub_url_credentials("https://host.com?TOKEN=secret"),
292            "https://host.com?TOKEN=[Filtered]"
293        );
294        assert_eq!(
295            scrub_url_credentials("https://host.com?Token=abc123"),
296            "https://host.com?Token=[Filtered]"
297        );
298        // api_key param
299        assert_eq!(
300            scrub_url_credentials("https://host.com?api_key=xyz&page=1"),
301            "https://host.com?api_key=[Filtered]&page=1"
302        );
303    }
304
305    #[test]
306    fn test_scrub_url_credentials_non_ascii() {
307        // Non-ASCII in URL path — should not panic or corrupt
308        assert_eq!(
309            scrub_url_credentials("https://host.com/путь?token=secret"),
310            "https://host.com/путь?token=[Filtered]"
311        );
312        // Non-ASCII in value — scrubbed correctly
313        assert_eq!(
314            scrub_url_credentials("https://host.com?key=ключ&page=1"),
315            "https://host.com?key=[Filtered]&page=1"
316        );
317        // Pure non-ASCII string — unchanged
318        assert_eq!(scrub_url_credentials("Привет мир"), "Привет мир");
319    }
320
321    #[test]
322    fn test_scrub_url_credentials_multiple_same_param() {
323        // Multiple token params
324        assert_eq!(
325            scrub_url_credentials("https://h.com?token=a&other=b&token=c"),
326            "https://h.com?token=[Filtered]&other=b&token=[Filtered]"
327        );
328    }
329
330    #[test]
331    fn test_scrub_url_credentials_fragment() {
332        // Fragment delimiter stops value
333        assert_eq!(
334            scrub_url_credentials("https://host.com?token=secret#section"),
335            "https://host.com?token=[Filtered]#section"
336        );
337    }
338
339    #[test]
340    fn test_scrub_map() {
341        let mut map = std::collections::BTreeMap::new();
342        map.insert("Authorization".to_string(), "Bearer xyz".to_string());
343        map.insert("Content-Type".to_string(), "application/json".to_string());
344        map.insert("x-api-key".to_string(), "secret123".to_string());
345
346        scrub_map(&mut map);
347
348        assert_eq!(map["Authorization"], "[Filtered]");
349        assert_eq!(map["Content-Type"], "application/json");
350        assert_eq!(map["x-api-key"], "[Filtered]");
351    }
352
353    #[test]
354    fn test_sentry_config_default() {
355        let config = SentryConfig::default();
356        assert!(config.dsn.is_none());
357        assert!(config.environment.is_none());
358        assert!(config.sample_rate.is_none());
359        assert!(config.traces_sample_rate.is_none());
360    }
361}