Skip to main content

relay_spans/
otel_to_sentry_v2.rs

1use chrono::{TimeZone, Utc};
2use opentelemetry_proto::tonic::common::v1::InstrumentationScope;
3use opentelemetry_proto::tonic::resource::v1::Resource;
4use opentelemetry_proto::tonic::trace::v1::span::Link as OtelLink;
5use opentelemetry_proto::tonic::trace::v1::span::SpanKind as OtelSpanKind;
6use relay_conventions::attributes::{
7    SENTRY__CLIENT_SAMPLE_RATE, SENTRY__IS_REMOTE, SENTRY__KIND, SENTRY__ORIGIN, SENTRY__PLATFORM,
8    SENTRY__SEGMENT__ID, SENTRY__SEGMENT__NAME, SENTRY__STATUS__MESSAGE,
9};
10use relay_event_schema::protocol::{Attributes, SpanKind};
11use relay_otel::otel_resource_to_platform;
12use relay_otel::otel_value_to_attribute;
13use relay_protocol::ErrorKind;
14
15use crate::otel_trace::{
16    Span as OtelSpan, SpanFlags as OtelSpanFlags, status::StatusCode as OtelStatusCode,
17};
18use relay_event_schema::protocol::{
19    SpanId, SpanV2 as SentrySpanV2, SpanV2Link, SpanV2Status, Timestamp, TraceId,
20};
21use relay_protocol::{Annotated, Error, Value};
22
23/// Transform an OTEL span to a Sentry span V2.
24///
25/// This uses attributes in the OTEL span to populate various fields in the Sentry span.
26/// * The Sentry span's `name` field may be set based on `db` or `http` attributes
27///   if the OTEL span's `name` is empty.
28/// * The Sentry span's `sentry.description` attribute may be set based on `db` or `http` attributes
29///   if the OTEL span's `sentry.description` attribute is empty.
30///
31/// All other attributes are carried over from the OTEL span to the Sentry span.
32pub fn otel_to_sentry_span(
33    otel_span: OtelSpan,
34    resource: Option<&Resource>,
35    scope: Option<&InstrumentationScope>,
36) -> SentrySpanV2 {
37    let OtelSpan {
38        trace_id,
39        span_id,
40        parent_span_id,
41        flags,
42        name,
43        kind,
44        attributes,
45        status,
46        links,
47        start_time_unix_nano,
48        end_time_unix_nano,
49        trace_state,
50        dropped_attributes_count: _,
51        events: _,
52        dropped_events_count: _,
53        dropped_links_count: _,
54    } = otel_span;
55
56    let start_timestamp = Utc.timestamp_nanos(start_time_unix_nano as i64);
57    let end_timestamp = Utc.timestamp_nanos(end_time_unix_nano as i64);
58
59    let span_id: Annotated<SpanId> = SpanId::try_from(span_id.as_slice()).into();
60    let trace_id = TraceId::try_from_slice_or_random(trace_id.as_slice());
61
62    let parent_span_id = match parent_span_id.as_slice() {
63        &[] => Annotated::empty(),
64        bytes => SpanId::try_from(bytes).into(),
65    };
66
67    let mut sentry_attributes = Attributes::new();
68
69    relay_otel::otel_scope_into_attributes(&mut sentry_attributes, resource, scope);
70
71    sentry_attributes.insert(SENTRY__ORIGIN, "auto.otlp.spans".to_owned());
72    if let Some(resource) = resource
73        && let Some(platform) = otel_resource_to_platform(resource)
74    {
75        sentry_attributes.insert(SENTRY__PLATFORM, platform.to_owned());
76    }
77
78    let mut name = if name.is_empty() { None } else { Some(name) };
79    for (key, value) in attributes.into_iter().flat_map(|attribute| {
80        let value = attribute.value?.value?;
81        Some((attribute.key, value))
82    }) {
83        match key.as_str() {
84            key if key.starts_with("db") => {
85                name = name.or(Some("db".to_owned()));
86            }
87            "http.method" | "http.request.method" => {
88                let http_op = match kind {
89                    2 => "http.server",
90                    3 => "http.client",
91                    _ => "http",
92                };
93                name = name.or(Some(http_op.to_owned()));
94            }
95            _ => (),
96        }
97
98        if let Some(v) = otel_value_to_attribute(value) {
99            sentry_attributes.0.insert(key, Annotated::new(v));
100        }
101    }
102
103    if sentry_attributes
104        .get_value(SENTRY__CLIENT_SAMPLE_RATE)
105        .is_none()
106        && let Some(sample_rate) = client_sample_rate_from_trace_state(&trace_state)
107    {
108        sentry_attributes.insert(SENTRY__CLIENT_SAMPLE_RATE, sample_rate);
109    }
110
111    let sentry_links: Vec<Annotated<SpanV2Link>> = links
112        .into_iter()
113        .map(|link| otel_to_sentry_link(link).into())
114        .collect();
115
116    if let Some(status_message) = status.clone().map(|status| status.message) {
117        sentry_attributes.insert(SENTRY__STATUS__MESSAGE.to_owned(), status_message);
118    }
119
120    let is_remote = otel_flags_is_remote(flags);
121    if let Some(is_remote) = is_remote {
122        sentry_attributes.insert(SENTRY__IS_REMOTE, is_remote);
123    }
124
125    sentry_attributes.insert(
126        SENTRY__KIND,
127        otel_to_sentry_kind(kind).map_value(|v| v.to_string()),
128    );
129
130    // A remote span is a segment span, but not every segment span is remote.
131    // A span is also a segment if it has no parent span (i.e., it's a root span).
132    let is_root_span = parent_span_id.value().is_none();
133    let is_segment = is_root_span || is_remote.unwrap_or(false);
134
135    if is_segment {
136        if let Some(span_id) = span_id.value() {
137            // It's fine to use SENTRY__SEGMENT__ID here, it gets normalized in `sentry`:
138            // https://github.com/getsentry/sentry/blob/0bb54f81a56c68bba25487f0f081ffd31ea5a3c7/src/sentry/spans/consumers/process_segments/convert.py#L38
139            sentry_attributes.insert(SENTRY__SEGMENT__ID, span_id.to_string());
140        }
141        if let Some(ref segment_name) = name {
142            sentry_attributes.insert(SENTRY__SEGMENT__NAME, segment_name.clone());
143        }
144    }
145
146    SentrySpanV2 {
147        name: name.into(),
148        trace_id,
149        span_id,
150        parent_span_id,
151        is_segment: is_segment.into(),
152        start_timestamp: Timestamp(start_timestamp).into(),
153        end_timestamp: Timestamp(end_timestamp).into(),
154        status: status
155            .map(|status| otel_to_sentry_status(status.code))
156            .unwrap_or(SpanV2Status::Ok)
157            .into(),
158        links: sentry_links.into(),
159        attributes: Annotated::new(sentry_attributes),
160        ..Default::default()
161    }
162}
163
164/// Number of distinct 56-bit values used by OTel consistent sampling.
165const OTEL_MAX_ADJUSTED_COUNT: u64 = 1 << 56;
166
167/// Extracts the client sample rate from an OTEL W3C TraceState string.
168///
169/// OpenTelemetry encodes the sampling threshold in the `ot` vendor entry as `th:<hex>`.
170/// The threshold is a 56-bit rejection threshold; the sample rate (probability) is
171/// `(2^56 - threshold) / 2^56`.
172///
173/// See <https://opentelemetry.io/docs/specs/otel/trace/tracestate-handling/>.
174fn client_sample_rate_from_trace_state(trace_state: &str) -> Option<f64> {
175    let ot_value = trace_state
176        .split(',')
177        .map(str::trim)
178        .find_map(|member| member.strip_prefix("ot="))?;
179
180    // Spec: only 1 `th` value is permitted.
181    let mut thresholds = ot_value.split(';').filter_map(|kv| {
182        let (key, value) = kv.split_once(':')?;
183        (key == "th").then_some(value)
184    });
185    let th = thresholds.next()?;
186    if thresholds.next().is_some() {
187        return None;
188    }
189
190    // Spec: 1–14 lowercase hexadecimal digits.
191    if th.is_empty() || th.len() > 14 || !th.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
192    {
193        return None;
194    }
195
196    // Extend with trailing zeros to 14 hex digits, then parse as a 56-bit unsigned integer.
197    let threshold = u64::from_str_radix(th, 16).ok()? << ((14 - th.len()) * 4);
198
199    Some((OTEL_MAX_ADJUSTED_COUNT - threshold) as f64 / OTEL_MAX_ADJUSTED_COUNT as f64)
200}
201
202fn otel_flags_is_remote(value: u32) -> Option<bool> {
203    if value & OtelSpanFlags::ContextHasIsRemoteMask as u32 == 0 {
204        None
205    } else {
206        Some(value & OtelSpanFlags::ContextIsRemoteMask as u32 != 0)
207    }
208}
209
210fn otel_to_sentry_kind(kind: i32) -> Annotated<SpanKind> {
211    match kind {
212        kind if kind == OtelSpanKind::Unspecified as i32 => Annotated::empty(),
213        kind if kind == OtelSpanKind::Internal as i32 => Annotated::new(SpanKind::Internal),
214        kind if kind == OtelSpanKind::Server as i32 => Annotated::new(SpanKind::Server),
215        kind if kind == OtelSpanKind::Client as i32 => Annotated::new(SpanKind::Client),
216        kind if kind == OtelSpanKind::Producer as i32 => Annotated::new(SpanKind::Producer),
217        kind if kind == OtelSpanKind::Consumer as i32 => Annotated::new(SpanKind::Consumer),
218        _ => Annotated::from_error(ErrorKind::InvalidData, Some(Value::I64(kind as i64))),
219    }
220}
221
222fn otel_to_sentry_status(status_code: i32) -> SpanV2Status {
223    if status_code == OtelStatusCode::Unset as i32 || status_code == OtelStatusCode::Ok as i32 {
224        SpanV2Status::Ok
225    } else {
226        SpanV2Status::Error
227    }
228}
229
230// This function has been moved to relay-otel crate as otel_value_to_attribute
231
232fn otel_to_sentry_link(otel_link: OtelLink) -> Result<SpanV2Link, Error> {
233    // See the W3C trace context specification:
234    // <https://www.w3.org/TR/trace-context-2/#sampled-flag>
235    const W3C_TRACE_CONTEXT_SAMPLED: u32 = 1 << 0;
236
237    let attributes = Attributes::from_iter(otel_link.attributes.into_iter().filter_map(|kv| {
238        let value = kv.value?.value?;
239        let attr_value = otel_value_to_attribute(value)?;
240        Some((kv.key, Annotated::new(attr_value)))
241    }));
242
243    let trace_id = TraceId::try_from_or_random(otel_link.trace_id.as_slice());
244    let span_link = SpanV2Link {
245        trace_id,
246        span_id: SpanId::try_from(otel_link.span_id.as_slice())?.into(),
247        sampled: (otel_link.flags & W3C_TRACE_CONTEXT_SAMPLED != 0).into(),
248        attributes: Annotated::new(attributes),
249        other: Default::default(),
250    };
251
252    Ok(span_link)
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use relay_protocol::SerializableAnnotated;
259
260    #[test]
261    fn parse_span() {
262        let json = r#"{
263            "traceId": "89143b0763095bd9c9955e8175d1fb23",
264            "spanId": "e342abb1214ca181",
265            "parentSpanId": "0c7a7dea069bf5a6",
266            "name": "middleware - fastify -> @fastify/multipart",
267            "kind": 1,
268            "startTimeUnixNano": "1697620454980000000",
269            "endTimeUnixNano": "1697620454980078800",
270            "attributes": [
271                {
272                    "key": "sentry.environment",
273                    "value": {
274                        "stringValue": "test"
275                    }
276                },
277                {
278                "key": "fastify.type",
279                    "value": {
280                        "stringValue": "middleware"
281                    }
282                },
283                {
284                    "key": "plugin.name",
285                    "value": {
286                        "stringValue": "fastify -> @fastify/multipart"
287                    }
288                },
289                {
290                    "key": "hook.name",
291                    "value": {
292                        "stringValue": "onResponse"
293                    }
294                },
295                {
296                    "key": "sentry.sample_rate",
297                    "value": {
298                        "intValue": "1"
299                    }
300                },
301                {
302                    "key": "sentry.parentSampled",
303                    "value": {
304                        "boolValue": true
305                    }
306                },
307                {
308                    "key": "sentry.exclusive_time",
309                    "value": {
310                        "doubleValue": 1000.0
311                    }
312                }
313            ],
314            "droppedAttributesCount": 0,
315            "events": [],
316            "droppedEventsCount": 0,
317            "status": {
318                "code": 0,
319                "message": "test"
320            },
321            "links": [],
322            "droppedLinksCount": 0
323        }"#;
324
325        let resource = serde_json::from_value(serde_json::json!({
326            "attributes": [{
327                "key": "service.name",
328                "value": {"stringValue": "test-service"},
329            }, {
330              "key": "telemetry.sdk.language",
331              "value": {"stringValue": "nodejs"},
332            }]
333        }))
334        .unwrap();
335
336        let scope = InstrumentationScope {
337            name: "Eins Name".to_owned(),
338            version: "123.42".to_owned(),
339            attributes: Vec::new(),
340            dropped_attributes_count: 12,
341        };
342
343        let otel_span: OtelSpan = serde_json::from_str(json).unwrap();
344        let event_span = otel_to_sentry_span(otel_span, Some(&resource), Some(&scope));
345        let annotated_span: Annotated<SentrySpanV2> = Annotated::new(event_span);
346        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r#"
347        {
348          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
349          "parent_span_id": "0c7a7dea069bf5a6",
350          "span_id": "e342abb1214ca181",
351          "name": "middleware - fastify -> @fastify/multipart",
352          "status": "ok",
353          "is_segment": false,
354          "start_timestamp": 1697620454.98,
355          "end_timestamp": 1697620454.980079,
356          "links": [],
357          "attributes": {
358            "fastify.type": {
359              "type": "string",
360              "value": "middleware"
361            },
362            "hook.name": {
363              "type": "string",
364              "value": "onResponse"
365            },
366            "instrumentation.name": {
367              "type": "string",
368              "value": "Eins Name"
369            },
370            "instrumentation.version": {
371              "type": "string",
372              "value": "123.42"
373            },
374            "plugin.name": {
375              "type": "string",
376              "value": "fastify -> @fastify/multipart"
377            },
378            "resource.service.name": {
379              "type": "string",
380              "value": "test-service"
381            },
382            "resource.telemetry.sdk.language": {
383              "type": "string",
384              "value": "nodejs"
385            },
386            "sentry.environment": {
387              "type": "string",
388              "value": "test"
389            },
390            "sentry.exclusive_time": {
391              "type": "double",
392              "value": 1000.0
393            },
394            "sentry.kind": {
395              "type": "string",
396              "value": "internal"
397            },
398            "sentry.origin": {
399              "type": "string",
400              "value": "auto.otlp.spans"
401            },
402            "sentry.parentSampled": {
403              "type": "boolean",
404              "value": true
405            },
406            "sentry.platform": {
407              "type": "string",
408              "value": "node"
409            },
410            "sentry.sample_rate": {
411              "type": "integer",
412              "value": 1
413            },
414            "sentry.status.message": {
415              "type": "string",
416              "value": "test"
417            }
418          }
419        }
420        "#);
421    }
422
423    #[test]
424    fn parse_span_with_exclusive_time_attribute() {
425        let json = r#"{
426          "traceId": "89143b0763095bd9c9955e8175d1fb23",
427          "spanId": "e342abb1214ca181",
428          "parentSpanId": "0c7a7dea069bf5a6",
429          "name": "middleware - fastify -> @fastify/multipart",
430          "kind": 1,
431          "startTimeUnixNano": "1697620454980000000",
432          "endTimeUnixNano": "1697620454980078800",
433          "attributes": [
434            {
435              "key": "sentry.exclusive_time",
436              "value": {
437                "doubleValue": 3200.000000
438              }
439            }
440          ]
441        }"#;
442        let otel_span: OtelSpan = serde_json::from_str(json).unwrap();
443        let event_span = otel_to_sentry_span(otel_span, None, None);
444        let annotated_span: Annotated<SentrySpanV2> = Annotated::new(event_span);
445        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r#"
446        {
447          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
448          "parent_span_id": "0c7a7dea069bf5a6",
449          "span_id": "e342abb1214ca181",
450          "name": "middleware - fastify -> @fastify/multipart",
451          "status": "ok",
452          "is_segment": false,
453          "start_timestamp": 1697620454.98,
454          "end_timestamp": 1697620454.980079,
455          "links": [],
456          "attributes": {
457            "sentry.exclusive_time": {
458              "type": "double",
459              "value": 3200.0
460            },
461            "sentry.kind": {
462              "type": "string",
463              "value": "internal"
464            },
465            "sentry.origin": {
466              "type": "string",
467              "value": "auto.otlp.spans"
468            }
469          }
470        }
471        "#);
472    }
473
474    #[test]
475    fn parse_span_with_db_attributes() {
476        let json = r#"{
477          "traceId": "89143b0763095bd9c9955e8175d1fb23",
478          "spanId": "e342abb1214ca181",
479          "parentSpanId": "0c7a7dea069bf5a6",
480          "name": "database query",
481          "kind": 3,
482          "startTimeUnixNano": "1697620454980000000",
483          "endTimeUnixNano": "1697620454980078800",
484          "attributes": [
485            {
486              "key": "db.name",
487              "value": {
488                "stringValue": "database"
489              }
490            },
491            {
492              "key": "db.type",
493              "value": {
494                "stringValue": "sql"
495              }
496            },
497            {
498              "key": "db.statement",
499              "value": {
500                "stringValue": "SELECT \"table\".\"col\" FROM \"table\" WHERE \"table\".\"col\" = %s"
501              }
502            }
503          ]
504        }"#;
505        let otel_span: OtelSpan = serde_json::from_str(json).unwrap();
506        let event_span = otel_to_sentry_span(otel_span, None, None);
507        let annotated_span: Annotated<SentrySpanV2> = Annotated::new(event_span);
508        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r#"
509        {
510          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
511          "parent_span_id": "0c7a7dea069bf5a6",
512          "span_id": "e342abb1214ca181",
513          "name": "database query",
514          "status": "ok",
515          "is_segment": false,
516          "start_timestamp": 1697620454.98,
517          "end_timestamp": 1697620454.980079,
518          "links": [],
519          "attributes": {
520            "db.name": {
521              "type": "string",
522              "value": "database"
523            },
524            "db.statement": {
525              "type": "string",
526              "value": "SELECT \"table\".\"col\" FROM \"table\" WHERE \"table\".\"col\" = %s"
527            },
528            "db.type": {
529              "type": "string",
530              "value": "sql"
531            },
532            "sentry.kind": {
533              "type": "string",
534              "value": "client"
535            },
536            "sentry.origin": {
537              "type": "string",
538              "value": "auto.otlp.spans"
539            }
540          }
541        }
542        "#);
543    }
544
545    #[test]
546    fn parse_span_with_db_attributes_and_description() {
547        let json = r#"{
548          "traceId": "89143b0763095bd9c9955e8175d1fb23",
549          "spanId": "e342abb1214ca181",
550          "parentSpanId": "0c7a7dea069bf5a6",
551          "name": "database query",
552          "kind": 3,
553          "startTimeUnixNano": "1697620454980000000",
554          "endTimeUnixNano": "1697620454980078800",
555          "attributes": [
556            {
557              "key": "db.name",
558              "value": {
559                "stringValue": "database"
560              }
561            },
562            {
563              "key": "db.type",
564              "value": {
565                "stringValue": "sql"
566              }
567            },
568            {
569              "key": "db.statement",
570              "value": {
571                "stringValue": "SELECT \"table\".\"col\" FROM \"table\" WHERE \"table\".\"col\" = %s"
572              }
573            },
574            {
575              "key": "sentry.description",
576              "value": {
577                "stringValue": "index view query"
578              }
579            }
580          ]
581        }"#;
582        let otel_span: OtelSpan = serde_json::from_str(json).unwrap();
583        let event_span = otel_to_sentry_span(otel_span, None, None);
584        let annotated_span: Annotated<SentrySpanV2> = Annotated::new(event_span);
585        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r#"
586        {
587          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
588          "parent_span_id": "0c7a7dea069bf5a6",
589          "span_id": "e342abb1214ca181",
590          "name": "database query",
591          "status": "ok",
592          "is_segment": false,
593          "start_timestamp": 1697620454.98,
594          "end_timestamp": 1697620454.980079,
595          "links": [],
596          "attributes": {
597            "db.name": {
598              "type": "string",
599              "value": "database"
600            },
601            "db.statement": {
602              "type": "string",
603              "value": "SELECT \"table\".\"col\" FROM \"table\" WHERE \"table\".\"col\" = %s"
604            },
605            "db.type": {
606              "type": "string",
607              "value": "sql"
608            },
609            "sentry.description": {
610              "type": "string",
611              "value": "index view query"
612            },
613            "sentry.kind": {
614              "type": "string",
615              "value": "client"
616            },
617            "sentry.origin": {
618              "type": "string",
619              "value": "auto.otlp.spans"
620            }
621          }
622        }
623        "#);
624    }
625
626    #[test]
627    fn parse_span_with_http_attributes() {
628        let json = r#"{
629          "traceId": "89143b0763095bd9c9955e8175d1fb23",
630          "spanId": "e342abb1214ca181",
631          "parentSpanId": "0c7a7dea069bf5a6",
632          "name": "http client request",
633          "kind": 3,
634          "startTimeUnixNano": "1697620454980000000",
635          "endTimeUnixNano": "1697620454980078800",
636          "attributes": [
637            {
638              "key": "http.request.method",
639              "value": {
640                "stringValue": "GET"
641              }
642            },
643            {
644              "key": "url.path",
645              "value": {
646                "stringValue": "/api/search?q=foobar"
647              }
648            }
649          ]
650        }"#;
651        let otel_span: OtelSpan = serde_json::from_str(json).unwrap();
652        let event_span = otel_to_sentry_span(otel_span, None, None);
653        let annotated_span: Annotated<SentrySpanV2> = Annotated::new(event_span);
654        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r#"
655        {
656          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
657          "parent_span_id": "0c7a7dea069bf5a6",
658          "span_id": "e342abb1214ca181",
659          "name": "http client request",
660          "status": "ok",
661          "is_segment": false,
662          "start_timestamp": 1697620454.98,
663          "end_timestamp": 1697620454.980079,
664          "links": [],
665          "attributes": {
666            "http.request.method": {
667              "type": "string",
668              "value": "GET"
669            },
670            "sentry.kind": {
671              "type": "string",
672              "value": "client"
673            },
674            "sentry.origin": {
675              "type": "string",
676              "value": "auto.otlp.spans"
677            },
678            "url.path": {
679              "type": "string",
680              "value": "/api/search?q=foobar"
681            }
682          }
683        }
684        "#);
685    }
686
687    /// Intended to be synced with `relay-event-schema::protocol::span::convert::tests::roundtrip`.
688    #[test]
689    fn parse_sentry_attributes() {
690        let json = r#"{
691          "traceId": "4c79f60c11214eb38604f4ae0781bfb2",
692          "spanId": "fa90fdead5f74052",
693          "parentSpanId": "fa90fdead5f74051",
694          "startTimeUnixNano": "123000000000",
695          "endTimeUnixNano": "123500000000",
696          "name": "myname",
697          "status": {
698            "code": 0,
699            "message": "foo"
700          },
701          "attributes": [
702            {
703              "key": "browser.name",
704              "value": {
705                "stringValue": "Chrome"
706              }
707            },
708            {
709              "key": "sentry.description",
710              "value": {
711                "stringValue": "mydescription"
712              }
713            },
714            {
715              "key": "sentry.environment",
716              "value": {
717                "stringValue": "prod"
718              }
719            },
720            {
721              "key": "sentry.op",
722              "value": {
723                "stringValue": "myop"
724              }
725            },
726            {
727              "key": "sentry.platform",
728              "value": {
729                "stringValue": "php"
730              }
731            },
732            {
733              "key": "sentry.profile_id",
734              "value": {
735                "stringValue": "a0aaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaab"
736              }
737            },
738            {
739              "key": "sentry.release",
740              "value": {
741                "stringValue": "myapp@1.0.0"
742              }
743            },
744            {
745              "key": "sentry.sdk.name",
746              "value": {
747                "stringValue": "sentry.php"
748              }
749            },
750            {
751              "key": "sentry.segment.id",
752              "value": {
753                "stringValue": "FA90FDEAD5F74052"
754              }
755            },
756            {
757              "key": "sentry.segment.name",
758              "value": {
759                "stringValue": "my 1st transaction"
760              }
761            },
762            {
763              "key": "sentry.metrics_summary.some_metric",
764              "value": {
765                "arrayValue": {
766                  "values": [
767                    {
768                      "kvlistValue": {
769                        "values": [
770                          {
771                            "key": "min",
772                            "value": {
773                              "doubleValue": 1
774                            }
775                          },
776                          {
777                            "key": "max",
778                            "value": {
779                              "doubleValue": 2
780                            }
781                          },
782                          {
783                            "key": "sum",
784                            "value": {
785                              "doubleValue": 3
786                            }
787                          },
788                          {
789                            "key": "count",
790                            "value": {
791                              "intValue": "2"
792                            }
793                          },
794                          {
795                            "key": "tags",
796                            "value": {
797                              "kvlistValue": {
798                                "values": [
799                                  {
800                                    "key": "environment",
801                                    "value": {
802                                      "stringValue": "test"
803                                    }
804                                  }
805                                ]
806                              }
807                            }
808                          }
809                        ]
810                      }
811                    }
812                  ]
813                }
814              }
815            }
816          ]
817        }"#;
818
819        let otel_span: OtelSpan = serde_json::from_str(json).unwrap();
820        let event_span = otel_to_sentry_span(otel_span, None, None);
821
822        let annotated_span: Annotated<SentrySpanV2> = Annotated::new(event_span);
823        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r#"
824        {
825          "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
826          "parent_span_id": "fa90fdead5f74051",
827          "span_id": "fa90fdead5f74052",
828          "name": "myname",
829          "status": "ok",
830          "is_segment": false,
831          "start_timestamp": 123.0,
832          "end_timestamp": 123.5,
833          "links": [],
834          "attributes": {
835            "browser.name": {
836              "type": "string",
837              "value": "Chrome"
838            },
839            "sentry.description": {
840              "type": "string",
841              "value": "mydescription"
842            },
843            "sentry.environment": {
844              "type": "string",
845              "value": "prod"
846            },
847            "sentry.metrics_summary.some_metric": {
848              "type": "array",
849              "value": []
850            },
851            "sentry.op": {
852              "type": "string",
853              "value": "myop"
854            },
855            "sentry.origin": {
856              "type": "string",
857              "value": "auto.otlp.spans"
858            },
859            "sentry.platform": {
860              "type": "string",
861              "value": "php"
862            },
863            "sentry.profile_id": {
864              "type": "string",
865              "value": "a0aaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaab"
866            },
867            "sentry.release": {
868              "type": "string",
869              "value": "myapp@1.0.0"
870            },
871            "sentry.sdk.name": {
872              "type": "string",
873              "value": "sentry.php"
874            },
875            "sentry.segment.id": {
876              "type": "string",
877              "value": "FA90FDEAD5F74052"
878            },
879            "sentry.segment.name": {
880              "type": "string",
881              "value": "my 1st transaction"
882            },
883            "sentry.status.message": {
884              "type": "string",
885              "value": "foo"
886            }
887          }
888        }
889        "#);
890    }
891
892    #[test]
893    fn parse_span_is_remote() {
894        let json = r#"{
895          "traceId": "89143b0763095bd9c9955e8175d1fb23",
896          "spanId": "e342abb1214ca181",
897          "parentSpanId": "0c7a7dea069bf5a6",
898          "startTimeUnixNano": "123000000000",
899          "endTimeUnixNano": "123500000000",
900          "flags": 768
901        }"#;
902        let otel_span: OtelSpan = serde_json::from_str(json).unwrap();
903        let event_span = otel_to_sentry_span(otel_span, None, None);
904        let annotated_span: Annotated<SentrySpanV2> = Annotated::new(event_span);
905        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r#"
906        {
907          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
908          "parent_span_id": "0c7a7dea069bf5a6",
909          "span_id": "e342abb1214ca181",
910          "status": "ok",
911          "is_segment": true,
912          "start_timestamp": 123.0,
913          "end_timestamp": 123.5,
914          "links": [],
915          "attributes": {
916            "sentry.is_remote": {
917              "type": "boolean",
918              "value": true
919            },
920            "sentry.origin": {
921              "type": "string",
922              "value": "auto.otlp.spans"
923            },
924            "sentry.segment.id": {
925              "type": "string",
926              "value": "e342abb1214ca181"
927            }
928          }
929        }
930        "#);
931    }
932
933    #[test]
934    fn parse_span_is_not_remote() {
935        let json = r#"{
936          "traceId": "89143b0763095bd9c9955e8175d1fb23",
937          "spanId": "e342abb1214ca181",
938          "parentSpanId": "0c7a7dea069bf5a6",
939          "startTimeUnixNano": "123000000000",
940          "endTimeUnixNano": "123500000000",
941          "flags": 256
942        }"#;
943        let otel_span: OtelSpan = serde_json::from_str(json).unwrap();
944        let event_span = otel_to_sentry_span(otel_span, None, None);
945        let annotated_span: Annotated<SentrySpanV2> = Annotated::new(event_span);
946        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r#"
947        {
948          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
949          "parent_span_id": "0c7a7dea069bf5a6",
950          "span_id": "e342abb1214ca181",
951          "status": "ok",
952          "is_segment": false,
953          "start_timestamp": 123.0,
954          "end_timestamp": 123.5,
955          "links": [],
956          "attributes": {
957            "sentry.is_remote": {
958              "type": "boolean",
959              "value": false
960            },
961            "sentry.origin": {
962              "type": "string",
963              "value": "auto.otlp.spans"
964            }
965          }
966        }
967        "#);
968    }
969
970    #[test]
971    fn span_is_segment_if_it_has_no_parent() {
972        let json = r#"{
973          "traceId": "89143b0763095bd9c9955e8175d1fb23",
974          "spanId": "e342abb1214ca181",
975          "startTimeUnixNano": "123000000000",
976          "endTimeUnixNano": "123500000000"
977        }"#;
978        let otel_span: OtelSpan = serde_json::from_str(json).unwrap();
979        let event_span = otel_to_sentry_span(otel_span, None, None);
980        let annotated_span: Annotated<SentrySpanV2> = Annotated::new(event_span);
981        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r#"
982        {
983          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
984          "span_id": "e342abb1214ca181",
985          "status": "ok",
986          "is_segment": true,
987          "start_timestamp": 123.0,
988          "end_timestamp": 123.5,
989          "links": [],
990          "attributes": {
991            "sentry.origin": {
992              "type": "string",
993              "value": "auto.otlp.spans"
994            },
995            "sentry.segment.id": {
996              "type": "string",
997              "value": "e342abb1214ca181"
998            }
999          }
1000        }
1001        "#);
1002    }
1003
1004    #[test]
1005    fn segment_span_with_name_is_copied_to_attributes() {
1006        let json = r#"{
1007          "traceId": "89143b0763095bd9c9955e8175d1fb23",
1008          "spanId": "e342abb1214ca181",
1009          "name": "my segment span",
1010          "startTimeUnixNano": "123000000000",
1011          "endTimeUnixNano": "123500000000"
1012        }"#;
1013        let otel_span: OtelSpan = serde_json::from_str(json).unwrap();
1014        let event_span = otel_to_sentry_span(otel_span, None, None);
1015        let annotated_span: Annotated<SentrySpanV2> = Annotated::new(event_span);
1016        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r#"
1017        {
1018          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
1019          "span_id": "e342abb1214ca181",
1020          "name": "my segment span",
1021          "status": "ok",
1022          "is_segment": true,
1023          "start_timestamp": 123.0,
1024          "end_timestamp": 123.5,
1025          "links": [],
1026          "attributes": {
1027            "sentry.origin": {
1028              "type": "string",
1029              "value": "auto.otlp.spans"
1030            },
1031            "sentry.segment.id": {
1032              "type": "string",
1033              "value": "e342abb1214ca181"
1034            },
1035            "sentry.segment.name": {
1036              "type": "string",
1037              "value": "my segment span"
1038            }
1039          }
1040        }
1041        "#);
1042    }
1043
1044    #[test]
1045    fn extract_span_kind() {
1046        let json = r#"{
1047          "traceId": "89143b0763095bd9c9955e8175d1fb23",
1048          "spanId": "e342abb1214ca181",
1049          "parentSpanId": "0c7a7dea069bf5a6",
1050          "startTimeUnixNano": "123000000000",
1051          "endTimeUnixNano": "123500000000",
1052          "kind": 3
1053        }"#;
1054        let otel_span: OtelSpan = serde_json::from_str(json).unwrap();
1055        let event_span = otel_to_sentry_span(otel_span, None, None);
1056        let annotated_span: Annotated<SentrySpanV2> = Annotated::new(event_span);
1057        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r#"
1058        {
1059          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
1060          "parent_span_id": "0c7a7dea069bf5a6",
1061          "span_id": "e342abb1214ca181",
1062          "status": "ok",
1063          "is_segment": false,
1064          "start_timestamp": 123.0,
1065          "end_timestamp": 123.5,
1066          "links": [],
1067          "attributes": {
1068            "sentry.kind": {
1069              "type": "string",
1070              "value": "client"
1071            },
1072            "sentry.origin": {
1073              "type": "string",
1074              "value": "auto.otlp.spans"
1075            }
1076          }
1077        }
1078        "#);
1079    }
1080
1081    #[test]
1082    fn parse_link() {
1083        let json = r#"{
1084          "traceId": "3c79f60c11214eb38604f4ae0781bfb2",
1085          "spanId": "e342abb1214ca181",
1086          "links": [
1087            {
1088              "traceId": "4c79f60c11214eb38604f4ae0781bfb2",
1089              "spanId": "fa90fdead5f74052",
1090              "attributes": [
1091                {
1092                  "key": "str_key",
1093                  "value": {
1094                    "stringValue": "str_value"
1095                  }
1096                },
1097                {
1098                  "key": "bool_key",
1099                  "value": {
1100                    "boolValue": true
1101                  }
1102                },
1103                {
1104                  "key": "int_key",
1105                  "value": {
1106                    "intValue": "123"
1107                  }
1108                },
1109                {
1110                  "key": "double_key",
1111                  "value": {
1112                    "doubleValue": 1.23
1113                  }
1114                }
1115              ],
1116              "flags": 1
1117            }
1118          ]
1119        }"#;
1120        let otel_span: OtelSpan = serde_json::from_str(json).unwrap();
1121        let event_span = otel_to_sentry_span(otel_span, None, None);
1122        let annotated_span: Annotated<SentrySpanV2> = Annotated::new(event_span);
1123
1124        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r#"
1125        {
1126          "trace_id": "3c79f60c11214eb38604f4ae0781bfb2",
1127          "span_id": "e342abb1214ca181",
1128          "status": "ok",
1129          "is_segment": true,
1130          "start_timestamp": 0.0,
1131          "end_timestamp": 0.0,
1132          "links": [
1133            {
1134              "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
1135              "span_id": "fa90fdead5f74052",
1136              "sampled": true,
1137              "attributes": {
1138                "bool_key": {
1139                  "type": "boolean",
1140                  "value": true
1141                },
1142                "double_key": {
1143                  "type": "double",
1144                  "value": 1.23
1145                },
1146                "int_key": {
1147                  "type": "integer",
1148                  "value": 123
1149                },
1150                "str_key": {
1151                  "type": "string",
1152                  "value": "str_value"
1153                }
1154              }
1155            }
1156          ],
1157          "attributes": {
1158            "sentry.origin": {
1159              "type": "string",
1160              "value": "auto.otlp.spans"
1161            },
1162            "sentry.segment.id": {
1163              "type": "string",
1164              "value": "e342abb1214ca181"
1165            }
1166          }
1167        }
1168        "#);
1169    }
1170
1171    #[test]
1172    fn parse_span_error_status() {
1173        let json = r#"{
1174          "traceId": "89143b0763095bd9c9955e8175d1fb23",
1175          "spanId": "e342abb1214ca181",
1176          "status": {
1177            "code": 2,
1178            "message": "2 is the error status code"
1179          }
1180        }"#;
1181        let otel_span: OtelSpan = serde_json::from_str(json).unwrap();
1182        let event_span = otel_to_sentry_span(otel_span, None, None);
1183        let annotated_span: Annotated<SentrySpanV2> = Annotated::new(event_span);
1184        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r#"
1185        {
1186          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
1187          "span_id": "e342abb1214ca181",
1188          "status": "error",
1189          "is_segment": true,
1190          "start_timestamp": 0.0,
1191          "end_timestamp": 0.0,
1192          "links": [],
1193          "attributes": {
1194            "sentry.origin": {
1195              "type": "string",
1196              "value": "auto.otlp.spans"
1197            },
1198            "sentry.segment.id": {
1199              "type": "string",
1200              "value": "e342abb1214ca181"
1201            },
1202            "sentry.status.message": {
1203              "type": "string",
1204              "value": "2 is the error status code"
1205            }
1206          }
1207        }
1208        "#);
1209    }
1210
1211    #[test]
1212    fn client_sample_rate_from_trace_state_examples() {
1213        // `th:0` is 100% sampling.
1214        assert_eq!(client_sample_rate_from_trace_state("ot=th:0"), Some(1.0));
1215        // `th:c` is the spec example for 25% sampling.
1216        assert_eq!(client_sample_rate_from_trace_state("ot=th:c"), Some(0.25));
1217        // Short thresholds are extended with trailing zeroes, not parsed as-is.
1218        assert_eq!(
1219            client_sample_rate_from_trace_state("ot=th:12"),
1220            Some(0.9296875)
1221        );
1222        // Other `ot` sub-keys and vendor entries must be ignored.
1223        assert_eq!(
1224            client_sample_rate_from_trace_state(
1225                "foo=t61rcWkgMzE,ot=p:8;r:62;th:c,bar=00f067aa0ba902b7"
1226            ),
1227            Some(0.25)
1228        );
1229    }
1230
1231    #[test]
1232    fn client_sample_rate_from_trace_state_invalid() {
1233        assert_eq!(client_sample_rate_from_trace_state(""), None);
1234        assert_eq!(client_sample_rate_from_trace_state("ot=p:8;r:62"), None);
1235        assert_eq!(client_sample_rate_from_trace_state("ot=th:"), None);
1236        assert_eq!(client_sample_rate_from_trace_state("ot=th:zzzz"), None);
1237        assert_eq!(client_sample_rate_from_trace_state("ot=th:C"), None);
1238        assert_eq!(client_sample_rate_from_trace_state("ot=th:c;th:0"), None);
1239        assert_eq!(
1240            client_sample_rate_from_trace_state("ot=th:123456789012345"),
1241            None
1242        );
1243        assert_eq!(client_sample_rate_from_trace_state("vendor=th:c"), None);
1244    }
1245
1246    #[test]
1247    fn parse_span_client_sample_rate_from_trace_state() {
1248        let json = r#"{
1249          "traceId": "89143b0763095bd9c9955e8175d1fb23",
1250          "spanId": "e342abb1214ca181",
1251          "parentSpanId": "0c7a7dea069bf5a6",
1252          "startTimeUnixNano": "123000000000",
1253          "endTimeUnixNano": "123500000000",
1254          "traceState": "ot=th:c"
1255        }"#;
1256        let otel_span: OtelSpan = serde_json::from_str(json).unwrap();
1257        let event_span = otel_to_sentry_span(otel_span, None, None);
1258        let annotated_span: Annotated<SentrySpanV2> = Annotated::new(event_span);
1259        insta::assert_json_snapshot!(SerializableAnnotated(&annotated_span), @r#"
1260        {
1261          "trace_id": "89143b0763095bd9c9955e8175d1fb23",
1262          "parent_span_id": "0c7a7dea069bf5a6",
1263          "span_id": "e342abb1214ca181",
1264          "status": "ok",
1265          "is_segment": false,
1266          "start_timestamp": 123.0,
1267          "end_timestamp": 123.5,
1268          "links": [],
1269          "attributes": {
1270            "sentry.client_sample_rate": {
1271              "type": "double",
1272              "value": 0.25
1273            },
1274            "sentry.origin": {
1275              "type": "string",
1276              "value": "auto.otlp.spans"
1277            }
1278          }
1279        }
1280        "#);
1281    }
1282
1283    #[test]
1284    fn parse_span_client_sample_rate_attribute_takes_precedence() {
1285        let json = r#"{
1286          "traceId": "89143b0763095bd9c9955e8175d1fb23",
1287          "spanId": "e342abb1214ca181",
1288          "parentSpanId": "0c7a7dea069bf5a6",
1289          "startTimeUnixNano": "123000000000",
1290          "endTimeUnixNano": "123500000000",
1291          "traceState": "ot=th:c",
1292          "attributes": [
1293            {
1294              "key": "sentry.client_sample_rate",
1295              "value": {
1296                "doubleValue": 0.1
1297              }
1298            }
1299          ]
1300        }"#;
1301        let otel_span: OtelSpan = serde_json::from_str(json).unwrap();
1302        let event_span = otel_to_sentry_span(otel_span, None, None);
1303        let rate = event_span
1304            .attributes
1305            .value()
1306            .and_then(|attrs| attrs.get_value(SENTRY__CLIENT_SAMPLE_RATE))
1307            .and_then(|v| v.as_f64());
1308        assert_eq!(rate, Some(0.1));
1309    }
1310}