Skip to main content

zebrad/
sentry.rs

1//! Integration with sentry.io for event reporting.
2
3use std::{collections::BTreeMap, env, sync::Arc};
4
5use sentry::{
6    integrations::tracing::EventFilter,
7    protocol::{Context, Event, Exception, Log, LogAttribute, Map, Mechanism, Value},
8    ClientInitGuard, ClientOptions, Scope,
9};
10use tracing::Level;
11
12use crate::application::{build_version, ZebradApp};
13
14/// Environment and CI metadata attached to all Sentry events.
15#[derive(Debug, Default)]
16struct Metadata {
17    environment: Option<String>,
18    tags: BTreeMap<&'static str, String>,
19    ci_context: BTreeMap<&'static str, String>,
20}
21
22impl Metadata {
23    fn from_env() -> Self {
24        Self::from_lookup(env_var)
25    }
26
27    fn from_lookup<F>(lookup: F) -> Self
28    where
29        F: Fn(&str) -> Option<String>,
30    {
31        let mut metadata = Self {
32            environment: lookup_value(&lookup, "SENTRY_ENVIRONMENT"),
33            ..Default::default()
34        };
35
36        insert_lookup_values(
37            &lookup,
38            &mut metadata.ci_context,
39            &[
40                ("GITHUB_RUN_ID", "run_id"),
41                ("GITHUB_RUN_ATTEMPT", "run_attempt"),
42                ("GITHUB_WORKFLOW", "workflow"),
43                ("GITHUB_JOB", "job"),
44            ],
45        );
46
47        insert_lookup_values(
48            &lookup,
49            &mut metadata.tags,
50            &[
51                ("GITHUB_EVENT_NAME", "deploy.trigger"),
52                ("CI_PR_NUMBER", "pr.number"),
53                ("CI_TEST_ID", "test.id"),
54            ],
55        );
56
57        if let Some(git_ref) = slugged_git_ref(&lookup) {
58            metadata.tags.insert("git.ref", git_ref);
59        }
60
61        let git_sha = lookup_value(&lookup, "GITHUB_SHA")
62            .or_else(|| ZebradApp::git_commit().map(ToOwned::to_owned));
63        if let Some(git_sha) = git_sha {
64            metadata.tags.insert("git.sha", git_sha);
65        }
66
67        if lookup_value(&lookup, "GITHUB_ACTIONS").is_some_and(|v| v.eq_ignore_ascii_case("true")) {
68            metadata
69                .tags
70                .insert("ci.provider", "github-actions".to_owned());
71        }
72
73        metadata
74    }
75
76    fn client_options(&self) -> ClientOptions {
77        self.client_options_with_release(release_name())
78    }
79
80    fn client_options_with_release(&self, release: String) -> ClientOptions {
81        let log_attributes = self.log_attributes();
82        ClientOptions {
83            release: Some(release.into()),
84            environment: self.environment.clone().map(Into::into),
85            enable_logs: true,
86            before_send_log: Some(Arc::new(move |mut log: Log| {
87                merge_log_attributes(&mut log, &log_attributes);
88                Some(log)
89            })),
90            ..Default::default()
91        }
92    }
93
94    /// Build the static CI/git attributes to attach to every outgoing [`Log`].
95    ///
96    /// `Scope::set_tag` only propagates to [`Event`]s, so Logs need their own
97    /// enrichment path. CI context is namespaced under `ci.*` to mirror the
98    /// grouping used for Event contexts.
99    fn log_attributes(&self) -> Vec<(String, LogAttribute)> {
100        let mut attrs = Vec::with_capacity(self.tags.len() + self.ci_context.len());
101        for (key, value) in &self.tags {
102            attrs.push(((*key).to_owned(), LogAttribute::from(value.clone())));
103        }
104        for (key, value) in &self.ci_context {
105            attrs.push((format!("ci.{key}"), LogAttribute::from(value.clone())));
106        }
107        attrs
108    }
109
110    fn apply_to_scope(&self, scope: &mut Scope) {
111        for (key, value) in &self.tags {
112            scope.set_tag(key, value.as_str());
113        }
114
115        if !self.ci_context.is_empty() {
116            scope.set_context("ci", Context::Other(context_map(&self.ci_context)));
117        }
118    }
119
120    #[cfg(test)]
121    fn environment(&self) -> Option<&str> {
122        self.environment.as_deref()
123    }
124}
125
126pub(crate) fn init() -> ClientInitGuard {
127    let metadata = Metadata::from_env();
128    let guard = sentry::init(metadata.client_options());
129    sentry::configure_scope(|scope| metadata.apply_to_scope(scope));
130    guard
131}
132
133pub(crate) fn tracing_layer<S>() -> impl tracing_subscriber::Layer<S>
134where
135    S: tracing::Subscriber + for<'span> tracing_subscriber::registry::LookupSpan<'span>,
136{
137    sentry::integrations::tracing::layer().event_filter(|metadata| match *metadata.level() {
138        Level::ERROR => EventFilter::Event | EventFilter::Log,
139        Level::WARN => EventFilter::Log | EventFilter::Breadcrumb,
140        Level::INFO => EventFilter::Breadcrumb,
141        _ => EventFilter::Ignore,
142    })
143}
144
145pub(crate) fn panic_event_from<T>(msg: T) -> Event<'static>
146where
147    T: ToString,
148{
149    let exception = Exception {
150        ty: "panic".into(),
151        mechanism: Some(Mechanism {
152            ty: "panic".into(),
153            handled: Some(false),
154            ..Default::default()
155        }),
156        value: Some(msg.to_string()),
157        ..Default::default()
158    };
159
160    Event {
161        exception: vec![exception].into(),
162        level: sentry::Level::Fatal,
163        ..Default::default()
164    }
165}
166
167/// Merge `attrs` into `log.attributes`, letting existing keys win.
168///
169/// Tracing event fields are already present on the [`Log`] when the
170/// `before_send_log` hook fires, so we must not overwrite them; this keeps
171/// our CI/git metadata as a fallback that enriches (but never masks) the
172/// log's own attributes.
173fn merge_log_attributes(log: &mut Log, attrs: &[(String, LogAttribute)]) {
174    for (key, value) in attrs {
175        log.attributes
176            .entry(key.clone())
177            .or_insert_with(|| value.clone());
178    }
179}
180
181fn env_var(key: &str) -> Option<String> {
182    env::var(key).ok()
183}
184
185fn lookup_value<F>(lookup: &F, key: &str) -> Option<String>
186where
187    F: Fn(&str) -> Option<String>,
188{
189    let value = lookup(key)?;
190    let value = value.trim();
191
192    (!value.is_empty()).then(|| value.to_owned())
193}
194
195fn insert_lookup_values<F>(
196    lookup: &F,
197    target: &mut BTreeMap<&'static str, String>,
198    mappings: &[(&str, &'static str)],
199) where
200    F: Fn(&str) -> Option<String>,
201{
202    for (env_key, target_key) in mappings {
203        if let Some(value) = lookup_value(lookup, env_key) {
204            target.insert(*target_key, value);
205        }
206    }
207}
208
209fn slugged_git_ref<F>(lookup: &F) -> Option<String>
210where
211    F: Fn(&str) -> Option<String>,
212{
213    [
214        "GITHUB_REF_POINT_SLUG_URL",
215        "GITHUB_HEAD_REF_SLUG_URL",
216        "GITHUB_REF_NAME_SLUG_URL",
217    ]
218    .into_iter()
219    .find_map(|key| lookup_value(lookup, key))
220}
221
222fn context_map(values: &BTreeMap<&'static str, String>) -> Map<String, Value> {
223    values
224        .iter()
225        .map(|(key, value)| (key.to_string(), Value::String(value.clone())))
226        .collect()
227}
228
229fn release_name() -> String {
230    release_name_from(&env_var, ZebradApp::git_commit())
231}
232
233fn release_name_from<F>(lookup: &F, git_commit: Option<&str>) -> String
234where
235    F: Fn(&str) -> Option<String>,
236{
237    if let Some(release) = lookup_value(lookup, "SENTRY_RELEASE") {
238        return release;
239    }
240
241    let version = build_version();
242
243    if version.build.is_empty() {
244        if let Some(git_sha) = git_commit {
245            return format!("{version}+git.{git_sha}");
246        }
247    }
248
249    version.to_string()
250}
251
252#[cfg(test)]
253mod tests {
254    use std::{
255        collections::{BTreeMap, HashMap},
256        time::SystemTime,
257    };
258
259    use sentry::protocol::{Log, LogAttribute, LogLevel};
260
261    use crate::application::build_version;
262
263    use super::{merge_log_attributes, release_name_from, Metadata};
264
265    #[test]
266    fn metadata_ignores_empty_values() {
267        let env = HashMap::from([
268            ("SENTRY_ENVIRONMENT", "".to_string()),
269            ("CI_TEST_ID", "   ".to_string()),
270        ]);
271        let metadata = Metadata::from_lookup(|key| env.get(key).cloned());
272
273        assert_eq!(metadata.environment(), None);
274        assert_eq!(metadata.tags.get("test.id"), None);
275        assert!(metadata.ci_context.is_empty());
276    }
277
278    #[test]
279    fn metadata_reads_expected_tags_and_ci_context() {
280        let env = HashMap::from([
281            ("SENTRY_ENVIRONMENT", "stage".to_string()),
282            ("GITHUB_ACTIONS", "true".to_string()),
283            ("GITHUB_EVENT_NAME", "push".to_string()),
284            ("GITHUB_REF_POINT_SLUG_URL", "main".to_string()),
285            ("GITHUB_SHA", "deadbeef".to_string()),
286            ("GITHUB_RUN_ID", "42".to_string()),
287            ("GITHUB_RUN_ATTEMPT", "2".to_string()),
288            ("GITHUB_WORKFLOW", "CI".to_string()),
289            ("GITHUB_JOB", "deploy".to_string()),
290            ("CI_TEST_ID", "sync-full-mainnet".to_string()),
291        ]);
292        let metadata = Metadata::from_lookup(|key| env.get(key).cloned());
293
294        assert_eq!(metadata.environment(), Some("stage"));
295        assert_eq!(
296            metadata.tags.get("deploy.trigger").map(String::as_str),
297            Some("push"),
298        );
299        assert_eq!(
300            metadata.tags.get("git.ref").map(String::as_str),
301            Some("main"),
302        );
303        assert_eq!(
304            metadata.tags.get("git.sha").map(String::as_str),
305            Some("deadbeef"),
306        );
307        assert_eq!(
308            metadata.tags.get("ci.provider").map(String::as_str),
309            Some("github-actions"),
310        );
311        assert_eq!(
312            metadata.tags.get("test.id").map(String::as_str),
313            Some("sync-full-mainnet"),
314        );
315        assert_eq!(
316            metadata.ci_context.get("run_id").map(String::as_str),
317            Some("42"),
318        );
319        assert_eq!(
320            metadata.ci_context.get("run_attempt").map(String::as_str),
321            Some("2"),
322        );
323        assert_eq!(
324            metadata.ci_context.get("workflow").map(String::as_str),
325            Some("CI"),
326        );
327        assert_eq!(
328            metadata.ci_context.get("job").map(String::as_str),
329            Some("deploy"),
330        );
331    }
332
333    #[test]
334    fn metadata_reads_pull_request_number_from_ci_input() {
335        let env = HashMap::from([
336            ("GITHUB_ACTIONS", "true".to_string()),
337            ("GITHUB_REF_POINT_SLUG_URL", "fix-sentry-tags".to_string()),
338            ("CI_PR_NUMBER", "84".to_string()),
339        ]);
340        let metadata = Metadata::from_lookup(|key| env.get(key).cloned());
341
342        assert_eq!(
343            metadata.tags.get("git.ref").map(String::as_str),
344            Some("fix-sentry-tags"),
345        );
346        assert_eq!(
347            metadata.tags.get("pr.number").map(String::as_str),
348            Some("84"),
349        );
350    }
351
352    #[test]
353    fn metadata_prefers_slugged_git_ref_when_available() {
354        let env = HashMap::from([
355            (
356                "GITHUB_REF_POINT_SLUG_URL",
357                "feature-use-sentry".to_string(),
358            ),
359            (
360                "GITHUB_HEAD_REF_SLUG_URL",
361                "feature-use-plus-sentry".to_string(),
362            ),
363            ("GITHUB_REF_NAME_SLUG_URL", "84-merge".to_string()),
364        ]);
365        let metadata = Metadata::from_lookup(|key| env.get(key).cloned());
366
367        assert_eq!(
368            metadata.tags.get("git.ref").map(String::as_str),
369            Some("feature-use-sentry"),
370        );
371    }
372
373    #[test]
374    fn metadata_ignores_missing_slugged_git_ref() {
375        let env = HashMap::from([
376            ("GITHUB_ACTIONS", "true".to_string()),
377            ("GITHUB_EVENT_NAME", "push".to_string()),
378        ]);
379        let metadata = Metadata::from_lookup(|key| env.get(key).cloned());
380
381        assert_eq!(metadata.tags.get("git.ref"), None);
382    }
383
384    #[test]
385    fn release_name_prefers_sentry_release_override() {
386        let env = HashMap::from([("SENTRY_RELEASE", "[email protected]".to_string())]);
387
388        let release = release_name_from(&|key| env.get(key).cloned(), Some("deadbeef"));
389
390        assert_eq!(release, "[email protected]");
391    }
392
393    #[test]
394    fn release_name_appends_git_sha_when_build_metadata_is_empty() {
395        let env: HashMap<&str, String> = HashMap::new();
396
397        let release = release_name_from(&|key| env.get(key).cloned(), Some("deadbeef"));
398
399        let version = build_version();
400        if version.build.is_empty() {
401            assert_eq!(release, format!("{version}+git.deadbeef"));
402        } else {
403            // Tagged build already carries +build metadata; do not double-append.
404            assert_eq!(release, version.to_string());
405        }
406    }
407
408    #[test]
409    fn release_name_falls_back_to_version_without_git_sha() {
410        let env: HashMap<&str, String> = HashMap::new();
411
412        let release = release_name_from(&|key| env.get(key).cloned(), None);
413
414        assert_eq!(release, build_version().to_string());
415    }
416
417    #[test]
418    fn client_options_enables_logs_and_roundtrips_environment() {
419        let env = HashMap::from([("SENTRY_ENVIRONMENT", "stage".to_string())]);
420        let metadata = Metadata::from_lookup(|key| env.get(key).cloned());
421
422        let options = metadata.client_options_with_release("zebrad@test".to_string());
423
424        assert!(options.enable_logs);
425        assert_eq!(options.environment.as_deref(), Some("stage"));
426        assert_eq!(options.release.as_deref(), Some("zebrad@test"));
427    }
428
429    #[test]
430    fn client_options_omits_environment_when_unset() {
431        let metadata = Metadata::from_lookup(|_: &str| -> Option<String> { None });
432
433        let options = metadata.client_options_with_release("zebrad@test".to_string());
434
435        assert!(options.environment.is_none());
436    }
437
438    #[test]
439    fn log_attributes_include_tags_and_ci_context() {
440        let env = HashMap::from([
441            ("GITHUB_ACTIONS", "true".to_string()),
442            ("GITHUB_EVENT_NAME", "push".to_string()),
443            ("GITHUB_SHA", "deadbeef".to_string()),
444            ("GITHUB_REF_POINT_SLUG_URL", "main".to_string()),
445            ("GITHUB_RUN_ID", "42".to_string()),
446            ("GITHUB_RUN_ATTEMPT", "2".to_string()),
447            ("GITHUB_WORKFLOW", "CI".to_string()),
448            ("GITHUB_JOB", "deploy".to_string()),
449            ("CI_TEST_ID", "sync-full-mainnet".to_string()),
450            ("CI_PR_NUMBER", "84".to_string()),
451        ]);
452        let metadata = Metadata::from_lookup(|key| env.get(key).cloned());
453
454        let attrs: BTreeMap<String, LogAttribute> = metadata.log_attributes().into_iter().collect();
455
456        let expect = |key: &str, value: &str| {
457            assert_eq!(
458                attrs.get(key).and_then(|attr| attr.0.as_str()),
459                Some(value),
460                "missing or wrong log attribute {key}",
461            );
462        };
463
464        expect("test.id", "sync-full-mainnet");
465        expect("git.sha", "deadbeef");
466        expect("git.ref", "main");
467        expect("ci.provider", "github-actions");
468        expect("pr.number", "84");
469        expect("deploy.trigger", "push");
470        expect("ci.run_id", "42");
471        expect("ci.run_attempt", "2");
472        expect("ci.workflow", "CI");
473        expect("ci.job", "deploy");
474    }
475
476    #[test]
477    fn merge_log_attributes_preserves_existing() {
478        let mut log = Log {
479            level: LogLevel::Info,
480            body: "test".to_string(),
481            trace_id: None,
482            timestamp: SystemTime::UNIX_EPOCH,
483            severity_number: None,
484            attributes: BTreeMap::new(),
485        };
486
487        log.attributes.insert(
488            "git.sha".to_string(),
489            LogAttribute::from("event-sha".to_string()),
490        );
491
492        let attrs = vec![
493            (
494                "git.sha".to_string(),
495                LogAttribute::from("metadata-sha".to_string()),
496            ),
497            (
498                "ci.run_id".to_string(),
499                LogAttribute::from("42".to_string()),
500            ),
501        ];
502
503        merge_log_attributes(&mut log, &attrs);
504
505        assert_eq!(
506            log.attributes
507                .get("git.sha")
508                .and_then(|attr| attr.0.as_str()),
509            Some("event-sha"),
510            "pre-existing attribute must not be overwritten",
511        );
512        assert_eq!(
513            log.attributes
514                .get("ci.run_id")
515                .and_then(|attr| attr.0.as_str()),
516            Some("42"),
517            "new attribute must be inserted",
518        );
519    }
520}