1use 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#[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 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
167fn 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 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}