relay_spans/
v1_to_v2.rs

1use std::borrow::Cow;
2
3use relay_conventions::{IS_REMOTE, SPAN_KIND};
4use relay_event_schema::protocol::{
5    Attribute, AttributeType, AttributeValue, Attributes, JsonLenientString, Span as SpanV1,
6    SpanData, SpanLink, SpanStatus as SpanV1Status, SpanV2, SpanV2Link, SpanV2Status,
7};
8use relay_protocol::{Annotated, Empty, Error, IntoValue, Meta, Value};
9
10use crate::name::name_for_attributes;
11
12/// Converts a legacy span to the new Span V2 schema.
13///
14/// - `tags`, `sentry_tags`, `measurements` and `data` are transferred to `attributes`.
15/// - Nested `data` items are encoded as JSON.
16pub fn span_v1_to_span_v2(span_v1: SpanV1) -> SpanV2 {
17    let SpanV1 {
18        timestamp,
19        start_timestamp,
20        exclusive_time,
21        op,
22        span_id,
23        parent_span_id,
24        trace_id,
25        segment_id,
26        is_segment,
27        is_remote,
28        status,
29        description,
30        tags,
31        origin,
32        profile_id,
33        data,
34        links,
35        sentry_tags,
36        received: _, // needs to go into the Kafka span eventually, but makes no sense in Span V2 schema.
37        measurements,
38        platform,
39        was_transaction,
40        kind,
41        performance_issues_spans,
42        other: _,
43    } = span_v1;
44
45    let mut annotated_attributes = attributes_from_data(data);
46    let attributes = annotated_attributes.get_or_insert_with(Default::default);
47
48    // Top-level fields have higher precedence than `data`:
49    attributes.insert("sentry.exclusive_time", exclusive_time);
50    attributes.insert("sentry.op", op);
51
52    attributes.insert("sentry.segment.id", segment_id.map_value(|v| v.to_string()));
53    attributes.insert("sentry.description", description);
54    attributes.insert("sentry.origin", origin);
55    attributes.insert("sentry.profile_id", profile_id.map_value(|v| v.to_string()));
56    attributes.insert("sentry.platform", platform);
57    attributes.insert("sentry.was_transaction", was_transaction);
58    attributes.insert(
59        "sentry._internal.performance_issues_spans",
60        performance_issues_spans,
61    );
62
63    // Use same precedence as `backfill_data` for data bags:
64    if let Some(measurements) = measurements.into_value() {
65        for (key, measurement) in measurements.0 {
66            let key = match key.as_str() {
67                "client_sample_rate" => "sentry.client_sample_rate",
68                "server_sample_rate" => "sentry.server_sample_rate",
69                other => other,
70            };
71
72            attributes.insert_if_missing(key, || match measurement {
73                Annotated(Some(measurement), _) => measurement.value.map_value(|f| f.to_f64()),
74                Annotated(None, meta) => Annotated(None, meta),
75            });
76        }
77    }
78    if let Some(tags) = tags.into_value() {
79        for (key, value) in tags {
80            if !attributes.contains_key(&key) {
81                attributes.0.insert(
82                    key,
83                    value
84                        .map_value(|JsonLenientString(s)| AttributeValue::from(s))
85                        .and_then(Attribute::from),
86                );
87            }
88        }
89    }
90    if let Some(tags) = sentry_tags.into_value()
91        && let Value::Object(tags) = tags.into_value()
92    {
93        for (key, value) in tags {
94            let key = match key.as_str() {
95                "description" => "sentry.normalized_description".into(),
96                other => Cow::Owned(format!("sentry.{}", other)),
97            };
98            if !value.is_empty() && !attributes.contains_key(key.as_ref()) {
99                attributes
100                    .0
101                    .insert(key.into_owned(), attribute_from_value(value));
102            }
103        }
104    }
105
106    let name = attributes
107        .remove("sentry.name")
108        .and_then(|name| name.map_value(|attr| attr.into_string()).transpose())
109        .unwrap_or_else(|| name_for_attributes(attributes).into());
110
111    if let Some(is_remote) = is_remote.value() {
112        attributes.insert(IS_REMOTE, *is_remote);
113    }
114    attributes.insert(SPAN_KIND, kind.map_value(|kind| kind.to_string()));
115
116    let is_segment = match (is_segment.value(), is_remote.value()) {
117        (None, Some(true)) => is_remote,
118        _ => is_segment,
119    };
120
121    SpanV2 {
122        trace_id,
123        parent_span_id,
124        span_id,
125        name,
126        status: Annotated::map_value(status, span_v1_status_to_span_v2_status)
127            .or_else(|| SpanV2Status::Ok.into()),
128        is_segment,
129        start_timestamp,
130        end_timestamp: timestamp,
131        links: links.map_value(span_v1_links_to_span_v2_links),
132        attributes: annotated_attributes,
133        other: Default::default(), // cannot carry over because of schema mismatch
134    }
135}
136
137fn span_v1_status_to_span_v2_status(status: SpanV1Status) -> SpanV2Status {
138    match status {
139        SpanV1Status::Ok => SpanV2Status::Ok,
140        _ => SpanV2Status::Error,
141    }
142}
143
144fn span_v1_links_to_span_v2_links(links: Vec<Annotated<SpanLink>>) -> Vec<Annotated<SpanV2Link>> {
145    links
146        .into_iter()
147        .map(|link| {
148            link.map_value(
149                |SpanLink {
150                     trace_id,
151                     span_id,
152                     sampled,
153                     attributes,
154                     other,
155                 }| {
156                    SpanV2Link {
157                        trace_id,
158                        span_id,
159                        sampled,
160                        attributes: attributes.map_value(|attrs| {
161                            Attributes::from_iter(
162                                attrs
163                                    .into_iter()
164                                    .map(|(key, value)| (key, attribute_from_value(value))),
165                            )
166                        }),
167                        other,
168                    }
169                },
170            )
171        })
172        .collect()
173}
174
175fn attributes_from_data(data: Annotated<SpanData>) -> Annotated<Attributes> {
176    let Annotated(data, meta) = data;
177    let Some(data) = data else {
178        return Annotated(None, meta);
179    };
180    let Value::Object(data) = data.into_value() else {
181        debug_assert!(false, "`SpanData` must convert to Object");
182        return Annotated(None, meta);
183    };
184
185    Annotated::new(Attributes::from_iter(data.into_iter().filter_map(
186        |(key, value)| (!value.is_empty()).then_some((key, attribute_from_value(value))),
187    )))
188}
189
190fn attribute_from_value(value: Annotated<Value>) -> Annotated<Attribute> {
191    let value: Annotated<AttributeValue> = value.and_then(attribute_value_from_value);
192    value.map_value(Attribute::from)
193}
194
195/// Converts a generic [`Value`] into an annotated attribute value with the proper type.
196///
197/// - Any conversion errors are documented in [`Meta`].
198/// - Nested values are serialized into strings.
199fn attribute_value_from_value(value: Value) -> Annotated<AttributeValue> {
200    match value {
201        Value::Bool(v) => AttributeValue::from(v),
202        Value::I64(v) => AttributeValue::from(v),
203        Value::U64(v) => match i64::try_from(v) {
204            Ok(i) => AttributeValue::from(i),
205            Err(_) => return Annotated::from_error(Error::invalid("integer too large"), None),
206        },
207        Value::F64(v) => AttributeValue::from(v),
208        Value::String(v) => AttributeValue::from(v),
209        Value::Array(_) | Value::Object(_) => {
210            return match serde_json::to_string(&NoMeta(&value)) {
211                Ok(s) => Annotated(
212                    Some(AttributeValue {
213                        ty: AttributeType::String.into(),
214                        value: Value::String(s).into(),
215                    }),
216                    Meta::from_error(Error::expected("scalar attribute")),
217                ),
218                Err(_) => Annotated::from_error(
219                    Error::invalid("failed to serialize nested attribute"),
220                    None,
221                ),
222            };
223        }
224    }
225    .into()
226}
227
228/// A wrapper for [`IntoValue`] types which allows serde serialization and discards metadata.
229struct NoMeta<'a, T>(&'a T);
230
231impl<T> serde::Serialize for NoMeta<'_, T>
232where
233    T: IntoValue,
234{
235    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
236    where
237        S: serde::Serializer,
238    {
239        self.0.serialize_payload(serializer, Default::default())
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use chrono::DateTime;
247    use relay_event_schema::protocol::{Event, Timestamp};
248    use relay_protocol::{FromValue, SerializableAnnotated};
249
250    #[test]
251    fn parse() {
252        let json = serde_json::json!({
253          "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
254          "parent_span_id": "fa90fdead5f74051",
255          "span_id": "fa90fdead5f74052",
256          "status": "ok",
257          "is_remote": true,
258          "kind": "server",
259          "start_timestamp": -63158400.0,
260          "timestamp": 0.0,
261          "links": [
262            {
263            "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
264            "span_id": "fa90fdead5f74052",
265            "sampled": true,
266              "attributes": {
267                "boolAttr": true,
268                "numAttr": 123,
269                "stringAttr": "foo"
270              }
271            }
272          ],
273          "tags": {
274            "foo": "bar"
275          },
276          "measurements": {
277            "memory": {
278              "value": 9001.0,
279              "unit": "byte"
280            },
281            "client_sample_rate": {
282              "value": 0.11
283            },
284            "server_sample_rate": {
285              "value": 0.22
286            }
287          },
288          "data": {
289            "my.data.field": "my.data.value",
290            "my.array": ["str", 123],
291            "my.nested": {
292              "numbers": [
293                1,
294                2,
295                3
296              ]
297            }
298          },
299          "_performance_issues_spans": true,
300          "description": "raw description",
301          "exclusive_time": 1.23,
302          "is_segment": true,
303          "sentry_tags": {
304            "description": "normalized description",
305            "user": "id:user123",
306          },
307          "op": "operation",
308          "origin": "auto.http",
309          "platform": "javascript",
310          "profile_id": "4c79f60c11214eb38604f4ae0781bfb0",
311          "segment_id": "fa90fdead5f74050",
312          "was_transaction": true,
313
314          "received": 0.2,
315          "additional_field": "additional field value"
316        });
317
318        let span_v1 = SpanV1::from_value(json.into()).into_value().unwrap();
319        let span_v2 = span_v1_to_span_v2(span_v1);
320
321        let annotated_span_v2: Annotated<SpanV2> = Annotated::new(span_v2);
322        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span_v2), @r#"
323        {
324          "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
325          "parent_span_id": "fa90fdead5f74051",
326          "span_id": "fa90fdead5f74052",
327          "name": "operation",
328          "status": "ok",
329          "is_segment": true,
330          "start_timestamp": -63158400.0,
331          "end_timestamp": 0.0,
332          "links": [
333            {
334              "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
335              "span_id": "fa90fdead5f74052",
336              "sampled": true,
337              "attributes": {
338                "boolAttr": {
339                  "type": "boolean",
340                  "value": true
341                },
342                "numAttr": {
343                  "type": "integer",
344                  "value": 123
345                },
346                "stringAttr": {
347                  "type": "string",
348                  "value": "foo"
349                }
350              }
351            }
352          ],
353          "attributes": {
354            "foo": {
355              "type": "string",
356              "value": "bar"
357            },
358            "memory": {
359              "type": "double",
360              "value": 9001.0
361            },
362            "my.array": {
363              "type": "string",
364              "value": "[\"str\",123]"
365            },
366            "my.data.field": {
367              "type": "string",
368              "value": "my.data.value"
369            },
370            "my.nested": {
371              "type": "string",
372              "value": "{\"numbers\":[1,2,3]}"
373            },
374            "sentry._internal.performance_issues_spans": {
375              "type": "boolean",
376              "value": true
377            },
378            "sentry.client_sample_rate": {
379              "type": "double",
380              "value": 0.11
381            },
382            "sentry.description": {
383              "type": "string",
384              "value": "raw description"
385            },
386            "sentry.exclusive_time": {
387              "type": "double",
388              "value": 1.23
389            },
390            "sentry.is_remote": {
391              "type": "boolean",
392              "value": true
393            },
394            "sentry.kind": {
395              "type": "string",
396              "value": "server"
397            },
398            "sentry.normalized_description": {
399              "type": "string",
400              "value": "normalized description"
401            },
402            "sentry.op": {
403              "type": "string",
404              "value": "operation"
405            },
406            "sentry.origin": {
407              "type": "string",
408              "value": "auto.http"
409            },
410            "sentry.platform": {
411              "type": "string",
412              "value": "javascript"
413            },
414            "sentry.profile_id": {
415              "type": "string",
416              "value": "4c79f60c11214eb38604f4ae0781bfb0"
417            },
418            "sentry.segment.id": {
419              "type": "string",
420              "value": "fa90fdead5f74050"
421            },
422            "sentry.server_sample_rate": {
423              "type": "double",
424              "value": 0.22
425            },
426            "sentry.user": {
427              "type": "string",
428              "value": "id:user123"
429            },
430            "sentry.was_transaction": {
431              "type": "boolean",
432              "value": true
433            }
434          },
435          "_meta": {
436            "attributes": {
437              "my.array": {
438                "": {
439                  "err": [
440                    [
441                      "invalid_data",
442                      {
443                        "reason": "expected scalar attribute"
444                      }
445                    ]
446                  ]
447                }
448              },
449              "my.nested": {
450                "": {
451                  "err": [
452                    [
453                      "invalid_data",
454                      {
455                        "reason": "expected scalar attribute"
456                      }
457                    ]
458                  ]
459                }
460              }
461            }
462          }
463        }
464        "#);
465    }
466
467    #[test]
468    fn transaction_conversion() {
469        let txn = Annotated::<Event>::from_json(r#"{"transaction": "hi"}"#)
470            .unwrap()
471            .0
472            .unwrap();
473        assert_eq!(txn.transaction.as_str(), Some("hi"));
474        let span_v1 = SpanV1::from(&txn);
475        assert_eq!(
476            span_v1.data.value().unwrap().segment_name.as_str(),
477            Some("hi")
478        );
479        let span_v2 = span_v1_to_span_v2(span_v1);
480        assert_eq!(
481            span_v2
482                .attributes
483                .value()
484                .unwrap()
485                .get_value("sentry.segment.name")
486                .and_then(Value::as_str),
487            Some("hi")
488        );
489    }
490
491    #[test]
492    fn start_timestamp() {
493        let json = r#"{"timestamp": 123, "end_timestamp": "invalid data"}"#;
494        let span_v1 = Annotated::<SpanV1>::from_json(json).unwrap();
495        let span_v2 = span_v1_to_span_v2(span_v1.into_value().unwrap());
496
497        // Parsed version is still fine:
498        assert_eq!(
499            span_v2.end_timestamp.value().unwrap(),
500            &Timestamp(DateTime::from_timestamp_secs(123).unwrap())
501        );
502
503        let serialized = Annotated::from(span_v2).payload_to_json().unwrap();
504        assert_eq!(
505            &serialized,
506            r#"{"status":"ok","end_timestamp":123.0,"attributes":{}}"#
507        );
508    }
509}