1use std::collections::BTreeMap;
2use std::time::SystemTime;
3
4use serde_json::{json, Map, Value};
5use url::Url;
6use uuid::Uuid;
7
8use super::resource::{ResourceEvent, ResourceTiming, ResourceTimingData};
9use crate::config::{RumConfig, SDK_VERSION};
10use crate::context::RumContextSnapshot;
11use crate::error::{Result, RumError};
12use crate::time;
13use crate::trace::{TraceContext, TraceHeaderWriter};
14
15const MAX_PROPERTY_KEY_BYTES: usize = 20;
16const MAX_PROPERTY_VALUE_BYTES: usize = 5000;
17
18#[derive(Clone, Debug)]
19pub(crate) struct CustomEventData {
20 pub context: RumContextSnapshot,
21 pub event_id: String,
22 pub timestamp: i64,
23 pub parent_type: CustomParentType,
24 pub type_name: String,
25 pub name: String,
26 pub group: String,
27 pub snapshots: String,
28 pub value: f64,
29 pub log_level: Option<CustomLogLevel>,
30 pub log_content: Option<String>,
31 pub properties: BTreeMap<String, String>,
32}
33
34impl CustomEventData {
35 pub fn to_json(&self) -> Value {
36 let mut event = Map::new();
37 event.insert("timestamp".to_string(), json!(self.timestamp));
38 event.insert("event_id".to_string(), json!(self.event_id));
39 event.insert("event_type".to_string(), json!("custom"));
40 event.insert("parent_type".to_string(), json!(self.parent_type.as_str()));
41 event.insert("type".to_string(), json!(self.type_name));
42 event.insert("name".to_string(), json!(self.name));
43 event.insert("group".to_string(), json!(self.group));
44 event.insert("snapshots".to_string(), json!(self.snapshots));
45 event.insert("value".to_string(), json!(format_number(self.value)));
46 if let Some(level) = self.log_level {
47 event.insert("log_level".to_string(), json!(level.as_str()));
48 }
49 if let Some(content) = &self.log_content {
50 event.insert("log_content".to_string(), json!(content));
51 }
52 if !self.properties.is_empty() {
53 event.insert("properties".to_string(), properties_json(&self.properties));
54 }
55 Value::Object(event)
56 }
57}
58
59#[derive(Clone, Debug)]
60pub(crate) struct CustomExceptionEvent {
61 pub context: RumContextSnapshot,
62 pub event_id: String,
63 pub timestamp: i64,
64 pub name: String,
65 pub message: String,
66 pub source: String,
67 pub file: String,
68 pub stack: String,
69 pub caused_by: String,
70}
71
72impl CustomExceptionEvent {
73 pub fn to_json(&self) -> Value {
74 json!({
75 "timestamp": self.timestamp,
76 "event_id": self.event_id,
77 "event_type": "exception",
78 "type": "custom",
79 "name": self.name,
80 "message": self.message,
81 "source": self.source,
82 "file": self.file,
83 "stack": self.stack,
84 "caused_by": self.caused_by,
85 })
86 }
87}
88
89#[derive(Clone, Copy, Debug, Eq, PartialEq)]
90pub(crate) enum CustomParentType {
91 Event,
92 Log,
93}
94
95impl CustomParentType {
96 fn as_str(self) -> &'static str {
97 match self {
98 Self::Event => "event",
99 Self::Log => "log",
100 }
101 }
102}
103
104#[derive(Clone, Debug)]
105pub struct CustomEvent {
106 type_name: String,
107 name: String,
108 group: String,
109 snapshots: String,
110 value: f64,
111 properties: BTreeMap<String, String>,
112}
113
114impl CustomEvent {
115 pub fn new(event_type: impl Into<String>, name: impl Into<String>) -> Result<Self> {
116 let type_name = required_string("custom_event.type", event_type.into())?;
117 let name = required_string("custom_event.name", name.into())?;
118 Ok(Self {
119 type_name,
120 name,
121 group: "default".to_string(),
122 snapshots: String::new(),
123 value: 1.0,
124 properties: BTreeMap::new(),
125 })
126 }
127
128 pub fn group(mut self, value: impl Into<String>) -> Self {
129 self.group = value.into();
130 self
131 }
132
133 pub fn snapshots(mut self, value: impl Into<String>) -> Self {
134 self.snapshots = value.into();
135 self
136 }
137
138 pub fn value(mut self, value: f64) -> Result<Self> {
139 self.value = finite_number("custom_event.value", value)?;
140 Ok(self)
141 }
142
143 pub fn extra(mut self, key: impl Into<String>, value: impl Into<String>) -> Result<Self> {
144 insert_property(
145 &mut self.properties,
146 key.into(),
147 value.into(),
148 "custom_event.extra",
149 )?;
150 Ok(self)
151 }
152
153 pub(crate) fn into_event(self, context: RumContextSnapshot) -> CustomEventData {
154 CustomEventData {
155 context,
156 event_id: new_event_id(),
157 timestamp: now_millis(),
158 parent_type: CustomParentType::Event,
159 type_name: self.type_name,
160 name: self.name,
161 group: self.group,
162 snapshots: self.snapshots,
163 value: self.value,
164 log_level: None,
165 log_content: None,
166 properties: self.properties,
167 }
168 }
169}
170
171#[derive(Clone, Debug)]
172pub struct CustomLog {
173 type_name: String,
174 name: String,
175 group: String,
176 snapshots: String,
177 value: f64,
178 level: CustomLogLevel,
179 content: String,
180 properties: BTreeMap<String, String>,
181}
182
183impl CustomLog {
184 pub fn new(log_type: impl Into<String>, name: impl Into<String>) -> Result<Self> {
185 let type_name = required_string("custom_log.type", log_type.into())?;
186 let name = required_string("custom_log.name", name.into())?;
187 Ok(Self {
188 type_name,
189 name,
190 group: "default".to_string(),
191 snapshots: String::new(),
192 value: 1.0,
193 level: CustomLogLevel::Trace,
194 content: String::new(),
195 properties: BTreeMap::new(),
196 })
197 }
198
199 pub fn group(mut self, value: impl Into<String>) -> Self {
200 self.group = value.into();
201 self
202 }
203
204 pub fn snapshots(mut self, value: impl Into<String>) -> Self {
205 self.snapshots = value.into();
206 self
207 }
208
209 pub fn value(mut self, value: f64) -> Result<Self> {
210 self.value = finite_number("custom_log.value", value)?;
211 Ok(self)
212 }
213
214 pub fn level(mut self, value: CustomLogLevel) -> Self {
215 self.level = value;
216 self
217 }
218
219 pub fn content(mut self, value: impl Into<String>) -> Self {
220 self.content = value.into();
221 self
222 }
223
224 pub fn extra(mut self, key: impl Into<String>, value: impl Into<String>) -> Result<Self> {
225 insert_property(
226 &mut self.properties,
227 key.into(),
228 value.into(),
229 "custom_log.extra",
230 )?;
231 Ok(self)
232 }
233
234 pub(crate) fn into_event(self, context: RumContextSnapshot) -> CustomEventData {
235 CustomEventData {
236 context,
237 event_id: new_event_id(),
238 timestamp: now_millis(),
239 parent_type: CustomParentType::Log,
240 type_name: self.type_name,
241 name: self.name,
242 group: self.group,
243 snapshots: self.snapshots,
244 value: self.value,
245 log_level: Some(self.level),
246 log_content: Some(self.content),
247 properties: self.properties,
248 }
249 }
250}
251
252#[derive(Clone, Debug)]
253pub struct CustomException {
254 name: String,
255 message: String,
256 source: String,
257 file: String,
258 stack: String,
259 caused_by: String,
260}
261
262impl CustomException {
263 pub fn new(name: impl Into<String>, message: impl Into<String>) -> Result<Self> {
264 let name = required_string("custom_exception.name", name.into())?;
265 let message = required_string("custom_exception.message", message.into())?;
266 Ok(Self {
267 name,
268 message,
269 source: "event".to_string(),
270 file: String::new(),
271 stack: String::new(),
272 caused_by: String::new(),
273 })
274 }
275
276 pub fn source(mut self, value: impl Into<String>) -> Self {
277 self.source = value.into();
278 self
279 }
280
281 pub fn file(mut self, value: impl Into<String>) -> Self {
282 self.file = value.into();
283 self
284 }
285
286 pub fn stack(mut self, value: impl Into<String>) -> Self {
287 self.stack = value.into();
288 self
289 }
290
291 pub fn caused_by(mut self, value: impl Into<String>) -> Self {
292 self.caused_by = value.into();
293 self
294 }
295
296 pub(crate) fn into_event(self, context: RumContextSnapshot) -> CustomExceptionEvent {
297 CustomExceptionEvent {
298 context,
299 event_id: new_event_id(),
300 timestamp: now_millis(),
301 name: self.name,
302 message: self.message,
303 source: self.source,
304 file: self.file,
305 stack: self.stack,
306 caused_by: self.caused_by,
307 }
308 }
309}
310
311#[derive(Clone, Copy, Debug, Eq, PartialEq)]
312pub enum CustomLogLevel {
313 Trace,
314 Debug,
315 Info,
316 Warn,
317 Error,
318 Fatal,
319}
320
321impl CustomLogLevel {
322 pub(crate) fn as_str(self) -> &'static str {
323 match self {
324 Self::Trace => "TRACE",
325 Self::Debug => "DEBUG",
326 Self::Info => "INFO",
327 Self::Warn => "WARN",
328 Self::Error => "ERROR",
329 Self::Fatal => "FATAL",
330 }
331 }
332}
333
334#[derive(Clone, Debug)]
335pub struct CustomResource {
336 url: String,
337 method: String,
338 name: String,
339 resource_type: CustomResourceType,
340 status_code: i32,
341 success: bool,
342 message: String,
343 content_type: String,
344 provider_type: String,
345 measuring: Option<CustomResourceMeasuring>,
346 trace_context: Option<TraceContext>,
347 properties: BTreeMap<String, String>,
348}
349
350impl CustomResource {
351 pub fn new(url: impl Into<String>, method: impl Into<String>) -> Result<Self> {
352 let url = required_http_url("custom_resource.url", url.into())?;
353 let method = required_string("custom_resource.method", method.into())?;
354 Ok(Self {
355 name: url.clone(),
356 url,
357 method,
358 resource_type: CustomResourceType::Other,
359 status_code: 0,
360 success: true,
361 message: String::new(),
362 content_type: String::new(),
363 provider_type: String::new(),
364 measuring: None,
365 trace_context: None,
366 properties: BTreeMap::new(),
367 })
368 }
369
370 pub fn name(mut self, value: impl Into<String>) -> Self {
371 self.name = value.into();
372 self
373 }
374
375 pub fn resource_type(mut self, value: CustomResourceType) -> Self {
376 self.resource_type = value;
377 self
378 }
379
380 pub fn status_code(mut self, value: i32) -> Self {
381 self.status_code = value;
382 self
383 }
384
385 pub fn success(mut self, value: bool) -> Self {
386 self.success = value;
387 self
388 }
389
390 pub fn message(mut self, value: impl Into<String>) -> Self {
391 self.message = value.into();
392 self
393 }
394
395 pub fn content_type(mut self, value: impl Into<String>) -> Self {
396 self.content_type = value.into();
397 self
398 }
399
400 pub fn provider(mut self, value: impl Into<String>) -> Self {
401 self.provider_type = value.into();
402 self
403 }
404
405 pub fn measuring(mut self, value: CustomResourceMeasuring) -> Self {
406 self.measuring = Some(value);
407 self
408 }
409
410 pub fn trace_context(mut self, value: TraceContext) -> Self {
411 self.trace_context = Some(value);
412 self
413 }
414
415 pub fn attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Result<Self> {
416 insert_property(
417 &mut self.properties,
418 key.into(),
419 value.into(),
420 "custom_resource.attribute",
421 )?;
422 Ok(self)
423 }
424
425 pub(crate) fn into_resource_event(
426 self,
427 context: RumContextSnapshot,
428 config: &RumConfig,
429 ) -> ResourceEvent {
430 let timing = self
431 .measuring
432 .map(CustomResourceMeasuring::to_timing)
433 .unwrap_or_default();
434 let size = self
435 .measuring
436 .map(|measuring| measuring.size_bytes)
437 .unwrap_or_default();
438 let measuring = self.measuring.map(CustomResourceMeasuring::to_json_string);
439 let (trace_id, span_id, trace_headers) = trace_fields(self.trace_context.as_ref(), config);
440 let timestamp = now_millis();
441 let timing_data = ResourceTimingData::from_custom_measuring(self.url.clone(), &timing);
442 ResourceEvent {
443 context,
444 event_id: new_event_id(),
445 timestamp,
446 event_type: "resource".to_string(),
447 url: self.url,
448 method: self.method,
449 name: self.name,
450 resource_type: self.resource_type.as_str().to_string(),
451 status_code: self.status_code,
452 success: self.success,
453 message: self.message,
454 content_type: self.content_type,
455 size,
456 trace_id,
457 span_id,
458 trace_headers,
459 timing,
460 timing_data,
461 provider_type: Some(self.provider_type),
462 measuring,
463 properties: self.properties,
464 }
465 }
466}
467
468fn trace_fields(
469 context: Option<&TraceContext>,
470 config: &RumConfig,
471) -> (String, String, Vec<(String, String)>) {
472 let Some(context) = context else {
473 return (String::new(), String::new(), Vec::new());
474 };
475 let mut headers = TraceHeaderWriter::generate_headers(context);
476 headers.push((
477 "tracestate".to_string(),
478 format!(
479 "rum=v2,app_id={},sdk_version={},instrumentation=custom_resource",
480 config.app_id(),
481 SDK_VERSION
482 ),
483 ));
484 (
485 context.trace_id().to_string(),
486 context.span_id().to_string(),
487 headers,
488 )
489}
490
491#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
492pub struct CustomResourceMeasuring {
493 pub duration_ms: u64,
494 pub size_bytes: u64,
495 pub connect_duration_ms: u64,
496 pub ssl_duration_ms: u64,
497 pub dns_duration_ms: u64,
498 pub redirect_duration_ms: u64,
499 pub first_byte_duration_ms: u64,
500 pub download_duration_ms: u64,
501}
502
503impl CustomResourceMeasuring {
504 fn to_timing(self) -> ResourceTiming {
505 ResourceTiming {
506 duration_ms: self.duration_ms,
507 dns_duration_ms: self.dns_duration_ms,
508 connect_duration_ms: self.connect_duration_ms,
509 ssl_duration_ms: self.ssl_duration_ms,
510 redirect_duration_ms: self.redirect_duration_ms,
511 first_byte_duration_ms: self.first_byte_duration_ms,
512 download_duration_ms: self.download_duration_ms,
513 }
514 }
515
516 fn to_json_string(self) -> String {
517 json!({
518 "dns_duration": self.dns_duration_ms,
519 "connect_duration": self.connect_duration_ms,
520 "ssl_duration": self.ssl_duration_ms,
521 "first_byte_duration": self.first_byte_duration_ms,
522 "download_duration": self.download_duration_ms,
523 "redirect_duration": self.redirect_duration_ms,
524 "duration": self.duration_ms,
525 "size": self.size_bytes,
526 })
527 .to_string()
528 }
529}
530
531#[derive(Clone, Copy, Debug, Eq, PartialEq)]
532pub enum CustomResourceType {
533 Document,
534 Xhr,
535 Fetch,
536 Beacon,
537 Css,
538 Js,
539 Image,
540 Font,
541 Media,
542 Other,
543}
544
545impl CustomResourceType {
546 pub(crate) fn as_str(self) -> &'static str {
547 match self {
548 Self::Document => "document",
549 Self::Xhr => "xhr",
550 Self::Fetch => "fetch",
551 Self::Beacon => "beacon",
552 Self::Css => "css",
553 Self::Js => "js",
554 Self::Image => "image",
555 Self::Font => "font",
556 Self::Media => "media",
557 Self::Other => "other",
558 }
559 }
560}
561
562fn required_string(field: &'static str, value: String) -> Result<String> {
563 if value.trim().is_empty() {
564 return Err(RumError::InvalidConfig {
565 field,
566 message: "must not be empty".to_string(),
567 });
568 }
569 Ok(value)
570}
571
572fn required_http_url(field: &'static str, value: String) -> Result<String> {
573 let value = required_string(field, value)?;
574 let url = Url::parse(&value).map_err(|_| RumError::InvalidConfig {
575 field,
576 message: "must be a valid http or https URL".to_string(),
577 })?;
578 match url.scheme() {
579 "http" | "https" => Ok(value),
580 _ => Err(RumError::InvalidConfig {
581 field,
582 message: "must be a valid http or https URL".to_string(),
583 }),
584 }
585}
586
587fn finite_number(field: &'static str, value: f64) -> Result<f64> {
588 if !value.is_finite() {
589 return Err(RumError::InvalidConfig {
590 field,
591 message: "must be finite".to_string(),
592 });
593 }
594 Ok(value)
595}
596
597fn insert_property(
598 properties: &mut BTreeMap<String, String>,
599 key: String,
600 value: String,
601 field: &'static str,
602) -> Result<()> {
603 if key.trim().is_empty() {
604 return Err(RumError::InvalidConfig {
605 field,
606 message: "key must not be empty".to_string(),
607 });
608 }
609 if key.len() > MAX_PROPERTY_KEY_BYTES {
610 return Err(RumError::InvalidConfig {
611 field,
612 message: format!("key must be at most {MAX_PROPERTY_KEY_BYTES} UTF-8 bytes"),
613 });
614 }
615 if value.len() > MAX_PROPERTY_VALUE_BYTES {
616 return Err(RumError::InvalidConfig {
617 field,
618 message: format!("value must be at most {MAX_PROPERTY_VALUE_BYTES} UTF-8 bytes"),
619 });
620 }
621 properties.insert(key, value);
622 Ok(())
623}
624
625fn properties_json(properties: &BTreeMap<String, String>) -> Value {
626 let mut object = Map::new();
627 for (key, value) in properties {
628 object.insert(key.clone(), json!(value));
629 }
630 Value::Object(object)
631}
632
633fn new_event_id() -> String {
634 Uuid::new_v4().simple().to_string()
635}
636
637fn now_millis() -> i64 {
638 time::unix_millis(SystemTime::now())
639}
640
641fn format_number(value: f64) -> String {
642 value.to_string()
643}
644
645#[cfg(test)]
646mod tests {
647 use super::*;
648 use crate::trace::TraceProtocol;
649
650 fn context() -> RumContextSnapshot {
651 RumContextSnapshot {
652 session_id: "session".to_string(),
653 view_id: "view".to_string(),
654 view_name: "main-view".to_string(),
655 }
656 }
657
658 fn config() -> RumConfig {
659 RumConfig::builder()
660 .config_address("http://127.0.0.1:8080/rum")
661 .app_id("custom-test-app")
662 .build()
663 .unwrap()
664 }
665
666 #[test]
667 fn custom_event_defaults_and_json_mapping() {
668 let event = CustomEvent::new("biz", "checkout")
669 .unwrap()
670 .into_event(context());
671 let json = event.to_json();
672
673 assert_eq!(json["event_type"], "custom");
674 assert_eq!(json["parent_type"], "event");
675 assert_eq!(json["type"], "biz");
676 assert_eq!(json["name"], "checkout");
677 assert_eq!(json["group"], "default");
678 assert_eq!(json["snapshots"], "");
679 assert_eq!(json["value"], "1");
680 assert!(json.get("log_level").is_none());
681 assert!(json.get("log_content").is_none());
682 }
683
684 #[test]
685 fn custom_log_defaults_and_level_mapping() {
686 let levels = [
687 (CustomLogLevel::Trace, "TRACE"),
688 (CustomLogLevel::Debug, "DEBUG"),
689 (CustomLogLevel::Info, "INFO"),
690 (CustomLogLevel::Warn, "WARN"),
691 (CustomLogLevel::Error, "ERROR"),
692 (CustomLogLevel::Fatal, "FATAL"),
693 ];
694 for (level, expected) in levels {
695 assert_eq!(level.as_str(), expected);
696 }
697
698 let log = CustomLog::new("biz", "payment")
699 .unwrap()
700 .into_event(context());
701 let json = log.to_json();
702
703 assert_eq!(json["event_type"], "custom");
704 assert_eq!(json["parent_type"], "log");
705 assert_eq!(json["log_level"], "TRACE");
706 assert_eq!(json["log_content"], "");
707 }
708
709 #[test]
710 fn custom_values_must_be_finite() {
711 let event = CustomEvent::new("biz", "checkout")
712 .unwrap()
713 .value(2.5)
714 .unwrap()
715 .into_event(context());
716 assert_eq!(event.to_json()["value"], "2.5");
717
718 assert!(matches!(
719 CustomEvent::new("biz", "checkout").unwrap().value(f64::NAN),
720 Err(RumError::InvalidConfig {
721 field: "custom_event.value",
722 ..
723 })
724 ));
725 assert!(matches!(
726 CustomLog::new("biz", "payment")
727 .unwrap()
728 .value(f64::INFINITY),
729 Err(RumError::InvalidConfig {
730 field: "custom_log.value",
731 ..
732 })
733 ));
734 assert!(matches!(
735 CustomLog::new("biz", "payment")
736 .unwrap()
737 .value(f64::NEG_INFINITY),
738 Err(RumError::InvalidConfig {
739 field: "custom_log.value",
740 ..
741 })
742 ));
743 }
744
745 #[test]
746 fn custom_exception_defaults_and_setters() {
747 let exception = CustomException::new("Panic", "index out of bounds")
748 .unwrap()
749 .file("main.rs")
750 .stack("stack line")
751 .caused_by("panic hook")
752 .into_event(context());
753 let json = exception.to_json();
754
755 assert_eq!(json["event_type"], "exception");
756 assert_eq!(json["type"], "custom");
757 assert_eq!(json["name"], "Panic");
758 assert_eq!(json["message"], "index out of bounds");
759 assert_eq!(json["source"], "event");
760 assert_eq!(json["file"], "main.rs");
761 assert_eq!(json["stack"], "stack line");
762 assert_eq!(json["caused_by"], "panic hook");
763 }
764
765 #[test]
766 fn required_fields_reject_empty_values() {
767 assert!(matches!(
768 CustomEvent::new("", "checkout"),
769 Err(RumError::InvalidConfig {
770 field: "custom_event.type",
771 ..
772 })
773 ));
774 assert!(matches!(
775 CustomEvent::new("biz", ""),
776 Err(RumError::InvalidConfig {
777 field: "custom_event.name",
778 ..
779 })
780 ));
781 assert!(matches!(
782 CustomEvent::new(" ", "checkout"),
783 Err(RumError::InvalidConfig {
784 field: "custom_event.type",
785 ..
786 })
787 ));
788 assert!(matches!(
789 CustomEvent::new("biz", " "),
790 Err(RumError::InvalidConfig {
791 field: "custom_event.name",
792 ..
793 })
794 ));
795 assert!(matches!(
796 CustomLog::new("", "payment"),
797 Err(RumError::InvalidConfig {
798 field: "custom_log.type",
799 ..
800 })
801 ));
802 assert!(matches!(
803 CustomLog::new("biz", ""),
804 Err(RumError::InvalidConfig {
805 field: "custom_log.name",
806 ..
807 })
808 ));
809 assert!(matches!(
810 CustomLog::new(" ", "payment"),
811 Err(RumError::InvalidConfig {
812 field: "custom_log.type",
813 ..
814 })
815 ));
816 assert!(matches!(
817 CustomLog::new("biz", " "),
818 Err(RumError::InvalidConfig {
819 field: "custom_log.name",
820 ..
821 })
822 ));
823 assert!(matches!(
824 CustomException::new("", "message"),
825 Err(RumError::InvalidConfig {
826 field: "custom_exception.name",
827 ..
828 })
829 ));
830 assert!(matches!(
831 CustomException::new("Panic", ""),
832 Err(RumError::InvalidConfig {
833 field: "custom_exception.message",
834 ..
835 })
836 ));
837 assert!(matches!(
838 CustomException::new(" ", "message"),
839 Err(RumError::InvalidConfig {
840 field: "custom_exception.name",
841 ..
842 })
843 ));
844 assert!(matches!(
845 CustomException::new("Panic", " "),
846 Err(RumError::InvalidConfig {
847 field: "custom_exception.message",
848 ..
849 })
850 ));
851 assert!(matches!(
852 CustomResource::new("", "GET"),
853 Err(RumError::InvalidConfig {
854 field: "custom_resource.url",
855 ..
856 })
857 ));
858 assert!(matches!(
859 CustomResource::new(" ", "GET"),
860 Err(RumError::InvalidConfig {
861 field: "custom_resource.url",
862 ..
863 })
864 ));
865 assert!(matches!(
866 CustomResource::new("not a url", "GET"),
867 Err(RumError::InvalidConfig {
868 field: "custom_resource.url",
869 ..
870 })
871 ));
872 assert!(matches!(
873 CustomResource::new("ftp://example.com/file", "GET"),
874 Err(RumError::InvalidConfig {
875 field: "custom_resource.url",
876 ..
877 })
878 ));
879 assert!(matches!(
880 CustomResource::new("https://example.com", ""),
881 Err(RumError::InvalidConfig {
882 field: "custom_resource.method",
883 ..
884 })
885 ));
886 assert!(matches!(
887 CustomResource::new("https://example.com", " "),
888 Err(RumError::InvalidConfig {
889 field: "custom_resource.method",
890 ..
891 })
892 ));
893 }
894
895 #[test]
896 fn property_limits_are_counted_in_utf8_bytes() {
897 let key_20_bytes = "12345678901234567890";
898 CustomEvent::new("biz", "checkout")
899 .unwrap()
900 .extra(key_20_bytes, "ok")
901 .unwrap();
902
903 let key_21_bytes = "中文中文中文中";
904 assert_eq!(key_21_bytes.len(), 21);
905 assert!(matches!(
906 CustomEvent::new("biz", "checkout")
907 .unwrap()
908 .extra(key_21_bytes, "ok"),
909 Err(RumError::InvalidConfig {
910 field: "custom_event.extra",
911 ..
912 })
913 ));
914
915 let too_long_value = "x".repeat(5001);
916 assert!(matches!(
917 CustomLog::new("biz", "payment")
918 .unwrap()
919 .extra("order_id", too_long_value),
920 Err(RumError::InvalidConfig {
921 field: "custom_log.extra",
922 ..
923 })
924 ));
925
926 assert!(matches!(
927 CustomEvent::new("biz", "checkout").unwrap().extra("", "ok"),
928 Err(RumError::InvalidConfig {
929 field: "custom_event.extra",
930 ..
931 })
932 ));
933 assert!(matches!(
934 CustomLog::new("biz", "payment").unwrap().extra(" ", "ok"),
935 Err(RumError::InvalidConfig {
936 field: "custom_log.extra",
937 ..
938 })
939 ));
940 assert!(matches!(
941 CustomResource::new("https://api.example.com/orders/1", "GET")
942 .unwrap()
943 .attribute("", "ok"),
944 Err(RumError::InvalidConfig {
945 field: "custom_resource.attribute",
946 ..
947 })
948 ));
949 assert!(matches!(
950 CustomResource::new("https://api.example.com/orders/1", "GET")
951 .unwrap()
952 .attribute(" ", "ok"),
953 Err(RumError::InvalidConfig {
954 field: "custom_resource.attribute",
955 ..
956 })
957 ));
958 }
959
960 #[test]
961 fn custom_resource_type_mapping() {
962 let mappings = [
963 (CustomResourceType::Document, "document"),
964 (CustomResourceType::Xhr, "xhr"),
965 (CustomResourceType::Fetch, "fetch"),
966 (CustomResourceType::Beacon, "beacon"),
967 (CustomResourceType::Css, "css"),
968 (CustomResourceType::Js, "js"),
969 (CustomResourceType::Image, "image"),
970 (CustomResourceType::Font, "font"),
971 (CustomResourceType::Media, "media"),
972 (CustomResourceType::Other, "other"),
973 ];
974
975 for (resource_type, expected) in mappings {
976 assert_eq!(resource_type.as_str(), expected);
977 }
978 }
979
980 #[test]
981 fn custom_resource_defaults_to_zero_timing_without_measuring() {
982 let config = config();
983 let resource = CustomResource::new("https://api.example.com/orders/1", "GET")
984 .unwrap()
985 .into_resource_event(context(), &config);
986 let json = resource.to_json();
987
988 assert_eq!(json["event_type"], "resource");
989 assert_eq!(json["url"], "https://api.example.com/orders/1");
990 assert_eq!(json["method"], "GET");
991 assert_eq!(json["name"], "https://api.example.com/orders/1");
992 assert_eq!(json["type"], "other");
993 assert_eq!(json["status_code"], "0");
994 assert_eq!(json["success"], "1");
995 assert_eq!(json["message"], "");
996 assert_eq!(json["duration"], "0");
997 assert_eq!(json["size"], "0");
998 assert_eq!(json["content_type"], "");
999 assert_eq!(json["provider_type"], "");
1000 assert!(json.get("measuring").is_none());
1001 let trace_data: Value = serde_json::from_str(json["trace_data"].as_str().unwrap()).unwrap();
1002 assert_eq!(trace_data["spanId"], "");
1003 assert!(trace_data["headers"].as_object().unwrap().is_empty());
1004
1005 let timing_data: Value =
1006 serde_json::from_str(json["timing_data"].as_str().unwrap()).unwrap();
1007 assert_eq!(timing_data.as_object().unwrap().len(), 10);
1008 assert_eq!(timing_data["name"], "https://api.example.com/orders/1");
1009 assert_eq!(timing_data["duration"], "0");
1010 assert_eq!(timing_data["domainLookupStart"], "0");
1011 assert_eq!(timing_data["domainLookupEnd"], "0");
1012 assert_eq!(timing_data["connectStart"], "0");
1013 assert_eq!(timing_data["connectEnd"], "0");
1014 assert_eq!(timing_data["secureConnectionStart"], "0");
1015 assert_eq!(timing_data["requestStart"], "0");
1016 assert_eq!(timing_data["responseStart"], "0");
1017 assert_eq!(timing_data["responseEnd"], "0");
1018 assert!(timing_data.get("fetchStartDate").is_none());
1019 assert!(timing_data.get("connect_duration").is_none());
1020 }
1021
1022 #[test]
1023 fn custom_resource_status_code_preserves_any_i32() {
1024 let config = config();
1025 for status_code in [-1, 0, 200, 600] {
1026 let resource = CustomResource::new("https://api.example.com/orders/1", "CUSTOM")
1027 .unwrap()
1028 .status_code(status_code)
1029 .into_resource_event(context(), &config);
1030 let json = resource.to_json();
1031 assert_eq!(json["method"], "CUSTOM");
1032 assert_eq!(json["status_code"], status_code.to_string());
1033 }
1034 }
1035
1036 #[test]
1037 fn custom_resource_measuring_and_trace_mapping() {
1038 let config = config();
1039 let trace = TraceContext::new(
1040 "4bf92f3577b34da6a3ce929d0e0e4736",
1041 "00f067aa0ba902b7",
1042 TraceProtocol::TraceParent,
1043 )
1044 .unwrap();
1045 let resource = CustomResource::new("https://api.example.com/orders/1", "GET")
1046 .unwrap()
1047 .resource_type(CustomResourceType::Fetch)
1048 .status_code(200)
1049 .success(true)
1050 .provider("first-party")
1051 .measuring(CustomResourceMeasuring {
1052 duration_ms: 120,
1053 size_bytes: 2048,
1054 connect_duration_ms: 10,
1055 ssl_duration_ms: 20,
1056 dns_duration_ms: 5,
1057 redirect_duration_ms: 0,
1058 first_byte_duration_ms: 45,
1059 download_duration_ms: 40,
1060 })
1061 .trace_context(trace)
1062 .into_resource_event(context(), &config);
1063 let json = resource.to_json();
1064
1065 assert_eq!(json["type"], "fetch");
1066 assert_eq!(json["status_code"], "200");
1067 assert_eq!(json["success"], "1");
1068 assert_eq!(json["provider_type"], "first-party");
1069 assert_eq!(json["duration"], "120");
1070 assert_eq!(json["connect_duration"], "10");
1071 assert_eq!(json["ssl_duration"], "20");
1072 assert_eq!(json["dns_duration"], "5");
1073 assert_eq!(json["first_byte_duration"], "45");
1074 assert_eq!(json["download_duration"], "40");
1075 assert_eq!(json["size"], "2048");
1076 assert_eq!(json["trace_id"], "4bf92f3577b34da6a3ce929d0e0e4736");
1077
1078 let trace_data: Value = serde_json::from_str(json["trace_data"].as_str().unwrap()).unwrap();
1079 assert_eq!(trace_data["spanId"], "00f067aa0ba902b7");
1080 assert!(trace_data.get("traceId").is_none());
1081 assert_eq!(
1082 trace_data["headers"]["traceparent"],
1083 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
1084 );
1085 assert_eq!(
1086 trace_data["headers"]["tracestate"],
1087 format!(
1088 "rum=v2,app_id=custom-test-app,sdk_version={},instrumentation=custom_resource",
1089 SDK_VERSION
1090 )
1091 );
1092
1093 let measuring: Value = serde_json::from_str(json["measuring"].as_str().unwrap()).unwrap();
1094 assert_eq!(measuring["duration"], 120);
1095 assert_eq!(measuring["size"], 2048);
1096 assert_eq!(measuring["dns_duration"], 5);
1097
1098 let timing_data: Value =
1099 serde_json::from_str(json["timing_data"].as_str().unwrap()).unwrap();
1100 assert_eq!(timing_data["name"], "https://api.example.com/orders/1");
1101 assert_eq!(timing_data["duration"], "120");
1102 assert_eq!(timing_data["domainLookupStart"], "0");
1103 assert_eq!(timing_data["domainLookupEnd"], "5");
1104 assert_eq!(timing_data["connectStart"], "5");
1105 assert_eq!(timing_data["connectEnd"], "15");
1106 assert_eq!(timing_data["secureConnectionStart"], "0");
1107 assert_eq!(timing_data["requestStart"], "15");
1108 assert_eq!(timing_data["responseStart"], "60");
1109 assert_eq!(timing_data["responseEnd"], "120");
1110 assert!(timing_data.get("fetchStartDate").is_none());
1111 assert!(timing_data.get("connect_duration").is_none());
1112 }
1113}