mod convert;
use relay_protocol::{
Annotated, Array, Empty, Error, FromValue, Getter, IntoValue, Object, Val, Value,
};
use crate::processor::ProcessValue;
use crate::protocol::{
EventId, IpAddr, JsonLenientString, LenientString, Measurements, MetricsSummary, OperationType,
OriginType, SpanId, SpanStatus, ThreadId, Timestamp, TraceId,
};
#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
#[metastructure(process_func = "process_span", value_type = "Span")]
pub struct Span {
#[metastructure(required = "true", trim = "false")]
pub timestamp: Annotated<Timestamp>,
#[metastructure(required = "true", trim = "false")]
pub start_timestamp: Annotated<Timestamp>,
#[metastructure(trim = "false")]
pub exclusive_time: Annotated<f64>,
#[metastructure(max_chars = 128, trim = "false")]
pub op: Annotated<OperationType>,
#[metastructure(required = "true", trim = "false")]
pub span_id: Annotated<SpanId>,
#[metastructure(trim = "false")]
pub parent_span_id: Annotated<SpanId>,
#[metastructure(required = "true", trim = "false")]
pub trace_id: Annotated<TraceId>,
#[metastructure(trim = "false")]
pub segment_id: Annotated<SpanId>,
#[metastructure(trim = "false")]
pub is_segment: Annotated<bool>,
#[metastructure(trim = "false")]
pub status: Annotated<SpanStatus>,
#[metastructure(pii = "maybe", trim = "false")]
pub description: Annotated<String>,
#[metastructure(pii = "maybe", trim = "false")]
pub tags: Annotated<Object<JsonLenientString>>,
#[metastructure(max_chars = 128, allow_chars = "a-zA-Z0-9_.", trim = "false")]
pub origin: Annotated<OriginType>,
#[metastructure(trim = "false")]
pub profile_id: Annotated<EventId>,
#[metastructure(pii = "true", trim = "false")]
pub data: Annotated<SpanData>,
#[metastructure(trim = "false")]
pub sentry_tags: Annotated<Object<String>>,
#[metastructure(trim = "false")]
pub received: Annotated<Timestamp>,
#[metastructure(skip_serialization = "empty", trim = "false")]
#[metastructure(omit_from_schema)] pub measurements: Annotated<Measurements>,
#[metastructure(skip_serialization = "empty", trim = "false")]
pub _metrics_summary: Annotated<MetricsSummary>,
#[metastructure(skip_serialization = "empty", trim = "false")]
pub platform: Annotated<String>,
#[metastructure(skip_serialization = "empty", trim = "false")]
pub was_transaction: Annotated<bool>,
#[metastructure(additional_properties, retain = "true", pii = "maybe", trim = "false")]
pub other: Object<Value>,
}
impl Span {
fn attribute(&self, key: &str) -> Option<Val<'_>> {
Some(match self.data.value()?.get_value(key) {
Some(value) => value,
None => self.tags.value()?.get(key)?.as_str()?.into(),
})
}
}
impl Getter for Span {
fn get_value(&self, path: &str) -> Option<Val<'_>> {
let span_prefix = path.strip_prefix("span.");
if let Some(span_prefix) = span_prefix {
return Some(match span_prefix {
"exclusive_time" => self.exclusive_time.value()?.into(),
"description" => self.description.as_str()?.into(),
"op" => self.op.as_str()?.into(),
"span_id" => self.span_id.as_str()?.into(),
"parent_span_id" => self.parent_span_id.as_str()?.into(),
"trace_id" => self.trace_id.as_str()?.into(),
"status" => self.status.as_str()?.into(),
"origin" => self.origin.as_str()?.into(),
"duration" => {
let start_timestamp = *self.start_timestamp.value()?;
let timestamp = *self.timestamp.value()?;
relay_common::time::chrono_to_positive_millis(timestamp - start_timestamp)
.into()
}
"was_transaction" => self.was_transaction.value().unwrap_or(&false).into(),
path => {
if let Some(key) = path.strip_prefix("tags.") {
self.tags.value()?.get(key)?.as_str()?.into()
} else if let Some(key) = path.strip_prefix("data.") {
self.attribute(key)?
} else if let Some(key) = path.strip_prefix("sentry_tags.") {
self.sentry_tags.value()?.get(key)?.as_str()?.into()
} else if let Some(rest) = path.strip_prefix("measurements.") {
let name = rest.strip_suffix(".value")?;
self.measurements
.value()?
.get(name)?
.value()?
.value
.value()?
.into()
} else {
return None;
}
}
});
}
let event_prefix = path.strip_prefix("event.")?;
Some(match event_prefix {
"release" => self.data.value()?.release.as_str()?.into(),
"environment" => self.data.value()?.environment.as_str()?.into(),
"transaction" => self.data.value()?.segment_name.as_str()?.into(),
"contexts.browser.name" => self.data.value()?.browser_name.as_str()?.into(),
_ => return None,
})
}
}
#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
pub struct SpanData {
#[metastructure(field = "app_start_type")] pub app_start_type: Annotated<Value>,
#[metastructure(field = "ai.total_tokens.used")]
pub ai_total_tokens_used: Annotated<Value>,
#[metastructure(field = "ai.prompt_tokens.used")]
pub ai_prompt_tokens_used: Annotated<Value>,
#[metastructure(field = "ai.completion_tokens.used")]
pub ai_completion_tokens_used: Annotated<Value>,
#[metastructure(field = "browser.name")]
pub browser_name: Annotated<String>,
#[metastructure(field = "code.filepath", pii = "maybe")]
pub code_filepath: Annotated<Value>,
#[metastructure(field = "code.lineno", pii = "maybe")]
pub code_lineno: Annotated<Value>,
#[metastructure(field = "code.function", pii = "maybe")]
pub code_function: Annotated<Value>,
#[metastructure(field = "code.namespace", pii = "maybe")]
pub code_namespace: Annotated<Value>,
#[metastructure(field = "db.operation")]
pub db_operation: Annotated<Value>,
#[metastructure(field = "db.system")]
pub db_system: Annotated<Value>,
#[metastructure(
field = "db.collection.name",
legacy_alias = "db.cassandra.table",
legacy_alias = "db.cosmosdb.container",
legacy_alias = "db.mongodb.collection",
legacy_alias = "db.sql.table"
)]
pub db_collection_name: Annotated<Value>,
#[metastructure(field = "sentry.environment", legacy_alias = "environment")]
pub environment: Annotated<String>,
#[metastructure(field = "sentry.release", legacy_alias = "release")]
pub release: Annotated<LenientString>,
#[metastructure(field = "http.decoded_response_content_length")]
pub http_decoded_response_content_length: Annotated<Value>,
#[metastructure(
field = "http.request_method",
legacy_alias = "http.method",
legacy_alias = "method"
)]
pub http_request_method: Annotated<Value>,
#[metastructure(field = "http.response_content_length")]
pub http_response_content_length: Annotated<Value>,
#[metastructure(field = "http.response_transfer_size")]
pub http_response_transfer_size: Annotated<Value>,
#[metastructure(field = "resource.render_blocking_status")]
pub resource_render_blocking_status: Annotated<Value>,
#[metastructure(field = "server.address")]
pub server_address: Annotated<Value>,
#[metastructure(field = "cache.hit")]
pub cache_hit: Annotated<Value>,
#[metastructure(field = "cache.key")]
pub cache_key: Annotated<Value>,
#[metastructure(field = "cache.item_size")]
pub cache_item_size: Annotated<Value>,
#[metastructure(field = "http.response.status_code", legacy_alias = "status_code")]
pub http_response_status_code: Annotated<Value>,
#[metastructure(field = "ai.pipeline.name")]
pub ai_pipeline_name: Annotated<Value>,
#[metastructure(field = "ai.model_id")]
pub ai_model_id: Annotated<Value>,
#[metastructure(field = "ai.input_messages")]
pub ai_input_messages: Annotated<Value>,
#[metastructure(field = "ai.responses")]
pub ai_responses: Annotated<Value>,
#[metastructure(field = "thread.name")]
pub thread_name: Annotated<String>,
#[metastructure(field = "thread.id")]
pub thread_id: Annotated<ThreadId>,
#[metastructure(field = "sentry.segment.name", legacy_alias = "transaction")]
pub segment_name: Annotated<String>,
#[metastructure(field = "ui.component_name")]
pub ui_component_name: Annotated<Value>,
#[metastructure(field = "url.scheme")]
pub url_scheme: Annotated<Value>,
#[metastructure(field = "user")]
pub user: Annotated<Value>,
#[metastructure(field = "user.email")]
pub user_email: Annotated<String>,
#[metastructure(field = "user.full_name")]
pub user_full_name: Annotated<String>,
#[metastructure(field = "user.geo.country_code")]
pub user_geo_country_code: Annotated<String>,
#[metastructure(field = "user.geo.city")]
pub user_geo_city: Annotated<String>,
#[metastructure(field = "user.geo.subdivision")]
pub user_geo_subdivision: Annotated<String>,
#[metastructure(field = "user.geo.region")]
pub user_geo_region: Annotated<String>,
#[metastructure(field = "user.hash")]
pub user_hash: Annotated<String>,
#[metastructure(field = "user.id")]
pub user_id: Annotated<String>,
#[metastructure(field = "user.name")]
pub user_name: Annotated<String>,
#[metastructure(field = "user.roles")]
pub user_roles: Annotated<Array<String>>,
#[metastructure(field = "sentry.replay.id", legacy_alias = "replay_id")]
pub replay_id: Annotated<Value>,
#[metastructure(field = "sentry.sdk.name")]
pub sdk_name: Annotated<String>,
#[metastructure(field = "sentry.sdk.version")]
pub sdk_version: Annotated<String>,
#[metastructure(field = "sentry.frames.slow", legacy_alias = "frames.slow")]
pub frames_slow: Annotated<Value>,
#[metastructure(field = "sentry.frames.frozen", legacy_alias = "frames.frozen")]
pub frames_frozen: Annotated<Value>,
#[metastructure(field = "sentry.frames.total", legacy_alias = "frames.total")]
pub frames_total: Annotated<Value>,
#[metastructure(field = "frames.delay")]
pub frames_delay: Annotated<Value>,
#[metastructure(field = "messaging.destination.name")]
pub messaging_destination_name: Annotated<String>,
#[metastructure(field = "messaging.message.retry.count")]
pub messaging_message_retry_count: Annotated<Value>,
#[metastructure(field = "messaging.message.receive.latency")]
pub messaging_message_receive_latency: Annotated<Value>,
#[metastructure(field = "messaging.message.body.size")]
pub messaging_message_body_size: Annotated<Value>,
#[metastructure(field = "messaging.message.id")]
pub messaging_message_id: Annotated<String>,
#[metastructure(field = "user_agent.original")]
pub user_agent_original: Annotated<String>,
#[metastructure(field = "url.full")]
pub url_full: Annotated<String>,
#[metastructure(field = "client.address")]
pub client_address: Annotated<IpAddr>,
#[metastructure(pii = "maybe", skip_serialization = "empty")]
pub route: Annotated<Route>,
#[metastructure(field = "previousRoute", pii = "maybe", skip_serialization = "empty")]
pub previous_route: Annotated<Route>,
#[metastructure(field = "lcp.element")]
pub lcp_element: Annotated<String>,
#[metastructure(field = "lcp.size")]
pub lcp_size: Annotated<u64>,
#[metastructure(field = "lcp.id")]
pub lcp_id: Annotated<String>,
#[metastructure(field = "lcp.url")]
pub lcp_url: Annotated<String>,
#[metastructure(
additional_properties,
pii = "true",
retain = "true",
skip_serialization = "null" )]
pub other: Object<Value>,
}
impl Getter for SpanData {
fn get_value(&self, path: &str) -> Option<Val<'_>> {
Some(match path {
"app_start_type" => self.app_start_type.value()?.into(),
"browser\\.name" => self.browser_name.as_str()?.into(),
"code\\.filepath" => self.code_filepath.value()?.into(),
"code\\.function" => self.code_function.value()?.into(),
"code\\.lineno" => self.code_lineno.value()?.into(),
"code\\.namespace" => self.code_namespace.value()?.into(),
"db.operation" => self.db_operation.value()?.into(),
"db\\.system" => self.db_system.value()?.into(),
"environment" => self.environment.as_str()?.into(),
"http\\.decoded_response_content_length" => {
self.http_decoded_response_content_length.value()?.into()
}
"http\\.request_method" | "http\\.method" | "method" => {
self.http_request_method.value()?.into()
}
"http\\.response_content_length" => self.http_response_content_length.value()?.into(),
"http\\.response_transfer_size" => self.http_response_transfer_size.value()?.into(),
"http\\.response.status_code" | "status_code" => {
self.http_response_status_code.value()?.into()
}
"resource\\.render_blocking_status" => {
self.resource_render_blocking_status.value()?.into()
}
"server\\.address" => self.server_address.value()?.into(),
"thread\\.name" => self.thread_name.as_str()?.into(),
"ui\\.component_name" => self.ui_component_name.value()?.into(),
"url\\.scheme" => self.url_scheme.value()?.into(),
"user" => self.user.value()?.into(),
"user\\.email" => self.user_email.as_str()?.into(),
"user\\.full_name" => self.user_full_name.as_str()?.into(),
"user\\.geo\\.city" => self.user_geo_city.as_str()?.into(),
"user\\.geo\\.country_code" => self.user_geo_country_code.as_str()?.into(),
"user\\.geo\\.region" => self.user_geo_region.as_str()?.into(),
"user\\.geo\\.subdivision" => self.user_geo_subdivision.as_str()?.into(),
"user\\.hash" => self.user_hash.as_str()?.into(),
"user\\.id" => self.user_id.as_str()?.into(),
"user\\.name" => self.user_name.as_str()?.into(),
"transaction" => self.segment_name.as_str()?.into(),
"release" => self.release.as_str()?.into(),
_ => {
let escaped = path.replace("\\.", "\0");
let mut path = escaped.split('.').map(|s| s.replace('\0', "."));
let root = path.next()?;
let mut val = self.other.get(&root)?.value()?;
for part in path {
let relay_protocol::Value::Object(map) = val else {
return None;
};
val = map.get(&part)?.value()?;
}
val.into()
}
})
}
}
#[derive(Clone, Debug, Default, PartialEq, Empty, IntoValue, ProcessValue)]
pub struct Route {
#[metastructure(pii = "maybe", skip_serialization = "empty")]
pub name: Annotated<String>,
#[metastructure(
pii = "true",
skip_serialization = "empty",
max_depth = 5,
max_bytes = 2048
)]
pub params: Annotated<Object<Value>>,
#[metastructure(
additional_properties,
retain = "true",
pii = "maybe",
skip_serialization = "empty"
)]
pub other: Object<Value>,
}
impl FromValue for Route {
fn from_value(value: Annotated<Value>) -> Annotated<Self>
where
Self: Sized,
{
match value {
Annotated(Some(Value::String(name)), meta) => Annotated(
Some(Route {
name: Annotated::new(name),
..Default::default()
}),
meta,
),
Annotated(Some(Value::Object(mut values)), meta) => {
let mut route: Route = Default::default();
if let Some(Annotated(Some(Value::String(name)), _)) = values.remove("name") {
route.name = Annotated::new(name);
}
if let Some(Annotated(Some(Value::Object(params)), _)) = values.remove("params") {
route.params = Annotated::new(params);
}
if !values.is_empty() {
route.other = values;
}
Annotated(Some(route), meta)
}
Annotated(None, meta) => Annotated(None, meta),
Annotated(Some(value), mut meta) => {
meta.add_error(Error::expected("route expected to be an object"));
meta.set_original_value(Some(value));
Annotated(None, meta)
}
}
}
}
#[cfg(test)]
mod tests {
use crate::protocol::Measurement;
use chrono::{TimeZone, Utc};
use relay_base_schema::metrics::{InformationUnit, MetricUnit};
use relay_protocol::RuleCondition;
use similar_asserts::assert_eq;
use super::*;
#[test]
fn test_span_serialization() {
let json = r#"{
"timestamp": 0.0,
"start_timestamp": -63158400.0,
"exclusive_time": 1.23,
"op": "operation",
"span_id": "fa90fdead5f74052",
"trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
"status": "ok",
"description": "desc",
"origin": "auto.http",
"measurements": {
"memory": {
"value": 9001.0,
"unit": "byte"
}
}
}"#;
let mut measurements = Object::new();
measurements.insert(
"memory".into(),
Annotated::new(Measurement {
value: Annotated::new(9001.0),
unit: Annotated::new(MetricUnit::Information(InformationUnit::Byte)),
}),
);
let span = Annotated::new(Span {
timestamp: Annotated::new(Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap().into()),
start_timestamp: Annotated::new(
Utc.with_ymd_and_hms(1968, 1, 1, 0, 0, 0).unwrap().into(),
),
exclusive_time: Annotated::new(1.23),
description: Annotated::new("desc".to_owned()),
op: Annotated::new("operation".to_owned()),
trace_id: Annotated::new(TraceId("4c79f60c11214eb38604f4ae0781bfb2".into())),
span_id: Annotated::new(SpanId("fa90fdead5f74052".into())),
status: Annotated::new(SpanStatus::Ok),
origin: Annotated::new("auto.http".to_owned()),
measurements: Annotated::new(Measurements(measurements)),
..Default::default()
});
assert_eq!(json, span.to_json_pretty().unwrap());
let span_from_string = Annotated::from_json(json).unwrap();
assert_eq!(span, span_from_string);
}
#[test]
fn test_getter_span_data() {
let span = Annotated::<Span>::from_json(
r#"{
"data": {
"foo": {"bar": 1},
"foo.bar": 2
},
"measurements": {
"some": {"value": 100.0}
}
}"#,
)
.unwrap()
.into_value()
.unwrap();
assert_eq!(span.get_value("span.data.foo.bar"), Some(Val::I64(1)));
assert_eq!(span.get_value(r"span.data.foo\.bar"), Some(Val::I64(2)));
assert_eq!(span.get_value("span.data"), None);
assert_eq!(span.get_value("span.data."), None);
assert_eq!(span.get_value("span.data.x"), None);
assert_eq!(
span.get_value("span.measurements.some.value"),
Some(Val::F64(100.0))
);
}
#[test]
fn test_getter_was_transaction() {
let mut span = Span::default();
assert_eq!(
span.get_value("span.was_transaction"),
Some(Val::Bool(false))
);
assert!(RuleCondition::eq("span.was_transaction", false).matches(&span));
assert!(!RuleCondition::eq("span.was_transaction", true).matches(&span));
span.was_transaction.set_value(Some(false));
assert_eq!(
span.get_value("span.was_transaction"),
Some(Val::Bool(false))
);
assert!(RuleCondition::eq("span.was_transaction", false).matches(&span));
assert!(!RuleCondition::eq("span.was_transaction", true).matches(&span));
span.was_transaction.set_value(Some(true));
assert_eq!(
span.get_value("span.was_transaction"),
Some(Val::Bool(true))
);
assert!(RuleCondition::eq("span.was_transaction", true).matches(&span));
assert!(!RuleCondition::eq("span.was_transaction", false).matches(&span));
}
#[test]
fn test_span_fields_as_event() {
let span = Annotated::<Span>::from_json(
r#"{
"data": {
"release": "1.0",
"environment": "prod",
"sentry.segment.name": "/api/endpoint"
}
}"#,
)
.unwrap()
.into_value()
.unwrap();
assert_eq!(span.get_value("event.release"), Some(Val::String("1.0")));
assert_eq!(
span.get_value("event.environment"),
Some(Val::String("prod"))
);
assert_eq!(
span.get_value("event.transaction"),
Some(Val::String("/api/endpoint"))
);
}
#[test]
fn test_span_duration() {
let span = Annotated::<Span>::from_json(
r#"{
"start_timestamp": 1694732407.8367,
"timestamp": 1694732408.3145
}"#,
)
.unwrap()
.into_value()
.unwrap();
assert_eq!(span.get_value("span.duration"), Some(Val::F64(477.800131)));
}
#[test]
fn test_span_data() {
let data = r#"{
"foo": 2,
"bar": "3",
"db.system": "mysql",
"code.filepath": "task.py",
"code.lineno": 123,
"code.function": "fn()",
"code.namespace": "ns",
"frames.slow": 1,
"frames.frozen": 2,
"frames.total": 9,
"frames.delay": 100,
"messaging.destination.name": "default",
"messaging.message.retry.count": 3,
"messaging.message.receive.latency": 40,
"messaging.message.body.size": 100,
"messaging.message.id": "abc123",
"user_agent.original": "Chrome",
"url.full": "my_url.com",
"client.address": "192.168.0.1"
}"#;
let data = Annotated::<SpanData>::from_json(data)
.unwrap()
.into_value()
.unwrap();
insta::assert_debug_snapshot!(data, @r###"
SpanData {
app_start_type: ~,
ai_total_tokens_used: ~,
ai_prompt_tokens_used: ~,
ai_completion_tokens_used: ~,
browser_name: ~,
code_filepath: String(
"task.py",
),
code_lineno: I64(
123,
),
code_function: String(
"fn()",
),
code_namespace: String(
"ns",
),
db_operation: ~,
db_system: String(
"mysql",
),
db_collection_name: ~,
environment: ~,
release: ~,
http_decoded_response_content_length: ~,
http_request_method: ~,
http_response_content_length: ~,
http_response_transfer_size: ~,
resource_render_blocking_status: ~,
server_address: ~,
cache_hit: ~,
cache_key: ~,
cache_item_size: ~,
http_response_status_code: ~,
ai_pipeline_name: ~,
ai_model_id: ~,
ai_input_messages: ~,
ai_responses: ~,
thread_name: ~,
thread_id: ~,
segment_name: ~,
ui_component_name: ~,
url_scheme: ~,
user: ~,
user_email: ~,
user_full_name: ~,
user_geo_country_code: ~,
user_geo_city: ~,
user_geo_subdivision: ~,
user_geo_region: ~,
user_hash: ~,
user_id: ~,
user_name: ~,
user_roles: ~,
replay_id: ~,
sdk_name: ~,
sdk_version: ~,
frames_slow: I64(
1,
),
frames_frozen: I64(
2,
),
frames_total: I64(
9,
),
frames_delay: I64(
100,
),
messaging_destination_name: "default",
messaging_message_retry_count: I64(
3,
),
messaging_message_receive_latency: I64(
40,
),
messaging_message_body_size: I64(
100,
),
messaging_message_id: "abc123",
user_agent_original: "Chrome",
url_full: "my_url.com",
client_address: IpAddr(
"192.168.0.1",
),
route: ~,
previous_route: ~,
lcp_element: ~,
lcp_size: ~,
lcp_id: ~,
lcp_url: ~,
other: {
"bar": String(
"3",
),
"foo": I64(
2,
),
},
}
"###);
assert_eq!(data.get_value("foo"), Some(Val::U64(2)));
assert_eq!(data.get_value("bar"), Some(Val::String("3")));
assert_eq!(data.get_value("db\\.system"), Some(Val::String("mysql")));
assert_eq!(data.get_value("code\\.lineno"), Some(Val::U64(123)));
assert_eq!(data.get_value("code\\.function"), Some(Val::String("fn()")));
assert_eq!(data.get_value("code\\.namespace"), Some(Val::String("ns")));
assert_eq!(data.get_value("unknown"), None);
}
#[test]
fn test_span_data_empty_well_known_field() {
let span = r#"{
"data": {
"lcp.url": ""
}
}"#;
let span: Annotated<Span> = Annotated::from_json(span).unwrap();
assert_eq!(span.to_json().unwrap(), r#"{"data":{"lcp.url":""}}"#);
}
#[test]
fn test_span_data_empty_custom_field() {
let span = r#"{
"data": {
"custom_field_empty": ""
}
}"#;
let span: Annotated<Span> = Annotated::from_json(span).unwrap();
assert_eq!(
span.to_json().unwrap(),
r#"{"data":{"custom_field_empty":""}}"#
);
}
#[test]
fn test_span_data_completely_empty() {
let span = r#"{
"data": {}
}"#;
let span: Annotated<Span> = Annotated::from_json(span).unwrap();
assert_eq!(span.to_json().unwrap(), r#"{"data":{}}"#);
}
}