use std::collections::BTreeMap;
use std::time::SystemTime;
use serde_json::{json, Map, Value};
use url::Url;
use uuid::Uuid;
use super::resource::{ResourceEvent, ResourceTiming, ResourceTimingData};
use crate::config::{RumConfig, SDK_VERSION};
use crate::context::RumContextSnapshot;
use crate::error::{Result, RumError};
use crate::time;
use crate::trace::{TraceContext, TraceHeaderWriter};
const MAX_PROPERTY_KEY_BYTES: usize = 20;
const MAX_PROPERTY_VALUE_BYTES: usize = 5000;
#[derive(Clone, Debug)]
pub(crate) struct CustomEventData {
pub context: RumContextSnapshot,
pub event_id: String,
pub timestamp: i64,
pub parent_type: CustomParentType,
pub type_name: String,
pub name: String,
pub group: String,
pub snapshots: String,
pub value: f64,
pub log_level: Option<CustomLogLevel>,
pub log_content: Option<String>,
pub properties: BTreeMap<String, String>,
}
impl CustomEventData {
pub fn to_json(&self) -> Value {
let mut event = Map::new();
event.insert("timestamp".to_string(), json!(self.timestamp));
event.insert("event_id".to_string(), json!(self.event_id));
event.insert("event_type".to_string(), json!("custom"));
event.insert("parent_type".to_string(), json!(self.parent_type.as_str()));
event.insert("type".to_string(), json!(self.type_name));
event.insert("name".to_string(), json!(self.name));
event.insert("group".to_string(), json!(self.group));
event.insert("snapshots".to_string(), json!(self.snapshots));
event.insert("value".to_string(), json!(format_number(self.value)));
if let Some(level) = self.log_level {
event.insert("log_level".to_string(), json!(level.as_str()));
}
if let Some(content) = &self.log_content {
event.insert("log_content".to_string(), json!(content));
}
if !self.properties.is_empty() {
event.insert("properties".to_string(), properties_json(&self.properties));
}
Value::Object(event)
}
}
#[derive(Clone, Debug)]
pub(crate) struct CustomExceptionEvent {
pub context: RumContextSnapshot,
pub event_id: String,
pub timestamp: i64,
pub name: String,
pub message: String,
pub source: String,
pub file: String,
pub stack: String,
pub caused_by: String,
}
impl CustomExceptionEvent {
pub fn to_json(&self) -> Value {
json!({
"timestamp": self.timestamp,
"event_id": self.event_id,
"event_type": "exception",
"type": "custom",
"name": self.name,
"message": self.message,
"source": self.source,
"file": self.file,
"stack": self.stack,
"caused_by": self.caused_by,
})
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum CustomParentType {
Event,
Log,
}
impl CustomParentType {
fn as_str(self) -> &'static str {
match self {
Self::Event => "event",
Self::Log => "log",
}
}
}
#[derive(Clone, Debug)]
pub struct CustomEvent {
type_name: String,
name: String,
group: String,
snapshots: String,
value: f64,
properties: BTreeMap<String, String>,
}
impl CustomEvent {
pub fn new(event_type: impl Into<String>, name: impl Into<String>) -> Result<Self> {
let type_name = required_string("custom_event.type", event_type.into())?;
let name = required_string("custom_event.name", name.into())?;
Ok(Self {
type_name,
name,
group: "default".to_string(),
snapshots: String::new(),
value: 1.0,
properties: BTreeMap::new(),
})
}
pub fn group(mut self, value: impl Into<String>) -> Self {
self.group = value.into();
self
}
pub fn snapshots(mut self, value: impl Into<String>) -> Self {
self.snapshots = value.into();
self
}
pub fn value(mut self, value: f64) -> Result<Self> {
self.value = finite_number("custom_event.value", value)?;
Ok(self)
}
pub fn extra(mut self, key: impl Into<String>, value: impl Into<String>) -> Result<Self> {
insert_property(
&mut self.properties,
key.into(),
value.into(),
"custom_event.extra",
)?;
Ok(self)
}
pub(crate) fn into_event(self, context: RumContextSnapshot) -> CustomEventData {
CustomEventData {
context,
event_id: new_event_id(),
timestamp: now_millis(),
parent_type: CustomParentType::Event,
type_name: self.type_name,
name: self.name,
group: self.group,
snapshots: self.snapshots,
value: self.value,
log_level: None,
log_content: None,
properties: self.properties,
}
}
}
#[derive(Clone, Debug)]
pub struct CustomLog {
type_name: String,
name: String,
group: String,
snapshots: String,
value: f64,
level: CustomLogLevel,
content: String,
properties: BTreeMap<String, String>,
}
impl CustomLog {
pub fn new(log_type: impl Into<String>, name: impl Into<String>) -> Result<Self> {
let type_name = required_string("custom_log.type", log_type.into())?;
let name = required_string("custom_log.name", name.into())?;
Ok(Self {
type_name,
name,
group: "default".to_string(),
snapshots: String::new(),
value: 1.0,
level: CustomLogLevel::Trace,
content: String::new(),
properties: BTreeMap::new(),
})
}
pub fn group(mut self, value: impl Into<String>) -> Self {
self.group = value.into();
self
}
pub fn snapshots(mut self, value: impl Into<String>) -> Self {
self.snapshots = value.into();
self
}
pub fn value(mut self, value: f64) -> Result<Self> {
self.value = finite_number("custom_log.value", value)?;
Ok(self)
}
pub fn level(mut self, value: CustomLogLevel) -> Self {
self.level = value;
self
}
pub fn content(mut self, value: impl Into<String>) -> Self {
self.content = value.into();
self
}
pub fn extra(mut self, key: impl Into<String>, value: impl Into<String>) -> Result<Self> {
insert_property(
&mut self.properties,
key.into(),
value.into(),
"custom_log.extra",
)?;
Ok(self)
}
pub(crate) fn into_event(self, context: RumContextSnapshot) -> CustomEventData {
CustomEventData {
context,
event_id: new_event_id(),
timestamp: now_millis(),
parent_type: CustomParentType::Log,
type_name: self.type_name,
name: self.name,
group: self.group,
snapshots: self.snapshots,
value: self.value,
log_level: Some(self.level),
log_content: Some(self.content),
properties: self.properties,
}
}
}
#[derive(Clone, Debug)]
pub struct CustomException {
name: String,
message: String,
source: String,
file: String,
stack: String,
caused_by: String,
}
impl CustomException {
pub fn new(name: impl Into<String>, message: impl Into<String>) -> Result<Self> {
let name = required_string("custom_exception.name", name.into())?;
let message = required_string("custom_exception.message", message.into())?;
Ok(Self {
name,
message,
source: "event".to_string(),
file: String::new(),
stack: String::new(),
caused_by: String::new(),
})
}
pub fn source(mut self, value: impl Into<String>) -> Self {
self.source = value.into();
self
}
pub fn file(mut self, value: impl Into<String>) -> Self {
self.file = value.into();
self
}
pub fn stack(mut self, value: impl Into<String>) -> Self {
self.stack = value.into();
self
}
pub fn caused_by(mut self, value: impl Into<String>) -> Self {
self.caused_by = value.into();
self
}
pub(crate) fn into_event(self, context: RumContextSnapshot) -> CustomExceptionEvent {
CustomExceptionEvent {
context,
event_id: new_event_id(),
timestamp: now_millis(),
name: self.name,
message: self.message,
source: self.source,
file: self.file,
stack: self.stack,
caused_by: self.caused_by,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CustomLogLevel {
Trace,
Debug,
Info,
Warn,
Error,
Fatal,
}
impl CustomLogLevel {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Trace => "TRACE",
Self::Debug => "DEBUG",
Self::Info => "INFO",
Self::Warn => "WARN",
Self::Error => "ERROR",
Self::Fatal => "FATAL",
}
}
}
#[derive(Clone, Debug)]
pub struct CustomResource {
url: String,
method: String,
name: String,
resource_type: CustomResourceType,
status_code: i32,
success: bool,
message: String,
content_type: String,
provider_type: String,
measuring: Option<CustomResourceMeasuring>,
trace_context: Option<TraceContext>,
properties: BTreeMap<String, String>,
}
impl CustomResource {
pub fn new(url: impl Into<String>, method: impl Into<String>) -> Result<Self> {
let url = required_http_url("custom_resource.url", url.into())?;
let method = required_string("custom_resource.method", method.into())?;
Ok(Self {
name: url.clone(),
url,
method,
resource_type: CustomResourceType::Other,
status_code: 0,
success: true,
message: String::new(),
content_type: String::new(),
provider_type: String::new(),
measuring: None,
trace_context: None,
properties: BTreeMap::new(),
})
}
pub fn name(mut self, value: impl Into<String>) -> Self {
self.name = value.into();
self
}
pub fn resource_type(mut self, value: CustomResourceType) -> Self {
self.resource_type = value;
self
}
pub fn status_code(mut self, value: i32) -> Self {
self.status_code = value;
self
}
pub fn success(mut self, value: bool) -> Self {
self.success = value;
self
}
pub fn message(mut self, value: impl Into<String>) -> Self {
self.message = value.into();
self
}
pub fn content_type(mut self, value: impl Into<String>) -> Self {
self.content_type = value.into();
self
}
pub fn provider(mut self, value: impl Into<String>) -> Self {
self.provider_type = value.into();
self
}
pub fn measuring(mut self, value: CustomResourceMeasuring) -> Self {
self.measuring = Some(value);
self
}
pub fn trace_context(mut self, value: TraceContext) -> Self {
self.trace_context = Some(value);
self
}
pub fn attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Result<Self> {
insert_property(
&mut self.properties,
key.into(),
value.into(),
"custom_resource.attribute",
)?;
Ok(self)
}
pub(crate) fn into_resource_event(
self,
context: RumContextSnapshot,
config: &RumConfig,
) -> ResourceEvent {
let timing = self
.measuring
.map(CustomResourceMeasuring::to_timing)
.unwrap_or_default();
let size = self
.measuring
.map(|measuring| measuring.size_bytes)
.unwrap_or_default();
let measuring = self.measuring.map(CustomResourceMeasuring::to_json_string);
let (trace_id, span_id, trace_headers) = trace_fields(self.trace_context.as_ref(), config);
let timestamp = now_millis();
let timing_data = ResourceTimingData::from_custom_measuring(self.url.clone(), &timing);
ResourceEvent {
context,
event_id: new_event_id(),
timestamp,
event_type: "resource".to_string(),
url: self.url,
method: self.method,
name: self.name,
resource_type: self.resource_type.as_str().to_string(),
status_code: self.status_code,
success: self.success,
message: self.message,
content_type: self.content_type,
size,
trace_id,
span_id,
trace_headers,
timing,
timing_data,
provider_type: Some(self.provider_type),
measuring,
properties: self.properties,
}
}
}
fn trace_fields(
context: Option<&TraceContext>,
config: &RumConfig,
) -> (String, String, Vec<(String, String)>) {
let Some(context) = context else {
return (String::new(), String::new(), Vec::new());
};
let mut headers = TraceHeaderWriter::generate_headers(context);
headers.push((
"tracestate".to_string(),
format!(
"rum=v2,app_id={},sdk_version={},instrumentation=custom_resource",
config.app_id(),
SDK_VERSION
),
));
(
context.trace_id().to_string(),
context.span_id().to_string(),
headers,
)
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct CustomResourceMeasuring {
pub duration_ms: u64,
pub size_bytes: u64,
pub connect_duration_ms: u64,
pub ssl_duration_ms: u64,
pub dns_duration_ms: u64,
pub redirect_duration_ms: u64,
pub first_byte_duration_ms: u64,
pub download_duration_ms: u64,
}
impl CustomResourceMeasuring {
fn to_timing(self) -> ResourceTiming {
ResourceTiming {
duration_ms: self.duration_ms,
dns_duration_ms: self.dns_duration_ms,
connect_duration_ms: self.connect_duration_ms,
ssl_duration_ms: self.ssl_duration_ms,
redirect_duration_ms: self.redirect_duration_ms,
first_byte_duration_ms: self.first_byte_duration_ms,
download_duration_ms: self.download_duration_ms,
}
}
fn to_json_string(self) -> String {
json!({
"dns_duration": self.dns_duration_ms,
"connect_duration": self.connect_duration_ms,
"ssl_duration": self.ssl_duration_ms,
"first_byte_duration": self.first_byte_duration_ms,
"download_duration": self.download_duration_ms,
"redirect_duration": self.redirect_duration_ms,
"duration": self.duration_ms,
"size": self.size_bytes,
})
.to_string()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CustomResourceType {
Document,
Xhr,
Fetch,
Beacon,
Css,
Js,
Image,
Font,
Media,
Other,
}
impl CustomResourceType {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Document => "document",
Self::Xhr => "xhr",
Self::Fetch => "fetch",
Self::Beacon => "beacon",
Self::Css => "css",
Self::Js => "js",
Self::Image => "image",
Self::Font => "font",
Self::Media => "media",
Self::Other => "other",
}
}
}
fn required_string(field: &'static str, value: String) -> Result<String> {
if value.trim().is_empty() {
return Err(RumError::InvalidConfig {
field,
message: "must not be empty".to_string(),
});
}
Ok(value)
}
fn required_http_url(field: &'static str, value: String) -> Result<String> {
let value = required_string(field, value)?;
let url = Url::parse(&value).map_err(|_| RumError::InvalidConfig {
field,
message: "must be a valid http or https URL".to_string(),
})?;
match url.scheme() {
"http" | "https" => Ok(value),
_ => Err(RumError::InvalidConfig {
field,
message: "must be a valid http or https URL".to_string(),
}),
}
}
fn finite_number(field: &'static str, value: f64) -> Result<f64> {
if !value.is_finite() {
return Err(RumError::InvalidConfig {
field,
message: "must be finite".to_string(),
});
}
Ok(value)
}
fn insert_property(
properties: &mut BTreeMap<String, String>,
key: String,
value: String,
field: &'static str,
) -> Result<()> {
if key.trim().is_empty() {
return Err(RumError::InvalidConfig {
field,
message: "key must not be empty".to_string(),
});
}
if key.len() > MAX_PROPERTY_KEY_BYTES {
return Err(RumError::InvalidConfig {
field,
message: format!("key must be at most {MAX_PROPERTY_KEY_BYTES} UTF-8 bytes"),
});
}
if value.len() > MAX_PROPERTY_VALUE_BYTES {
return Err(RumError::InvalidConfig {
field,
message: format!("value must be at most {MAX_PROPERTY_VALUE_BYTES} UTF-8 bytes"),
});
}
properties.insert(key, value);
Ok(())
}
fn properties_json(properties: &BTreeMap<String, String>) -> Value {
let mut object = Map::new();
for (key, value) in properties {
object.insert(key.clone(), json!(value));
}
Value::Object(object)
}
fn new_event_id() -> String {
Uuid::new_v4().simple().to_string()
}
fn now_millis() -> i64 {
time::unix_millis(SystemTime::now())
}
fn format_number(value: f64) -> String {
value.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::trace::TraceProtocol;
fn context() -> RumContextSnapshot {
RumContextSnapshot {
session_id: "session".to_string(),
view_id: "view".to_string(),
view_name: "main-view".to_string(),
}
}
fn config() -> RumConfig {
RumConfig::builder()
.config_address("http://127.0.0.1:8080/rum")
.app_id("custom-test-app")
.build()
.unwrap()
}
#[test]
fn custom_event_defaults_and_json_mapping() {
let event = CustomEvent::new("biz", "checkout")
.unwrap()
.into_event(context());
let json = event.to_json();
assert_eq!(json["event_type"], "custom");
assert_eq!(json["parent_type"], "event");
assert_eq!(json["type"], "biz");
assert_eq!(json["name"], "checkout");
assert_eq!(json["group"], "default");
assert_eq!(json["snapshots"], "");
assert_eq!(json["value"], "1");
assert!(json.get("log_level").is_none());
assert!(json.get("log_content").is_none());
}
#[test]
fn custom_log_defaults_and_level_mapping() {
let levels = [
(CustomLogLevel::Trace, "TRACE"),
(CustomLogLevel::Debug, "DEBUG"),
(CustomLogLevel::Info, "INFO"),
(CustomLogLevel::Warn, "WARN"),
(CustomLogLevel::Error, "ERROR"),
(CustomLogLevel::Fatal, "FATAL"),
];
for (level, expected) in levels {
assert_eq!(level.as_str(), expected);
}
let log = CustomLog::new("biz", "payment")
.unwrap()
.into_event(context());
let json = log.to_json();
assert_eq!(json["event_type"], "custom");
assert_eq!(json["parent_type"], "log");
assert_eq!(json["log_level"], "TRACE");
assert_eq!(json["log_content"], "");
}
#[test]
fn custom_values_must_be_finite() {
let event = CustomEvent::new("biz", "checkout")
.unwrap()
.value(2.5)
.unwrap()
.into_event(context());
assert_eq!(event.to_json()["value"], "2.5");
assert!(matches!(
CustomEvent::new("biz", "checkout").unwrap().value(f64::NAN),
Err(RumError::InvalidConfig {
field: "custom_event.value",
..
})
));
assert!(matches!(
CustomLog::new("biz", "payment")
.unwrap()
.value(f64::INFINITY),
Err(RumError::InvalidConfig {
field: "custom_log.value",
..
})
));
assert!(matches!(
CustomLog::new("biz", "payment")
.unwrap()
.value(f64::NEG_INFINITY),
Err(RumError::InvalidConfig {
field: "custom_log.value",
..
})
));
}
#[test]
fn custom_exception_defaults_and_setters() {
let exception = CustomException::new("Panic", "index out of bounds")
.unwrap()
.file("main.rs")
.stack("stack line")
.caused_by("panic hook")
.into_event(context());
let json = exception.to_json();
assert_eq!(json["event_type"], "exception");
assert_eq!(json["type"], "custom");
assert_eq!(json["name"], "Panic");
assert_eq!(json["message"], "index out of bounds");
assert_eq!(json["source"], "event");
assert_eq!(json["file"], "main.rs");
assert_eq!(json["stack"], "stack line");
assert_eq!(json["caused_by"], "panic hook");
}
#[test]
fn required_fields_reject_empty_values() {
assert!(matches!(
CustomEvent::new("", "checkout"),
Err(RumError::InvalidConfig {
field: "custom_event.type",
..
})
));
assert!(matches!(
CustomEvent::new("biz", ""),
Err(RumError::InvalidConfig {
field: "custom_event.name",
..
})
));
assert!(matches!(
CustomEvent::new(" ", "checkout"),
Err(RumError::InvalidConfig {
field: "custom_event.type",
..
})
));
assert!(matches!(
CustomEvent::new("biz", " "),
Err(RumError::InvalidConfig {
field: "custom_event.name",
..
})
));
assert!(matches!(
CustomLog::new("", "payment"),
Err(RumError::InvalidConfig {
field: "custom_log.type",
..
})
));
assert!(matches!(
CustomLog::new("biz", ""),
Err(RumError::InvalidConfig {
field: "custom_log.name",
..
})
));
assert!(matches!(
CustomLog::new(" ", "payment"),
Err(RumError::InvalidConfig {
field: "custom_log.type",
..
})
));
assert!(matches!(
CustomLog::new("biz", " "),
Err(RumError::InvalidConfig {
field: "custom_log.name",
..
})
));
assert!(matches!(
CustomException::new("", "message"),
Err(RumError::InvalidConfig {
field: "custom_exception.name",
..
})
));
assert!(matches!(
CustomException::new("Panic", ""),
Err(RumError::InvalidConfig {
field: "custom_exception.message",
..
})
));
assert!(matches!(
CustomException::new(" ", "message"),
Err(RumError::InvalidConfig {
field: "custom_exception.name",
..
})
));
assert!(matches!(
CustomException::new("Panic", " "),
Err(RumError::InvalidConfig {
field: "custom_exception.message",
..
})
));
assert!(matches!(
CustomResource::new("", "GET"),
Err(RumError::InvalidConfig {
field: "custom_resource.url",
..
})
));
assert!(matches!(
CustomResource::new(" ", "GET"),
Err(RumError::InvalidConfig {
field: "custom_resource.url",
..
})
));
assert!(matches!(
CustomResource::new("not a url", "GET"),
Err(RumError::InvalidConfig {
field: "custom_resource.url",
..
})
));
assert!(matches!(
CustomResource::new("ftp://example.com/file", "GET"),
Err(RumError::InvalidConfig {
field: "custom_resource.url",
..
})
));
assert!(matches!(
CustomResource::new("https://example.com", ""),
Err(RumError::InvalidConfig {
field: "custom_resource.method",
..
})
));
assert!(matches!(
CustomResource::new("https://example.com", " "),
Err(RumError::InvalidConfig {
field: "custom_resource.method",
..
})
));
}
#[test]
fn property_limits_are_counted_in_utf8_bytes() {
let key_20_bytes = "12345678901234567890";
CustomEvent::new("biz", "checkout")
.unwrap()
.extra(key_20_bytes, "ok")
.unwrap();
let key_21_bytes = "䏿–‡ä¸æ–‡ä¸æ–‡ä¸";
assert_eq!(key_21_bytes.len(), 21);
assert!(matches!(
CustomEvent::new("biz", "checkout")
.unwrap()
.extra(key_21_bytes, "ok"),
Err(RumError::InvalidConfig {
field: "custom_event.extra",
..
})
));
let too_long_value = "x".repeat(5001);
assert!(matches!(
CustomLog::new("biz", "payment")
.unwrap()
.extra("order_id", too_long_value),
Err(RumError::InvalidConfig {
field: "custom_log.extra",
..
})
));
assert!(matches!(
CustomEvent::new("biz", "checkout").unwrap().extra("", "ok"),
Err(RumError::InvalidConfig {
field: "custom_event.extra",
..
})
));
assert!(matches!(
CustomLog::new("biz", "payment").unwrap().extra(" ", "ok"),
Err(RumError::InvalidConfig {
field: "custom_log.extra",
..
})
));
assert!(matches!(
CustomResource::new("https://api.example.com/orders/1", "GET")
.unwrap()
.attribute("", "ok"),
Err(RumError::InvalidConfig {
field: "custom_resource.attribute",
..
})
));
assert!(matches!(
CustomResource::new("https://api.example.com/orders/1", "GET")
.unwrap()
.attribute(" ", "ok"),
Err(RumError::InvalidConfig {
field: "custom_resource.attribute",
..
})
));
}
#[test]
fn custom_resource_type_mapping() {
let mappings = [
(CustomResourceType::Document, "document"),
(CustomResourceType::Xhr, "xhr"),
(CustomResourceType::Fetch, "fetch"),
(CustomResourceType::Beacon, "beacon"),
(CustomResourceType::Css, "css"),
(CustomResourceType::Js, "js"),
(CustomResourceType::Image, "image"),
(CustomResourceType::Font, "font"),
(CustomResourceType::Media, "media"),
(CustomResourceType::Other, "other"),
];
for (resource_type, expected) in mappings {
assert_eq!(resource_type.as_str(), expected);
}
}
#[test]
fn custom_resource_defaults_to_zero_timing_without_measuring() {
let config = config();
let resource = CustomResource::new("https://api.example.com/orders/1", "GET")
.unwrap()
.into_resource_event(context(), &config);
let json = resource.to_json();
assert_eq!(json["event_type"], "resource");
assert_eq!(json["url"], "https://api.example.com/orders/1");
assert_eq!(json["method"], "GET");
assert_eq!(json["name"], "https://api.example.com/orders/1");
assert_eq!(json["type"], "other");
assert_eq!(json["status_code"], "0");
assert_eq!(json["success"], "1");
assert_eq!(json["message"], "");
assert_eq!(json["duration"], "0");
assert_eq!(json["size"], "0");
assert_eq!(json["content_type"], "");
assert_eq!(json["provider_type"], "");
assert!(json.get("measuring").is_none());
let trace_data: Value = serde_json::from_str(json["trace_data"].as_str().unwrap()).unwrap();
assert_eq!(trace_data["spanId"], "");
assert!(trace_data["headers"].as_object().unwrap().is_empty());
let timing_data: Value =
serde_json::from_str(json["timing_data"].as_str().unwrap()).unwrap();
assert_eq!(timing_data.as_object().unwrap().len(), 10);
assert_eq!(timing_data["name"], "https://api.example.com/orders/1");
assert_eq!(timing_data["duration"], "0");
assert_eq!(timing_data["domainLookupStart"], "0");
assert_eq!(timing_data["domainLookupEnd"], "0");
assert_eq!(timing_data["connectStart"], "0");
assert_eq!(timing_data["connectEnd"], "0");
assert_eq!(timing_data["secureConnectionStart"], "0");
assert_eq!(timing_data["requestStart"], "0");
assert_eq!(timing_data["responseStart"], "0");
assert_eq!(timing_data["responseEnd"], "0");
assert!(timing_data.get("fetchStartDate").is_none());
assert!(timing_data.get("connect_duration").is_none());
}
#[test]
fn custom_resource_status_code_preserves_any_i32() {
let config = config();
for status_code in [-1, 0, 200, 600] {
let resource = CustomResource::new("https://api.example.com/orders/1", "CUSTOM")
.unwrap()
.status_code(status_code)
.into_resource_event(context(), &config);
let json = resource.to_json();
assert_eq!(json["method"], "CUSTOM");
assert_eq!(json["status_code"], status_code.to_string());
}
}
#[test]
fn custom_resource_measuring_and_trace_mapping() {
let config = config();
let trace = TraceContext::new(
"4bf92f3577b34da6a3ce929d0e0e4736",
"00f067aa0ba902b7",
TraceProtocol::TraceParent,
)
.unwrap();
let resource = CustomResource::new("https://api.example.com/orders/1", "GET")
.unwrap()
.resource_type(CustomResourceType::Fetch)
.status_code(200)
.success(true)
.provider("first-party")
.measuring(CustomResourceMeasuring {
duration_ms: 120,
size_bytes: 2048,
connect_duration_ms: 10,
ssl_duration_ms: 20,
dns_duration_ms: 5,
redirect_duration_ms: 0,
first_byte_duration_ms: 45,
download_duration_ms: 40,
})
.trace_context(trace)
.into_resource_event(context(), &config);
let json = resource.to_json();
assert_eq!(json["type"], "fetch");
assert_eq!(json["status_code"], "200");
assert_eq!(json["success"], "1");
assert_eq!(json["provider_type"], "first-party");
assert_eq!(json["duration"], "120");
assert_eq!(json["connect_duration"], "10");
assert_eq!(json["ssl_duration"], "20");
assert_eq!(json["dns_duration"], "5");
assert_eq!(json["first_byte_duration"], "45");
assert_eq!(json["download_duration"], "40");
assert_eq!(json["size"], "2048");
assert_eq!(json["trace_id"], "4bf92f3577b34da6a3ce929d0e0e4736");
let trace_data: Value = serde_json::from_str(json["trace_data"].as_str().unwrap()).unwrap();
assert_eq!(trace_data["spanId"], "00f067aa0ba902b7");
assert!(trace_data.get("traceId").is_none());
assert_eq!(
trace_data["headers"]["traceparent"],
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
);
assert_eq!(
trace_data["headers"]["tracestate"],
format!(
"rum=v2,app_id=custom-test-app,sdk_version={},instrumentation=custom_resource",
SDK_VERSION
)
);
let measuring: Value = serde_json::from_str(json["measuring"].as_str().unwrap()).unwrap();
assert_eq!(measuring["duration"], 120);
assert_eq!(measuring["size"], 2048);
assert_eq!(measuring["dns_duration"], 5);
let timing_data: Value =
serde_json::from_str(json["timing_data"].as_str().unwrap()).unwrap();
assert_eq!(timing_data["name"], "https://api.example.com/orders/1");
assert_eq!(timing_data["duration"], "120");
assert_eq!(timing_data["domainLookupStart"], "0");
assert_eq!(timing_data["domainLookupEnd"], "5");
assert_eq!(timing_data["connectStart"], "5");
assert_eq!(timing_data["connectEnd"], "15");
assert_eq!(timing_data["secureConnectionStart"], "0");
assert_eq!(timing_data["requestStart"], "15");
assert_eq!(timing_data["responseStart"], "60");
assert_eq!(timing_data["responseEnd"], "120");
assert!(timing_data.get("fetchStartDate").is_none());
assert!(timing_data.get("connect_duration").is_none());
}
}