Skip to main content

alibabacloud_rum/event/
custom.rs

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}