Skip to main content

relay_spans/
v1_to_v2.rs

1use std::borrow::Cow;
2
3use relay_conventions::attributes::*;
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.
16///
17/// Measurements are converted to attributes by looking up the
18/// replacement attribute's name in `sentry-conventions`.
19///
20/// `infer_name` controls whether the span's name is inferred based on its attributes.
21/// Only enable it if the source span has gone through PII scrubbing, otherwise the name
22/// might end up containing PII that doesn't get scrubbed later!
23pub fn span_v1_to_span_v2(span_v1: SpanV1, infer_name: bool) -> SpanV2 {
24    let SpanV1 {
25        timestamp,
26        start_timestamp,
27        exclusive_time,
28        op,
29        span_id,
30        parent_span_id,
31        trace_id,
32        segment_id,
33        is_segment,
34        is_remote,
35        status,
36        description,
37        tags,
38        origin,
39        profile_id,
40        data,
41        links,
42        sentry_tags,
43        received: _, // needs to go into the Kafka span eventually, but makes no sense in Span V2 schema.
44        measurements,
45        platform,
46        was_transaction: _,
47        kind,
48        other: _,
49    } = span_v1;
50
51    let mut annotated_attributes = attributes_from_data(data);
52    let attributes = annotated_attributes.get_or_insert_with(Default::default);
53
54    // Top-level fields have higher precedence than `data`:
55    attributes.insert(SENTRY__EXCLUSIVE_TIME, exclusive_time);
56    attributes.insert(SENTRY__OP, op);
57    attributes.insert(SENTRY__SEGMENT__ID, segment_id.map_value(|v| v.to_string()));
58    attributes.insert(SENTRY__DESCRIPTION, description);
59    attributes.insert(SENTRY__ORIGIN, origin);
60    attributes.insert(SENTRY__PROFILE_ID, profile_id.map_value(|v| v.to_string()));
61    attributes.insert(SENTRY__PLATFORM, platform);
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                // These measurements aren't defined in conventions; they are not sent
68                // by SDKs but inserted into `measurements` by Relay. As such, we
69                // special-case them here.
70                "client_sample_rate" => SENTRY__CLIENT_SAMPLE_RATE,
71                "server_sample_rate" => SENTRY__SERVER_SAMPLE_RATE,
72                other => relay_conventions::measurement_to_attribute(other).unwrap_or(other),
73            };
74
75            attributes.insert_if_missing(key, || match measurement {
76                Annotated(Some(measurement), _) => measurement.value.map_value(|f| f.to_f64()),
77                Annotated(None, meta) => Annotated(None, meta),
78            });
79        }
80    }
81    if let Some(tags) = tags.into_value() {
82        for (key, value) in tags {
83            if !attributes.contains_key(&key) {
84                attributes.0.insert(
85                    key,
86                    value
87                        .map_value(|JsonLenientString(s)| AttributeValue::from(s))
88                        .and_then(Attribute::from),
89                );
90            }
91        }
92    }
93    if let Some(tags) = sentry_tags.into_value()
94        && let Value::Object(tags) = tags.into_value()
95    {
96        for (key, value) in tags {
97            if value.is_empty() {
98                continue;
99            }
100            let conventional_key = match key.as_str() {
101                "user.email" => Some(USER__EMAIL),
102                "user.geo.city" => Some(USER__GEO__CITY),
103                "user.geo.country_code" => Some(USER__GEO__COUNTRY_CODE),
104                "user.geo.region" => Some(USER__GEO__REGION),
105                "user.geo.subdivision" => Some(USER__GEO__SUBDIVISION),
106                "user.id" => Some(USER__ID),
107                "user.ip" => Some(USER__IP_ADDRESS),
108                "user.username" => Some(USER__NAME),
109                _ => None,
110            };
111            if let Some(conv_key) = conventional_key
112                && !attributes.contains_key(conv_key)
113            {
114                attributes
115                    .0
116                    .insert(conv_key.to_owned(), attribute_from_value(value.clone()));
117            }
118            let key = match key.as_str() {
119                "description" => SENTRY__NORMALIZED_DESCRIPTION.into(),
120                other => Cow::Owned(format!("sentry.{}", other)),
121            };
122            if !attributes.contains_key(key.as_ref()) {
123                attributes
124                    .0
125                    .insert(key.into_owned(), attribute_from_value(value));
126            }
127        }
128    }
129
130    let name = attributes
131        .remove("sentry.name")
132        .and_then(|name| name.map_value(|attr| attr.into_string()).transpose())
133        .or_else(|| {
134            if infer_name {
135                name_for_attributes(attributes).map(Annotated::new)
136            } else {
137                None
138            }
139        })
140        .unwrap_or(Annotated::empty());
141
142    if let Some(is_remote) = is_remote.value() {
143        attributes.insert(SENTRY__IS_REMOTE, *is_remote);
144    }
145    attributes.insert(SENTRY__KIND, kind.map_value(|kind| kind.to_string()));
146
147    let is_segment = match (is_segment.value(), is_remote.value()) {
148        (None, Some(true)) => is_remote,
149        _ => is_segment,
150    };
151
152    SpanV2 {
153        trace_id,
154        parent_span_id,
155        span_id,
156        name,
157        status: Annotated::map_value(status, span_v1_status_to_span_v2_status)
158            .or_else(|| SpanV2Status::Ok.into()),
159        is_segment,
160        start_timestamp,
161        end_timestamp: timestamp,
162        links: links.map_value(span_v1_links_to_span_v2_links),
163        attributes: annotated_attributes,
164        other: Default::default(), // cannot carry over because of schema mismatch
165    }
166}
167
168fn span_v1_status_to_span_v2_status(status: SpanV1Status) -> SpanV2Status {
169    match status {
170        SpanV1Status::Ok => SpanV2Status::Ok,
171        _ => SpanV2Status::Error,
172    }
173}
174
175fn span_v1_links_to_span_v2_links(links: Vec<Annotated<SpanLink>>) -> Vec<Annotated<SpanV2Link>> {
176    links
177        .into_iter()
178        .map(|link| {
179            link.map_value(
180                |SpanLink {
181                     trace_id,
182                     span_id,
183                     sampled,
184                     attributes,
185                     other,
186                 }| {
187                    SpanV2Link {
188                        trace_id,
189                        span_id,
190                        sampled,
191                        attributes: attributes.map_value(|attrs| {
192                            Attributes::from_iter(
193                                attrs
194                                    .into_iter()
195                                    .map(|(key, value)| (key, attribute_from_value(value))),
196                            )
197                        }),
198                        other,
199                    }
200                },
201            )
202        })
203        .collect()
204}
205
206fn attributes_from_data(data: Annotated<SpanData>) -> Annotated<Attributes> {
207    let Annotated(data, meta) = data;
208    let Some(data) = data else {
209        return Annotated(None, meta);
210    };
211    let Value::Object(data) = data.into_value() else {
212        debug_assert!(false, "`SpanData` must convert to Object");
213        return Annotated(None, meta);
214    };
215
216    Annotated::new(Attributes::from_iter(data.into_iter().filter_map(
217        |(key, value)| (!value.is_empty()).then_some((key, attribute_from_value(value))),
218    )))
219}
220
221fn attribute_from_value(value: Annotated<Value>) -> Annotated<Attribute> {
222    let value: Annotated<AttributeValue> = value.and_then(attribute_value_from_value);
223    value.map_value(Attribute::from)
224}
225
226/// Converts a generic [`Value`] into an annotated attribute value with the proper type.
227///
228/// - Any conversion errors are documented in [`Meta`].
229/// - Nested values are serialized into strings.
230fn attribute_value_from_value(value: Value) -> Annotated<AttributeValue> {
231    match value {
232        Value::Bool(v) => AttributeValue::from(v),
233        Value::I64(v) => AttributeValue::from(v),
234        Value::U64(v) => match i64::try_from(v) {
235            Ok(i) => AttributeValue::from(i),
236            Err(_) => return Annotated::from_error(Error::invalid("integer too large"), None),
237        },
238        Value::F64(v) => AttributeValue::from(v),
239        Value::String(v) => AttributeValue::from(v),
240        Value::Array(_) | Value::Object(_) => {
241            return match serde_json::to_string(&NoMeta(&value)) {
242                Ok(s) => Annotated(
243                    Some(AttributeValue {
244                        ty: AttributeType::String.into(),
245                        value: Value::String(s).into(),
246                    }),
247                    Meta::from_error(Error::expected("scalar attribute")),
248                ),
249                Err(_) => Annotated::from_error(
250                    Error::invalid("failed to serialize nested attribute"),
251                    None,
252                ),
253            };
254        }
255    }
256    .into()
257}
258
259/// A wrapper for [`IntoValue`] types which allows serde serialization and discards metadata.
260struct NoMeta<'a, T>(&'a T);
261
262impl<T> serde::Serialize for NoMeta<'_, T>
263where
264    T: IntoValue,
265{
266    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
267    where
268        S: serde::Serializer,
269    {
270        self.0.serialize_payload(serializer, Default::default())
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use chrono::DateTime;
278    use relay_event_schema::protocol::{Event, Timestamp};
279    use relay_protocol::{FromValue, SerializableAnnotated};
280
281    #[test]
282    fn parse() {
283        let json = serde_json::json!({
284          "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
285          "parent_span_id": "fa90fdead5f74051",
286          "span_id": "fa90fdead5f74052",
287          "status": "ok",
288          "is_remote": true,
289          "kind": "server",
290          "start_timestamp": -63158400.0,
291          "timestamp": 0.0,
292          "links": [
293            {
294            "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
295            "span_id": "fa90fdead5f74052",
296            "sampled": true,
297              "attributes": {
298                "boolAttr": true,
299                "numAttr": 123,
300                "stringAttr": "foo"
301              }
302            }
303          ],
304          "tags": {
305            "foo": "bar"
306          },
307          "measurements": {
308            "memory": {
309              "value": 9001.0,
310              "unit": "byte"
311            },
312            "client_sample_rate": {
313              "value": 0.11
314            },
315            "server_sample_rate": {
316              "value": 0.22
317            }
318          },
319          "data": {
320            "my.data.field": "my.data.value",
321            "my.array": ["str", 123],
322            "my.nested": {
323              "numbers": [
324                1,
325                2,
326                3
327              ]
328            }
329          },
330          "_performance_issues_spans": true,
331          "description": "raw description",
332          "exclusive_time": 1.23,
333          "is_segment": true,
334          "sentry_tags": {
335            "description": "normalized description",
336            "user": "id:user123",
337            "user.email": "john@example.com",
338            "user.geo.city": "Vienna",
339            "user.geo.country_code": "AT",
340            "user.geo.region": "Europe",
341            "user.geo.subdivision": "AT-9",
342            "user.geo.subregion": "155",
343            "user.id": "user123",
344            "user.ip": "127.0.0.1",
345            "user.username": "john",
346          },
347          "op": "operation",
348          "origin": "auto.http",
349          "platform": "javascript",
350          "profile_id": "4c79f60c11214eb38604f4ae0781bfb0",
351          "segment_id": "fa90fdead5f74050",
352          "was_transaction": true,
353
354          "received": 0.2,
355          "additional_field": "additional field value"
356        });
357
358        let span_v1 = SpanV1::from_value(json.into()).into_value().unwrap();
359        let span_v2 = span_v1_to_span_v2(span_v1, false);
360
361        insta::assert_json_snapshot!(SerializableAnnotated(&Annotated::new(span_v2)), @r#"
362        {
363          "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
364          "parent_span_id": "fa90fdead5f74051",
365          "span_id": "fa90fdead5f74052",
366          "status": "ok",
367          "is_segment": true,
368          "start_timestamp": -63158400.0,
369          "end_timestamp": 0.0,
370          "links": [
371            {
372              "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
373              "span_id": "fa90fdead5f74052",
374              "sampled": true,
375              "attributes": {
376                "boolAttr": {
377                  "type": "boolean",
378                  "value": true
379                },
380                "numAttr": {
381                  "type": "integer",
382                  "value": 123
383                },
384                "stringAttr": {
385                  "type": "string",
386                  "value": "foo"
387                }
388              }
389            }
390          ],
391          "attributes": {
392            "foo": {
393              "type": "string",
394              "value": "bar"
395            },
396            "memory": {
397              "type": "double",
398              "value": 9001.0
399            },
400            "my.array": {
401              "type": "string",
402              "value": "[\"str\",123]"
403            },
404            "my.data.field": {
405              "type": "string",
406              "value": "my.data.value"
407            },
408            "my.nested": {
409              "type": "string",
410              "value": "{\"numbers\":[1,2,3]}"
411            },
412            "sentry.client_sample_rate": {
413              "type": "double",
414              "value": 0.11
415            },
416            "sentry.description": {
417              "type": "string",
418              "value": "raw description"
419            },
420            "sentry.exclusive_time": {
421              "type": "double",
422              "value": 1.23
423            },
424            "sentry.is_remote": {
425              "type": "boolean",
426              "value": true
427            },
428            "sentry.kind": {
429              "type": "string",
430              "value": "server"
431            },
432            "sentry.normalized_description": {
433              "type": "string",
434              "value": "normalized description"
435            },
436            "sentry.op": {
437              "type": "string",
438              "value": "operation"
439            },
440            "sentry.origin": {
441              "type": "string",
442              "value": "auto.http"
443            },
444            "sentry.platform": {
445              "type": "string",
446              "value": "javascript"
447            },
448            "sentry.profile_id": {
449              "type": "string",
450              "value": "4c79f60c11214eb38604f4ae0781bfb0"
451            },
452            "sentry.segment.id": {
453              "type": "string",
454              "value": "fa90fdead5f74050"
455            },
456            "sentry.server_sample_rate": {
457              "type": "double",
458              "value": 0.22
459            },
460            "sentry.user": {
461              "type": "string",
462              "value": "id:user123"
463            },
464            "sentry.user.email": {
465              "type": "string",
466              "value": "john@example.com"
467            },
468            "sentry.user.geo.city": {
469              "type": "string",
470              "value": "Vienna"
471            },
472            "sentry.user.geo.country_code": {
473              "type": "string",
474              "value": "AT"
475            },
476            "sentry.user.geo.region": {
477              "type": "string",
478              "value": "Europe"
479            },
480            "sentry.user.geo.subdivision": {
481              "type": "string",
482              "value": "AT-9"
483            },
484            "sentry.user.geo.subregion": {
485              "type": "string",
486              "value": "155"
487            },
488            "sentry.user.id": {
489              "type": "string",
490              "value": "user123"
491            },
492            "sentry.user.ip": {
493              "type": "string",
494              "value": "127.0.0.1"
495            },
496            "sentry.user.username": {
497              "type": "string",
498              "value": "john"
499            },
500            "user.email": {
501              "type": "string",
502              "value": "john@example.com"
503            },
504            "user.geo.city": {
505              "type": "string",
506              "value": "Vienna"
507            },
508            "user.geo.country_code": {
509              "type": "string",
510              "value": "AT"
511            },
512            "user.geo.region": {
513              "type": "string",
514              "value": "Europe"
515            },
516            "user.geo.subdivision": {
517              "type": "string",
518              "value": "AT-9"
519            },
520            "user.id": {
521              "type": "string",
522              "value": "user123"
523            },
524            "user.ip_address": {
525              "type": "string",
526              "value": "127.0.0.1"
527            },
528            "user.name": {
529              "type": "string",
530              "value": "john"
531            }
532          },
533          "_meta": {
534            "attributes": {
535              "my.array": {
536                "": {
537                  "err": [
538                    [
539                      "invalid_data",
540                      {
541                        "reason": "expected scalar attribute"
542                      }
543                    ]
544                  ]
545                }
546              },
547              "my.nested": {
548                "": {
549                  "err": [
550                    [
551                      "invalid_data",
552                      {
553                        "reason": "expected scalar attribute"
554                      }
555                    ]
556                  ]
557                }
558              }
559            }
560          }
561        }
562        "#);
563    }
564
565    #[test]
566    fn parse_with_name_inference() {
567        let json = serde_json::json!({
568          "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
569          "parent_span_id": "fa90fdead5f74051",
570          "span_id": "fa90fdead5f74052",
571          "status": "ok",
572          "is_remote": true,
573          "kind": "server",
574          "start_timestamp": -63158400.0,
575          "timestamp": 0.0,
576          "links": [
577            {
578            "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
579            "span_id": "fa90fdead5f74052",
580            "sampled": true,
581              "attributes": {
582                "boolAttr": true,
583                "numAttr": 123,
584                "stringAttr": "foo"
585              }
586            }
587          ],
588          "tags": {
589            "foo": "bar"
590          },
591          "measurements": {
592            "memory": {
593              "value": 9001.0,
594              "unit": "byte"
595            },
596            "client_sample_rate": {
597              "value": 0.11
598            },
599            "server_sample_rate": {
600              "value": 0.22
601            }
602          },
603          "data": {
604            "my.data.field": "my.data.value",
605            "my.array": ["str", 123],
606            "my.nested": {
607              "numbers": [
608                1,
609                2,
610                3
611              ]
612            }
613          },
614          "_performance_issues_spans": true,
615          "description": "raw description",
616          "exclusive_time": 1.23,
617          "is_segment": true,
618          "sentry_tags": {
619            "description": "normalized description",
620            "user": "id:user123",
621            "user.email": "john@example.com",
622            "user.geo.city": "Vienna",
623            "user.geo.country_code": "AT",
624            "user.geo.region": "Europe",
625            "user.geo.subdivision": "AT-9",
626            "user.geo.subregion": "155",
627            "user.id": "user123",
628            "user.ip": "127.0.0.1",
629            "user.username": "john",
630          },
631          "op": "operation",
632          "origin": "auto.http",
633          "platform": "javascript",
634          "profile_id": "4c79f60c11214eb38604f4ae0781bfb0",
635          "segment_id": "fa90fdead5f74050",
636          "was_transaction": true,
637
638          "received": 0.2,
639          "additional_field": "additional field value"
640        });
641
642        let span_v1 = SpanV1::from_value(json.into()).into_value().unwrap();
643        let span_v2 = span_v1_to_span_v2(span_v1, true);
644
645        insta::assert_json_snapshot!(SerializableAnnotated(&Annotated::new(span_v2)), @r#"
646        {
647          "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
648          "parent_span_id": "fa90fdead5f74051",
649          "span_id": "fa90fdead5f74052",
650          "name": "operation",
651          "status": "ok",
652          "is_segment": true,
653          "start_timestamp": -63158400.0,
654          "end_timestamp": 0.0,
655          "links": [
656            {
657              "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
658              "span_id": "fa90fdead5f74052",
659              "sampled": true,
660              "attributes": {
661                "boolAttr": {
662                  "type": "boolean",
663                  "value": true
664                },
665                "numAttr": {
666                  "type": "integer",
667                  "value": 123
668                },
669                "stringAttr": {
670                  "type": "string",
671                  "value": "foo"
672                }
673              }
674            }
675          ],
676          "attributes": {
677            "foo": {
678              "type": "string",
679              "value": "bar"
680            },
681            "memory": {
682              "type": "double",
683              "value": 9001.0
684            },
685            "my.array": {
686              "type": "string",
687              "value": "[\"str\",123]"
688            },
689            "my.data.field": {
690              "type": "string",
691              "value": "my.data.value"
692            },
693            "my.nested": {
694              "type": "string",
695              "value": "{\"numbers\":[1,2,3]}"
696            },
697            "sentry.client_sample_rate": {
698              "type": "double",
699              "value": 0.11
700            },
701            "sentry.description": {
702              "type": "string",
703              "value": "raw description"
704            },
705            "sentry.exclusive_time": {
706              "type": "double",
707              "value": 1.23
708            },
709            "sentry.is_remote": {
710              "type": "boolean",
711              "value": true
712            },
713            "sentry.kind": {
714              "type": "string",
715              "value": "server"
716            },
717            "sentry.normalized_description": {
718              "type": "string",
719              "value": "normalized description"
720            },
721            "sentry.op": {
722              "type": "string",
723              "value": "operation"
724            },
725            "sentry.origin": {
726              "type": "string",
727              "value": "auto.http"
728            },
729            "sentry.platform": {
730              "type": "string",
731              "value": "javascript"
732            },
733            "sentry.profile_id": {
734              "type": "string",
735              "value": "4c79f60c11214eb38604f4ae0781bfb0"
736            },
737            "sentry.segment.id": {
738              "type": "string",
739              "value": "fa90fdead5f74050"
740            },
741            "sentry.server_sample_rate": {
742              "type": "double",
743              "value": 0.22
744            },
745            "sentry.user": {
746              "type": "string",
747              "value": "id:user123"
748            },
749            "sentry.user.email": {
750              "type": "string",
751              "value": "john@example.com"
752            },
753            "sentry.user.geo.city": {
754              "type": "string",
755              "value": "Vienna"
756            },
757            "sentry.user.geo.country_code": {
758              "type": "string",
759              "value": "AT"
760            },
761            "sentry.user.geo.region": {
762              "type": "string",
763              "value": "Europe"
764            },
765            "sentry.user.geo.subdivision": {
766              "type": "string",
767              "value": "AT-9"
768            },
769            "sentry.user.geo.subregion": {
770              "type": "string",
771              "value": "155"
772            },
773            "sentry.user.id": {
774              "type": "string",
775              "value": "user123"
776            },
777            "sentry.user.ip": {
778              "type": "string",
779              "value": "127.0.0.1"
780            },
781            "sentry.user.username": {
782              "type": "string",
783              "value": "john"
784            },
785            "user.email": {
786              "type": "string",
787              "value": "john@example.com"
788            },
789            "user.geo.city": {
790              "type": "string",
791              "value": "Vienna"
792            },
793            "user.geo.country_code": {
794              "type": "string",
795              "value": "AT"
796            },
797            "user.geo.region": {
798              "type": "string",
799              "value": "Europe"
800            },
801            "user.geo.subdivision": {
802              "type": "string",
803              "value": "AT-9"
804            },
805            "user.id": {
806              "type": "string",
807              "value": "user123"
808            },
809            "user.ip_address": {
810              "type": "string",
811              "value": "127.0.0.1"
812            },
813            "user.name": {
814              "type": "string",
815              "value": "john"
816            }
817          },
818          "_meta": {
819            "attributes": {
820              "my.array": {
821                "": {
822                  "err": [
823                    [
824                      "invalid_data",
825                      {
826                        "reason": "expected scalar attribute"
827                      }
828                    ]
829                  ]
830                }
831              },
832              "my.nested": {
833                "": {
834                  "err": [
835                    [
836                      "invalid_data",
837                      {
838                        "reason": "expected scalar attribute"
839                      }
840                    ]
841                  ]
842                }
843              }
844            }
845          }
846        }
847        "#);
848    }
849
850    #[test]
851    fn transaction_conversion() {
852        let txn = Annotated::<Event>::from_json(r#"{"transaction": "hi"}"#)
853            .unwrap()
854            .0
855            .unwrap();
856        assert_eq!(txn.transaction.as_str(), Some("hi"));
857        let span_v1 = SpanV1::from(&txn);
858
859        // Both the name and the segment name are the same as the transaction.
860        insta::assert_json_snapshot!(SerializableAnnotated(&Annotated::new(span_v1.clone())), @r###"
861        {
862          "is_segment": true,
863          "is_remote": true,
864          "description": "hi",
865          "data": {
866            "sentry.segment.name": "hi",
867            "sentry.name": "hi"
868          },
869          "was_transaction": true
870        }
871        "###);
872
873        let span_v2 = span_v1_to_span_v2(span_v1, false);
874
875        // The `name` and the `sentry.segment.name` attribute are the same as the transaction.
876        insta::assert_json_snapshot!(SerializableAnnotated(&Annotated::new(span_v2)), @r###"
877        {
878          "name": "hi",
879          "status": "ok",
880          "is_segment": true,
881          "attributes": {
882            "sentry.description": {
883              "type": "string",
884              "value": "hi"
885            },
886            "sentry.is_remote": {
887              "type": "boolean",
888              "value": true
889            },
890            "sentry.segment.name": {
891              "type": "string",
892              "value": "hi"
893            }
894          }
895        }
896        "###);
897    }
898
899    #[test]
900    fn start_timestamp() {
901        let json = r#"{"timestamp": 123, "end_timestamp": "invalid data"}"#;
902        let span_v1 = Annotated::<SpanV1>::from_json(json).unwrap();
903        let span_v2 = span_v1_to_span_v2(span_v1.into_value().unwrap(), false);
904
905        // Parsed version is still fine:
906        assert_eq!(
907            span_v2.end_timestamp.value().unwrap(),
908            &Timestamp(DateTime::from_timestamp_secs(123).unwrap())
909        );
910
911        let serialized = Annotated::from(span_v2).payload_to_json().unwrap();
912        assert_eq!(
913            &serialized,
914            r#"{"status":"ok","end_timestamp":123.0,"attributes":{}}"#
915        );
916    }
917}