Skip to main content

studio_worker/
telemetry.rs

1//! Sentry telemetry — opt-in error/panic reporting.
2//!
3//! Disabled by default.  Operators enable it by setting `SENTRY_DSN`
4//! (and optionally `SENTRY_ENVIRONMENT`) before launching the worker.
5//! Nothing is hard-coded so the public repo never carries a DSN.
6//!
7//! Wiring:
8//!
9//! * `init()` reads env vars, constructs `SentryConfig`, and calls
10//!   `sentry::init`.  The returned `ClientInitGuard` must live for the
11//!   entire program — `main.rs` keeps it in a binding that drops on
12//!   shutdown, flushing any in-flight events.
13//! * `tracing_layer()` returns a `sentry-tracing` layer that maps
14//!   `error` -> Sentry event, `warn` -> breadcrumb, lower -> ignored.
15//!   Layered into the global `tracing-subscriber` registry in `main.rs`.
16//!
17//! Panic capture is on by default (the `panic` feature is part of
18//! `sentry`'s default feature set).
19use crate::{sys, RELEASE_NAME};
20use sentry_tracing::EventFilter;
21use std::borrow::Cow;
22
23/// Tracing target for telemetry events.  Stable so operators can
24/// filter with `RUST_LOG=studio_worker::telemetry=debug`.
25const TRACE_TARGET: &str = "studio_worker::telemetry";
26
27/// Fully-resolved Sentry client configuration.  Built either from the
28/// host environment (`from_env`) or — in tests — by passing inputs
29/// directly to `from_env_inner`.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct SentryConfig {
32    pub dsn: String,
33    pub environment: String,
34    pub release: String,
35    pub server_name: String,
36}
37
38impl SentryConfig {
39    /// Read `SENTRY_DSN` + `SENTRY_ENVIRONMENT` from the process env.
40    /// Returns `None` when no DSN is set (or it's whitespace only).
41    pub fn from_env() -> Option<Self> {
42        Self::from_env_inner(
43            std::env::var("SENTRY_DSN").ok(),
44            std::env::var("SENTRY_ENVIRONMENT").ok(),
45            RELEASE_NAME.to_string(),
46            sys::machine_name(),
47        )
48    }
49
50    /// Pure resolver used by `from_env` and the unit tests.  Keeps the
51    /// env-var plumbing isolated from the decision logic so tests don't
52    /// need to mutate process-global state.
53    pub fn from_env_inner(
54        dsn: Option<String>,
55        environment: Option<String>,
56        release: String,
57        server_name: String,
58    ) -> Option<Self> {
59        let dsn = dsn?.trim().to_string();
60        if dsn.is_empty() {
61            return None;
62        }
63        let environment = environment
64            .map(|s| s.trim().to_string())
65            .filter(|s| !s.is_empty())
66            .unwrap_or_else(|| "production".to_string());
67        Some(Self {
68            dsn,
69            environment,
70            release,
71            server_name,
72        })
73    }
74}
75
76/// Build the `sentry::ClientOptions` for a resolved config.  Returns
77/// `None` (and leaves a tracing warning) when the DSN string can't be
78/// parsed — split out from `init` so we can exercise both branches
79/// without mutating global Sentry state.
80pub fn build_client_options(cfg: &SentryConfig) -> Option<sentry::ClientOptions> {
81    let dsn = match cfg.dsn.parse() {
82        Ok(parsed) => parsed,
83        Err(e) => {
84            tracing::warn!(
85                target: TRACE_TARGET,
86                error = %e,
87                "ignoring SENTRY_DSN: not a valid sentry DSN"
88            );
89            return None;
90        }
91    };
92    Some(sentry::ClientOptions {
93        dsn: Some(dsn),
94        release: Some(Cow::Owned(cfg.release.clone())),
95        environment: Some(Cow::Owned(cfg.environment.clone())),
96        server_name: Some(Cow::Owned(cfg.server_name.clone())),
97        // We use Sentry purely for error/panic reporting; performance
98        // tracing would add network traffic for very little value on a
99        // worker that already ships structured logs.
100        traces_sample_rate: 0.0,
101        ..Default::default()
102    })
103}
104
105/// Initialise Sentry from the process environment.
106///
107/// Returns `None` when no DSN is configured, or when Sentry rejected
108/// the supplied DSN (invalid URL / unsupported scheme).  When a guard
109/// is returned, callers MUST keep it alive for the lifetime of the
110/// program — dropping it triggers a flush of pending events.
111pub fn init() -> Option<sentry::ClientInitGuard> {
112    let cfg = SentryConfig::from_env()?;
113    let options = build_client_options(&cfg)?;
114    let guard = sentry::init(options);
115    if !guard.is_enabled() {
116        tracing::warn!(
117            target: TRACE_TARGET,
118            "sentry::init returned a disabled client (likely invalid DSN); telemetry off"
119        );
120        return None;
121    }
122    tracing::info!(
123        target: TRACE_TARGET,
124        environment = %cfg.environment,
125        release = %cfg.release,
126        server_name = %cfg.server_name,
127        "sentry telemetry enabled"
128    );
129    Some(guard)
130}
131
132/// Build the `sentry-tracing` layer with our chosen severity mapping.
133///
134/// * `ERROR` -> Sentry event (operator-visible alert)
135/// * `WARN`  -> breadcrumb attached to the next event
136/// * `INFO`/`DEBUG`/`TRACE` -> ignored (too noisy for breadcrumbs;
137///   already surfaced via the structured log shipper)
138pub fn tracing_layer<S>() -> sentry_tracing::SentryLayer<S>
139where
140    S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
141{
142    sentry_tracing::layer().event_filter(|md| match *md.level() {
143        tracing::Level::ERROR => EventFilter::Event,
144        tracing::Level::WARN => EventFilter::Breadcrumb,
145        _ => EventFilter::Ignore,
146    })
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn from_env_inner_rejects_empty_string_after_trim() {
155        let resolved =
156            SentryConfig::from_env_inner(Some("\t \n".into()), None, "0.0.0".into(), "h".into());
157        assert!(resolved.is_none());
158    }
159
160    #[test]
161    fn from_env_inner_populates_all_fields() {
162        let cfg = SentryConfig::from_env_inner(
163            Some("https://k@example.ingest.sentry.io/1".into()),
164            Some("prod".into()),
165            "9.9.9".into(),
166            "machine".into(),
167        )
168        .expect("dsn set");
169        assert_eq!(cfg.dsn, "https://k@example.ingest.sentry.io/1");
170        assert_eq!(cfg.environment, "prod");
171        assert_eq!(cfg.release, "9.9.9");
172        assert_eq!(cfg.server_name, "machine");
173    }
174}