1use std::collections::HashMap;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use crate::breadcrumb_buffer::Breadcrumb;
10use crate::configuration::Configuration;
11use crate::pii_scrubber::{scrub_string, scrub_value, Value};
12use crate::sql_statement::{self, SqlObjects};
13
14pub const MAX_FRAMES: usize = 500;
17
18const CRATE_PREFIX: &str = "forge_ops_tracker::";
22
23const CONTEXT_LINES: usize = 5;
29const MAX_CONTEXT_LINE_LENGTH: usize = 500;
30
31const SDK_NAME: &str = "rust";
35
36#[derive(Clone, Debug, PartialEq)]
37pub struct Frame {
38 pub file: String,
39 pub line: u32,
40 pub method: String,
41 pub in_app: bool,
42 pub context_line: Option<String>,
43 pub pre_context: Option<Vec<String>>,
44 pub post_context: Option<Vec<String>>,
45}
46
47impl Frame {
48 pub fn new(file: String, line: u32, method: String, in_app: bool) -> Self {
51 Frame {
52 file,
53 line,
54 method,
55 in_app,
56 context_line: None,
57 pre_context: None,
58 post_context: None,
59 }
60 }
61}
62
63#[derive(Clone, Debug)]
64pub struct Event {
65 pub exception_class: String,
66 pub message: String,
67 pub backtrace: Vec<Frame>,
68 pub occurred_at: String,
69 pub environment: String,
70 pub release: Option<String>,
71 pub server_name: Option<String>,
72 pub context: HashMap<String, Value>,
73 pub tags: HashMap<String, Value>,
74 pub sdk_name: String,
75 pub user: Option<HashMap<String, Value>>,
76 pub breadcrumbs: Vec<Breadcrumb>,
77 pub sql_objects: Option<SqlObjects>,
81 pub sql_statement: Option<String>,
82 pub trace_id: Option<String>,
86}
87
88impl Event {
89 pub fn to_json(&self) -> String {
90 use crate::pii_scrubber::json_string;
91
92 let backtrace: Vec<String> = self
93 .backtrace
94 .iter()
95 .map(|f| {
96 let context_fields = match (&f.context_line, &f.pre_context, &f.post_context) {
101 (Some(context_line), Some(pre_context), Some(post_context)) => format!(
102 ",\"context_line\":{},\"pre_context\":{},\"post_context\":{}",
103 json_string(context_line),
104 string_array_json(pre_context),
105 string_array_json(post_context)
106 ),
107 _ => String::new(),
108 };
109
110 format!(
111 "{{\"file\":{},\"line\":{},\"method\":{},\"in_app\":{}{}}}",
112 json_string(&f.file),
113 f.line,
114 json_string(&f.method),
115 f.in_app,
116 context_fields
117 )
118 })
119 .collect();
120
121 let optional_string = |v: &Option<String>| {
122 v.as_deref()
123 .map(json_string)
124 .unwrap_or_else(|| "null".to_string())
125 };
126
127 let user_field = match &self.user {
131 Some(user) if !user.is_empty() => {
132 format!(",\"user\":{}", Value::Object(user.clone()).to_json())
133 }
134 _ => String::new(),
135 };
136
137 let breadcrumbs_field = if self.breadcrumbs.is_empty() {
141 String::new()
142 } else {
143 let entries: Vec<String> = self
144 .breadcrumbs
145 .iter()
146 .map(|b| {
147 format!(
148 "{{\"category\":{},\"message\":{},\"level\":{},\"timestamp\":{},\"data\":{}}}",
149 json_string(&b.category),
150 json_string(&b.message),
151 json_string(&b.level),
152 json_string(&b.timestamp),
153 Value::Object(b.data.clone()).to_json()
154 )
155 })
156 .collect();
157 format!(",\"breadcrumbs\":[{}]", entries.join(","))
158 };
159
160 let sql_field = format!(
161 "{}{}",
162 self.sql_objects
163 .as_ref()
164 .map(|o| format!(",\"sql_objects\":{}", o.to_json()))
165 .unwrap_or_default(),
166 self.sql_statement
167 .as_deref()
168 .map(|s| format!(",\"sql_statement\":{}", json_string(s)))
169 .unwrap_or_default()
170 );
171
172 let trace_field = self
173 .trace_id
174 .as_deref()
175 .map(|id| format!(",\"trace_id\":{}", json_string(id)))
176 .unwrap_or_default();
177
178 format!(
179 "{{\"exception_class\":{},\"message\":{},\"backtrace\":[{}],\"occurred_at\":{},\"environment\":{},\"release\":{},\"server_name\":{},\"context\":{},\"tags\":{},\"sdk_name\":{}{}{}{}{}}}",
180 json_string(&self.exception_class),
181 json_string(&self.message),
182 backtrace.join(","),
183 json_string(&self.occurred_at),
184 json_string(&self.environment),
185 optional_string(&self.release),
186 optional_string(&self.server_name),
187 Value::Object(self.context.clone()).to_json(),
188 Value::Object(self.tags.clone()).to_json(),
189 json_string(&self.sdk_name),
190 user_field,
191 breadcrumbs_field,
192 sql_field,
193 trace_field
194 )
195 }
196}
197
198fn string_array_json(items: &[String]) -> String {
201 Value::Array(items.iter().cloned().map(Value::String).collect()).to_json()
202}
203
204pub struct EventBuilder<'a> {
205 configuration: &'a Configuration,
206}
207
208impl<'a> EventBuilder<'a> {
209 pub fn new(configuration: &'a Configuration) -> Self {
210 EventBuilder { configuration }
211 }
212
213 #[cfg(test)]
216 #[allow(clippy::too_many_arguments)]
217 pub fn build(
218 &self,
219 exception_class: &str,
220 message: &str,
221 backtrace: Vec<Frame>,
222 context: HashMap<String, Value>,
223 user: Option<HashMap<String, Value>>,
224 breadcrumbs: Vec<Breadcrumb>,
225 ) -> Event {
226 self.build_with_sql(
227 exception_class,
228 message,
229 backtrace,
230 context,
231 user,
232 breadcrumbs,
233 None,
234 )
235 }
236
237 #[allow(clippy::too_many_arguments)]
241 pub fn build_with_sql(
242 &self,
243 exception_class: &str,
244 message: &str,
245 backtrace: Vec<Frame>,
246 context: HashMap<String, Value>,
247 user: Option<HashMap<String, Value>>,
248 breadcrumbs: Vec<Breadcrumb>,
249 sql: Option<&str>,
250 ) -> Event {
251 let mut event = Event {
252 exception_class: exception_class.to_string(),
253 message: message.to_string(),
254 backtrace,
255 occurred_at: format_now(),
256 environment: self.configuration.environment.clone(),
257 release: self.configuration.release.clone(),
258 server_name: self.configuration.server_name.clone(),
259 context,
260 tags: HashMap::new(),
261 sdk_name: SDK_NAME.to_string(),
262 user: user.filter(|u| !u.is_empty()),
263 breadcrumbs,
264 sql_objects: None,
265 sql_statement: None,
266 trace_id: crate::span_buffer::current_trace_id(),
269 };
270
271 if self.configuration.capture_sql_objects || self.configuration.capture_sql_statement {
272 if let Some(masked) = sql.and_then(sql_statement::mask) {
273 if self.configuration.capture_sql_objects {
274 event.sql_objects = sql_statement::extract_objects(&masked);
275 }
276 if self.configuration.capture_sql_statement {
277 event.sql_statement = Some(masked);
278 }
279 }
280 }
281
282 if self.configuration.scrub_pii {
283 event = scrub_event(event);
284 }
285 event
286 }
287}
288
289fn scrub_event(mut event: Event) -> Event {
294 event.message = scrub_string(&event.message);
295 event.backtrace = event
296 .backtrace
297 .into_iter()
298 .map(|f| Frame {
299 file: scrub_string(&f.file),
300 method: scrub_string(&f.method),
301 ..f
302 })
303 .collect();
304
305 let Value::Object(context) = scrub_value(&Value::Object(event.context), "") else {
306 unreachable!()
307 };
308 event.context = context;
309 let Value::Object(tags) = scrub_value(&Value::Object(event.tags), "") else {
310 unreachable!()
311 };
312 event.tags = tags;
313
314 event.sql_statement = event
319 .sql_statement
320 .map(|statement| scrub_string(&statement));
321
322 event.breadcrumbs = event
323 .breadcrumbs
324 .into_iter()
325 .map(|b| {
326 let Value::Object(data) = scrub_value(&Value::Object(b.data), "") else {
327 unreachable!()
328 };
329 Breadcrumb {
330 message: scrub_string(&b.message),
331 data,
332 ..b
333 }
334 })
335 .collect();
336 event
337}
338
339pub fn capture_backtrace(configuration: &Configuration) -> Vec<Frame> {
344 let bt = backtrace::Backtrace::new();
345 let mut frames = Vec::new();
346 let mut seen_app_frame = false;
347
348 'frames: for frame in bt.frames() {
349 for symbol in frame.symbols() {
350 let name = symbol
351 .name()
352 .map(|n| n.to_string())
353 .unwrap_or_else(|| "<unknown>".to_string());
354
355 if !seen_app_frame && name.starts_with(CRATE_PREFIX) {
361 continue;
362 }
363 seen_app_frame = true;
364
365 let file = symbol
366 .filename()
367 .map(|p| p.to_string_lossy().into_owned())
368 .unwrap_or_default();
369 let line = symbol.lineno().unwrap_or(0);
370 let in_app = is_in_app(configuration, &file);
371 let frame = Frame::new(file, line, name, in_app);
372 frames.push(attach_source_context(configuration, frame));
373
374 if frames.len() >= MAX_FRAMES {
375 break 'frames;
376 }
377 }
378 }
379 frames
380}
381
382fn is_in_app(configuration: &Configuration, file: &str) -> bool {
383 let root = match &configuration.app_root {
384 Some(r) if !r.is_empty() => r,
385 _ => return false,
386 };
387 if file.is_empty() || !file.starts_with(root.as_str()) {
388 return false;
389 }
390 !file.contains("/.cargo/registry/") && !file.contains("/rustc/")
394}
395
396fn attach_source_context(configuration: &Configuration, mut frame: Frame) -> Frame {
406 if !configuration.capture_source_context || !frame.in_app {
407 return frame;
408 }
409
410 let Ok(contents) = std::fs::read_to_string(&frame.file) else {
411 return frame;
412 };
413 let lines: Vec<&str> = contents.lines().collect();
414 if frame.line == 0 || frame.line as usize > lines.len() {
415 return frame;
416 }
417 let index = frame.line as usize - 1;
418
419 let from = index.saturating_sub(CONTEXT_LINES);
420 let to = (index + CONTEXT_LINES).min(lines.len() - 1);
421
422 frame.context_line = Some(truncate_line(lines[index]));
423 frame.pre_context = Some(
424 lines[from..index]
425 .iter()
426 .map(|l| truncate_line(l))
427 .collect(),
428 );
429 frame.post_context = Some(
430 lines[(index + 1)..=to]
431 .iter()
432 .map(|l| truncate_line(l))
433 .collect(),
434 );
435 frame
436}
437
438fn truncate_line(line: &str) -> String {
439 if line.chars().count() <= MAX_CONTEXT_LINE_LENGTH {
440 return line.to_string();
441 }
442 let truncated: String = line.chars().take(MAX_CONTEXT_LINE_LENGTH).collect();
443 format!("{truncated}...")
444}
445
446fn format_now() -> String {
447 let secs = SystemTime::now()
448 .duration_since(UNIX_EPOCH)
449 .map(|d| d.as_secs())
450 .unwrap_or(0);
451 format_unix_timestamp(secs)
452}
453
454pub(crate) fn format_unix_timestamp(secs: u64) -> String {
461 let days = secs / 86400;
462 let time_of_day = secs % 86400;
463 let (hour, minute, second) = (
464 time_of_day / 3600,
465 (time_of_day % 3600) / 60,
466 time_of_day % 60,
467 );
468
469 let (year, month, day) = civil_from_days(days as i64);
470 format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
471}
472
473fn civil_from_days(z: i64) -> (i64, u32, u32) {
478 let z = z + 719468;
479 let era = if z >= 0 { z } else { z - 146096 } / 146097;
480 let doe = (z - era * 146097) as u64;
481 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
482 let y = yoe as i64 + era * 400;
483 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
484 let mp = (5 * doy + 2) / 153;
485 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
486 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
487 let year = if m <= 2 { y + 1 } else { y };
488 (year, m, d)
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494
495 fn test_configuration() -> Configuration {
496 Configuration {
497 environment: "production".to_string(),
498 release: Some("a1b2c3d".to_string()),
499 server_name: Some("test-host".to_string()),
500 app_root: Some("/app".to_string()),
501 scrub_pii: true,
502 ..Configuration::new()
503 }
504 }
505
506 #[test]
507 fn build_basic_fields() {
508 let config = test_configuration();
509 let builder = EventBuilder::new(&config);
510 let mut context = HashMap::new();
511 context.insert("order_id".to_string(), Value::Number(42.0));
512
513 let event = builder.build("std::io::Error", "boom", vec![], context, None, vec![]);
514
515 assert_eq!(event.message, "boom");
516 assert_eq!(event.environment, "production");
517 assert_eq!(event.release, Some("a1b2c3d".to_string()));
518 assert_eq!(event.server_name, Some("test-host".to_string()));
519 assert_eq!(event.context["order_id"], Value::Number(42.0));
520 assert_eq!(event.sdk_name, "rust");
521 }
522
523 #[test]
524 fn build_includes_the_user_when_given_one_never_scrubbed_even_though_its_an_email() {
525 let config = test_configuration();
526 let builder = EventBuilder::new(&config);
527 let mut user = HashMap::new();
528 user.insert("id".to_string(), Value::Number(42.0));
529 user.insert(
530 "email".to_string(),
531 Value::String("ada@example.com".to_string()),
532 );
533
534 let event = builder.build("Error", "boom", vec![], HashMap::new(), Some(user), vec![]);
535
536 assert_eq!(
537 event.user.unwrap()["email"],
538 Value::String("ada@example.com".to_string())
539 );
540 }
541
542 #[test]
543 fn build_omits_the_user_entirely_when_none_was_given() {
544 let config = test_configuration();
545 let builder = EventBuilder::new(&config);
546
547 let event = builder.build("Error", "boom", vec![], HashMap::new(), None, vec![]);
548
549 assert_eq!(event.user, None);
550 }
551
552 #[test]
553 fn build_scrubs_message_and_context_when_enabled() {
554 let config = test_configuration();
555 let builder = EventBuilder::new(&config);
556 let mut context = HashMap::new();
557 context.insert(
558 "api_key".to_string(),
559 Value::String("shh-secret".to_string()),
560 );
561
562 let event = builder.build(
563 "Error",
564 "failed to charge user@example.com",
565 vec![],
566 context,
567 None,
568 vec![],
569 );
570
571 assert_eq!(event.message, "failed to charge [EMAIL FILTERED]");
572 assert_eq!(
573 event.context["api_key"],
574 Value::String(crate::pii_scrubber::REDACTED.to_string())
575 );
576 }
577
578 #[test]
579 fn build_does_not_scrub_when_disabled() {
580 let mut config = test_configuration();
581 config.scrub_pii = false;
582 let builder = EventBuilder::new(&config);
583
584 let event = builder.build(
585 "Error",
586 "contact user@example.com",
587 vec![],
588 HashMap::new(),
589 None,
590 vec![],
591 );
592
593 assert_eq!(event.message, "contact user@example.com");
594 }
595
596 #[test]
597 fn is_in_app_excludes_registry_and_toolchain_and_outside_root() {
598 let config = test_configuration();
599 assert!(is_in_app(&config, "/app/src/main.rs"));
600 assert!(!is_in_app(&config, "/other/src/main.rs"));
601 assert!(!is_in_app(
602 &config,
603 "/app/.cargo/registry/src/index.crates.io/crate/lib.rs"
604 ));
605 assert!(!is_in_app(&config, ""));
606 }
607
608 #[test]
609 fn capture_backtrace_excludes_this_crates_own_frames() {
610 let config = test_configuration();
611 let frames = capture_backtrace(&config);
612
613 assert!(!frames.is_empty(), "expected at least one backtrace frame");
614 for frame in &frames {
615 assert!(
616 !frame.method.starts_with(CRATE_PREFIX),
617 "frame {:?} should have been filtered out as SDK-internal",
618 frame.method
619 );
620 }
621 }
622
623 #[test]
624 fn format_unix_timestamp_known_value() {
625 assert_eq!(format_unix_timestamp(1705314600), "2024-01-15T10:30:00Z");
627 assert_eq!(format_unix_timestamp(0), "1970-01-01T00:00:00Z");
629 }
630
631 mod source_context {
632 use super::*;
633 use std::sync::atomic::{AtomicU64, Ordering};
634
635 struct TempFile {
638 path: std::path::PathBuf,
639 }
640
641 impl TempFile {
642 fn with_contents(contents: &str) -> Self {
643 static COUNTER: AtomicU64 = AtomicU64::new(0);
644 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
645 let nanos = SystemTime::now()
646 .duration_since(UNIX_EPOCH)
647 .unwrap()
648 .as_nanos();
649 let path = std::env::temp_dir().join(format!(
650 "forge_ops_tracker_test_{}_{nanos}_{n}.rs",
651 std::process::id()
652 ));
653 std::fs::write(&path, contents).expect("failed to write temp test file");
654 TempFile { path }
655 }
656
657 fn path_string(&self) -> String {
658 self.path.to_string_lossy().into_owned()
659 }
660 }
661
662 impl Drop for TempFile {
663 fn drop(&mut self) {
664 let _ = std::fs::remove_file(&self.path);
665 }
666 }
667
668 fn numbered_lines(count: usize) -> String {
669 (1..=count)
670 .map(|n| format!("line {n}"))
671 .collect::<Vec<_>>()
672 .join("\n")
673 }
674
675 fn frame_in_app(file: String, line: u32) -> Frame {
676 Frame::new(file, line, "call".to_string(), true)
677 }
678
679 #[test]
680 fn attaches_window_around_the_culprit_line_by_default() {
681 let file = TempFile::with_contents(&numbered_lines(20));
682 let mut config = test_configuration();
683 config.app_root = Some(std::env::temp_dir().to_string_lossy().into_owned());
684
685 let frame = attach_source_context(&config, frame_in_app(file.path_string(), 10));
686
687 assert_eq!(frame.context_line, Some("line 10".to_string()));
688 assert_eq!(
689 frame.pre_context,
690 Some((5..=9).map(|n| format!("line {n}")).collect())
691 );
692 assert_eq!(
693 frame.post_context,
694 Some((11..=15).map(|n| format!("line {n}")).collect())
695 );
696 }
697
698 #[test]
699 fn clamps_at_the_start_and_end_of_the_file_rather_than_panicking() {
700 let file = TempFile::with_contents(&numbered_lines(3));
701 let config = test_configuration();
702
703 let first = attach_source_context(&config, frame_in_app(file.path_string(), 1));
704 let last = attach_source_context(&config, frame_in_app(file.path_string(), 3));
705
706 assert_eq!(first.pre_context, Some(vec![]));
707 assert_eq!(
708 first.post_context,
709 Some(vec!["line 2".to_string(), "line 3".to_string()])
710 );
711 assert_eq!(
712 last.pre_context,
713 Some(vec!["line 1".to_string(), "line 2".to_string()])
714 );
715 assert_eq!(last.post_context, Some(vec![]));
716 }
717
718 #[test]
719 fn truncates_a_line_longer_than_max_context_line_length() {
720 let overlong = "x".repeat(600);
721 let file = TempFile::with_contents(&overlong);
722 let config = test_configuration();
723
724 let frame = attach_source_context(&config, frame_in_app(file.path_string(), 1));
725
726 assert_eq!(frame.context_line, Some(format!("{}...", "x".repeat(500))));
727 }
728
729 #[test]
730 fn never_attaches_context_to_a_frame_that_is_not_in_app() {
731 let file = TempFile::with_contents(&numbered_lines(20));
732 let config = test_configuration();
733 let frame = Frame::new(file.path_string(), 10, "call".to_string(), false);
734
735 let frame = attach_source_context(&config, frame);
736
737 assert_eq!(frame.context_line, None);
738 assert_eq!(frame.pre_context, None);
739 assert_eq!(frame.post_context, None);
740 }
741
742 #[test]
749 fn leaves_the_frame_untouched_when_capture_source_context_is_disabled() {
750 let file = TempFile::with_contents(&numbered_lines(20));
751 let mut config = test_configuration();
752 config.capture_source_context = false;
753
754 let frame = attach_source_context(&config, frame_in_app(file.path_string(), 10));
755
756 assert_eq!(frame.context_line, None);
757 assert_eq!(frame.pre_context, None);
758 assert_eq!(frame.post_context, None);
759 }
760
761 #[test]
762 fn leaves_the_frame_untouched_when_the_file_cannot_be_read() {
763 let config = test_configuration();
764 let missing_path = std::env::temp_dir()
765 .join("forge_ops_tracker_test_does_not_exist_12345.rs")
766 .to_string_lossy()
767 .into_owned();
768
769 let frame = attach_source_context(&config, frame_in_app(missing_path, 1));
770
771 assert_eq!(frame.context_line, None);
772 assert_eq!(frame.pre_context, None);
773 assert_eq!(frame.post_context, None);
774 }
775
776 #[test]
777 fn wire_format_uses_snake_case_keys_when_context_is_present_and_omits_them_otherwise() {
778 let with_context = Frame {
779 context_line: Some("line 10".to_string()),
780 pre_context: Some(vec!["line 9".to_string()]),
781 post_context: Some(vec!["line 11".to_string()]),
782 ..frame_in_app("/app/src/main.rs".to_string(), 10)
783 };
784 let event = Event {
785 exception_class: "Error".to_string(),
786 message: "boom".to_string(),
787 backtrace: vec![with_context],
788 occurred_at: "2024-01-15T10:30:00Z".to_string(),
789 environment: "production".to_string(),
790 release: None,
791 server_name: None,
792 context: HashMap::new(),
793 tags: HashMap::new(),
794 sdk_name: "rust".to_string(),
795 user: None,
796 breadcrumbs: vec![],
797 sql_objects: None,
798 sql_statement: None,
799 trace_id: None,
800 };
801
802 let json = event.to_json();
803
804 assert!(json.contains("\"context_line\":\"line 10\""));
805 assert!(json.contains("\"pre_context\":[\"line 9\"]"));
806 assert!(json.contains("\"post_context\":[\"line 11\"]"));
807 assert!(!json.contains("\"user\""));
808
809 let without_context = Event {
810 backtrace: vec![frame_in_app("/app/src/main.rs".to_string(), 10)],
811 ..event
812 };
813 let json = without_context.to_json();
814
815 assert!(!json.contains("context_line"));
816 assert!(!json.contains("pre_context"));
817 assert!(!json.contains("post_context"));
818 }
819
820 #[test]
821 fn wire_format_includes_user_when_present_and_omits_it_otherwise() {
822 let mut user = HashMap::new();
823 user.insert(
824 "email".to_string(),
825 Value::String("ada@example.com".to_string()),
826 );
827 let event = Event {
828 exception_class: "Error".to_string(),
829 message: "boom".to_string(),
830 backtrace: vec![],
831 occurred_at: "2024-01-15T10:30:00Z".to_string(),
832 environment: "production".to_string(),
833 release: None,
834 server_name: None,
835 context: HashMap::new(),
836 tags: HashMap::new(),
837 sdk_name: "rust".to_string(),
838 user: Some(user),
839 breadcrumbs: vec![],
840 sql_objects: None,
841 sql_statement: None,
842 trace_id: None,
843 };
844
845 let json = event.to_json();
846
847 assert!(json.contains("\"user\":{\"email\":\"ada@example.com\"}"));
848
849 let without_user = Event {
850 user: None,
851 ..event
852 };
853 assert!(!without_user.to_json().contains("\"user\""));
854 }
855
856 #[test]
857 fn wire_format_includes_breadcrumbs_when_present_and_omits_them_otherwise() {
858 let crumb = Breadcrumb {
859 category: "controller".to_string(),
860 message: "GET /orders/42".to_string(),
861 level: "info".to_string(),
862 timestamp: "2024-01-15T10:29:58Z".to_string(),
863 data: HashMap::from([("status".to_string(), Value::Number(200.0))]),
864 };
865 let event = Event {
866 exception_class: "Error".to_string(),
867 message: "boom".to_string(),
868 backtrace: vec![],
869 occurred_at: "2024-01-15T10:30:00Z".to_string(),
870 environment: "production".to_string(),
871 release: None,
872 server_name: None,
873 context: HashMap::new(),
874 tags: HashMap::new(),
875 sdk_name: "rust".to_string(),
876 user: None,
877 breadcrumbs: vec![crumb],
878 sql_objects: None,
879 sql_statement: None,
880 trace_id: None,
881 };
882
883 let json = event.to_json();
884
885 assert!(json.contains(
886 "\"breadcrumbs\":[{\"category\":\"controller\",\"message\":\"GET /orders/42\",\"level\":\"info\",\"timestamp\":\"2024-01-15T10:29:58Z\",\"data\":{\"status\":200}}]"
887 ));
888
889 let without_breadcrumbs = Event {
890 breadcrumbs: vec![],
891 ..event
892 };
893 assert!(!without_breadcrumbs.to_json().contains("\"breadcrumbs\""));
894 }
895
896 #[test]
897 fn wire_format_includes_trace_id_when_present_and_omits_it_otherwise() {
898 let event = Event {
899 exception_class: "Error".to_string(),
900 message: "boom".to_string(),
901 backtrace: vec![],
902 occurred_at: "2024-01-15T10:30:00Z".to_string(),
903 environment: "production".to_string(),
904 release: None,
905 server_name: None,
906 context: HashMap::new(),
907 tags: HashMap::new(),
908 sdk_name: "rust".to_string(),
909 user: None,
910 breadcrumbs: vec![],
911 sql_objects: None,
912 sql_statement: None,
913 trace_id: Some("4bf92f3577b34da6a3ce929d0e0e4736".to_string()),
914 };
915
916 assert!(event
917 .to_json()
918 .ends_with(",\"trace_id\":\"4bf92f3577b34da6a3ce929d0e0e4736\"}"));
919
920 let without_trace = Event {
921 trace_id: None,
922 ..event
923 };
924 assert!(!without_trace.to_json().contains("\"trace_id\""));
925 }
926 }
927
928 mod sql_capture {
929 use super::*;
930 use crate::Configuration;
931
932 fn build(config: &Configuration, sql: Option<&str>) -> Event {
933 EventBuilder::new(config).build_with_sql(
934 "Error",
935 "boom",
936 vec![],
937 HashMap::new(),
938 None,
939 vec![],
940 sql,
941 )
942 }
943
944 const SQL: &str = "EXEC dbo.sp_refund_order @order_id = 8814, @note = 'a@b.co'";
945
946 #[test]
947 fn sends_the_procedure_name_but_not_the_statement_by_default() {
948 let event = build(&Configuration::new(), Some(SQL));
949
950 assert_eq!(
951 event.sql_objects.unwrap().procedures,
952 vec!["dbo.sp_refund_order"]
953 );
954 assert_eq!(event.sql_statement, None);
955 }
956
957 #[test]
958 fn sends_the_masked_statement_when_opted_in() {
959 let mut config = Configuration::new();
960 config.capture_sql_statement = true;
961
962 let event = build(&config, Some(SQL));
963
964 assert_eq!(
965 event.sql_statement.as_deref(),
966 Some("EXEC dbo.sp_refund_order @order_id = ?, @note = ?")
967 );
968 assert!(event.to_json().contains(
969 "\"sql_statement\":\"EXEC dbo.sp_refund_order @order_id = ?, @note = ?\""
970 ));
971 assert!(event
972 .to_json()
973 .contains("\"sql_objects\":{\"operation\":\"EXEC\""));
974 }
975
976 #[test]
977 fn sends_nothing_when_both_are_off_or_no_sql_was_given() {
978 let mut config = Configuration::new();
979 config.capture_sql_objects = false;
980 let off = build(&config, Some(SQL));
981 assert!(off.sql_objects.is_none() && off.sql_statement.is_none());
982
983 let none = build(&Configuration::new(), None);
984 assert!(none.sql_objects.is_none() && none.sql_statement.is_none());
985 assert!(!none.to_json().contains("sql_"));
986 }
987 }
988}