Skip to main content

relay_event_schema/protocol/span/
convert.rs

1//! This module defines bidirectional field mappings between spans and transactions.
2
3use relay_conventions::attributes::{
4    BROWSER__NAME, HTTP__QUERY, SENTRY__ENVIRONMENT, SENTRY__EVENT__SERIALIZED_BREADCRUMBS,
5    SENTRY__EVENT__SERIALIZED_CONTEXTS, SENTRY__EVENT__SERIALIZED_EXTRA, SENTRY__RELEASE,
6    SENTRY__SDK__NAME, SENTRY__SDK__VERSION, SENTRY__SEGMENT__NAME, URL__QUERY,
7};
8use relay_protocol::{IntoValue, Object, SerializePayload, SkipSerialization};
9use serde::ser::SerializeMap;
10use serde::{Serialize, Serializer};
11
12use crate::protocol::{
13    BrowserContext, ContextInner, DefaultContext, Event, ProfileContext, Span, SpanData,
14    TraceContext,
15};
16
17/// Serializes a borrowed contexts map, skipping the given key.
18struct ContextsWithout<'a> {
19    contexts: &'a Object<ContextInner>,
20    skip_key: &'a str,
21}
22
23impl Serialize for ContextsWithout<'_> {
24    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
25    where
26        S: Serializer,
27    {
28        let behavior = SkipSerialization::default();
29        let mut map = serializer.serialize_map(None)?;
30        for (key, value) in self.contexts {
31            if key == self.skip_key || value.skip_serialization(behavior) {
32                continue;
33            }
34            map.serialize_entry(key, &SerializePayload(value, behavior))?;
35        }
36        map.end()
37    }
38}
39
40impl From<&Event> for Span {
41    fn from(event: &Event) -> Self {
42        let Event {
43            transaction,
44
45            platform,
46            timestamp,
47            start_timestamp,
48            received,
49            release,
50            environment,
51            tags,
52            contexts,
53            breadcrumbs,
54            extra,
55            measurements,
56            _metrics,
57            ..
58        } = event;
59
60        let trace = event.context::<TraceContext>();
61
62        // Fill data from trace context:
63        let mut data = trace
64            .map(|c| c.data.clone().map_value(SpanData::from))
65            .unwrap_or_default();
66
67        // Overwrite specific fields:
68        let span_data = data.get_or_insert_with(Default::default);
69        span_data.insert(
70            SENTRY__SEGMENT__NAME,
71            transaction.clone().map_value(IntoValue::into_value),
72        );
73        // For root spans, the name should just be the transaction name.
74        span_data.insert(
75            "sentry.name",
76            transaction.clone().map_value(IntoValue::into_value),
77        );
78        span_data.insert(
79            SENTRY__RELEASE,
80            release.clone().map_value(IntoValue::into_value),
81        );
82        span_data.insert(
83            SENTRY__ENVIRONMENT,
84            environment.clone().map_value(IntoValue::into_value),
85        );
86        if let Some(browser) = event.context::<BrowserContext>() {
87            span_data.insert(
88                BROWSER__NAME,
89                browser.name.clone().map_value(IntoValue::into_value),
90            );
91        }
92        if let Some(client_sdk) = event.client_sdk.value() {
93            span_data.insert(
94                SENTRY__SDK__NAME,
95                client_sdk.name.clone().map_value(IntoValue::into_value),
96            );
97            span_data.insert(
98                SENTRY__SDK__VERSION,
99                client_sdk.version.clone().map_value(IntoValue::into_value),
100            );
101        }
102        if let Some(request) = event.request.value()
103            && let Some(query) = request.query_string.value()
104            && let Some(qs) = query.to_query_string()
105        {
106            span_data.insert_value(HTTP__QUERY, format!("?{qs}"));
107            span_data.insert_value(URL__QUERY, qs);
108        }
109
110        if let Some(contexts) = contexts.value() {
111            let has_other = contexts.0.iter().any(|(key, value)| {
112                key != TraceContext::default_key()
113                    && !value.skip_serialization(SkipSerialization::default())
114            });
115            if has_other {
116                let payload = ContextsWithout {
117                    contexts: &contexts.0,
118                    skip_key: TraceContext::default_key(),
119                };
120                if let Ok(json) = serde_json::to_string(&payload) {
121                    span_data.insert_value(SENTRY__EVENT__SERIALIZED_CONTEXTS, json);
122                }
123            }
124        }
125        if breadcrumbs
126            .value()
127            .and_then(|b| b.values.value())
128            .is_some_and(|v| !v.is_empty())
129            && let Ok(json) = breadcrumbs.payload_to_json()
130        {
131            span_data.insert_value(SENTRY__EVENT__SERIALIZED_BREADCRUMBS, json);
132        }
133        if extra.value().is_some_and(|e| !e.is_empty())
134            && let Ok(json) = extra.payload_to_json()
135        {
136            span_data.insert_value(SENTRY__EVENT__SERIALIZED_EXTRA, json);
137        }
138
139        Self {
140            timestamp: timestamp.clone(),
141            start_timestamp: start_timestamp.clone(),
142            exclusive_time: trace.map(|c| c.exclusive_time.clone()).unwrap_or_default(),
143            op: trace.map(|c| c.op.clone()).unwrap_or_default(),
144            span_id: trace.map(|c| c.span_id.clone()).unwrap_or_default(),
145            parent_span_id: trace.map(|c| c.parent_span_id.clone()).unwrap_or_default(),
146            trace_id: trace.map(|c| c.trace_id.clone()).unwrap_or_default(),
147            segment_id: trace.map(|c| c.span_id.clone()).unwrap_or_default(),
148            is_segment: true.into(),
149            // NB: Technically, this span may not be an actual remote span if this is a child
150            // transaction created within the same service as its parent. We still set `is_remote`
151            // as the best proxy to ensure this span will be detected as a segment by the spans
152            // pipeline.
153            is_remote: true.into(),
154            status: trace.map(|c| c.status.clone()).unwrap_or_default(),
155            description: transaction.clone(),
156            tags: tags.clone().map_value(|t| t.into()),
157            origin: trace.map(|c| c.origin.clone()).unwrap_or_default(),
158            profile_id: event
159                .context::<ProfileContext>()
160                .map(|c| c.profile_id.clone())
161                .unwrap_or_default(),
162            data,
163            links: trace.map(|c| c.links.clone()).unwrap_or_default(),
164            sentry_tags: Default::default(),
165            received: received.clone(),
166            measurements: measurements.clone(),
167            platform: platform.clone(),
168            was_transaction: true.into(),
169            kind: Default::default(),
170            other: Default::default(),
171        }
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use relay_protocol::Annotated;
178
179    use super::*;
180
181    #[test]
182    fn convert() {
183        let event = Annotated::<Event>::from_json(
184            r#"{
185                "type": "transaction",
186                "platform": "php",
187                "sdk": {"name": "sentry.php", "version": "1.2.3"},
188                "release": "myapp@1.0.0",
189                "environment": "prod",
190                "transaction": "my 1st transaction",
191                "contexts": {
192                    "browser": {"name": "Chrome"},
193                    "profile": {"profile_id": "a0aaaaaaaaaaaaaaaaaaaaaaaaaaaaab"},
194                    "trace": {
195                        "trace_id": "4C79F60C11214EB38604F4AE0781BFB2",
196                        "span_id": "FA90FDEAD5F74052",
197                        "type": "trace",
198                        "origin": "manual",
199                        "op": "myop",
200                        "status": "ok",
201                        "exclusive_time": 123.4,
202                        "parent_span_id": "FA90FDEAD5F74051",
203                        "data": {
204                            "custom_attribute": 42
205                        },
206                        "links": [
207                            {
208                                "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
209                                "span_id": "fa90fdead5f74052",
210                                "sampled": true,
211                                "attributes": {
212                                    "sentry.link.type": "previous_trace"
213                                }
214                            }
215                        ]
216                    }
217                },
218                "breadcrumbs": [
219                    {"type": "default", "category": "auth", "message": "login"}
220                ],
221                "extra": {
222                    "my_key": 1,
223                    "some_other_value": "foo bar"
224                },
225                "request": {
226                    "url": "http://example.com/api/0/organizations/",
227                    "method": "GET",
228                    "query_string": "project=1&sort=date"
229                },
230                "measurements": {
231                    "memory": {
232                        "value": 9001.0,
233                        "unit": "byte"
234                    }
235                }
236            }"#,
237        )
238        .unwrap()
239        .into_value()
240        .unwrap();
241
242        let span_from_event = Span::from(&event);
243        insta::assert_debug_snapshot!(span_from_event, @r#"
244        Span {
245            timestamp: ~,
246            start_timestamp: ~,
247            exclusive_time: 123.4,
248            op: "myop",
249            span_id: SpanId("fa90fdead5f74052"),
250            parent_span_id: SpanId("fa90fdead5f74051"),
251            trace_id: TraceId("4c79f60c11214eb38604f4ae0781bfb2"),
252            segment_id: SpanId("fa90fdead5f74052"),
253            is_segment: true,
254            is_remote: true,
255            status: Ok,
256            description: "my 1st transaction",
257            tags: ~,
258            origin: "manual",
259            profile_id: EventId(
260                a0aaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaab,
261            ),
262            data: SpanData {
263                other: {
264                    "browser.name": String(
265                        "Chrome",
266                    ),
267                    "custom_attribute": I64(
268                        42,
269                    ),
270                    "http.query": String(
271                        "?project=1&sort=date",
272                    ),
273                    "sentry.environment": String(
274                        "prod",
275                    ),
276                    "sentry.event.serialized_breadcrumbs": String(
277                        "{\"values\":[{\"type\":\"default\",\"category\":\"auth\",\"message\":\"login\"}]}",
278                    ),
279                    "sentry.event.serialized_contexts": String(
280                        "{\"browser\":{\"name\":\"Chrome\",\"type\":\"browser\"},\"profile\":{\"profile_id\":\"a0aaaaaaaaaaaaaaaaaaaaaaaaaaaaab\",\"type\":\"profile\"}}",
281                    ),
282                    "sentry.event.serialized_extra": String(
283                        "{\"my_key\":1,\"some_other_value\":\"foo bar\"}",
284                    ),
285                    "sentry.name": String(
286                        "my 1st transaction",
287                    ),
288                    "sentry.release": String(
289                        "myapp@1.0.0",
290                    ),
291                    "sentry.sdk.name": String(
292                        "sentry.php",
293                    ),
294                    "sentry.sdk.version": String(
295                        "1.2.3",
296                    ),
297                    "sentry.segment.name": String(
298                        "my 1st transaction",
299                    ),
300                    "url.query": String(
301                        "project=1&sort=date",
302                    ),
303                },
304            },
305            links: [
306                SpanLink {
307                    trace_id: TraceId("4c79f60c11214eb38604f4ae0781bfb2"),
308                    span_id: SpanId("fa90fdead5f74052"),
309                    sampled: true,
310                    attributes: {
311                        "sentry.link.type": String(
312                            "previous_trace",
313                        ),
314                    },
315                    other: {},
316                },
317            ],
318            sentry_tags: ~,
319            received: ~,
320            measurements: Measurements(
321                {
322                    "memory": Measurement {
323                        value: 9001.0,
324                        unit: Information(
325                            Byte,
326                        ),
327                    },
328                },
329            ),
330            platform: "php",
331            was_transaction: true,
332            kind: ~,
333            other: {},
334        }
335        "#);
336    }
337
338    #[test]
339    fn convert_preserves_contexts_breadcrumbs_extra() {
340        let event = Annotated::<Event>::from_json(
341            r#"{
342                "type": "transaction",
343                "transaction": "my transaction",
344                "contexts": {
345                    "browser": {"name": "Chrome"},
346                    "trace": {
347                        "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
348                        "span_id": "fa90fdead5f74052"
349                    }
350                },
351                "breadcrumbs": [
352                    {"type": "default", "category": "auth", "message": "login"}
353                ],
354                "extra": {
355                    "my_key": 1,
356                    "some_other_value": "foo bar"
357                }
358            }"#,
359        )
360        .unwrap()
361        .into_value()
362        .unwrap();
363
364        let span = Span::from(&event);
365        let data = span.data.value().unwrap();
366
367        assert_eq!(
368            data.get_str(SENTRY__EVENT__SERIALIZED_CONTEXTS),
369            Some(r#"{"browser":{"name":"Chrome","type":"browser"}}"#)
370        );
371        assert_eq!(
372            data.get_str(SENTRY__EVENT__SERIALIZED_BREADCRUMBS),
373            Some(r#"{"values":[{"type":"default","category":"auth","message":"login"}]}"#)
374        );
375        assert_eq!(
376            data.get_str(SENTRY__EVENT__SERIALIZED_EXTRA),
377            Some(r#"{"my_key":1,"some_other_value":"foo bar"}"#)
378        );
379    }
380
381    #[test]
382    fn convert_omits_absent_contexts_breadcrumbs_extra() {
383        let event = Annotated::<Event>::from_json(
384            r#"{
385                "type": "transaction",
386                "transaction": "my transaction"
387            }"#,
388        )
389        .unwrap()
390        .into_value()
391        .unwrap();
392
393        let span = Span::from(&event);
394
395        if let Some(data) = span.data.value() {
396            assert!(!data.contains(SENTRY__EVENT__SERIALIZED_CONTEXTS));
397            assert!(!data.contains(SENTRY__EVENT__SERIALIZED_BREADCRUMBS));
398            assert!(!data.contains(SENTRY__EVENT__SERIALIZED_EXTRA));
399        }
400    }
401
402    #[test]
403    fn convert_omits_empty_contexts_breadcrumbs_extra() {
404        let event = Annotated::<Event>::from_json(
405            r#"{
406                "type": "transaction",
407                "transaction": "my transaction",
408                "contexts": {},
409                "breadcrumbs": [],
410                "extra": {}
411            }"#,
412        )
413        .unwrap()
414        .into_value()
415        .unwrap();
416
417        let span = Span::from(&event);
418
419        if let Some(data) = span.data.value() {
420            assert!(!data.contains(SENTRY__EVENT__SERIALIZED_CONTEXTS));
421            assert!(!data.contains(SENTRY__EVENT__SERIALIZED_BREADCRUMBS));
422            assert!(!data.contains(SENTRY__EVENT__SERIALIZED_EXTRA));
423        }
424    }
425}