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,
6    SENTRY__EVENT__SERIALIZED_META, SENTRY__EVENT__SERIALIZED_REQUEST, SENTRY__RELEASE,
7    SENTRY__SDK__NAME, SENTRY__SDK__VERSION, SENTRY__SEGMENT__NAME, URL__QUERY,
8};
9use relay_protocol::{Empty, IntoValue, MetaTree};
10
11use crate::protocol::{BrowserContext, Event, ProfileContext, Span, SpanData, TraceContext};
12
13impl From<&Event> for Span {
14    fn from(event: &Event) -> Self {
15        let Event {
16            transaction,
17
18            platform,
19            timestamp,
20            start_timestamp,
21            received,
22            release,
23            environment,
24            tags,
25            contexts,
26            breadcrumbs,
27            extra,
28            request,
29            measurements,
30            _metrics,
31            ..
32        } = event;
33
34        let trace = event.context::<TraceContext>();
35
36        // Fill data from trace context:
37        let mut data = trace
38            .map(|c| c.data.clone().map_value(SpanData::from))
39            .unwrap_or_default();
40
41        // Overwrite specific fields:
42        let span_data = data.get_or_insert_with(Default::default);
43        span_data.insert(
44            SENTRY__SEGMENT__NAME,
45            transaction.clone().map_value(IntoValue::into_value),
46        );
47        // For root spans, the name should just be the transaction name.
48        span_data.insert(
49            "sentry.name",
50            transaction.clone().map_value(IntoValue::into_value),
51        );
52        span_data.insert(
53            SENTRY__RELEASE,
54            release.clone().map_value(IntoValue::into_value),
55        );
56        span_data.insert(
57            SENTRY__ENVIRONMENT,
58            environment.clone().map_value(IntoValue::into_value),
59        );
60        if let Some(browser) = event.context::<BrowserContext>() {
61            span_data.insert(
62                BROWSER__NAME,
63                browser.name.clone().map_value(IntoValue::into_value),
64            );
65        }
66        if let Some(client_sdk) = event.client_sdk.value() {
67            span_data.insert(
68                SENTRY__SDK__NAME,
69                client_sdk.name.clone().map_value(IntoValue::into_value),
70            );
71            span_data.insert(
72                SENTRY__SDK__VERSION,
73                client_sdk.version.clone().map_value(IntoValue::into_value),
74            );
75        }
76        if let Some(request) = request.value()
77            && let Some(query) = request.query_string.value()
78            && let Some(qs) = query.to_query_string()
79        {
80            span_data.insert_value(HTTP__QUERY, format!("?{qs}"));
81            span_data.insert_value(URL__QUERY, qs);
82        }
83
84        if contexts.value().is_some_and(|c| !c.0.is_empty())
85            && let Ok(json) = contexts.payload_to_json()
86        {
87            span_data.insert_value(SENTRY__EVENT__SERIALIZED_CONTEXTS, json);
88        }
89        if breadcrumbs
90            .value()
91            .and_then(|b| b.values.value())
92            .is_some_and(|v| !v.is_empty())
93            && let Ok(json) = breadcrumbs.payload_to_json()
94        {
95            span_data.insert_value(SENTRY__EVENT__SERIALIZED_BREADCRUMBS, json);
96        }
97        if extra.value().is_some_and(|e| !e.is_empty())
98            && let Ok(json) = extra.payload_to_json()
99        {
100            span_data.insert_value(SENTRY__EVENT__SERIALIZED_EXTRA, json);
101        }
102        if request.value().is_some_and(|r| !r.is_empty())
103            && let Ok(json) = request.payload_to_json()
104        {
105            span_data.insert_value(SENTRY__EVENT__SERIALIZED_REQUEST, json);
106        }
107
108        let mut meta = MetaTree::default();
109        let contexts_meta = IntoValue::extract_meta_tree(contexts);
110        if !contexts_meta.is_empty() {
111            meta.children.insert("contexts".to_owned(), contexts_meta);
112        }
113        let breadcrumbs_meta = IntoValue::extract_meta_tree(breadcrumbs);
114        if !breadcrumbs_meta.is_empty() {
115            meta.children
116                .insert("breadcrumbs".to_owned(), breadcrumbs_meta);
117        }
118        let extra_meta = IntoValue::extract_meta_tree(extra);
119        if !extra_meta.is_empty() {
120            meta.children.insert("extra".to_owned(), extra_meta);
121        }
122        let request_meta = IntoValue::extract_meta_tree(request);
123        if !request_meta.is_empty() {
124            meta.children.insert("request".to_owned(), request_meta);
125        }
126        if !meta.is_empty()
127            && let Ok(json) = serde_json::to_string(&meta)
128        {
129            span_data.insert_value(SENTRY__EVENT__SERIALIZED_META, json);
130        }
131
132        Self {
133            timestamp: timestamp.clone(),
134            start_timestamp: start_timestamp.clone(),
135            exclusive_time: trace.map(|c| c.exclusive_time.clone()).unwrap_or_default(),
136            op: trace.map(|c| c.op.clone()).unwrap_or_default(),
137            span_id: trace.map(|c| c.span_id.clone()).unwrap_or_default(),
138            parent_span_id: trace.map(|c| c.parent_span_id.clone()).unwrap_or_default(),
139            trace_id: trace.map(|c| c.trace_id.clone()).unwrap_or_default(),
140            segment_id: trace.map(|c| c.span_id.clone()).unwrap_or_default(),
141            is_segment: true.into(),
142            // NB: Technically, this span may not be an actual remote span if this is a child
143            // transaction created within the same service as its parent. We still set `is_remote`
144            // as the best proxy to ensure this span will be detected as a segment by the spans
145            // pipeline.
146            is_remote: true.into(),
147            status: trace.map(|c| c.status.clone()).unwrap_or_default(),
148            description: transaction.clone(),
149            tags: tags.clone().map_value(|t| t.into()),
150            origin: trace.map(|c| c.origin.clone()).unwrap_or_default(),
151            profile_id: event
152                .context::<ProfileContext>()
153                .map(|c| c.profile_id.clone())
154                .unwrap_or_default(),
155            data,
156            links: trace.map(|c| c.links.clone()).unwrap_or_default(),
157            sentry_tags: Default::default(),
158            received: received.clone(),
159            measurements: measurements.clone(),
160            platform: platform.clone(),
161            was_transaction: true.into(),
162            kind: Default::default(),
163            other: Default::default(),
164        }
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use relay_protocol::Annotated;
171
172    use super::*;
173
174    #[test]
175    fn convert() {
176        let event = Annotated::<Event>::from_json(
177            r#"{
178                "type": "transaction",
179                "platform": "php",
180                "sdk": {"name": "sentry.php", "version": "1.2.3"},
181                "release": "myapp@1.0.0",
182                "environment": "prod",
183                "transaction": "my 1st transaction",
184                "contexts": {
185                    "browser": {"name": "Chrome"},
186                    "profile": {"profile_id": "a0aaaaaaaaaaaaaaaaaaaaaaaaaaaaab"},
187                    "trace": {
188                        "trace_id": "4C79F60C11214EB38604F4AE0781BFB2",
189                        "span_id": "FA90FDEAD5F74052",
190                        "type": "trace",
191                        "origin": "manual",
192                        "op": "myop",
193                        "status": "ok",
194                        "exclusive_time": 123.4,
195                        "parent_span_id": "FA90FDEAD5F74051",
196                        "data": {
197                            "custom_attribute": 42
198                        },
199                        "links": [
200                            {
201                                "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
202                                "span_id": "fa90fdead5f74052",
203                                "sampled": true,
204                                "attributes": {
205                                    "sentry.link.type": "previous_trace"
206                                }
207                            }
208                        ]
209                    }
210                },
211                "breadcrumbs": [
212                    {"type": "default", "category": "auth", "message": "login"}
213                ],
214                "extra": {
215                    "my_key": 1,
216                    "some_other_value": "foo bar"
217                },
218                "request": {
219                    "url": "http://example.com/api/0/organizations/",
220                    "method": "GET",
221                    "query_string": "project=1&sort=date"
222                },
223                "measurements": {
224                    "memory": {
225                        "value": 9001.0,
226                        "unit": "byte"
227                    }
228                }
229            }"#,
230        )
231        .unwrap()
232        .into_value()
233        .unwrap();
234
235        let span_from_event = Span::from(&event);
236        insta::assert_debug_snapshot!(span_from_event, @r#"
237        Span {
238            timestamp: ~,
239            start_timestamp: ~,
240            exclusive_time: 123.4,
241            op: "myop",
242            span_id: SpanId("fa90fdead5f74052"),
243            parent_span_id: SpanId("fa90fdead5f74051"),
244            trace_id: TraceId("4c79f60c11214eb38604f4ae0781bfb2"),
245            segment_id: SpanId("fa90fdead5f74052"),
246            is_segment: true,
247            is_remote: true,
248            status: Ok,
249            description: "my 1st transaction",
250            tags: ~,
251            origin: "manual",
252            profile_id: EventId(
253                a0aaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaab,
254            ),
255            data: SpanData {
256                other: {
257                    "browser.name": String(
258                        "Chrome",
259                    ),
260                    "custom_attribute": I64(
261                        42,
262                    ),
263                    "http.query": String(
264                        "?project=1&sort=date",
265                    ),
266                    "sentry.environment": String(
267                        "prod",
268                    ),
269                    "sentry.event.serialized_breadcrumbs": String(
270                        "{\"values\":[{\"type\":\"default\",\"category\":\"auth\",\"message\":\"login\"}]}",
271                    ),
272                    "sentry.event.serialized_contexts": String(
273                        "{\"browser\":{\"name\":\"Chrome\",\"type\":\"browser\"},\"profile\":{\"profile_id\":\"a0aaaaaaaaaaaaaaaaaaaaaaaaaaaaab\",\"type\":\"profile\"},\"trace\":{\"trace_id\":\"4c79f60c11214eb38604f4ae0781bfb2\",\"span_id\":\"fa90fdead5f74052\",\"parent_span_id\":\"fa90fdead5f74051\",\"op\":\"myop\",\"status\":\"ok\",\"exclusive_time\":123.4,\"origin\":\"manual\",\"data\":{\"custom_attribute\":42},\"links\":[{\"trace_id\":\"4c79f60c11214eb38604f4ae0781bfb2\",\"span_id\":\"fa90fdead5f74052\",\"sampled\":true,\"attributes\":{\"sentry.link.type\":\"previous_trace\"}}],\"type\":\"trace\"}}",
274                    ),
275                    "sentry.event.serialized_extra": String(
276                        "{\"my_key\":1,\"some_other_value\":\"foo bar\"}",
277                    ),
278                    "sentry.event.serialized_request": String(
279                        "{\"url\":\"http://example.com/api/0/organizations/\",\"method\":\"GET\",\"query_string\":[[\"project\",\"1\"],[\"sort\",\"date\"]]}",
280                    ),
281                    "sentry.name": String(
282                        "my 1st transaction",
283                    ),
284                    "sentry.release": String(
285                        "myapp@1.0.0",
286                    ),
287                    "sentry.sdk.name": String(
288                        "sentry.php",
289                    ),
290                    "sentry.sdk.version": String(
291                        "1.2.3",
292                    ),
293                    "sentry.segment.name": String(
294                        "my 1st transaction",
295                    ),
296                    "url.query": String(
297                        "project=1&sort=date",
298                    ),
299                },
300            },
301            links: [
302                SpanLink {
303                    trace_id: TraceId("4c79f60c11214eb38604f4ae0781bfb2"),
304                    span_id: SpanId("fa90fdead5f74052"),
305                    sampled: true,
306                    attributes: {
307                        "sentry.link.type": String(
308                            "previous_trace",
309                        ),
310                    },
311                    other: {},
312                },
313            ],
314            sentry_tags: ~,
315            received: ~,
316            measurements: Measurements(
317                {
318                    "memory": Measurement {
319                        value: 9001.0,
320                        unit: Information(
321                            Byte,
322                        ),
323                    },
324                },
325            ),
326            platform: "php",
327            was_transaction: true,
328            kind: ~,
329            other: {},
330        }
331        "#);
332    }
333
334    #[test]
335    fn convert_preserves_contexts_breadcrumbs_extra() {
336        let event = Annotated::<Event>::from_json(
337            r#"{
338                "type": "transaction",
339                "transaction": "my transaction",
340                "contexts": {
341                    "browser": {"name": "Chrome"},
342                    "trace": {
343                        "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
344                        "span_id": "fa90fdead5f74052"
345                    }
346                },
347                "breadcrumbs": [
348                    {"type": "default", "category": "auth", "message": "login"}
349                ],
350                "extra": {
351                    "my_key": 1,
352                    "some_other_value": "foo bar"
353                }
354            }"#,
355        )
356        .unwrap()
357        .into_value()
358        .unwrap();
359
360        let span = Span::from(&event);
361        let data = span.data.value().unwrap();
362
363        assert_eq!(
364            data.get_str(SENTRY__EVENT__SERIALIZED_CONTEXTS),
365            Some(
366                r#"{"browser":{"name":"Chrome","type":"browser"},"trace":{"trace_id":"4c79f60c11214eb38604f4ae0781bfb2","span_id":"fa90fdead5f74052","type":"trace"}}"#
367            )
368        );
369        assert_eq!(
370            data.get_str(SENTRY__EVENT__SERIALIZED_BREADCRUMBS),
371            Some(r#"{"values":[{"type":"default","category":"auth","message":"login"}]}"#)
372        );
373        assert_eq!(
374            data.get_str(SENTRY__EVENT__SERIALIZED_EXTRA),
375            Some(r#"{"my_key":1,"some_other_value":"foo bar"}"#)
376        );
377    }
378
379    #[test]
380    fn convert_preserves_request() {
381        let event = Annotated::<Event>::from_json(
382            r#"{
383                "type": "transaction",
384                "request": {
385                    "url": "https://example.com/api/",
386                    "method": "POST",
387                    "data": {"answer": 42},
388                    "query_string": "project=1",
389                    "headers": {"content-type": "application/json"},
390                    "custom": "value"
391                },
392                "_meta": {
393                    "request": {"url": {"": {"len": 100}}}
394                }
395            }"#,
396        )
397        .unwrap()
398        .into_value()
399        .unwrap();
400
401        let span = Span::from(&event);
402        let data = span.data.value().unwrap();
403
404        assert_eq!(
405            data.get_str(SENTRY__EVENT__SERIALIZED_REQUEST),
406            Some(
407                r#"{"url":"https://example.com/api/","method":"POST","data":{"answer":42},"query_string":[["project","1"]],"headers":[["Content-Type","application/json"]],"custom":"value"}"#
408            )
409        );
410        assert_eq!(
411            data.get_str(SENTRY__EVENT__SERIALIZED_META),
412            Some(r#"{"request":{"url":{"":{"len":100}}}}"#)
413        );
414    }
415
416    #[test]
417    fn convert_omits_absent_or_empty_request() {
418        for json in [
419            r#"{"type":"transaction"}"#,
420            r#"{"type":"transaction","request":null}"#,
421            r#"{"type":"transaction","request":{}}"#,
422        ] {
423            let event = Annotated::<Event>::from_json(json)
424                .unwrap()
425                .into_value()
426                .unwrap();
427
428            let span = Span::from(&event);
429            let data = span.data.value().unwrap();
430
431            assert!(!data.contains(SENTRY__EVENT__SERIALIZED_REQUEST));
432        }
433    }
434
435    #[test]
436    fn convert_preserves_trace_only_contexts() {
437        let event = Annotated::<Event>::from_json(
438            r#"{
439                "type": "transaction",
440                "contexts": {
441                    "trace": {
442                        "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
443                        "span_id": "fa90fdead5f74052"
444                    }
445                }
446            }"#,
447        )
448        .unwrap()
449        .into_value()
450        .unwrap();
451
452        let span = Span::from(&event);
453        let data = span.data.value().unwrap();
454
455        assert_eq!(
456            data.get_str(SENTRY__EVENT__SERIALIZED_CONTEXTS),
457            Some(
458                r#"{"trace":{"trace_id":"4c79f60c11214eb38604f4ae0781bfb2","span_id":"fa90fdead5f74052","type":"trace"}}"#
459            )
460        );
461    }
462
463    #[test]
464    fn convert_omits_absent_contexts_breadcrumbs_extra() {
465        let event = Annotated::<Event>::from_json(
466            r#"{
467                "type": "transaction",
468                "transaction": "my transaction"
469            }"#,
470        )
471        .unwrap()
472        .into_value()
473        .unwrap();
474
475        let span = Span::from(&event);
476
477        if let Some(data) = span.data.value() {
478            assert!(!data.contains(SENTRY__EVENT__SERIALIZED_CONTEXTS));
479            assert!(!data.contains(SENTRY__EVENT__SERIALIZED_BREADCRUMBS));
480            assert!(!data.contains(SENTRY__EVENT__SERIALIZED_EXTRA));
481        }
482    }
483
484    #[test]
485    fn convert_omits_empty_contexts_breadcrumbs_extra() {
486        let event = Annotated::<Event>::from_json(
487            r#"{
488                "type": "transaction",
489                "transaction": "my transaction",
490                "contexts": {},
491                "breadcrumbs": [],
492                "extra": {}
493            }"#,
494        )
495        .unwrap()
496        .into_value()
497        .unwrap();
498
499        let span = Span::from(&event);
500
501        if let Some(data) = span.data.value() {
502            assert!(!data.contains(SENTRY__EVENT__SERIALIZED_CONTEXTS));
503            assert!(!data.contains(SENTRY__EVENT__SERIALIZED_BREADCRUMBS));
504            assert!(!data.contains(SENTRY__EVENT__SERIALIZED_EXTRA));
505        }
506    }
507
508    #[test]
509    fn convert_preserves_meta() {
510        let event = Annotated::<Event>::from_json(
511            r#"{
512                "type": "transaction",
513                "transaction": "my transaction",
514                "contexts": {
515                    "browser": {"name": "Chrome"},
516                    "trace": {
517                        "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
518                        "span_id": "fa90fdead5f74052"
519                    }
520                },
521                "breadcrumbs": [
522                    {"type": "default", "category": "auth", "message": "login"}
523                ],
524                "extra": {
525                    "my_key": "[Filtered]"
526                },
527                "request": {
528                    "data": {"note": "[Filtered]"}
529                },
530                "_meta": {
531                    "contexts": {
532                        "browser": {
533                            "name": {"": {"rem": [["browser_rule", "s"]]}}
534                        },
535                        "trace": {
536                            "trace_id": {"": {"rem": [["trace_rule", "s"]]}}
537                        }
538                    },
539                    "breadcrumbs": {
540                        "0": {
541                            "message": {"": {"rem": [["breadcrumb_rule", "s"]]}}
542                        }
543                    },
544                    "extra": {
545                        "my_key": {"": {"rem": [["extra_rule", "s", 0, 10]]}}
546                    },
547                    "request": {
548                        "data": {
549                            "note": {"": {"rem": [["request_rule", "s"]]}}
550                        }
551                    }
552                }
553            }"#,
554        )
555        .unwrap()
556        .into_value()
557        .unwrap();
558
559        let span = Span::from(&event);
560        let data = span.data.value().unwrap();
561
562        let meta = data.get_str(SENTRY__EVENT__SERIALIZED_META).unwrap();
563
564        assert_eq!(
565            meta,
566            r#"{"breadcrumbs":{"values":{"0":{"message":{"":{"rem":[["breadcrumb_rule","s"]]}}}}},"contexts":{"browser":{"name":{"":{"rem":[["browser_rule","s"]]}}},"trace":{"trace_id":{"":{"rem":[["trace_rule","s"]]}}}},"extra":{"my_key":{"":{"rem":[["extra_rule","s",0,10]]}}},"request":{"data":{"note":{"":{"rem":[["request_rule","s"]]}}}}}"#
567        );
568    }
569
570    #[test]
571    fn convert_omits_meta_when_absent() {
572        let event = Annotated::<Event>::from_json(
573            r#"{
574                "type": "transaction",
575                "transaction": "my transaction",
576                "contexts": {
577                    "browser": {"name": "Chrome"}
578                },
579                "extra": {
580                    "my_key": 1
581                }
582            }"#,
583        )
584        .unwrap()
585        .into_value()
586        .unwrap();
587
588        let span = Span::from(&event);
589
590        if let Some(data) = span.data.value() {
591            assert!(!data.contains(SENTRY__EVENT__SERIALIZED_META));
592        }
593    }
594
595    #[test]
596    fn convert_preserves_meta_when_only_trace_context_has_meta() {
597        let event = Annotated::<Event>::from_json(
598            r#"{
599                "type": "transaction",
600                "transaction": "my transaction",
601                "contexts": {
602                    "trace": {
603                        "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
604                        "span_id": "fa90fdead5f74052"
605                    }
606                },
607                "_meta": {
608                    "contexts": {
609                        "trace": {
610                            "trace_id": {"": {"rem": [["trace_rule", "s"]]}}
611                        }
612                    }
613                }
614            }"#,
615        )
616        .unwrap()
617        .into_value()
618        .unwrap();
619
620        let span = Span::from(&event);
621        let data = span.data.value().unwrap();
622
623        assert_eq!(
624            data.get_str(SENTRY__EVENT__SERIALIZED_META),
625            Some(r#"{"contexts":{"trace":{"trace_id":{"":{"rem":[["trace_rule","s"]]}}}}}"#)
626        );
627    }
628}