relay_spans/
v2_to_v1.rs

1use std::str::FromStr;
2
3use relay_event_schema::protocol::{SpanKind, SpanV2Link};
4
5use crate::status_codes;
6use relay_event_schema::protocol::{
7    Attributes, EventId, Span as SpanV1, SpanData, SpanId, SpanLink, SpanStatus, SpanV2,
8    SpanV2Kind, SpanV2Status,
9};
10use relay_protocol::{Annotated, FromValue, Object, Value};
11use url::Url;
12
13/// Transforms a Sentry span V2 to a Sentry span V1.
14///
15/// This uses attributes in the V2 span to populate various fields in the V1 span.
16/// * The V1 span's `op` field will be set based on the V2 span's `sentry.op` attribute, or
17///   inferred from other attributes if the `sentry.op` attribute is not set.
18/// * The V1 span's `description` field will be set based on the V2 span's `sentry.description`
19///   attribute, or inferred from other attributes if the `sentry.description` attribute is not set.
20/// * The V1 span's `description` field is set based on the V2 span's `sentry.description` attribute.
21/// * The V1 span's `status` field is set based on the V2 span's `status` field and
22///   `http.status_code` and `rpc.grpc.status_code` attributes.
23/// * The V1 span's `exclusive_time` field is set based on the V2 span's `exclusive_time_nano`
24///   attribute, or the difference between the start and end timestamp if that attribute is not set.
25/// * The V1 span's `platform` field is set based on the V2 span's `sentry.platform` attribute.
26/// * The V1 span's `profile_id` field is set based on the V2 span's `sentry.profile.id` attribute.
27/// * The V1 span's `segment_id` field is set based on the V2 span's `sentry.segment.id` attribute.
28///
29/// All other attributes are carried over from the V2 span to the V1 span's `data`.
30pub fn span_v2_to_span_v1(span_v2: SpanV2) -> SpanV1 {
31    let mut exclusive_time_ms = 0f64;
32    let mut data = Object::new();
33
34    let inferred_op = derive_op_for_v2_span(&span_v2);
35    // NOTE: Inferring the description should happen after inferring the op, since the op may affect
36    // how we infer the description.
37    let inferred_description = derive_description_for_v2_span(&span_v2);
38
39    let SpanV2 {
40        start_timestamp,
41        end_timestamp,
42        trace_id,
43        span_id,
44        parent_span_id,
45        name,
46        kind,
47        links,
48        attributes,
49        status,
50        is_remote,
51        other: _other,
52    } = span_v2;
53
54    let mut description = Annotated::empty();
55    let mut op = Annotated::empty();
56    let mut http_status_code = Annotated::empty();
57    let mut grpc_status_code = Annotated::empty();
58    let mut platform = Annotated::empty();
59    let mut segment_id = Annotated::empty();
60    let mut profile_id = Annotated::empty();
61
62    for (key, value) in attributes.into_value().into_iter().flat_map(|attributes| {
63        attributes.into_iter().flat_map(|(key, attribute)| {
64            let attribute = attribute.into_value()?;
65            Some((key, attribute.value.value))
66        })
67    }) {
68        match key.as_str() {
69            "sentry.description" => {
70                description = String::from_value(value);
71            }
72            "sentry.op" => {
73                op = String::from_value(value);
74            }
75            key if key.contains("exclusive_time_nano") => {
76                let value = match value.value() {
77                    Some(Value::I64(v)) => *v as f64,
78                    Some(Value::U64(v)) => *v as f64,
79                    Some(Value::F64(v)) => *v,
80                    Some(Value::String(v)) => v.parse::<f64>().unwrap_or_default(),
81                    _ => 0f64,
82                };
83                exclusive_time_ms = value / 1e6f64;
84            }
85            "http.status_code" => {
86                http_status_code = i64::from_value(value.clone());
87                data.insert(key.to_owned(), value);
88            }
89            "rpc.grpc.status_code" => {
90                grpc_status_code = i64::from_value(value.clone());
91                data.insert(key.to_owned(), value);
92            }
93            "sentry.platform" => {
94                platform = String::from_value(value);
95            }
96            "sentry.segment.id" => {
97                segment_id = SpanId::from_value(value);
98            }
99            "sentry.profile.id" => {
100                profile_id = EventId::from_value(value);
101            }
102            _ => {
103                data.insert(key.to_owned(), value);
104            }
105        }
106    }
107
108    // Write the incoming `name` field to a the `sentry.name` attribute, since the V1
109    // Span schema doesn't have a top-level `name` field.
110    if let Some(name) = name.value() {
111        data.insert(
112            "sentry.name".to_owned(),
113            Annotated::new(Value::String(name.to_owned())),
114        );
115    }
116
117    if exclusive_time_ms == 0f64 {
118        if let (Some(start), Some(end)) = (start_timestamp.value(), end_timestamp.value()) {
119            if let Some(nanos) = (end.0 - start.0).num_nanoseconds() {
120                exclusive_time_ms = nanos as f64 / 1e6f64;
121            }
122        }
123    }
124
125    let links = links.map_value(|links| {
126        links
127            .into_iter()
128            .map(|link| link.map_value(span_v2_link_to_span_v1_link))
129            .collect()
130    });
131
132    let status = span_v2_status_to_span_v1_status(status, http_status_code, grpc_status_code);
133
134    // If the SDK sent in a `sentry.op` attribute, use it. If not, derive it from the span attributes.
135    let op = op.or_else(|| Annotated::from(inferred_op));
136
137    // If the SDK sent in a `sentry.description` attribute, use it. If not, derive it from the span attributes.
138    let description = description.or_else(|| Annotated::from(inferred_description));
139
140    SpanV1 {
141        op,
142        description,
143        data: SpanData::from_value(Annotated::new(data.into())),
144        exclusive_time: exclusive_time_ms.into(),
145        parent_span_id,
146        segment_id,
147        span_id,
148        is_remote,
149        profile_id,
150        start_timestamp,
151        status,
152        timestamp: end_timestamp,
153        trace_id,
154        platform,
155        kind: kind.map_value(span_v2_kind_to_span_v1_kind),
156        links,
157        ..Default::default()
158    }
159}
160
161fn span_v2_status_to_span_v1_status(
162    status: Annotated<SpanV2Status>,
163    http_status_code: Annotated<i64>,
164    grpc_status_code: Annotated<i64>,
165) -> Annotated<SpanStatus> {
166    status
167        .and_then(|status| (status == SpanV2Status::Ok).then_some(SpanStatus::Ok))
168        .or_else(|| {
169            http_status_code.and_then(|http_status_code| {
170                status_codes::HTTP
171                    .get(&http_status_code)
172                    .and_then(|sentry_status| SpanStatus::from_str(sentry_status).ok())
173            })
174        })
175        .or_else(|| {
176            grpc_status_code.and_then(|grpc_status_code| {
177                status_codes::GRPC
178                    .get(&grpc_status_code)
179                    .and_then(|sentry_status| SpanStatus::from_str(sentry_status).ok())
180            })
181        })
182        .or_else(|| Annotated::new(SpanStatus::Unknown))
183}
184
185fn span_v2_kind_to_span_v1_kind(kind: SpanV2Kind) -> SpanKind {
186    match kind {
187        SpanV2Kind::Internal => SpanKind::Internal,
188        SpanV2Kind::Server => SpanKind::Server,
189        SpanV2Kind::Client => SpanKind::Client,
190        SpanV2Kind::Producer => SpanKind::Producer,
191        SpanV2Kind::Consumer => SpanKind::Consumer,
192    }
193}
194
195fn span_v2_link_to_span_v1_link(link: SpanV2Link) -> SpanLink {
196    let SpanV2Link {
197        trace_id,
198        span_id,
199        sampled,
200        attributes,
201        other,
202    } = link;
203
204    let attributes = attributes.map_value(|attributes| {
205        attributes
206            .into_iter()
207            .map(|(key, attribute)| {
208                (
209                    key,
210                    attribute.and_then(|attribute| attribute.value.value.into_value()),
211                )
212            })
213            .collect()
214    });
215    SpanLink {
216        trace_id,
217        span_id,
218        sampled,
219        attributes,
220        other,
221    }
222}
223
224/// Generates a `sentry.op` attribute for V2 span, if possible.
225///
226/// This uses attributes of the span to figure out an appropriate operation name, inferring what the
227/// SDK might have sent. Reliably infers an op for well-known OTel span kinds like database
228/// operations. Does not infer an op for frontend and mobile spans sent by Sentry SDKs that don't
229/// have an OTel equivalent (e.g., resource loads).
230fn derive_op_for_v2_span(span: &SpanV2) -> String {
231    // NOTE: `op` is not a required field in the SDK, so the fallback is an empty string.
232    let op = String::from("default");
233
234    let Some(attributes) = span.attributes.value() else {
235        return op;
236    };
237
238    if attributes.contains_key("http.request.method") || attributes.contains_key("http.method") {
239        return match span.kind.value() {
240            Some(SpanV2Kind::Client) => String::from("http.client"),
241            Some(SpanV2Kind::Server) => String::from("http.server"),
242            _ => {
243                if attributes.contains_key("sentry.http.prefetch") {
244                    String::from("http.prefetch")
245                } else {
246                    String::from("http")
247                }
248            }
249        };
250    }
251
252    if attributes.contains_key("db.system") || attributes.contains_key("db.system.name") {
253        return String::from("db");
254    }
255
256    if attributes.contains_key("gen_ai.system") {
257        return String::from("gen_ai");
258    }
259
260    if attributes.contains_key("rpc.service") {
261        return String::from("rpc");
262    }
263
264    if attributes.contains_key("messaging.system") {
265        return String::from("message");
266    }
267
268    if let Some(faas_trigger) = attributes
269        .get_value("faas.trigger")
270        .and_then(|v| v.as_str())
271    {
272        return faas_trigger.to_owned();
273    }
274
275    op
276}
277
278/// Generates a `sentry.description` attribute for V2 span, if possible.
279///
280/// This uses attributes of the span to figure out an appropriate description, trying to match what
281/// the SDK might have sent. This works well for HTTP and database spans, but doesn't have a
282/// thorough implementation for other types of spans for now.
283fn derive_description_for_v2_span(span: &SpanV2) -> Option<String> {
284    // `name` is a low-cardinality description of the span, so it makes for a good fallback.
285    let description = span.name.value().map(|v| v.to_owned());
286
287    let Some(attributes) = span.attributes.value() else {
288        return description;
289    };
290
291    if let Some(http_description) = derive_http_description(attributes, &span.kind.value()) {
292        return Some(http_description);
293    }
294
295    if let Some(database_description) = derive_db_description(attributes) {
296        return Some(database_description);
297    }
298
299    description
300}
301
302fn derive_http_description(attributes: &Attributes, kind: &Option<&SpanV2Kind>) -> Option<String> {
303    // Get HTTP method
304    let http_method = attributes
305        .get_value("http.request.method")
306        .or_else(|| attributes.get_value("http.method"))
307        .and_then(|v| v.as_str())?;
308
309    let description = http_method.to_owned();
310
311    // Get URL path information
312    let url_path = match kind {
313        Some(SpanV2Kind::Server) => get_server_url_path(attributes),
314        Some(SpanV2Kind::Client) => get_client_url_path(attributes),
315        _ => None,
316    };
317
318    let Some(url_path) = url_path else {
319        return Some(description);
320    };
321    let base_description = format!("{http_method} {url_path}");
322
323    // Check for GraphQL operations
324    if let Some(graphql_ops) = attributes
325        .get_value("sentry.graphql.operation")
326        .and_then(|v| v.as_str())
327    {
328        return Some(format!("{base_description} ({graphql_ops})"));
329    }
330
331    Some(base_description)
332}
333
334fn derive_db_description(attributes: &Attributes) -> Option<String> {
335    // Check if this is a cache operation. Cache operations look very similar to database
336    // operations, since they have a `db.system` attribute, but should be treated differently, since
337    // we don't want their statements to end up in description for now.
338    if attributes
339        .get_value("sentry.op")
340        .and_then(|v| v.as_str())
341        .is_some_and(|op| op.starts_with("cache."))
342    {
343        return None;
344    }
345
346    // Check the `db.system` attribute. It's mandatory, so if it's missing, return `None` right
347    // away, since there's not much point trying to derive a description.
348    attributes
349        .get_value("db.system")
350        .or_else(|| attributes.get_value("db.system.name"))
351        .and_then(|v| v.as_str())?;
352
353    // `db.query.text` is a recommended attribute, and it contains the full query text if available.
354    // This is the ideal description.
355    if let Some(query_text) = attributes
356        .get_value("db.query.text")
357        .and_then(|v| v.as_str())
358    {
359        return Some(query_text.to_owned());
360    }
361
362    // Other SDKs check for `db.statement`, it's a legacy OTel attribute, useful as a fallback in some cases.
363    if let Some(statement) = attributes
364        .get_value("db.statement")
365        .and_then(|v| v.as_str())
366    {
367        return Some(statement.to_owned());
368    }
369
370    None
371}
372
373fn get_server_url_path(attributes: &Attributes) -> Option<String> {
374    // `http.route` takes precedence. If available, this is the matched route of the server
375    // framework for server spans. Not always available, even for server spans.
376    if let Some(route) = attributes.get_value("http.route").and_then(|v| v.as_str()) {
377        return Some(route.to_owned());
378    }
379
380    // `url.path` is the path of the HTTP request for server spans. This is required for server spans.
381    if let Some(path) = attributes.get_value("url.path").and_then(|v| v.as_str()) {
382        return Some(path.to_owned());
383    }
384
385    // `http.target` is deprecated, but might be present in older data. Here as a fallback
386    if let Some(target) = attributes.get_value("http.target").and_then(|v| v.as_str()) {
387        return Some(strip_url_query_and_fragment(target));
388    }
389
390    None
391}
392
393fn strip_url_query_and_fragment(url: &str) -> String {
394    url.split(&['?', '#']).next().unwrap_or(url).to_owned()
395}
396
397fn get_client_url_path(attributes: &Attributes) -> Option<String> {
398    let url = attributes
399        .get_value("url.full")
400        .or_else(|| attributes.get_value("http.url"))?
401        .as_str()?;
402
403    let parsed_url = Url::parse(url).ok()?;
404
405    Some(format!(
406        "{}://{}{}",
407        parsed_url.scheme(),
408        parsed_url.domain().unwrap_or(""),
409        parsed_url.path()
410    ))
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use relay_protocol::SerializableAnnotated;
417
418    #[test]
419    fn parse_span() {
420        let json = r#"{
421            "trace_id": "89143b0763095bd9c9955e8175d1fb23",
422            "span_id": "e342abb1214ca181",
423            "parent_span_id": "0c7a7dea069bf5a6",
424            "name": "middleware - fastify -> @fastify/multipart",
425            "kind": "internal",
426            "start_timestamp": "2023-10-18T09:14:14.980Z",
427            "end_timestamp": "2023-10-18T09:14:14.980078800Z",
428            "links": [],
429            "attributes": {
430                "sentry.environment": {
431                    "value": "test",
432                    "type": "string"
433                },
434                "fastify.type": {
435                    "value": "middleware",
436                    "type": "string"
437                },
438                "plugin.name": {
439                    "value": "fastify -> @fastify/multipart",
440                    "type": "string"
441                },
442                "hook.name": {
443                    "value": "onResponse",
444                    "type": "string"
445                },
446                "sentry.sample_rate": {
447                    "value": 1,
448                    "type": "u64"
449                },
450                "sentry.parentSampled": {
451                    "value": true,
452                    "type": "boolean"
453                },
454                "sentry.exclusive_time_nano": {
455                    "value": "1000000000",
456                    "type": "u64"
457                }
458            },
459            "status": "ok",
460            "links": []
461        }"#;
462        let span_v2 = Annotated::from_json(json).unwrap().into_value().unwrap();
463        let span_v1: SpanV1 = span_v2_to_span_v1(span_v2);
464        let annotated_span: Annotated<SpanV1> = Annotated::new(span_v1);
465        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r###"
466        {
467          "timestamp": 1697620454.980079,
468          "start_timestamp": 1697620454.98,
469          "exclusive_time": 1000.0,
470          "op": "default",
471          "span_id": "e342abb1214ca181",
472          "parent_span_id": "0c7a7dea069bf5a6",
473          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
474          "status": "ok",
475          "description": "middleware - fastify -> @fastify/multipart",
476          "data": {
477            "sentry.environment": "test",
478            "fastify.type": "middleware",
479            "hook.name": "onResponse",
480            "plugin.name": "fastify -> @fastify/multipart",
481            "sentry.name": "middleware - fastify -> @fastify/multipart",
482            "sentry.parentSampled": true,
483            "sentry.sample_rate": 1
484          },
485          "links": [],
486          "kind": "internal"
487        }
488        "###);
489    }
490
491    #[test]
492    fn parse_span_with_exclusive_time_nano_attribute() {
493        let json = r#"{
494            "trace_id": "89143b0763095bd9c9955e8175d1fb23",
495            "span_id": "e342abb1214ca181",
496            "parent_span_id": "0c7a7dea069bf5a6",
497            "name": "middleware - fastify -> @fastify/multipart",
498            "kind": "internal",
499            "start_timestamp": "2023-10-18T09:14:14.980Z",
500            "end_timestamp": "2023-10-18T09:14:14.980078800Z",
501            "links": [],
502            "attributes": {
503                "sentry.exclusive_time_nano": {
504                    "value": 3200000000,
505                    "type": "u64"
506                }
507            }
508        }"#;
509        let span_v2 = Annotated::from_json(json).unwrap().into_value().unwrap();
510        let span_v1: SpanV1 = span_v2_to_span_v1(span_v2);
511        let annotated_span: Annotated<SpanV1> = Annotated::new(span_v1);
512        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r###"
513        {
514          "timestamp": 1697620454.980079,
515          "start_timestamp": 1697620454.98,
516          "exclusive_time": 3200.0,
517          "op": "default",
518          "span_id": "e342abb1214ca181",
519          "parent_span_id": "0c7a7dea069bf5a6",
520          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
521          "status": "unknown",
522          "description": "middleware - fastify -> @fastify/multipart",
523          "data": {
524            "sentry.name": "middleware - fastify -> @fastify/multipart"
525          },
526          "links": [],
527          "kind": "internal"
528        }
529        "###);
530    }
531
532    #[test]
533    fn parse_span_no_exclusive_time_nano_attribute() {
534        let json = r#"{
535            "trace_id": "89143b0763095bd9c9955e8175d1fb23",
536            "span_id": "e342abb1214ca181",
537            "parent_span_id": "0c7a7dea069bf5a6",
538            "name": "middleware - fastify -> @fastify/multipart",
539            "kind": "internal",
540            "start_timestamp": "2023-10-18T09:14:14.980Z",
541            "end_timestamp": "2023-10-18T09:14:14.980078800Z",
542            "links": []
543        }"#;
544        let span_v2 = Annotated::from_json(json).unwrap().into_value().unwrap();
545        let span_v1: SpanV1 = span_v2_to_span_v1(span_v2);
546        let annotated_span: Annotated<SpanV1> = Annotated::new(span_v1);
547        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r###"
548        {
549          "timestamp": 1697620454.980079,
550          "start_timestamp": 1697620454.98,
551          "exclusive_time": 0.0788,
552          "op": "default",
553          "span_id": "e342abb1214ca181",
554          "parent_span_id": "0c7a7dea069bf5a6",
555          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
556          "status": "unknown",
557          "description": "middleware - fastify -> @fastify/multipart",
558          "data": {
559            "sentry.name": "middleware - fastify -> @fastify/multipart"
560          },
561          "links": [],
562          "kind": "internal"
563        }
564        "###);
565    }
566
567    #[test]
568    fn parse_sentry_attributes() {
569        let json = r#"{
570            "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
571            "span_id": "fa90fdead5f74052",
572            "parent_span_id": "fa90fdead5f74051",
573            "start_timestamp": 123,
574            "end_timestamp": 123.5,
575            "name": "myname",
576            "status": "ok",
577            "links": [],
578            "attributes": {
579                "browser.name": {
580                    "value": "Chrome",
581                    "type": "string"
582                },
583                "sentry.description": {
584                    "value": "mydescription",
585                    "type": "string"
586                },
587                "sentry.environment": {
588                    "value": "prod",
589                    "type": "string"
590                },
591                "sentry.op": {
592                    "value": "myop",
593                    "type": "string"
594                },
595                "sentry.platform": {
596                    "value": "php",
597                    "type": "string"
598                },
599                "sentry.profile.id": {
600                    "value": "a0aaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaab",
601                    "type": "string"
602                },
603                "sentry.release": {
604                    "value": "myapp@1.0.0",
605                    "type": "string"
606                },
607                "sentry.sdk.name": {
608                    "value": "sentry.php",
609                    "type": "string"
610                },
611                "sentry.segment.id": {
612                    "value": "FA90FDEAD5F74052",
613                    "type": "string"
614                },
615                "sentry.segment.name": {
616                    "value": "my 1st transaction",
617                    "type": "string"
618                }
619            }
620        }"#;
621
622        let span_v2 = Annotated::from_json(json).unwrap().into_value().unwrap();
623        let span_v1: SpanV1 = span_v2_to_span_v1(span_v2);
624
625        let annotated_span: Annotated<SpanV1> = Annotated::new(span_v1);
626        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r###"
627        {
628          "timestamp": 123.5,
629          "start_timestamp": 123.0,
630          "exclusive_time": 500.0,
631          "op": "myop",
632          "span_id": "fa90fdead5f74052",
633          "parent_span_id": "fa90fdead5f74051",
634          "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
635          "segment_id": "fa90fdead5f74052",
636          "status": "ok",
637          "description": "mydescription",
638          "profile_id": "a0aaaaaaaaaaaaaaaaaaaaaaaaaaaaab",
639          "data": {
640            "browser.name": "Chrome",
641            "sentry.environment": "prod",
642            "sentry.release": "myapp@1.0.0",
643            "sentry.segment.name": "my 1st transaction",
644            "sentry.sdk.name": "sentry.php",
645            "sentry.name": "myname"
646          },
647          "links": [],
648          "platform": "php"
649        }
650        "###);
651    }
652
653    #[test]
654    fn parse_span_is_remote() {
655        let json = r#"{
656            "trace_id": "89143b0763095bd9c9955e8175d1fb23",
657            "span_id": "e342abb1214ca181",
658            "parent_span_id": "0c7a7dea069bf5a6",
659            "start_timestamp": 123,
660            "end_timestamp": 123.5,
661            "is_remote": true,
662            "links": []
663        }"#;
664        let span_v2 = Annotated::from_json(json).unwrap().into_value().unwrap();
665        let span_v1: SpanV1 = span_v2_to_span_v1(span_v2);
666        let annotated_span: Annotated<SpanV1> = Annotated::new(span_v1);
667        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r###"
668        {
669          "timestamp": 123.5,
670          "start_timestamp": 123.0,
671          "exclusive_time": 500.0,
672          "op": "default",
673          "span_id": "e342abb1214ca181",
674          "parent_span_id": "0c7a7dea069bf5a6",
675          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
676          "is_remote": true,
677          "status": "unknown",
678          "data": {},
679          "links": []
680        }
681        "###);
682    }
683
684    #[test]
685    fn parse_span_is_not_remote() {
686        let json = r#"{
687            "trace_id": "89143b0763095bd9c9955e8175d1fb23",
688            "span_id": "e342abb1214ca181",
689            "parent_span_id": "0c7a7dea069bf5a6",
690            "start_timestamp": 123,
691            "end_timestamp": 123.5,
692            "is_remote": false,
693            "links": []
694        }"#;
695        let span_v2 = Annotated::from_json(json).unwrap().into_value().unwrap();
696        let span_v1: SpanV1 = span_v2_to_span_v1(span_v2);
697        let annotated_span: Annotated<SpanV1> = Annotated::new(span_v1);
698        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r###"
699        {
700          "timestamp": 123.5,
701          "start_timestamp": 123.0,
702          "exclusive_time": 500.0,
703          "op": "default",
704          "span_id": "e342abb1214ca181",
705          "parent_span_id": "0c7a7dea069bf5a6",
706          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
707          "is_remote": false,
708          "status": "unknown",
709          "data": {},
710          "links": []
711        }
712        "###);
713    }
714
715    #[test]
716    fn parse_http_client_span_only_method() {
717        let json = r#"{
718            "trace_id": "89143b0763095bd9c9955e8175d1fb23",
719            "span_id": "e342abb1214ca181",
720            "parent_span_id": "0c7a7dea069bf5a6",
721            "start_timestamp": 123,
722            "end_timestamp": 123.5,
723            "kind": "client",
724            "attributes": {
725                "http.method": {
726                    "value": "GET",
727                    "type": "string"
728                }
729            }
730        }"#;
731        let span_v2 = Annotated::from_json(json).unwrap().into_value().unwrap();
732        let span_v1: SpanV1 = span_v2_to_span_v1(span_v2);
733        let annotated_span: Annotated<SpanV1> = Annotated::new(span_v1);
734        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r###"
735        {
736          "timestamp": 123.5,
737          "start_timestamp": 123.0,
738          "exclusive_time": 500.0,
739          "op": "http.client",
740          "span_id": "e342abb1214ca181",
741          "parent_span_id": "0c7a7dea069bf5a6",
742          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
743          "status": "unknown",
744          "description": "GET",
745          "data": {
746            "http.request_method": "GET"
747          },
748          "kind": "client"
749        }
750        "###);
751    }
752
753    #[test]
754    fn parse_semantic_http_client_span() {
755        let json = r#"{
756            "trace_id": "89143b0763095bd9c9955e8175d1fb23",
757            "span_id": "e342abb1214ca181",
758            "parent_span_id": "0c7a7dea069bf5a6",
759            "start_timestamp": 123,
760            "end_timestamp": 123.5,
761            "kind": "client",
762            "attributes": {
763                "server.address": {
764                    "value": "github.com",
765                    "type": "string"
766                },
767                "server.port": {
768                    "value": 443,
769                    "type": "integer"
770                },
771                "http.request.method": {
772                    "value": "GET",
773                    "type": "string"
774                },
775                "url.full": {
776                    "value": "https://github.com/rust-lang/rust/issues?labels=E-easy&state=open",
777                    "type": "string"
778                }
779            }
780        }"#;
781        let span_v2 = Annotated::from_json(json).unwrap().into_value().unwrap();
782        let span_v1: SpanV1 = span_v2_to_span_v1(span_v2);
783        let annotated_span: Annotated<SpanV1> = Annotated::new(span_v1);
784        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r###"
785        {
786          "timestamp": 123.5,
787          "start_timestamp": 123.0,
788          "exclusive_time": 500.0,
789          "op": "http.client",
790          "span_id": "e342abb1214ca181",
791          "parent_span_id": "0c7a7dea069bf5a6",
792          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
793          "status": "unknown",
794          "description": "GET https://github.com/rust-lang/rust/issues",
795          "data": {
796            "server.address": "github.com",
797            "url.full": "https://github.com/rust-lang/rust/issues?labels=E-easy&state=open",
798            "http.request.method": "GET",
799            "server.port": 443
800          },
801          "kind": "client"
802        }
803        "###);
804    }
805
806    #[test]
807    fn parse_http_server_span_only_method() {
808        let json = r#"{
809            "trace_id": "89143b0763095bd9c9955e8175d1fb23",
810            "span_id": "e342abb1214ca181",
811            "parent_span_id": "0c7a7dea069bf5a6",
812            "start_timestamp": 123,
813            "end_timestamp": 123.5,
814            "kind": "server",
815            "attributes": {
816                "http.method": {
817                    "value": "GET",
818                    "type": "string"
819                }
820            }
821        }"#;
822        let span_v2 = Annotated::from_json(json).unwrap().into_value().unwrap();
823        let span_v1: SpanV1 = span_v2_to_span_v1(span_v2);
824        let annotated_span: Annotated<SpanV1> = Annotated::new(span_v1);
825        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r###"
826        {
827          "timestamp": 123.5,
828          "start_timestamp": 123.0,
829          "exclusive_time": 500.0,
830          "op": "http.server",
831          "span_id": "e342abb1214ca181",
832          "parent_span_id": "0c7a7dea069bf5a6",
833          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
834          "status": "unknown",
835          "description": "GET",
836          "data": {
837            "http.request_method": "GET"
838          },
839          "kind": "server"
840        }
841        "###);
842    }
843
844    #[test]
845    fn parse_semantic_http_server_span() {
846        let json = r#"{
847            "trace_id": "89143b0763095bd9c9955e8175d1fb23",
848            "span_id": "e342abb1214ca181",
849            "parent_span_id": "0c7a7dea069bf5a6",
850            "start_timestamp": 123,
851            "end_timestamp": 123.5,
852            "kind": "server",
853            "attributes": {
854                "http.request.method": {
855                    "value": "GET",
856                    "type": "string"
857                },
858                "url.path": {
859                    "value": "/users",
860                    "type": "string"
861                },
862                "url.scheme": {
863                    "value": "GET",
864                    "type": "string"
865                }
866            }
867        }"#;
868        let span_v2 = Annotated::from_json(json).unwrap().into_value().unwrap();
869        let span_v1: SpanV1 = span_v2_to_span_v1(span_v2);
870        let annotated_span: Annotated<SpanV1> = Annotated::new(span_v1);
871        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r###"
872        {
873          "timestamp": 123.5,
874          "start_timestamp": 123.0,
875          "exclusive_time": 500.0,
876          "op": "http.server",
877          "span_id": "e342abb1214ca181",
878          "parent_span_id": "0c7a7dea069bf5a6",
879          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
880          "status": "unknown",
881          "description": "GET /users",
882          "data": {
883            "url.scheme": "GET",
884            "http.request.method": "GET",
885            "url.path": "/users"
886          },
887          "kind": "server"
888        }
889        "###);
890    }
891
892    #[test]
893    fn parse_database_span_only_system() {
894        let json = r#"{
895            "trace_id": "89143b0763095bd9c9955e8175d1fb23",
896            "span_id": "e342abb1214ca181",
897            "parent_span_id": "0c7a7dea069bf5a6",
898            "start_timestamp": 123,
899            "name": "SELECT users",
900            "end_timestamp": 123.5,
901            "kind": "client",
902            "attributes": {
903                "db.system": {
904                    "value": "postgres",
905                    "type": "string"
906                }
907            }
908        }"#;
909        let span_v2 = Annotated::from_json(json).unwrap().into_value().unwrap();
910        let span_v1: SpanV1 = span_v2_to_span_v1(span_v2);
911        let annotated_span: Annotated<SpanV1> = Annotated::new(span_v1);
912        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r###"
913        {
914          "timestamp": 123.5,
915          "start_timestamp": 123.0,
916          "exclusive_time": 500.0,
917          "op": "db",
918          "span_id": "e342abb1214ca181",
919          "parent_span_id": "0c7a7dea069bf5a6",
920          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
921          "status": "unknown",
922          "description": "SELECT users",
923          "data": {
924            "db.system": "postgres",
925            "sentry.name": "SELECT users"
926          },
927          "kind": "client"
928        }
929        "###);
930    }
931
932    #[test]
933    fn parse_cache_span() {
934        let json = r#"{
935            "trace_id": "89143b0763095bd9c9955e8175d1fb23",
936            "span_id": "e342abb1214ca181",
937            "parent_span_id": "0c7a7dea069bf5a6",
938            "start_timestamp": 123,
939            "name": "CACHE HIT",
940            "end_timestamp": 123.1,
941            "kind": "client",
942            "attributes": {
943                "db.system": {
944                    "value": "redis",
945                    "type": "string"
946                },
947                "db.statement": {
948                    "value": "GET s:user:123",
949                    "type": "string"
950                },
951                "sentry.op": {
952                    "value": "cache.hit",
953                    "type": "string"
954                }
955            }
956        }"#;
957        let span_v2 = Annotated::from_json(json).unwrap().into_value().unwrap();
958        let span_v1: SpanV1 = span_v2_to_span_v1(span_v2);
959        let annotated_span: Annotated<SpanV1> = Annotated::new(span_v1);
960        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r###"
961        {
962          "timestamp": 123.1,
963          "start_timestamp": 123.0,
964          "exclusive_time": 99.999999,
965          "op": "cache.hit",
966          "span_id": "e342abb1214ca181",
967          "parent_span_id": "0c7a7dea069bf5a6",
968          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
969          "status": "unknown",
970          "description": "CACHE HIT",
971          "data": {
972            "db.system": "redis",
973            "db.statement": "GET s:user:123",
974            "sentry.name": "CACHE HIT"
975          },
976          "kind": "client"
977        }
978        "###);
979    }
980
981    #[test]
982    fn parse_semantic_database_span() {
983        let json = r#"{
984            "trace_id": "89143b0763095bd9c9955e8175d1fb23",
985            "span_id": "e342abb1214ca181",
986            "parent_span_id": "0c7a7dea069bf5a6",
987            "start_timestamp": 123,
988            "end_timestamp": 123.5,
989            "kind": "client",
990            "attributes": {
991                "db.system": {
992                    "value": "postgres",
993                    "type": "string"
994                },
995                "db.statement": {
996                    "value": "SELECT * FROM users",
997                    "type": "string"
998                }
999            }
1000        }"#;
1001        let span_v2 = Annotated::from_json(json).unwrap().into_value().unwrap();
1002        let span_v1: SpanV1 = span_v2_to_span_v1(span_v2);
1003        let annotated_span: Annotated<SpanV1> = Annotated::new(span_v1);
1004        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r###"
1005        {
1006          "timestamp": 123.5,
1007          "start_timestamp": 123.0,
1008          "exclusive_time": 500.0,
1009          "op": "db",
1010          "span_id": "e342abb1214ca181",
1011          "parent_span_id": "0c7a7dea069bf5a6",
1012          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
1013          "status": "unknown",
1014          "description": "SELECT * FROM users",
1015          "data": {
1016            "db.system": "postgres",
1017            "db.statement": "SELECT * FROM users"
1018          },
1019          "kind": "client"
1020        }
1021        "###);
1022    }
1023
1024    #[test]
1025    fn parse_gen_ai_span() {
1026        let json = r#"{
1027            "trace_id": "89143b0763095bd9c9955e8175d1fb23",
1028            "span_id": "e342abb1214ca181",
1029            "parent_span_id": "0c7a7dea069bf5a6",
1030            "start_timestamp": 123,
1031            "end_timestamp": 123.5,
1032            "kind": "client",
1033            "attributes": {
1034                "gen_ai.system": {
1035                    "value": "openai",
1036                    "type": "string"
1037                },
1038                "gen_ai.agent.name": {
1039                    "value": "Seer",
1040                    "type": "string"
1041                }
1042            }
1043        }"#;
1044        let span_v2 = Annotated::from_json(json).unwrap().into_value().unwrap();
1045        let span_v1: SpanV1 = span_v2_to_span_v1(span_v2);
1046        let annotated_span: Annotated<SpanV1> = Annotated::new(span_v1);
1047        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r###"
1048        {
1049          "timestamp": 123.5,
1050          "start_timestamp": 123.0,
1051          "exclusive_time": 500.0,
1052          "op": "gen_ai",
1053          "span_id": "e342abb1214ca181",
1054          "parent_span_id": "0c7a7dea069bf5a6",
1055          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
1056          "status": "unknown",
1057          "data": {
1058            "gen_ai.agent.name": "Seer",
1059            "gen_ai.system": "openai"
1060          },
1061          "kind": "client"
1062        }
1063        "###);
1064    }
1065
1066    #[test]
1067    fn parse_span_with_sentry_op() {
1068        let json = r#"{
1069            "trace_id": "89143b0763095bd9c9955e8175d1fb23",
1070            "span_id": "e342abb1214ca181",
1071            "parent_span_id": "0c7a7dea069bf5a6",
1072            "start_timestamp": 123,
1073            "end_timestamp": 123.5,
1074            "kind": "client",
1075            "attributes": {
1076                "db.system": {
1077                    "value": "postgres",
1078                    "type": "string"
1079                },
1080                "sentry.op": {
1081                    "value": "function",
1082                    "type": "string"
1083                }
1084            }
1085        }"#;
1086        let span_v2 = Annotated::from_json(json).unwrap().into_value().unwrap();
1087        let span_v1: SpanV1 = span_v2_to_span_v1(span_v2);
1088        let annotated_span: Annotated<SpanV1> = Annotated::new(span_v1);
1089        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r###"
1090        {
1091          "timestamp": 123.5,
1092          "start_timestamp": 123.0,
1093          "exclusive_time": 500.0,
1094          "op": "function",
1095          "span_id": "e342abb1214ca181",
1096          "parent_span_id": "0c7a7dea069bf5a6",
1097          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
1098          "status": "unknown",
1099          "data": {
1100            "db.system": "postgres"
1101          },
1102          "kind": "client"
1103        }
1104        "###);
1105    }
1106
1107    #[test]
1108    fn extract_span_kind() {
1109        let json = r#"{
1110            "trace_id": "89143b0763095bd9c9955e8175d1fb23",
1111            "span_id": "e342abb1214ca181",
1112            "parent_span_id": "0c7a7dea069bf5a6",
1113            "start_timestamp": 123,
1114            "end_timestamp": 123.5,
1115            "kind": "client",
1116            "links": []
1117        }"#;
1118        let span_v2 = Annotated::from_json(json).unwrap().into_value().unwrap();
1119        let span_v1: SpanV1 = span_v2_to_span_v1(span_v2);
1120        let annotated_span: Annotated<SpanV1> = Annotated::new(span_v1);
1121        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r###"
1122        {
1123          "timestamp": 123.5,
1124          "start_timestamp": 123.0,
1125          "exclusive_time": 500.0,
1126          "op": "default",
1127          "span_id": "e342abb1214ca181",
1128          "parent_span_id": "0c7a7dea069bf5a6",
1129          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
1130          "status": "unknown",
1131          "data": {},
1132          "links": [],
1133          "kind": "client"
1134        }
1135        "###);
1136    }
1137
1138    #[test]
1139    fn parse_link() {
1140        let json = r#"{
1141            "trace_id": "3c79f60c11214eb38604f4ae0781bfb2",
1142            "links": [
1143                {
1144                    "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
1145                    "span_id": "fa90fdead5f74052",
1146                    "sampled": true,
1147                    "attributes": {
1148                        "str_key": {
1149                            "value": "str_value",
1150                            "type": "string"
1151                        },
1152                        "bool_key": {
1153                            "value": true,
1154                            "type": "boolean"
1155                        },
1156                        "int_key": {
1157                            "value": 123,
1158                            "type": "i64"
1159                        },
1160                        "double_key": {
1161                            "value": 1.23,
1162                            "type": "f64"
1163                        }
1164                    }
1165                }
1166            ]
1167        }"#;
1168        let span_v2 = Annotated::from_json(json).unwrap().into_value().unwrap();
1169        let span_v1: SpanV1 = span_v2_to_span_v1(span_v2);
1170        let annotated_span: Annotated<SpanV1> = Annotated::new(span_v1);
1171
1172        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r###"
1173        {
1174          "exclusive_time": 0.0,
1175          "op": "default",
1176          "trace_id": "3c79f60c11214eb38604f4ae0781bfb2",
1177          "status": "unknown",
1178          "data": {},
1179          "links": [
1180            {
1181              "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
1182              "span_id": "fa90fdead5f74052",
1183              "sampled": true,
1184              "attributes": {
1185                "bool_key": true,
1186                "double_key": 1.23,
1187                "int_key": 123,
1188                "str_key": "str_value"
1189              }
1190            }
1191          ]
1192        }
1193        "###);
1194    }
1195
1196    #[test]
1197    fn parse_faas_trigger_span() {
1198        let json = r#"{
1199            "trace_id": "89143b0763095bd9c9955e8175d1fb23",
1200            "span_id": "e342abb1214ca181",
1201            "parent_span_id": "0c7a7dea069bf5a6",
1202            "name": "FAAS",
1203            "attributes": {
1204                "faas.trigger": {
1205                    "value": "http",
1206                    "type": "string"
1207                }
1208            }
1209        }"#;
1210        let span_v2 = Annotated::from_json(json).unwrap().into_value().unwrap();
1211        let span_v1: SpanV1 = span_v2_to_span_v1(span_v2);
1212        let annotated_span: Annotated<SpanV1> = Annotated::new(span_v1);
1213        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r###"
1214        {
1215          "exclusive_time": 0.0,
1216          "op": "http",
1217          "span_id": "e342abb1214ca181",
1218          "parent_span_id": "0c7a7dea069bf5a6",
1219          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
1220          "status": "unknown",
1221          "description": "FAAS",
1222          "data": {
1223            "faas.trigger": "http",
1224            "sentry.name": "FAAS"
1225          }
1226        }
1227        "###);
1228    }
1229
1230    #[test]
1231    fn parse_http_span_with_route() {
1232        let json = r#"{
1233            "trace_id": "89143b0763095bd9c9955e8175d1fb23",
1234            "span_id": "e342abb1214ca181",
1235            "parent_span_id": "0c7a7dea069bf5a6",
1236            "start_timestamp": 123,
1237            "end_timestamp": 123.5,
1238            "name": "GET /api/users",
1239            "kind": "server",
1240            "attributes": {
1241                "http.method": {
1242                    "value": "GET",
1243                    "type": "string"
1244                },
1245                "http.route": {
1246                    "value": "/api/users",
1247                    "type": "string"
1248                }
1249            }
1250        }"#;
1251        let span_v2 = Annotated::from_json(json).unwrap().into_value().unwrap();
1252        let span_v1: SpanV1 = span_v2_to_span_v1(span_v2);
1253        let annotated_span: Annotated<SpanV1> = Annotated::new(span_v1);
1254        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r###"
1255        {
1256          "timestamp": 123.5,
1257          "start_timestamp": 123.0,
1258          "exclusive_time": 500.0,
1259          "op": "http.server",
1260          "span_id": "e342abb1214ca181",
1261          "parent_span_id": "0c7a7dea069bf5a6",
1262          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
1263          "status": "unknown",
1264          "description": "GET /api/users",
1265          "data": {
1266            "http.request_method": "GET",
1267            "http.route": "/api/users",
1268            "sentry.name": "GET /api/users"
1269          },
1270          "kind": "server"
1271        }
1272        "###);
1273    }
1274
1275    #[test]
1276    fn parse_db_span_with_statement() {
1277        let json = r#"{
1278            "trace_id": "89143b0763095bd9c9955e8175d1fb23",
1279            "span_id": "e342abb1214ca181",
1280            "parent_span_id": "0c7a7dea069bf5a6",
1281            "start_timestamp": 123,
1282            "end_timestamp": 123.5,
1283            "name": "SELECT users",
1284            "kind": "client",
1285            "attributes": {
1286                "db.system": {
1287                    "value": "postgres",
1288                    "type": "string"
1289                },
1290                "db.statement": {
1291                    "value": "SELECT * FROM users WHERE id = $1",
1292                    "type": "string"
1293                }
1294            }
1295        }"#;
1296        let span_v2 = Annotated::from_json(json).unwrap().into_value().unwrap();
1297        let span_v1: SpanV1 = span_v2_to_span_v1(span_v2);
1298        let annotated_span: Annotated<SpanV1> = Annotated::new(span_v1);
1299        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r###"
1300        {
1301          "timestamp": 123.5,
1302          "start_timestamp": 123.0,
1303          "exclusive_time": 500.0,
1304          "op": "db",
1305          "span_id": "e342abb1214ca181",
1306          "parent_span_id": "0c7a7dea069bf5a6",
1307          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
1308          "status": "unknown",
1309          "description": "SELECT * FROM users WHERE id = $1",
1310          "data": {
1311            "db.system": "postgres",
1312            "db.statement": "SELECT * FROM users WHERE id = $1",
1313            "sentry.name": "SELECT users"
1314          },
1315          "kind": "client"
1316        }
1317        "###);
1318    }
1319
1320    #[test]
1321    fn parse_http_span_with_graphql() {
1322        let json = r#"{
1323            "trace_id": "89143b0763095bd9c9955e8175d1fb23",
1324            "span_id": "e342abb1214ca181",
1325            "parent_span_id": "0c7a7dea069bf5a6",
1326            "start_timestamp": 123,
1327            "end_timestamp": 123.5,
1328            "name": "POST /graphql",
1329            "kind": "server",
1330            "attributes": {
1331                "http.method": {
1332                    "value": "POST",
1333                    "type": "string"
1334                },
1335                "http.route": {
1336                    "value": "/graphql",
1337                    "type": "string"
1338                },
1339                "sentry.graphql.operation": {
1340                    "value": "getUserById",
1341                    "type": "string"
1342                }
1343            }
1344        }"#;
1345        let span_v2 = Annotated::from_json(json).unwrap().into_value().unwrap();
1346        let span_v1: SpanV1 = span_v2_to_span_v1(span_v2);
1347        let annotated_span: Annotated<SpanV1> = Annotated::new(span_v1);
1348        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r###"
1349        {
1350          "timestamp": 123.5,
1351          "start_timestamp": 123.0,
1352          "exclusive_time": 500.0,
1353          "op": "http.server",
1354          "span_id": "e342abb1214ca181",
1355          "parent_span_id": "0c7a7dea069bf5a6",
1356          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
1357          "status": "unknown",
1358          "description": "POST /graphql (getUserById)",
1359          "data": {
1360            "http.request_method": "POST",
1361            "http.route": "/graphql",
1362            "sentry.graphql.operation": "getUserById",
1363            "sentry.name": "POST /graphql"
1364          },
1365          "kind": "server"
1366        }
1367        "###);
1368    }
1369}