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