relay_event_schema/protocol/span/
compat.rs

1use relay_conventions::{DESCRIPTION, PROFILE_ID, SEGMENT_ID};
2use relay_protocol::{Annotated, Empty, Error, FromValue, IntoValue, Object, Value};
3
4use crate::protocol::{Attributes, EventId, SpanV2, Timestamp};
5
6/// Temporary type that amends a SpansV2 span with fields needed by the sentry span consumer.
7/// This can be removed once the consumer has been updated to use the new schema.
8#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue)]
9pub struct CompatSpan {
10    #[metastructure(flatten)]
11    pub span_v2: SpanV2,
12
13    pub data: Annotated<Object<Value>>,
14    pub description: Annotated<String>,
15    pub duration_ms: Annotated<u64>,
16    pub end_timestamp_precise: Annotated<Timestamp>,
17    pub profile_id: Annotated<EventId>,
18    pub segment_id: Annotated<String>,
19    pub start_timestamp_ms: Annotated<u64>, // TODO: remove from kafka schema, no longer used in consumer
20    pub start_timestamp_precise: Annotated<Timestamp>,
21
22    #[metastructure(field = "_performance_issues_spans")]
23    pub performance_issues_spans: Annotated<bool>, // TODO: add to Kafka schema?
24}
25
26impl TryFrom<SpanV2> for CompatSpan {
27    type Error = uuid::Error;
28
29    fn try_from(span_v2: SpanV2) -> Result<Self, uuid::Error> {
30        let mut compat_span = CompatSpan {
31            start_timestamp_precise: span_v2.start_timestamp.clone(),
32            start_timestamp_ms: span_v2
33                .start_timestamp
34                .clone()
35                .map_value(|ts| ts.0.timestamp_millis() as u64),
36            end_timestamp_precise: span_v2.end_timestamp.clone(),
37            ..Default::default()
38        };
39
40        if let (Some(start_timestamp), Some(end_timestamp)) = (
41            span_v2.start_timestamp.value(),
42            span_v2.end_timestamp.value(),
43        ) {
44            let delta = (*end_timestamp - *start_timestamp).num_milliseconds();
45            compat_span.duration_ms = u64::try_from(delta).unwrap_or(0).into();
46        }
47
48        if let Some(attributes) = span_v2.attributes.value() {
49            // Write all attributes to data:
50            for (key, value) in attributes.iter() {
51                compat_span
52                    .data
53                    .get_or_insert_with(Default::default)
54                    .insert(key.clone(), value.clone().and_then(|attr| attr.value.value));
55            }
56
57            // Double-write some attributes to top-level fields:
58            if let Some(description) = get_string_or_error(attributes, DESCRIPTION)
59            // TODO: EAP expects sentry.raw_description, double write this somewhere.
60            {
61                compat_span.description = description;
62            }
63            if let Some(profile_id) = get_string_or_error(attributes, PROFILE_ID) {
64                compat_span.profile_id = profile_id.and_then(|s| match s.parse::<EventId>() {
65                    Ok(id) => Annotated::from(id),
66                    Err(_) => Annotated::from_error(Error::invalid("profile_id"), None),
67                });
68            }
69            if let Some(segment_id) = get_string_or_error(attributes, SEGMENT_ID) {
70                compat_span.segment_id = segment_id;
71            }
72            if let Some(Value::Bool(b)) =
73                attributes.get_value("sentry._internal.performance_issues_spans")
74            {
75                // ignoring meta here is OK, internal attribute set by Relay.
76                compat_span.performance_issues_spans = Annotated::new(*b);
77            }
78        }
79
80        compat_span.span_v2 = span_v2;
81        Ok(compat_span)
82    }
83}
84
85fn get_string_or_error(attributes: &Attributes, key: &str) -> Option<Annotated<String>> {
86    let annotated = attributes.0.get(key)?;
87    let value: Annotated<Value> = annotated.clone().and_then(|attr| attr.value.value);
88    match value {
89        Annotated(Some(Value::String(description)), meta) => {
90            Some(Annotated(Some(description), meta))
91        }
92        Annotated(None, meta) => Some(Annotated(None, meta)),
93        // Deliberate silent fail. We assume this is a naming collision, not invalid data.
94        _ => None,
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use std::collections::BTreeMap;
101
102    use chrono::DateTime;
103    use insta::assert_debug_snapshot;
104    use relay_protocol::{Error, SerializableAnnotated};
105
106    use crate::protocol::Attributes;
107
108    use super::*;
109
110    #[test]
111    fn basic_conversion() {
112        let json = r#"{
113            "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
114            "span_id": "fa90fdead5f74052",
115            "parent_span_id": "fa90fdead5f74051",
116            "start_timestamp": 123,
117            "end_timestamp": 123.5,
118            "name": "myname",
119            "status": "ok",
120            "links": [],
121            "attributes": {
122                "browser.name": {
123                    "value": "Chrome",
124                    "type": "string"
125                },
126                "sentry.description": {
127                    "value": "mydescription",
128                    "type": "string"
129                },
130                "sentry.environment": {
131                    "value": "prod",
132                    "type": "string"
133                },
134                "sentry.op": {
135                    "value": "myop",
136                    "type": "string"
137                },
138                "sentry.platform": {
139                    "value": "php",
140                    "type": "string"
141                },
142                "sentry.profile_id": {
143                    "value": "a0aaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaab",
144                    "type": "string"
145                },
146                "sentry.release": {
147                    "value": "myapp@1.0.0",
148                    "type": "string"
149                },
150                "sentry.sdk.name": {
151                    "value": "sentry.php",
152                    "type": "string"
153                },
154                "sentry.segment.id": {
155                    "value": "FA90FDEAD5F74052",
156                    "type": "string"
157                },
158                "sentry.segment.name": {
159                    "value": "my 1st transaction",
160                    "type": "string"
161                },
162                "sentry._internal.performance_issues_spans": {
163                    "value": true,
164                    "type": "bool"
165                }
166            }
167        }"#;
168
169        let span_v2: SpanV2 = Annotated::from_json(json).unwrap().into_value().unwrap();
170        let compat_span = CompatSpan::try_from(span_v2).unwrap();
171
172        insta::assert_json_snapshot!(SerializableAnnotated(&Annotated::from(compat_span)), @r###"
173        {
174          "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
175          "parent_span_id": "fa90fdead5f74051",
176          "span_id": "fa90fdead5f74052",
177          "name": "myname",
178          "status": "ok",
179          "start_timestamp": 123.0,
180          "end_timestamp": 123.5,
181          "links": [],
182          "attributes": {
183            "browser.name": {
184              "type": "string",
185              "value": "Chrome"
186            },
187            "sentry._internal.performance_issues_spans": {
188              "type": "bool",
189              "value": true
190            },
191            "sentry.description": {
192              "type": "string",
193              "value": "mydescription"
194            },
195            "sentry.environment": {
196              "type": "string",
197              "value": "prod"
198            },
199            "sentry.op": {
200              "type": "string",
201              "value": "myop"
202            },
203            "sentry.platform": {
204              "type": "string",
205              "value": "php"
206            },
207            "sentry.profile_id": {
208              "type": "string",
209              "value": "a0aaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaab"
210            },
211            "sentry.release": {
212              "type": "string",
213              "value": "myapp@1.0.0"
214            },
215            "sentry.sdk.name": {
216              "type": "string",
217              "value": "sentry.php"
218            },
219            "sentry.segment.id": {
220              "type": "string",
221              "value": "FA90FDEAD5F74052"
222            },
223            "sentry.segment.name": {
224              "type": "string",
225              "value": "my 1st transaction"
226            }
227          },
228          "data": {
229            "browser.name": "Chrome",
230            "sentry._internal.performance_issues_spans": true,
231            "sentry.description": "mydescription",
232            "sentry.environment": "prod",
233            "sentry.op": "myop",
234            "sentry.platform": "php",
235            "sentry.profile_id": "a0aaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaab",
236            "sentry.release": "myapp@1.0.0",
237            "sentry.sdk.name": "sentry.php",
238            "sentry.segment.id": "FA90FDEAD5F74052",
239            "sentry.segment.name": "my 1st transaction"
240          },
241          "description": "mydescription",
242          "duration_ms": 500,
243          "end_timestamp_precise": 123.5,
244          "profile_id": "a0aaaaaaaaaaaaaaaaaaaaaaaaaaaaab",
245          "segment_id": "FA90FDEAD5F74052",
246          "start_timestamp_ms": 123000,
247          "start_timestamp_precise": 123.0,
248          "_performance_issues_spans": true
249        }
250        "###);
251    }
252
253    #[test]
254    fn negative_duration() {
255        let span_v2 = SpanV2 {
256            start_timestamp: Timestamp(DateTime::from_timestamp_nanos(100)).into(),
257            end_timestamp: Timestamp(DateTime::from_timestamp_nanos(50)).into(),
258            ..Default::default()
259        };
260
261        let compat_span = CompatSpan::try_from(span_v2).unwrap();
262        assert_eq!(compat_span.duration_ms.value(), Some(&0));
263    }
264
265    #[test]
266    fn meta_conversion() {
267        let span_v2 = SpanV2 {
268            trace_id: Annotated::from_error(Error::invalid("trace_id"), None),
269            parent_span_id: Annotated::from_error(Error::invalid("parent_span_id"), None),
270            span_id: Annotated::from_error(Error::invalid("span_id"), None),
271            name: Annotated::from_error(Error::invalid("name"), None),
272            status: Annotated::from_error(Error::invalid("status"), None),
273            is_remote: Annotated::from_error(Error::invalid("is_remote"), None),
274            kind: Annotated::from_error(Error::invalid("kind"), None),
275            start_timestamp: Annotated::from_error(Error::invalid("start_timestamp"), None),
276            end_timestamp: Annotated::from_error(Error::invalid("end_timestamp"), None),
277            links: Annotated::from_error(Error::invalid("links"), None),
278            attributes: Annotated::new(Attributes::from_iter([
279                (
280                    "sentry.description".to_owned(),
281                    Annotated::from_error(Error::invalid("description"), None),
282                ),
283                (
284                    "sentry.profile_id".to_owned(),
285                    Annotated::from_error(Error::invalid("profile ID"), None),
286                ),
287                (
288                    "sentry.segment.id".to_owned(),
289                    Annotated::from_error(Error::invalid("segment ID"), None),
290                ),
291                (
292                    "performance_issues_spans".to_owned(),
293                    Annotated::from_error(Error::invalid("flag"), None),
294                ),
295                (
296                    "other_attribute".to_owned(),
297                    Annotated::from_error(Error::invalid("other_attribute"), None),
298                ),
299            ])),
300            other: BTreeMap::from([(
301                "foo".to_owned(),
302                Annotated::from_error(Error::invalid("other"), None),
303            )]),
304        };
305
306        let compat_span = CompatSpan::try_from(span_v2).unwrap();
307        assert_debug_snapshot!(compat_span);
308    }
309}