relay_spans/
v2_to_v1.rs

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