relay_spans/
v1_to_v2.rs

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