studio_worker/
telemetry.rs1use crate::{sys, RELEASE_NAME};
20use sentry_tracing::EventFilter;
21use std::borrow::Cow;
22
23const TRACE_TARGET: &str = "studio_worker::telemetry";
26
27#[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 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 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
76pub 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 traces_sample_rate: 0.0,
101 ..Default::default()
102 })
103}
104
105pub 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
132pub fn event_filter_for_level(level: tracing::Level) -> EventFilter {
143 match level {
144 tracing::Level::ERROR => EventFilter::Event,
145 tracing::Level::WARN => EventFilter::Breadcrumb,
146 _ => EventFilter::Ignore,
147 }
148}
149
150pub fn tracing_layer<S>() -> sentry_tracing::SentryLayer<S>
153where
154 S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
155{
156 sentry_tracing::layer().event_filter(|md| event_filter_for_level(*md.level()))
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use crate::test_support::capture;
163
164 fn sample_config(dsn: &str) -> SentryConfig {
165 SentryConfig {
166 dsn: dsn.to_string(),
167 environment: "staging".into(),
168 release: "studio-worker@9.9.9".into(),
169 server_name: "rig-01".into(),
170 }
171 }
172
173 #[test]
174 fn build_client_options_carries_release_environment_and_server_name() {
175 let cfg = sample_config("https://abc123@o1.ingest.sentry.io/42");
176 let opts = build_client_options(&cfg).expect("a valid DSN must yield options");
177 assert!(opts.dsn.is_some(), "the parsed DSN must be attached");
178 assert_eq!(opts.release.as_deref(), Some("studio-worker@9.9.9"));
179 assert_eq!(opts.environment.as_deref(), Some("staging"));
180 assert_eq!(opts.server_name.as_deref(), Some("rig-01"));
181 assert!(
185 opts.traces_sample_rate.abs() < f32::EPSILON,
186 "traces_sample_rate must be disabled (0.0), got {}",
187 opts.traces_sample_rate
188 );
189 }
190
191 #[test]
192 fn build_client_options_rejects_invalid_dsn_and_warns() {
193 let cfg = sample_config("not-a-valid-dsn");
197 let logs = capture(move || {
198 assert!(
199 build_client_options(&cfg).is_none(),
200 "an unparseable DSN must yield no options"
201 );
202 });
203 assert!(logs.contains("WARN"), "expected WARN event, got: {logs}");
204 assert!(
205 logs.contains("studio_worker::telemetry"),
206 "expected telemetry target, got: {logs}"
207 );
208 assert!(
209 logs.contains("not a valid sentry DSN"),
210 "expected the invalid-DSN message, got: {logs}"
211 );
212 }
213
214 #[test]
215 fn from_env_inner_rejects_empty_string_after_trim() {
216 let resolved =
217 SentryConfig::from_env_inner(Some("\t \n".into()), None, "0.0.0".into(), "h".into());
218 assert!(resolved.is_none());
219 }
220
221 #[test]
222 fn event_filter_maps_each_severity_to_its_sentry_action() {
223 use sentry_tracing::EventFilter;
234 assert_eq!(
235 event_filter_for_level(tracing::Level::ERROR).bits(),
236 EventFilter::Event.bits(),
237 "ERROR must surface as a Sentry event"
238 );
239 assert_eq!(
240 event_filter_for_level(tracing::Level::WARN).bits(),
241 EventFilter::Breadcrumb.bits(),
242 "WARN must attach a breadcrumb, not raise an event"
243 );
244 for quiet in [
245 tracing::Level::INFO,
246 tracing::Level::DEBUG,
247 tracing::Level::TRACE,
248 ] {
249 assert_eq!(
250 event_filter_for_level(quiet).bits(),
251 EventFilter::Ignore.bits(),
252 "{quiet} must be ignored by Sentry (it ships via the log shipper)"
253 );
254 }
255 }
256
257 #[test]
258 fn from_env_inner_populates_all_fields() {
259 let cfg = SentryConfig::from_env_inner(
260 Some("https://k@example.ingest.sentry.io/1".into()),
261 Some("prod".into()),
262 "9.9.9".into(),
263 "machine".into(),
264 )
265 .expect("dsn set");
266 assert_eq!(cfg.dsn, "https://k@example.ingest.sentry.io/1");
267 assert_eq!(cfg.environment, "prod");
268 assert_eq!(cfg.release, "9.9.9");
269 assert_eq!(cfg.server_name, "machine");
270 }
271}