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