use std::collections::BTreeMap;
use std::time::{Duration, Instant, SystemTime};
use serde_json::{json, Map, Value};
use url::Url;
use uuid::Uuid;
use crate::context::RumContextSnapshot;
use crate::propagation::TraceInfo;
use crate::time;
#[derive(Clone, Debug, Default)]
pub(crate) struct ResourceTiming {
pub duration_ms: u64,
pub dns_duration_ms: u64,
pub connect_duration_ms: u64,
pub ssl_duration_ms: u64,
pub redirect_duration_ms: u64,
pub first_byte_duration_ms: u64,
pub download_duration_ms: u64,
}
impl ResourceTiming {
pub fn from_durations(total: Duration, first_byte: Option<Duration>) -> Self {
let duration_ms = total.as_millis() as u64;
let first_byte_duration_ms = first_byte.map(|d| d.as_millis() as u64).unwrap_or_default();
let download_duration_ms = duration_ms.saturating_sub(first_byte_duration_ms);
Self {
duration_ms,
dns_duration_ms: 0,
connect_duration_ms: 0,
ssl_duration_ms: 0,
redirect_duration_ms: 0,
first_byte_duration_ms,
download_duration_ms,
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub(crate) struct ResourceTimingData {
pub name: String,
pub duration_ms: u64,
pub domain_lookup_start_ms: u64,
pub domain_lookup_end_ms: u64,
pub connect_start_ms: u64,
pub connect_end_ms: u64,
pub secure_connection_start_ms: u64,
pub request_start_ms: u64,
pub response_start_ms: u64,
pub response_end_ms: u64,
}
impl ResourceTimingData {
pub fn from_reqwest(name: impl Into<String>, timing: &ResourceTiming) -> Self {
Self {
name: name.into(),
duration_ms: timing.duration_ms,
response_start_ms: timing.first_byte_duration_ms,
response_end_ms: timing.duration_ms,
..Self::default()
}
}
pub fn from_custom_measuring(name: impl Into<String>, timing: &ResourceTiming) -> Self {
let domain_lookup_start_ms = timing.redirect_duration_ms;
let domain_lookup_end_ms = domain_lookup_start_ms.saturating_add(timing.dns_duration_ms);
let connect_start_ms = domain_lookup_end_ms;
let connect_end_ms = connect_start_ms.saturating_add(timing.connect_duration_ms);
let secure_connection_start_ms =
if timing.ssl_duration_ms > 0 && timing.ssl_duration_ms <= timing.connect_duration_ms {
connect_end_ms.saturating_sub(timing.ssl_duration_ms)
} else {
0
};
let request_start_ms = connect_end_ms;
let response_start_ms = if timing.first_byte_duration_ms > 0 {
request_start_ms
.saturating_add(timing.first_byte_duration_ms)
.min(timing.duration_ms)
} else if timing.download_duration_ms > 0 {
timing
.duration_ms
.saturating_sub(timing.download_duration_ms)
} else {
0
};
Self {
name: name.into(),
duration_ms: timing.duration_ms,
domain_lookup_start_ms,
domain_lookup_end_ms,
connect_start_ms,
connect_end_ms,
secure_connection_start_ms,
request_start_ms,
response_start_ms,
response_end_ms: timing.duration_ms,
}
}
fn to_json(&self) -> Value {
json!({
"name": self.name,
"duration": self.duration_ms.to_string(),
"domainLookupStart": self.domain_lookup_start_ms.to_string(),
"domainLookupEnd": self.domain_lookup_end_ms.to_string(),
"connectStart": self.connect_start_ms.to_string(),
"connectEnd": self.connect_end_ms.to_string(),
"secureConnectionStart": self.secure_connection_start_ms.to_string(),
"requestStart": self.request_start_ms.to_string(),
"responseStart": self.response_start_ms.to_string(),
"responseEnd": self.response_end_ms.to_string(),
})
}
}
#[derive(Clone, Debug)]
pub(crate) struct PendingResource {
pub context: RumContextSnapshot,
pub event_id: String,
pub timestamp: i64,
pub start: Instant,
pub first_byte: Option<Duration>,
pub url: String,
pub method: String,
pub name: String,
pub resource_type: String,
pub status_code: i32,
pub content_type: String,
pub size: u64,
pub trace: TraceInfo,
}
impl PendingResource {
#[allow(clippy::too_many_arguments)]
pub fn new(
context: RumContextSnapshot,
timestamp: SystemTime,
start: Instant,
url: String,
method: String,
status_code: i32,
content_type: String,
size: u64,
trace: TraceInfo,
) -> Self {
Self {
context,
event_id: Uuid::new_v4().simple().to_string(),
timestamp: time::unix_millis(timestamp),
start,
first_byte: None,
name: resource_name(&url),
resource_type: "api".to_string(),
url,
method,
status_code,
content_type,
size,
trace,
}
}
pub fn complete(self, success: bool, message: impl Into<String>, at: Instant) -> ResourceEvent {
let total = at.saturating_duration_since(self.start);
let timing = ResourceTiming::from_durations(total, self.first_byte);
let timing_data = ResourceTimingData::from_reqwest(self.url.clone(), &timing);
ResourceEvent {
context: self.context,
event_id: self.event_id,
timestamp: self.timestamp,
event_type: "resource".to_string(),
url: self.url,
method: self.method,
name: self.name,
resource_type: self.resource_type,
status_code: self.status_code,
success,
message: message.into(),
content_type: self.content_type,
size: self.size,
trace_id: self.trace.trace_id.unwrap_or_default(),
span_id: self.trace.span_id.unwrap_or_default(),
trace_headers: self.trace.headers,
timing,
timing_data,
provider_type: None,
measuring: None,
properties: BTreeMap::new(),
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct ResourceEvent {
pub context: RumContextSnapshot,
pub event_id: String,
pub timestamp: i64,
pub event_type: String,
pub url: String,
pub method: String,
pub name: String,
pub resource_type: String,
pub status_code: i32,
pub success: bool,
pub message: String,
pub content_type: String,
pub size: u64,
pub trace_id: String,
pub span_id: String,
pub trace_headers: Vec<(String, String)>,
pub timing: ResourceTiming,
pub timing_data: ResourceTimingData,
pub provider_type: Option<String>,
pub measuring: Option<String>,
pub properties: BTreeMap<String, String>,
}
impl ResourceEvent {
pub fn to_json(&self) -> serde_json::Value {
let mut headers = serde_json::Map::new();
for (key, value) in &self.trace_headers {
headers.insert(key.clone(), serde_json::Value::String(value.clone()));
}
let trace_data = json!({
"spanId": self.span_id,
"sampled": "true",
"headers": headers
});
let timing_data = self.timing_data.to_json();
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!(self.event_type));
event.insert("url".to_string(), json!(self.url));
event.insert("method".to_string(), json!(self.method));
event.insert("name".to_string(), json!(self.name));
event.insert("type".to_string(), json!(self.resource_type));
event.insert(
"status_code".to_string(),
json!(self.status_code.to_string()),
);
event.insert(
"success".to_string(),
json!(if self.success { "1" } else { "0" }),
);
event.insert("message".to_string(), json!(self.message));
event.insert(
"duration".to_string(),
json!(self.timing.duration_ms.to_string()),
);
event.insert(
"connect_duration".to_string(),
json!(self.timing.connect_duration_ms.to_string()),
);
event.insert(
"dns_duration".to_string(),
json!(self.timing.dns_duration_ms.to_string()),
);
event.insert(
"download_duration".to_string(),
json!(self.timing.download_duration_ms.to_string()),
);
event.insert(
"first_byte_duration".to_string(),
json!(self.timing.first_byte_duration_ms.to_string()),
);
event.insert(
"redirect_duration".to_string(),
json!(self.timing.redirect_duration_ms.to_string()),
);
event.insert(
"ssl_duration".to_string(),
json!(self.timing.ssl_duration_ms.to_string()),
);
event.insert("size".to_string(), json!(self.size.to_string()));
event.insert("content_type".to_string(), json!(self.content_type));
event.insert("trace_id".to_string(), json!(self.trace_id));
event.insert("trace_data".to_string(), json!(trace_data.to_string()));
event.insert("timing_data".to_string(), json!(timing_data.to_string()));
if let Some(provider_type) = &self.provider_type {
event.insert("provider_type".to_string(), json!(provider_type));
}
if let Some(measuring) = &self.measuring {
event.insert("measuring".to_string(), json!(measuring));
}
if !self.properties.is_empty() {
let mut properties = Map::new();
for (key, value) in &self.properties {
properties.insert(key.clone(), json!(value));
}
event.insert("properties".to_string(), Value::Object(properties));
}
Value::Object(event)
}
}
fn resource_name(url: &str) -> String {
Url::parse(url)
.map(|url| {
let path = url.path();
if path.is_empty() {
"/".to_string()
} else {
path.to_string()
}
})
.unwrap_or_else(|_| url.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn context() -> RumContextSnapshot {
RumContextSnapshot {
session_id: "session".to_string(),
view_id: "view".to_string(),
view_name: "main-view".to_string(),
}
}
#[test]
fn reqwest_resource_does_not_output_custom_resource_fields_by_default() {
let event = ResourceEvent {
context: context(),
event_id: "event".to_string(),
timestamp: 1,
event_type: "resource".to_string(),
url: "https://example.com/api".to_string(),
method: "GET".to_string(),
name: "/api".to_string(),
resource_type: "api".to_string(),
status_code: 200,
success: true,
message: String::new(),
content_type: "application/json".to_string(),
size: 10,
trace_id: "trace".to_string(),
span_id: "span".to_string(),
trace_headers: Vec::new(),
timing: ResourceTiming::default(),
timing_data: ResourceTimingData::default(),
provider_type: None,
measuring: None,
properties: BTreeMap::new(),
};
let json = event.to_json();
assert!(json.get("provider_type").is_none());
assert!(json.get("measuring").is_none());
assert!(json.get("properties").is_none());
}
#[test]
fn reqwest_resource_timing_data_uses_offset_fields() {
let timing = ResourceTiming {
duration_ms: 120,
first_byte_duration_ms: 80,
download_duration_ms: 40,
..ResourceTiming::default()
};
let event = ResourceEvent {
context: context(),
event_id: "event".to_string(),
timestamp: 1_700_000_000_000,
event_type: "resource".to_string(),
url: "https://example.com/api".to_string(),
method: "GET".to_string(),
name: "/api".to_string(),
resource_type: "api".to_string(),
status_code: 200,
success: true,
message: String::new(),
content_type: "application/json".to_string(),
size: 10,
trace_id: "trace".to_string(),
span_id: "span".to_string(),
trace_headers: Vec::new(),
timing: timing.clone(),
timing_data: ResourceTimingData::from_reqwest("https://example.com/api", &timing),
provider_type: None,
measuring: None,
properties: BTreeMap::new(),
};
let json = event.to_json();
let timing_data: Value =
serde_json::from_str(json["timing_data"].as_str().unwrap()).unwrap();
assert_eq!(timing_data["name"], "https://example.com/api");
assert_eq!(timing_data["duration"], "120");
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"], "80");
assert_eq!(timing_data["responseEnd"], "120");
assert!(timing_data.get("fetchStartDate").is_none());
assert!(timing_data.get("connect_duration").is_none());
}
}