1use std::collections::{BTreeMap, BTreeSet};
2
3use relay_base_schema::events::EventType;
4use relay_base_schema::project::ProjectId;
5use relay_common::time::UnixTimestamp;
6use relay_dynamic_config::{CombinedMetricExtractionConfig, TransactionMetricsConfig};
7use relay_event_normalization::span::country_subregion::Subregion;
8use relay_event_normalization::utils as normalize_utils;
9use relay_event_schema::protocol::{
10 AsPair, BrowserContext, Event, OsContext, PerformanceScoreContext, TraceContext,
11 TransactionSource,
12};
13use relay_metrics::{Bucket, DurationUnit};
14use relay_protocol::FiniteF64;
15use relay_sampling::evaluation::SamplingDecision;
16
17use crate::metrics_extraction::IntoMetric;
18use crate::metrics_extraction::generic;
19use crate::metrics_extraction::transactions::types::{
20 CommonTag, CommonTags, ExtractMetricsError, LightTransactionTags, TransactionCPRTags,
21 TransactionMeasurementTags, TransactionMetric,
22};
23use crate::statsd::RelayCounters;
24use crate::utils;
25
26pub mod types;
27
28const PLACEHOLDER_UNPARAMETERIZED: &str = "<< unparameterized >>";
31
32const PERFORMANCE_SCORE_TAGS: [CommonTag; 7] = [
36 CommonTag::BrowserName,
37 CommonTag::Environment,
38 CommonTag::GeoCountryCode,
39 CommonTag::UserSubregion,
40 CommonTag::Release,
41 CommonTag::Transaction,
42 CommonTag::TransactionOp,
43];
44
45fn extract_http_method(transaction: &Event) -> Option<String> {
48 let request = transaction.request.value()?;
49 let method = request.method.value()?;
50 Some(method.clone())
51}
52
53fn extract_browser_name(event: &Event) -> Option<String> {
55 let browser = event.context::<BrowserContext>()?;
56 browser.name.value().cloned()
57}
58
59fn extract_os_name(event: &Event) -> Option<String> {
61 let os = event.context::<OsContext>()?;
62 os.name.value().cloned()
63}
64
65fn extract_geo_country_code(event: &Event) -> Option<String> {
67 let user = event.user.value()?;
68 let geo = user.geo.value()?;
69 geo.country_code.value().cloned()
70}
71
72fn is_low_cardinality(source: &TransactionSource) -> bool {
73 match source {
74 TransactionSource::Custom => true,
76
77 TransactionSource::Url => false,
79
80 TransactionSource::Route
82 | TransactionSource::View
83 | TransactionSource::Component
84 | TransactionSource::Task => true,
85
86 TransactionSource::Sanitized => true,
89
90 TransactionSource::Unknown => true,
93
94 TransactionSource::Other(_) => false,
97 }
98}
99
100pub fn get_transaction_name(event: &Event) -> Option<String> {
104 let original = event.transaction.value()?;
105
106 let source = event
107 .transaction_info
108 .value()
109 .and_then(|info| info.source.value());
110
111 match source {
112 Some(source) if is_low_cardinality(source) => Some(original.clone()),
113 Some(TransactionSource::Other(_)) | None => None,
114 Some(_) => Some(PLACEHOLDER_UNPARAMETERIZED.to_owned()),
115 }
116}
117
118fn track_transaction_name_stats(event: &Event) {
119 let name_used = match get_transaction_name(event).as_deref() {
120 Some(self::PLACEHOLDER_UNPARAMETERIZED) => "placeholder",
121 Some(_) => "original",
122 None => "none",
123 };
124
125 relay_statsd::metric!(
126 counter(RelayCounters::MetricsTransactionNameExtracted) += 1,
127 source = utils::transaction_source_tag(event),
128 sdk_name = event
129 .client_sdk
130 .value()
131 .and_then(|sdk| sdk.name.as_str())
132 .unwrap_or_default(),
133 name_used = name_used,
134 );
135}
136
137fn extract_light_transaction_tags(tags: &CommonTags) -> LightTransactionTags {
139 LightTransactionTags {
140 transaction_op: tags.0.get(&CommonTag::TransactionOp).cloned(),
141 transaction: tags.0.get(&CommonTag::Transaction).cloned(),
142 }
143}
144
145fn extract_universal_tags(event: &Event, config: &TransactionMetricsConfig) -> CommonTags {
147 let mut tags = BTreeMap::new();
148 if let Some(release) = event.release.as_str() {
149 tags.insert(CommonTag::Release, release.to_owned());
150 }
151 if let Some(dist) = event.dist.value() {
152 tags.insert(CommonTag::Dist, dist.to_string());
153 }
154 if let Some(environment) = event.environment.as_str() {
155 tags.insert(CommonTag::Environment, environment.to_owned());
156 }
157 if let Some(transaction_name) = get_transaction_name(event) {
158 tags.insert(CommonTag::Transaction, transaction_name);
159 }
160
161 let platform = match event.platform.as_str() {
166 Some(platform) if relay_event_normalization::is_valid_platform(platform) => platform,
167 _ => "other",
168 };
169
170 tags.insert(CommonTag::Platform, platform.to_owned());
171
172 if let Some(trace_context) = event.context::<TraceContext>() {
173 if let Some(status) = trace_context.status.value() {
176 tags.insert(CommonTag::TransactionStatus, status.to_string());
177 }
178
179 if let Some(op) = normalize_utils::extract_transaction_op(trace_context) {
180 tags.insert(CommonTag::TransactionOp, op);
181 }
182 }
183
184 if let Some(http_method) = extract_http_method(event) {
185 tags.insert(CommonTag::HttpMethod, http_method);
186 }
187
188 if let Some(browser_name) = extract_browser_name(event) {
189 tags.insert(CommonTag::BrowserName, browser_name);
190 }
191
192 if let Some(os_name) = extract_os_name(event) {
193 tags.insert(CommonTag::OsName, os_name);
194 }
195
196 if let Some(geo_country_code) = extract_geo_country_code(event) {
197 tags.insert(CommonTag::GeoCountryCode, geo_country_code);
198 }
199
200 if let Some(_browser_name) = extract_browser_name(event)
202 && let Some(geo_country_code) = extract_geo_country_code(event)
203 && let Some(subregion) = Subregion::from_iso2(geo_country_code.as_str())
204 {
205 let numerical_subregion = subregion as u8;
206 tags.insert(CommonTag::UserSubregion, numerical_subregion.to_string());
207 }
208
209 if let Some(status_code) = normalize_utils::extract_http_status_code(event) {
210 tags.insert(CommonTag::HttpStatusCode, status_code);
211 }
212
213 if normalize_utils::MOBILE_SDKS.contains(&event.sdk_name())
214 && let Some(device_class) = event.tag_value("device.class")
215 {
216 tags.insert(CommonTag::DeviceClass, device_class.to_owned());
217 }
218
219 let custom_tags = &config.extract_custom_tags;
220 if !custom_tags.is_empty() {
221 if let Some(event_tags) = event.tags.value() {
223 for tag_entry in &**event_tags {
224 if let Some(entry) = tag_entry.value() {
225 let (key, value) = entry.as_pair();
226 if let (Some(key), Some(value)) = (key.as_str(), value.as_str())
227 && custom_tags.contains(key)
228 {
229 tags.insert(CommonTag::Custom(key.to_owned()), value.to_owned());
230 }
231 }
232 }
233 }
234 }
235
236 CommonTags(tags)
237}
238
239#[derive(Debug, Default)]
245pub struct ExtractedMetrics {
246 pub project_metrics: Vec<Bucket>,
248
249 pub sampling_metrics: Vec<Bucket>,
251}
252
253pub struct TransactionExtractor<'a> {
255 pub config: &'a TransactionMetricsConfig,
256 pub generic_config: Option<CombinedMetricExtractionConfig<'a>>,
257 pub transaction_from_dsc: Option<&'a str>,
258 pub sampling_decision: SamplingDecision,
259 pub target_project_id: ProjectId,
260}
261
262impl TransactionExtractor<'_> {
263 pub fn extract(&self, event: &Event) -> Result<ExtractedMetrics, ExtractMetricsError> {
264 let mut metrics = ExtractedMetrics::default();
265
266 if event.ty.value() != Some(&EventType::Transaction) {
267 return Ok(metrics);
268 }
269
270 let (Some(&start), Some(&end)) = (event.start_timestamp.value(), event.timestamp.value())
271 else {
272 relay_log::debug!("failed to extract the start and the end timestamps from the event");
273 return Err(ExtractMetricsError::MissingTimestamp);
274 };
275
276 let Some(timestamp) = UnixTimestamp::from_datetime(end.into_inner()) else {
277 relay_log::debug!("event timestamp is not a valid unix timestamp");
278 return Err(ExtractMetricsError::InvalidTimestamp);
279 };
280
281 track_transaction_name_stats(event);
282 let tags = extract_universal_tags(event, self.config);
283 let light_tags = extract_light_transaction_tags(&tags);
284
285 let measurement_names: BTreeSet<_> = event
287 .measurements
288 .value()
289 .into_iter()
290 .flat_map(|measurements| measurements.keys())
291 .map(String::as_str)
292 .collect();
293 if let Some(measurements) = event.measurements.value() {
294 for (name, annotated) in measurements.iter() {
295 let Some(measurement) = annotated.value() else {
296 continue;
297 };
298
299 let Some(value) = measurement.value.value().copied() else {
300 continue;
301 };
302
303 let is_performance_score = name == "score.total"
306 || name
307 .strip_prefix("score.weight.")
308 .or_else(|| name.strip_prefix("score."))
309 .is_some_and(|suffix| measurement_names.contains(suffix));
310
311 let measurement_tags = TransactionMeasurementTags {
312 measurement_rating: get_measurement_rating(name, value.to_f64()),
313 universal_tags: if is_performance_score {
314 CommonTags(
315 tags.0
316 .iter()
317 .filter(|&(key, _)| PERFORMANCE_SCORE_TAGS.contains(key))
318 .map(|(key, value)| (key.clone(), value.clone()))
319 .collect::<BTreeMap<_, _>>(),
320 )
321 } else {
322 tags.clone()
323 },
324 score_profile_version: is_performance_score
325 .then(|| event.context::<PerformanceScoreContext>())
326 .and_then(|context| context?.score_profile_version.value().cloned()),
327 };
328
329 metrics.project_metrics.push(
330 TransactionMetric::Measurement {
331 name: name.to_string(),
332 value,
333 unit: measurement.unit.value().copied().unwrap_or_default(),
334 tags: measurement_tags,
335 }
336 .into_metric(timestamp),
337 );
338 }
339 }
340
341 if let Some(breakdowns) = event.breakdowns.value() {
343 for (breakdown, measurements) in breakdowns.iter() {
344 if let Some(measurements) = measurements.value() {
345 for (measurement_name, annotated) in measurements.iter() {
346 if measurement_name == "total.time" {
347 continue;
350 }
351
352 let Some(measurement) = annotated.value() else {
353 continue;
354 };
355
356 let Some(value) = measurement.value.value().copied() else {
357 continue;
358 };
359
360 metrics.project_metrics.push(
361 TransactionMetric::Breakdown {
362 name: format!("{breakdown}.{measurement_name}"),
363 value,
364 tags: tags.clone(),
365 }
366 .into_metric(timestamp),
367 );
368 }
369 }
370 }
371 }
372
373 metrics
375 .project_metrics
376 .push(TransactionMetric::Usage.into_metric(timestamp));
377
378 let duration = relay_common::time::chrono_to_positive_millis(end - start);
380 if let Some(duration) = FiniteF64::new(duration) {
381 metrics.project_metrics.push(
382 TransactionMetric::Duration {
383 unit: DurationUnit::MilliSecond,
384 value: duration,
385 tags: tags.clone(),
386 }
387 .into_metric(timestamp),
388 );
389
390 metrics.project_metrics.push(
392 TransactionMetric::DurationLight {
393 unit: DurationUnit::MilliSecond,
394 value: duration,
395 tags: light_tags,
396 }
397 .into_metric(timestamp),
398 );
399 } else {
400 relay_log::error!(
401 tags.field = "duration",
402 "non-finite float value in transaction metric extraction"
403 );
404 }
405
406 let root_counter_tags = {
407 let mut universal_tags = CommonTags(BTreeMap::default());
408 if let Some(transaction_from_dsc) = self.transaction_from_dsc {
409 universal_tags
410 .0
411 .insert(CommonTag::Transaction, transaction_from_dsc.to_owned());
412 }
413
414 TransactionCPRTags {
415 decision: self.sampling_decision.to_string(),
416 target_project_id: self.target_project_id,
417 universal_tags,
418 }
419 };
420 metrics.sampling_metrics.push(
422 TransactionMetric::CountPerRootProject {
423 tags: root_counter_tags,
424 }
425 .into_metric(timestamp),
426 );
427
428 if let Some(user) = event.user.value()
430 && let Some(value) = user.sentry_user.value()
431 {
432 metrics.project_metrics.push(
433 TransactionMetric::User {
434 value: value.clone(),
435 tags,
436 }
437 .into_metric(timestamp),
438 );
439 }
440
441 if let Some(generic_config) = self.generic_config {
444 generic::tmp_apply_tags(&mut metrics.project_metrics, event, generic_config.tags());
445 generic::tmp_apply_tags(&mut metrics.sampling_metrics, event, generic_config.tags());
446 }
447
448 Ok(metrics)
449 }
450}
451
452fn get_measurement_rating(name: &str, value: f64) -> Option<String> {
453 let rate_range = |meh_ceiling: f64, poor_ceiling: f64| {
454 debug_assert!(meh_ceiling < poor_ceiling);
455 Some(if value < meh_ceiling {
456 "good".to_owned()
457 } else if value < poor_ceiling {
458 "meh".to_owned()
459 } else {
460 "poor".to_owned()
461 })
462 };
463
464 match name {
465 "lcp" => rate_range(2500.0, 4000.0),
466 "fcp" => rate_range(1000.0, 3000.0),
467 "fid" => rate_range(100.0, 300.0),
468 "inp" => rate_range(200.0, 500.0),
469 "cls" => rate_range(0.1, 0.25),
470 _ => None,
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use relay_dynamic_config::{
477 AcceptTransactionNames, CombinedMetricExtractionConfig, MetricExtractionConfig, TagMapping,
478 };
479 use relay_event_normalization::{
480 BreakdownsConfig, CombinedMeasurementsConfig, EventValidationConfig, MeasurementsConfig,
481 NormalizationConfig, PerformanceScoreConfig, PerformanceScoreProfile,
482 PerformanceScoreWeightedComponent, normalize_event, set_default_transaction_source,
483 validate_event,
484 };
485 use relay_metrics::BucketValue;
486 use relay_protocol::{Annotated, RuleCondition};
487
488 use super::*;
489
490 #[test]
491 fn test_extract_transaction_metrics() {
492 let json = r#"
493 {
494 "type": "transaction",
495 "platform": "javascript",
496 "start_timestamp": "2021-04-26T07:59:01+0100",
497 "timestamp": "2021-04-26T08:00:00+0100",
498 "release": "1.2.3",
499 "dist": "foo ",
500 "environment": "fake_environment",
501 "transaction": "gEt /api/:version/users/",
502 "transaction_info": {"source": "custom"},
503 "user": {
504 "id": "user123",
505 "geo": {
506 "country_code": "US"
507 }
508 },
509 "tags": {
510 "fOO": "bar",
511 "bogus": "absolutely",
512 "device.class": "1"
513 },
514 "measurements": {
515 "foo": {"value": 420.69},
516 "lcp": {"value": 3000.0, "unit": "millisecond"}
517 },
518 "contexts": {
519 "trace": {
520 "trace_id": "ff62a8b040f340bda5d830223def1d81",
521 "span_id": "bd429c44b67a3eb4",
522 "op": "mYOp",
523 "status": "ok"
524 },
525 "browser": {
526 "name": "Chrome"
527 },
528 "os": {
529 "name": "Windows"
530 }
531 },
532 "request": {
533 "method": "post"
534 },
535 "spans": [
536 {
537 "description": "<OrganizationContext>",
538 "op": "react.mount",
539 "parent_span_id": "bd429c44b67a3eb4",
540 "span_id": "8f5a2b8768cafb4e",
541 "start_timestamp": 1597976300.0000000,
542 "timestamp": 1597976302.0000000,
543 "trace_id": "ff62a8b040f340bda5d830223def1d81"
544 }
545 ]
546 }
547 "#;
548
549 let mut event = Annotated::from_json(json).unwrap();
550
551 let breakdowns_config: BreakdownsConfig = serde_json::from_str(
552 r#"{
553 "span_ops": {
554 "type": "spanOperations",
555 "matches": ["react.mount"]
556 }
557 }"#,
558 )
559 .unwrap();
560
561 validate_event(&mut event, &EventValidationConfig::default()).unwrap();
562
563 normalize_event(
564 &mut event,
565 &NormalizationConfig {
566 breakdowns_config: Some(&breakdowns_config),
567 enrich_spans: false,
568 performance_score: Some(&PerformanceScoreConfig {
569 profiles: vec![PerformanceScoreProfile {
570 name: Some("".into()),
571 score_components: vec![PerformanceScoreWeightedComponent {
572 measurement: "lcp".into(),
573 weight: 0.5.try_into().unwrap(),
574 p10: 2.0.try_into().unwrap(),
575 p50: 3.0.try_into().unwrap(),
576 optional: false,
577 }],
578 condition: Some(RuleCondition::all()),
579 version: Some("alpha".into()),
580 }],
581 }),
582 ..Default::default()
583 },
584 );
585
586 let config: TransactionMetricsConfig = serde_json::from_str(
587 r#"{
588 "version": 1,
589 "extractCustomTags": ["fOO"]
590 }"#,
591 )
592 .unwrap();
593
594 let extractor = TransactionExtractor {
595 config: &config,
596 generic_config: None,
597 transaction_from_dsc: Some("test_transaction"),
598 sampling_decision: SamplingDecision::Keep,
599 target_project_id: ProjectId::new(4711),
600 };
601
602 let extracted = extractor.extract(event.value().unwrap()).unwrap();
603 insta::assert_debug_snapshot!(event.value().unwrap().spans, @r###"
604 [
605 Span {
606 timestamp: Timestamp(
607 2020-08-21T02:18:22Z,
608 ),
609 start_timestamp: Timestamp(
610 2020-08-21T02:18:20Z,
611 ),
612 exclusive_time: 2000.0,
613 op: "react.mount",
614 span_id: SpanId("8f5a2b8768cafb4e"),
615 parent_span_id: SpanId("bd429c44b67a3eb4"),
616 trace_id: TraceId("ff62a8b040f340bda5d830223def1d81"),
617 segment_id: ~,
618 is_segment: ~,
619 is_remote: ~,
620 status: ~,
621 description: "<OrganizationContext>",
622 tags: ~,
623 origin: ~,
624 profile_id: ~,
625 data: ~,
626 links: ~,
627 sentry_tags: ~,
628 received: ~,
629 measurements: ~,
630 platform: ~,
631 was_transaction: ~,
632 kind: ~,
633 performance_issues_spans: ~,
634 other: {},
635 },
636 ]
637 "###);
638
639 insta::assert_debug_snapshot!(extracted.project_metrics, @r###"
640 [
641 Bucket {
642 timestamp: UnixTimestamp(1619420400),
643 width: 0,
644 name: MetricName(
645 "d:transactions/measurements.foo@none",
646 ),
647 value: Distribution(
648 [
649 420.69,
650 ],
651 ),
652 tags: {
653 "browser.name": "Chrome",
654 "dist": "foo",
655 "environment": "fake_environment",
656 "fOO": "bar",
657 "geo.country_code": "US",
658 "http.method": "POST",
659 "os.name": "Windows",
660 "platform": "javascript",
661 "release": "1.2.3",
662 "transaction": "gEt /api/:version/users/",
663 "transaction.op": "mYOp",
664 "transaction.status": "ok",
665 "user.geo.subregion": "21",
666 },
667 metadata: BucketMetadata {
668 merges: 1,
669 received_at: Some(
670 UnixTimestamp(0),
671 ),
672 extracted_from_indexed: false,
673 },
674 },
675 Bucket {
676 timestamp: UnixTimestamp(1619420400),
677 width: 0,
678 name: MetricName(
679 "d:transactions/measurements.lcp@millisecond",
680 ),
681 value: Distribution(
682 [
683 3000.0,
684 ],
685 ),
686 tags: {
687 "browser.name": "Chrome",
688 "dist": "foo",
689 "environment": "fake_environment",
690 "fOO": "bar",
691 "geo.country_code": "US",
692 "http.method": "POST",
693 "measurement_rating": "meh",
694 "os.name": "Windows",
695 "platform": "javascript",
696 "release": "1.2.3",
697 "transaction": "gEt /api/:version/users/",
698 "transaction.op": "mYOp",
699 "transaction.status": "ok",
700 "user.geo.subregion": "21",
701 },
702 metadata: BucketMetadata {
703 merges: 1,
704 received_at: Some(
705 UnixTimestamp(0),
706 ),
707 extracted_from_indexed: false,
708 },
709 },
710 Bucket {
711 timestamp: UnixTimestamp(1619420400),
712 width: 0,
713 name: MetricName(
714 "d:transactions/measurements.score.lcp@ratio",
715 ),
716 value: Distribution(
717 [
718 0.0,
719 ],
720 ),
721 tags: {
722 "browser.name": "Chrome",
723 "environment": "fake_environment",
724 "geo.country_code": "US",
725 "release": "1.2.3",
726 "sentry.score_profile_version": "alpha",
727 "transaction": "gEt /api/:version/users/",
728 "transaction.op": "mYOp",
729 "user.geo.subregion": "21",
730 },
731 metadata: BucketMetadata {
732 merges: 1,
733 received_at: Some(
734 UnixTimestamp(0),
735 ),
736 extracted_from_indexed: false,
737 },
738 },
739 Bucket {
740 timestamp: UnixTimestamp(1619420400),
741 width: 0,
742 name: MetricName(
743 "d:transactions/measurements.score.ratio.lcp@ratio",
744 ),
745 value: Distribution(
746 [
747 0.0,
748 ],
749 ),
750 tags: {
751 "browser.name": "Chrome",
752 "dist": "foo",
753 "environment": "fake_environment",
754 "fOO": "bar",
755 "geo.country_code": "US",
756 "http.method": "POST",
757 "os.name": "Windows",
758 "platform": "javascript",
759 "release": "1.2.3",
760 "transaction": "gEt /api/:version/users/",
761 "transaction.op": "mYOp",
762 "transaction.status": "ok",
763 "user.geo.subregion": "21",
764 },
765 metadata: BucketMetadata {
766 merges: 1,
767 received_at: Some(
768 UnixTimestamp(0),
769 ),
770 extracted_from_indexed: false,
771 },
772 },
773 Bucket {
774 timestamp: UnixTimestamp(1619420400),
775 width: 0,
776 name: MetricName(
777 "d:transactions/measurements.score.total@ratio",
778 ),
779 value: Distribution(
780 [
781 0.0,
782 ],
783 ),
784 tags: {
785 "browser.name": "Chrome",
786 "environment": "fake_environment",
787 "geo.country_code": "US",
788 "release": "1.2.3",
789 "sentry.score_profile_version": "alpha",
790 "transaction": "gEt /api/:version/users/",
791 "transaction.op": "mYOp",
792 "user.geo.subregion": "21",
793 },
794 metadata: BucketMetadata {
795 merges: 1,
796 received_at: Some(
797 UnixTimestamp(0),
798 ),
799 extracted_from_indexed: false,
800 },
801 },
802 Bucket {
803 timestamp: UnixTimestamp(1619420400),
804 width: 0,
805 name: MetricName(
806 "d:transactions/measurements.score.weight.lcp@ratio",
807 ),
808 value: Distribution(
809 [
810 1.0,
811 ],
812 ),
813 tags: {
814 "browser.name": "Chrome",
815 "environment": "fake_environment",
816 "geo.country_code": "US",
817 "release": "1.2.3",
818 "sentry.score_profile_version": "alpha",
819 "transaction": "gEt /api/:version/users/",
820 "transaction.op": "mYOp",
821 "user.geo.subregion": "21",
822 },
823 metadata: BucketMetadata {
824 merges: 1,
825 received_at: Some(
826 UnixTimestamp(0),
827 ),
828 extracted_from_indexed: false,
829 },
830 },
831 Bucket {
832 timestamp: UnixTimestamp(1619420400),
833 width: 0,
834 name: MetricName(
835 "d:transactions/breakdowns.span_ops.ops.react.mount@millisecond",
836 ),
837 value: Distribution(
838 [
839 2000.0,
840 ],
841 ),
842 tags: {
843 "browser.name": "Chrome",
844 "dist": "foo",
845 "environment": "fake_environment",
846 "fOO": "bar",
847 "geo.country_code": "US",
848 "http.method": "POST",
849 "os.name": "Windows",
850 "platform": "javascript",
851 "release": "1.2.3",
852 "transaction": "gEt /api/:version/users/",
853 "transaction.op": "mYOp",
854 "transaction.status": "ok",
855 "user.geo.subregion": "21",
856 },
857 metadata: BucketMetadata {
858 merges: 1,
859 received_at: Some(
860 UnixTimestamp(0),
861 ),
862 extracted_from_indexed: false,
863 },
864 },
865 Bucket {
866 timestamp: UnixTimestamp(1619420400),
867 width: 0,
868 name: MetricName(
869 "c:transactions/usage@none",
870 ),
871 value: Counter(
872 1.0,
873 ),
874 tags: {},
875 metadata: BucketMetadata {
876 merges: 1,
877 received_at: Some(
878 UnixTimestamp(0),
879 ),
880 extracted_from_indexed: false,
881 },
882 },
883 Bucket {
884 timestamp: UnixTimestamp(1619420400),
885 width: 0,
886 name: MetricName(
887 "d:transactions/duration@millisecond",
888 ),
889 value: Distribution(
890 [
891 59000.0,
892 ],
893 ),
894 tags: {
895 "browser.name": "Chrome",
896 "dist": "foo",
897 "environment": "fake_environment",
898 "fOO": "bar",
899 "geo.country_code": "US",
900 "http.method": "POST",
901 "os.name": "Windows",
902 "platform": "javascript",
903 "release": "1.2.3",
904 "transaction": "gEt /api/:version/users/",
905 "transaction.op": "mYOp",
906 "transaction.status": "ok",
907 "user.geo.subregion": "21",
908 },
909 metadata: BucketMetadata {
910 merges: 1,
911 received_at: Some(
912 UnixTimestamp(0),
913 ),
914 extracted_from_indexed: false,
915 },
916 },
917 Bucket {
918 timestamp: UnixTimestamp(1619420400),
919 width: 0,
920 name: MetricName(
921 "d:transactions/duration_light@millisecond",
922 ),
923 value: Distribution(
924 [
925 59000.0,
926 ],
927 ),
928 tags: {
929 "transaction": "gEt /api/:version/users/",
930 "transaction.op": "mYOp",
931 },
932 metadata: BucketMetadata {
933 merges: 1,
934 received_at: Some(
935 UnixTimestamp(0),
936 ),
937 extracted_from_indexed: false,
938 },
939 },
940 Bucket {
941 timestamp: UnixTimestamp(1619420400),
942 width: 0,
943 name: MetricName(
944 "s:transactions/user@none",
945 ),
946 value: Set(
947 {
948 933084975,
949 },
950 ),
951 tags: {
952 "browser.name": "Chrome",
953 "dist": "foo",
954 "environment": "fake_environment",
955 "fOO": "bar",
956 "geo.country_code": "US",
957 "http.method": "POST",
958 "os.name": "Windows",
959 "platform": "javascript",
960 "release": "1.2.3",
961 "transaction": "gEt /api/:version/users/",
962 "transaction.op": "mYOp",
963 "transaction.status": "ok",
964 "user.geo.subregion": "21",
965 },
966 metadata: BucketMetadata {
967 merges: 1,
968 received_at: Some(
969 UnixTimestamp(0),
970 ),
971 extracted_from_indexed: false,
972 },
973 },
974 ]
975 "###);
976 }
977
978 #[test]
979 fn test_metric_measurement_units() {
980 let json = r#"
981 {
982 "type": "transaction",
983 "timestamp": "2021-04-26T08:00:00+0100",
984 "start_timestamp": "2021-04-26T07:59:01+0100",
985 "measurements": {
986 "fcp": {"value": 1.1},
987 "stall_count": {"value": 3.3},
988 "foo": {"value": 8.8}
989 },
990 "contexts": {
991 "trace": {
992 "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
993 "span_id": "fa90fdead5f74053"
994 }
995 }
996 }
997 "#;
998
999 let mut event = Annotated::from_json(json).unwrap();
1001 normalize_event(&mut event, &NormalizationConfig::default());
1002
1003 let config = TransactionMetricsConfig::default();
1004 let extractor = TransactionExtractor {
1005 config: &config,
1006 generic_config: None,
1007 transaction_from_dsc: Some("test_transaction"),
1008 sampling_decision: SamplingDecision::Keep,
1009 target_project_id: ProjectId::new(4711),
1010 };
1011
1012 let extracted = extractor.extract(event.value().unwrap()).unwrap();
1013 insta::assert_debug_snapshot!(extracted.project_metrics, @r###"
1014 [
1015 Bucket {
1016 timestamp: UnixTimestamp(1619420400),
1017 width: 0,
1018 name: MetricName(
1019 "d:transactions/measurements.fcp@millisecond",
1020 ),
1021 value: Distribution(
1022 [
1023 1.1,
1024 ],
1025 ),
1026 tags: {
1027 "measurement_rating": "good",
1028 "platform": "other",
1029 "transaction": "<unlabeled transaction>",
1030 "transaction.status": "unknown",
1031 },
1032 metadata: BucketMetadata {
1033 merges: 1,
1034 received_at: Some(
1035 UnixTimestamp(0),
1036 ),
1037 extracted_from_indexed: false,
1038 },
1039 },
1040 Bucket {
1041 timestamp: UnixTimestamp(1619420400),
1042 width: 0,
1043 name: MetricName(
1044 "d:transactions/measurements.foo@none",
1045 ),
1046 value: Distribution(
1047 [
1048 8.8,
1049 ],
1050 ),
1051 tags: {
1052 "platform": "other",
1053 "transaction": "<unlabeled transaction>",
1054 "transaction.status": "unknown",
1055 },
1056 metadata: BucketMetadata {
1057 merges: 1,
1058 received_at: Some(
1059 UnixTimestamp(0),
1060 ),
1061 extracted_from_indexed: false,
1062 },
1063 },
1064 Bucket {
1065 timestamp: UnixTimestamp(1619420400),
1066 width: 0,
1067 name: MetricName(
1068 "d:transactions/measurements.stall_count@none",
1069 ),
1070 value: Distribution(
1071 [
1072 3.3,
1073 ],
1074 ),
1075 tags: {
1076 "platform": "other",
1077 "transaction": "<unlabeled transaction>",
1078 "transaction.status": "unknown",
1079 },
1080 metadata: BucketMetadata {
1081 merges: 1,
1082 received_at: Some(
1083 UnixTimestamp(0),
1084 ),
1085 extracted_from_indexed: false,
1086 },
1087 },
1088 Bucket {
1089 timestamp: UnixTimestamp(1619420400),
1090 width: 0,
1091 name: MetricName(
1092 "c:transactions/usage@none",
1093 ),
1094 value: Counter(
1095 1.0,
1096 ),
1097 tags: {},
1098 metadata: BucketMetadata {
1099 merges: 1,
1100 received_at: Some(
1101 UnixTimestamp(0),
1102 ),
1103 extracted_from_indexed: false,
1104 },
1105 },
1106 Bucket {
1107 timestamp: UnixTimestamp(1619420400),
1108 width: 0,
1109 name: MetricName(
1110 "d:transactions/duration@millisecond",
1111 ),
1112 value: Distribution(
1113 [
1114 59000.0,
1115 ],
1116 ),
1117 tags: {
1118 "platform": "other",
1119 "transaction": "<unlabeled transaction>",
1120 "transaction.status": "unknown",
1121 },
1122 metadata: BucketMetadata {
1123 merges: 1,
1124 received_at: Some(
1125 UnixTimestamp(0),
1126 ),
1127 extracted_from_indexed: false,
1128 },
1129 },
1130 Bucket {
1131 timestamp: UnixTimestamp(1619420400),
1132 width: 0,
1133 name: MetricName(
1134 "d:transactions/duration_light@millisecond",
1135 ),
1136 value: Distribution(
1137 [
1138 59000.0,
1139 ],
1140 ),
1141 tags: {
1142 "transaction": "<unlabeled transaction>",
1143 },
1144 metadata: BucketMetadata {
1145 merges: 1,
1146 received_at: Some(
1147 UnixTimestamp(0),
1148 ),
1149 extracted_from_indexed: false,
1150 },
1151 },
1152 ]
1153 "###);
1154 }
1155
1156 #[test]
1157 fn test_metric_measurement_unit_overrides() {
1158 let json = r#"{
1159 "type": "transaction",
1160 "timestamp": "2021-04-26T08:00:00+0100",
1161 "start_timestamp": "2021-04-26T07:59:01+0100",
1162 "measurements": {
1163 "fcp": {"value": 1.1, "unit": "second"},
1164 "lcp": {"value": 2.2, "unit": "none"}
1165 },
1166 "contexts": {
1167 "trace": {
1168 "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
1169 "span_id": "fa90fdead5f74053"
1170 }
1171 }
1172 }"#;
1173
1174 let mut event = Annotated::from_json(json).unwrap();
1176 normalize_event(&mut event, &NormalizationConfig::default());
1177
1178 let config: TransactionMetricsConfig = TransactionMetricsConfig::default();
1179 let extractor = TransactionExtractor {
1180 config: &config,
1181 generic_config: None,
1182 transaction_from_dsc: Some("test_transaction"),
1183 sampling_decision: SamplingDecision::Keep,
1184 target_project_id: ProjectId::new(4711),
1185 };
1186
1187 let extracted = extractor.extract(event.value().unwrap()).unwrap();
1188 insta::assert_debug_snapshot!(extracted.project_metrics, @r###"
1189 [
1190 Bucket {
1191 timestamp: UnixTimestamp(1619420400),
1192 width: 0,
1193 name: MetricName(
1194 "d:transactions/measurements.fcp@second",
1195 ),
1196 value: Distribution(
1197 [
1198 1.1,
1199 ],
1200 ),
1201 tags: {
1202 "measurement_rating": "good",
1203 "platform": "other",
1204 "transaction": "<unlabeled transaction>",
1205 "transaction.status": "unknown",
1206 },
1207 metadata: BucketMetadata {
1208 merges: 1,
1209 received_at: Some(
1210 UnixTimestamp(0),
1211 ),
1212 extracted_from_indexed: false,
1213 },
1214 },
1215 Bucket {
1216 timestamp: UnixTimestamp(1619420400),
1217 width: 0,
1218 name: MetricName(
1219 "d:transactions/measurements.lcp@none",
1220 ),
1221 value: Distribution(
1222 [
1223 2.2,
1224 ],
1225 ),
1226 tags: {
1227 "measurement_rating": "good",
1228 "platform": "other",
1229 "transaction": "<unlabeled transaction>",
1230 "transaction.status": "unknown",
1231 },
1232 metadata: BucketMetadata {
1233 merges: 1,
1234 received_at: Some(
1235 UnixTimestamp(0),
1236 ),
1237 extracted_from_indexed: false,
1238 },
1239 },
1240 Bucket {
1241 timestamp: UnixTimestamp(1619420400),
1242 width: 0,
1243 name: MetricName(
1244 "c:transactions/usage@none",
1245 ),
1246 value: Counter(
1247 1.0,
1248 ),
1249 tags: {},
1250 metadata: BucketMetadata {
1251 merges: 1,
1252 received_at: Some(
1253 UnixTimestamp(0),
1254 ),
1255 extracted_from_indexed: false,
1256 },
1257 },
1258 Bucket {
1259 timestamp: UnixTimestamp(1619420400),
1260 width: 0,
1261 name: MetricName(
1262 "d:transactions/duration@millisecond",
1263 ),
1264 value: Distribution(
1265 [
1266 59000.0,
1267 ],
1268 ),
1269 tags: {
1270 "platform": "other",
1271 "transaction": "<unlabeled transaction>",
1272 "transaction.status": "unknown",
1273 },
1274 metadata: BucketMetadata {
1275 merges: 1,
1276 received_at: Some(
1277 UnixTimestamp(0),
1278 ),
1279 extracted_from_indexed: false,
1280 },
1281 },
1282 Bucket {
1283 timestamp: UnixTimestamp(1619420400),
1284 width: 0,
1285 name: MetricName(
1286 "d:transactions/duration_light@millisecond",
1287 ),
1288 value: Distribution(
1289 [
1290 59000.0,
1291 ],
1292 ),
1293 tags: {
1294 "transaction": "<unlabeled transaction>",
1295 },
1296 metadata: BucketMetadata {
1297 merges: 1,
1298 received_at: Some(
1299 UnixTimestamp(0),
1300 ),
1301 extracted_from_indexed: false,
1302 },
1303 },
1304 ]
1305 "###);
1306 }
1307
1308 #[test]
1309 fn test_transaction_duration() {
1310 let json = r#"
1311 {
1312 "type": "transaction",
1313 "platform": "bogus",
1314 "timestamp": "2021-04-26T08:00:00+0100",
1315 "start_timestamp": "2021-04-26T07:59:01+0100",
1316 "release": "1.2.3",
1317 "environment": "fake_environment",
1318 "transaction": "mytransaction",
1319 "contexts": {
1320 "trace": {
1321 "status": "ok"
1322 }
1323 }
1324 }
1325 "#;
1326
1327 let event = Annotated::from_json(json).unwrap();
1328
1329 let config = TransactionMetricsConfig::default();
1330 let extractor = TransactionExtractor {
1331 config: &config,
1332 generic_config: None,
1333 transaction_from_dsc: Some("test_transaction"),
1334 sampling_decision: SamplingDecision::Keep,
1335 target_project_id: ProjectId::new(4711),
1336 };
1337
1338 let extracted = extractor.extract(event.value().unwrap()).unwrap();
1339 let duration_metric = extracted
1340 .project_metrics
1341 .iter()
1342 .find(|m| &*m.name == "d:transactions/duration@millisecond")
1343 .unwrap();
1344
1345 assert_eq!(
1346 &*duration_metric.name,
1347 "d:transactions/duration@millisecond"
1348 );
1349 assert_eq!(
1350 duration_metric.value,
1351 BucketValue::distribution(59000.into())
1352 );
1353
1354 assert_eq!(duration_metric.tags.len(), 4);
1355 assert_eq!(duration_metric.tags["release"], "1.2.3");
1356 assert_eq!(duration_metric.tags["transaction.status"], "ok");
1357 assert_eq!(duration_metric.tags["environment"], "fake_environment");
1358 assert_eq!(duration_metric.tags["platform"], "other");
1359 }
1360
1361 #[test]
1362 fn test_custom_measurements() {
1363 let json = r#"
1364 {
1365 "type": "transaction",
1366 "transaction": "foo",
1367 "start_timestamp": "2021-04-26T08:00:00+0100",
1368 "timestamp": "2021-04-26T08:00:02+0100",
1369 "measurements": {
1370 "a_custom1": {"value": 41},
1371 "fcp": {"value": 0.123, "unit": "millisecond"},
1372 "g_custom2": {"value": 42, "unit": "second"},
1373 "h_custom3": {"value": 43}
1374 },
1375 "contexts": {
1376 "trace": {
1377 "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
1378 "span_id": "fa90fdead5f74053"
1379 }}
1380 }
1381 "#;
1382
1383 let mut event = Annotated::from_json(json).unwrap();
1384
1385 let measurements_config: MeasurementsConfig = serde_json::from_value(serde_json::json!(
1387 {
1388 "builtinMeasurements": [{"name": "fcp", "unit": "millisecond"}],
1389 "maxCustomMeasurements": 2
1390 }
1391 ))
1392 .unwrap();
1393
1394 let config = CombinedMeasurementsConfig::new(Some(&measurements_config), None);
1395
1396 normalize_event(
1397 &mut event,
1398 &NormalizationConfig {
1399 measurements: Some(config),
1400 ..Default::default()
1401 },
1402 );
1403
1404 let config = TransactionMetricsConfig::default();
1405 let extractor = TransactionExtractor {
1406 config: &config,
1407 generic_config: None,
1408 transaction_from_dsc: Some("test_transaction"),
1409 sampling_decision: SamplingDecision::Keep,
1410 target_project_id: ProjectId::new(4711),
1411 };
1412
1413 let extracted = extractor.extract(event.value().unwrap()).unwrap();
1414 insta::assert_debug_snapshot!(extracted.project_metrics, @r###"
1415 [
1416 Bucket {
1417 timestamp: UnixTimestamp(1619420402),
1418 width: 0,
1419 name: MetricName(
1420 "d:transactions/measurements.a_custom1@none",
1421 ),
1422 value: Distribution(
1423 [
1424 41.0,
1425 ],
1426 ),
1427 tags: {
1428 "platform": "other",
1429 "transaction": "foo",
1430 "transaction.status": "unknown",
1431 },
1432 metadata: BucketMetadata {
1433 merges: 1,
1434 received_at: Some(
1435 UnixTimestamp(0),
1436 ),
1437 extracted_from_indexed: false,
1438 },
1439 },
1440 Bucket {
1441 timestamp: UnixTimestamp(1619420402),
1442 width: 0,
1443 name: MetricName(
1444 "d:transactions/measurements.fcp@millisecond",
1445 ),
1446 value: Distribution(
1447 [
1448 0.123,
1449 ],
1450 ),
1451 tags: {
1452 "measurement_rating": "good",
1453 "platform": "other",
1454 "transaction": "foo",
1455 "transaction.status": "unknown",
1456 },
1457 metadata: BucketMetadata {
1458 merges: 1,
1459 received_at: Some(
1460 UnixTimestamp(0),
1461 ),
1462 extracted_from_indexed: false,
1463 },
1464 },
1465 Bucket {
1466 timestamp: UnixTimestamp(1619420402),
1467 width: 0,
1468 name: MetricName(
1469 "d:transactions/measurements.g_custom2@second",
1470 ),
1471 value: Distribution(
1472 [
1473 42.0,
1474 ],
1475 ),
1476 tags: {
1477 "platform": "other",
1478 "transaction": "foo",
1479 "transaction.status": "unknown",
1480 },
1481 metadata: BucketMetadata {
1482 merges: 1,
1483 received_at: Some(
1484 UnixTimestamp(0),
1485 ),
1486 extracted_from_indexed: false,
1487 },
1488 },
1489 Bucket {
1490 timestamp: UnixTimestamp(1619420402),
1491 width: 0,
1492 name: MetricName(
1493 "c:transactions/usage@none",
1494 ),
1495 value: Counter(
1496 1.0,
1497 ),
1498 tags: {},
1499 metadata: BucketMetadata {
1500 merges: 1,
1501 received_at: Some(
1502 UnixTimestamp(0),
1503 ),
1504 extracted_from_indexed: false,
1505 },
1506 },
1507 Bucket {
1508 timestamp: UnixTimestamp(1619420402),
1509 width: 0,
1510 name: MetricName(
1511 "d:transactions/duration@millisecond",
1512 ),
1513 value: Distribution(
1514 [
1515 2000.0,
1516 ],
1517 ),
1518 tags: {
1519 "platform": "other",
1520 "transaction": "foo",
1521 "transaction.status": "unknown",
1522 },
1523 metadata: BucketMetadata {
1524 merges: 1,
1525 received_at: Some(
1526 UnixTimestamp(0),
1527 ),
1528 extracted_from_indexed: false,
1529 },
1530 },
1531 Bucket {
1532 timestamp: UnixTimestamp(1619420402),
1533 width: 0,
1534 name: MetricName(
1535 "d:transactions/duration_light@millisecond",
1536 ),
1537 value: Distribution(
1538 [
1539 2000.0,
1540 ],
1541 ),
1542 tags: {
1543 "transaction": "foo",
1544 },
1545 metadata: BucketMetadata {
1546 merges: 1,
1547 received_at: Some(
1548 UnixTimestamp(0),
1549 ),
1550 extracted_from_indexed: false,
1551 },
1552 },
1553 ]
1554 "###);
1555 }
1556
1557 #[test]
1558 fn test_unknown_transaction_status_no_trace_context() {
1559 let json = r#"
1560 {
1561 "type": "transaction",
1562 "timestamp": "2021-04-26T08:00:00+0100",
1563 "start_timestamp": "2021-04-26T07:59:01+0100"
1564 }
1565 "#;
1566
1567 let event = Annotated::from_json(json).unwrap();
1568
1569 let config = TransactionMetricsConfig::default();
1570 let extractor = TransactionExtractor {
1571 config: &config,
1572 generic_config: None,
1573 transaction_from_dsc: Some("test_transaction"),
1574 sampling_decision: SamplingDecision::Keep,
1575 target_project_id: ProjectId::new(4711),
1576 };
1577
1578 let extracted = extractor.extract(event.value().unwrap()).unwrap();
1579 let duration_metric = extracted
1580 .project_metrics
1581 .iter()
1582 .find(|m| &*m.name == "d:transactions/duration@millisecond")
1583 .unwrap();
1584
1585 assert_eq!(
1586 duration_metric.tags,
1587 BTreeMap::from([("platform".to_owned(), "other".to_owned())])
1588 );
1589 }
1590
1591 #[test]
1592 fn test_unknown_transaction_status() {
1593 let json = r#"
1594 {
1595 "type": "transaction",
1596 "timestamp": "2021-04-26T08:00:00+0100",
1597 "start_timestamp": "2021-04-26T07:59:01+0100",
1598 "contexts": {
1599 "trace": {
1600 "status": "ok"
1601 }
1602 }
1603 }
1604 "#;
1605
1606 let event = Annotated::from_json(json).unwrap();
1607
1608 let config = TransactionMetricsConfig::default();
1609 let extractor = TransactionExtractor {
1610 config: &config,
1611 generic_config: None,
1612 transaction_from_dsc: Some("test_transaction"),
1613 sampling_decision: SamplingDecision::Keep,
1614 target_project_id: ProjectId::new(4711),
1615 };
1616
1617 let extracted = extractor.extract(event.value().unwrap()).unwrap();
1618 let duration_metric = extracted
1619 .project_metrics
1620 .iter()
1621 .find(|m| &*m.name == "d:transactions/duration@millisecond")
1622 .unwrap();
1623
1624 assert_eq!(
1625 duration_metric.tags,
1626 BTreeMap::from([
1627 ("transaction.status".to_owned(), "ok".to_owned()),
1628 ("platform".to_owned(), "other".to_owned())
1629 ])
1630 );
1631 }
1632
1633 #[test]
1634 fn test_span_tags() {
1635 let json = r#"
1637 {
1638 "type": "transaction",
1639 "timestamp": "2021-04-26T08:00:00+0100",
1640 "start_timestamp": "2021-04-26T07:59:01+0100",
1641 "contexts": {
1642 "trace": {
1643 "status": "ok"
1644 }
1645 },
1646 "spans": [
1647 {
1648 "description": "<OrganizationContext>",
1649 "op": "react.mount",
1650 "parent_span_id": "8f5a2b8768cafb4e",
1651 "span_id": "bd429c44b67a3eb4",
1652 "start_timestamp": 1597976300.0000000,
1653 "timestamp": 1597976302.0000000,
1654 "trace_id": "ff62a8b040f340bda5d830223def1d81"
1655 },
1656 {
1657 "description": "POST http://sth.subdomain.domain.tld:targetport/api/hi",
1658 "op": "http.client",
1659 "parent_span_id": "8f5a2b8768cafb4e",
1660 "span_id": "bd2eb23da2beb459",
1661 "start_timestamp": 1597976300.0000000,
1662 "timestamp": 1597976302.0000000,
1663 "trace_id": "ff62a8b040f340bda5d830223def1d81",
1664 "status": "ok",
1665 "data": {
1666 "http.method": "POST",
1667 "http.response.status_code": "200"
1668 }
1669 }
1670 ]
1671 }
1672 "#;
1673
1674 let event = Annotated::from_json(json).unwrap();
1675
1676 let config = TransactionMetricsConfig::default();
1677 let extractor = TransactionExtractor {
1678 config: &config,
1679 generic_config: None,
1680 transaction_from_dsc: Some("test_transaction"),
1681 sampling_decision: SamplingDecision::Keep,
1682 target_project_id: ProjectId::new(4711),
1683 };
1684
1685 let extracted = extractor.extract(event.value().unwrap()).unwrap();
1686 insta::assert_debug_snapshot!(extracted.project_metrics, @r###"
1687 [
1688 Bucket {
1689 timestamp: UnixTimestamp(1619420400),
1690 width: 0,
1691 name: MetricName(
1692 "c:transactions/usage@none",
1693 ),
1694 value: Counter(
1695 1.0,
1696 ),
1697 tags: {},
1698 metadata: BucketMetadata {
1699 merges: 1,
1700 received_at: Some(
1701 UnixTimestamp(0),
1702 ),
1703 extracted_from_indexed: false,
1704 },
1705 },
1706 Bucket {
1707 timestamp: UnixTimestamp(1619420400),
1708 width: 0,
1709 name: MetricName(
1710 "d:transactions/duration@millisecond",
1711 ),
1712 value: Distribution(
1713 [
1714 59000.0,
1715 ],
1716 ),
1717 tags: {
1718 "http.status_code": "200",
1719 "platform": "other",
1720 "transaction.status": "ok",
1721 },
1722 metadata: BucketMetadata {
1723 merges: 1,
1724 received_at: Some(
1725 UnixTimestamp(0),
1726 ),
1727 extracted_from_indexed: false,
1728 },
1729 },
1730 Bucket {
1731 timestamp: UnixTimestamp(1619420400),
1732 width: 0,
1733 name: MetricName(
1734 "d:transactions/duration_light@millisecond",
1735 ),
1736 value: Distribution(
1737 [
1738 59000.0,
1739 ],
1740 ),
1741 tags: {},
1742 metadata: BucketMetadata {
1743 merges: 1,
1744 received_at: Some(
1745 UnixTimestamp(0),
1746 ),
1747 extracted_from_indexed: false,
1748 },
1749 },
1750 ]
1751 "###);
1752 }
1753
1754 #[test]
1755 fn test_device_class_mobile() {
1756 let json = r#"
1757 {
1758 "type": "transaction",
1759 "timestamp": "2021-04-26T08:00:00+0100",
1760 "start_timestamp": "2021-04-26T07:59:01+0100",
1761 "contexts": {
1762 "trace": {
1763 "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
1764 "span_id": "fa90fdead5f74053"
1765 }
1766 },
1767 "measurements": {
1768 "frames_frozen": {
1769 "value": 3
1770 }
1771 },
1772 "tags": {
1773 "device.class": "2"
1774 },
1775 "sdk": {
1776 "name": "sentry.cocoa"
1777 }
1778 }
1779 "#;
1780 let event = Annotated::from_json(json).unwrap();
1781
1782 let config = TransactionMetricsConfig::default();
1783 let extractor = TransactionExtractor {
1784 config: &config,
1785 generic_config: None,
1786 transaction_from_dsc: Some("test_transaction"),
1787 sampling_decision: SamplingDecision::Keep,
1788 target_project_id: ProjectId::new(4711),
1789 };
1790
1791 let extracted = extractor.extract(event.value().unwrap()).unwrap();
1792 let buckets_by_name = extracted
1793 .project_metrics
1794 .into_iter()
1795 .map(|Bucket { name, tags, .. }| (name, tags))
1796 .collect::<BTreeMap<_, _>>();
1797 assert_eq!(
1798 buckets_by_name["d:transactions/measurements.frames_frozen@none"]["device.class"],
1799 "2"
1800 );
1801 assert_eq!(
1802 buckets_by_name["d:transactions/duration@millisecond"]["device.class"],
1803 "2"
1804 );
1805 }
1806
1807 fn extract_transaction_name(json: &str) -> Option<String> {
1809 let mut event = Annotated::<Event>::from_json(json).unwrap();
1810
1811 set_default_transaction_source(event.value_mut().as_mut().unwrap());
1814
1815 let config = TransactionMetricsConfig::default();
1816 let extractor = TransactionExtractor {
1817 config: &config,
1818 generic_config: None,
1819 transaction_from_dsc: Some("test_transaction"),
1820 sampling_decision: SamplingDecision::Keep,
1821 target_project_id: ProjectId::new(4711),
1822 };
1823
1824 let extracted = extractor.extract(event.value().unwrap()).unwrap();
1825 let duration_metric = extracted
1826 .project_metrics
1827 .iter()
1828 .find(|m| &*m.name == "d:transactions/duration@millisecond")
1829 .unwrap();
1830
1831 duration_metric.tags.get("transaction").cloned()
1832 }
1833
1834 #[test]
1835 fn test_root_counter_keep() {
1836 let json = r#"
1837 {
1838 "type": "transaction",
1839 "timestamp": "2021-04-26T08:00:00+0100",
1840 "start_timestamp": "2021-04-26T07:59:01+0100",
1841 "transaction": "ignored",
1842 "contexts": {
1843 "trace": {
1844 "status": "ok"
1845 }
1846 }
1847 }
1848 "#;
1849
1850 let event = Annotated::from_json(json).unwrap();
1851
1852 let config = TransactionMetricsConfig::default();
1853 let extractor = TransactionExtractor {
1854 config: &config,
1855 generic_config: None,
1856 transaction_from_dsc: Some("root_transaction"),
1857 sampling_decision: SamplingDecision::Keep,
1858 target_project_id: ProjectId::new(4711),
1859 };
1860
1861 let extracted = extractor.extract(event.value().unwrap()).unwrap();
1862 insta::assert_debug_snapshot!(extracted.sampling_metrics, @r###"
1863 [
1864 Bucket {
1865 timestamp: UnixTimestamp(1619420400),
1866 width: 0,
1867 name: MetricName(
1868 "c:transactions/count_per_root_project@none",
1869 ),
1870 value: Counter(
1871 1.0,
1872 ),
1873 tags: {
1874 "decision": "keep",
1875 "target_project_id": "4711",
1876 "transaction": "root_transaction",
1877 },
1878 metadata: BucketMetadata {
1879 merges: 1,
1880 received_at: Some(
1881 UnixTimestamp(0),
1882 ),
1883 extracted_from_indexed: false,
1884 },
1885 },
1886 ]
1887 "###);
1888 }
1889
1890 #[test]
1891 fn test_legacy_js_looks_like_url() {
1892 let json = r#"
1893 {
1894 "type": "transaction",
1895 "transaction": "foo/",
1896 "timestamp": "2021-04-26T08:00:00+0100",
1897 "start_timestamp": "2021-04-26T07:59:01+0100",
1898 "contexts": {"trace": {}},
1899 "sdk": {"name": "sentry.javascript.browser"}
1900 }
1901 "#;
1902
1903 let name = extract_transaction_name(json);
1904 assert!(name.is_none());
1905 }
1906
1907 #[test]
1908 fn test_legacy_js_does_not_look_like_url() {
1909 let json = r#"
1910 {
1911 "type": "transaction",
1912 "transaction": "foo",
1913 "timestamp": "2021-04-26T08:00:00+0100",
1914 "start_timestamp": "2021-04-26T07:59:01+0100",
1915 "contexts": {"trace": {}},
1916 "sdk": {"name": "sentry.javascript.browser"}
1917 }
1918 "#;
1919
1920 let name = extract_transaction_name(json);
1921 assert_eq!(name.as_deref(), Some("foo"));
1922 }
1923
1924 #[test]
1925 fn test_js_url_strict() {
1926 let json = r#"
1927 {
1928 "type": "transaction",
1929 "transaction": "foo",
1930 "timestamp": "2021-04-26T08:00:00+0100",
1931 "start_timestamp": "2021-04-26T07:59:01+0100",
1932 "contexts": {"trace": {}},
1933 "sdk": {"name": "sentry.javascript.browser"},
1934 "transaction_info": {"source": "url"}
1935 }
1936 "#;
1937
1938 let name = extract_transaction_name(json);
1939 assert_eq!(name, Some("<< unparameterized >>".to_owned()));
1940 }
1941
1942 #[test]
1943 fn test_python_404() {
1944 let json = r#"
1945 {
1946 "type": "transaction",
1947 "transaction": "foo/",
1948 "timestamp": "2021-04-26T08:00:00+0100",
1949 "start_timestamp": "2021-04-26T07:59:01+0100",
1950 "contexts": {"trace": {}},
1951 "sdk": {"name": "sentry.python", "integrations":["django"]},
1952 "tags": {"http.status_code": "404"}
1953 }
1954 "#;
1955
1956 let name = extract_transaction_name(json);
1957 assert!(name.is_none());
1958 }
1959
1960 #[test]
1961 fn test_python_200() {
1962 let json = r#"
1963 {
1964 "type": "transaction",
1965 "transaction": "foo/",
1966 "timestamp": "2021-04-26T08:00:00+0100",
1967 "start_timestamp": "2021-04-26T07:59:01+0100",
1968 "contexts": {"trace": {}},
1969 "sdk": {"name": "sentry.python", "integrations":["django"]},
1970 "tags": {"http.status_code": "200"}
1971 }
1972 "#;
1973
1974 let name = extract_transaction_name(json);
1975 assert_eq!(name, Some("foo/".to_owned()));
1976 }
1977
1978 #[test]
1979 fn test_express_options() {
1980 let json = r#"
1981 {
1982 "type": "transaction",
1983 "transaction": "foo/",
1984 "timestamp": "2021-04-26T08:00:00+0100",
1985 "start_timestamp": "2021-04-26T07:59:01+0100",
1986 "contexts": {"trace": {}},
1987 "sdk": {"name": "sentry.javascript.node", "integrations":["Express"]},
1988 "request": {"method": "OPTIONS"}
1989 }
1990 "#;
1991
1992 let name = extract_transaction_name(json);
1993 assert!(name.is_none());
1994 }
1995
1996 #[test]
1997 fn test_express() {
1998 let json = r#"
1999 {
2000 "type": "transaction",
2001 "transaction": "foo/",
2002 "timestamp": "2021-04-26T08:00:00+0100",
2003 "start_timestamp": "2021-04-26T07:59:01+0100",
2004 "contexts": {"trace": {}},
2005 "sdk": {"name": "sentry.javascript.node", "integrations":["Express"]},
2006 "request": {"method": "GET"}
2007 }
2008 "#;
2009
2010 let name = extract_transaction_name(json);
2011 assert_eq!(name, Some("foo/".to_owned()));
2012 }
2013
2014 #[test]
2015 fn test_other_client_unknown() {
2016 let json = r#"
2017 {
2018 "type": "transaction",
2019 "transaction": "foo/",
2020 "timestamp": "2021-04-26T08:00:00+0100",
2021 "start_timestamp": "2021-04-26T07:59:01+0100",
2022 "contexts": {"trace": {}},
2023 "sdk": {"name": "some_client"}
2024 }
2025 "#;
2026
2027 let name = extract_transaction_name(json);
2028 assert_eq!(name.as_deref(), Some("foo/"));
2029 }
2030
2031 #[test]
2032 fn test_other_client_url() {
2033 let json = r#"
2034 {
2035 "type": "transaction",
2036 "transaction": "foo",
2037 "timestamp": "2021-04-26T08:00:00+0100",
2038 "start_timestamp": "2021-04-26T07:59:01+0100",
2039 "contexts": {"trace": {}},
2040 "sdk": {"name": "some_client"},
2041 "transaction_info": {"source": "url"}
2042 }
2043 "#;
2044
2045 let name = extract_transaction_name(json);
2046 assert_eq!(name, Some("<< unparameterized >>".to_owned()));
2047 }
2048
2049 #[test]
2050 fn test_any_client_route() {
2051 let json = r#"
2052 {
2053 "type": "transaction",
2054 "transaction": "foo",
2055 "timestamp": "2021-04-26T08:00:00+0100",
2056 "start_timestamp": "2021-04-26T07:59:01+0100",
2057 "contexts": {"trace": {}},
2058 "sdk": {"name": "some_client"},
2059 "transaction_info": {"source": "route"}
2060 }
2061 "#;
2062
2063 let name = extract_transaction_name(json);
2064 assert_eq!(name, Some("foo".to_owned()));
2065 }
2066
2067 #[test]
2068 fn test_parse_transaction_name_strategy() {
2069 for (config_str, expected_strategy) in [
2070 (r#"{}"#, AcceptTransactionNames::ClientBased),
2071 (
2072 r#"{"acceptTransactionNames": "unknown-strategy"}"#,
2073 AcceptTransactionNames::ClientBased,
2074 ),
2075 (
2076 r#"{"acceptTransactionNames": "strict"}"#,
2077 AcceptTransactionNames::Strict,
2078 ),
2079 (
2080 r#"{"acceptTransactionNames": "clientBased"}"#,
2081 AcceptTransactionNames::ClientBased,
2082 ),
2083 ] {
2084 let config: TransactionMetricsConfig = serde_json::from_str(config_str).unwrap();
2085 assert_eq!(config.deprecated1, expected_strategy, "{config_str}");
2086 }
2087 }
2088
2089 #[test]
2090 fn test_computed_metrics() {
2091 let json = r#"{
2092 "type": "transaction",
2093 "timestamp": 1619420520,
2094 "start_timestamp": 1619420400,
2095 "contexts": {
2096 "trace": {
2097 "trace_id": "4c79f60c11214eb38604f4ae0781bfb2",
2098 "span_id": "fa90fdead5f74053"
2099 }
2100 },
2101 "measurements": {
2102 "frames_frozen": {
2103 "value": 2
2104 },
2105 "frames_slow": {
2106 "value": 1
2107 },
2108 "frames_total": {
2109 "value": 4
2110 },
2111 "stall_total_time": {
2112 "value": 4,
2113 "unit": "millisecond"
2114 }
2115 }
2116 }"#;
2117
2118 let mut event = Annotated::from_json(json).unwrap();
2119 normalize_event(&mut event, &NormalizationConfig::default());
2121
2122 let config = TransactionMetricsConfig::default();
2123 let extractor = TransactionExtractor {
2124 config: &config,
2125 generic_config: None,
2126 transaction_from_dsc: Some("test_transaction"),
2127 sampling_decision: SamplingDecision::Keep,
2128 target_project_id: ProjectId::new(4711),
2129 };
2130
2131 let extracted = extractor.extract(event.value().unwrap()).unwrap();
2132
2133 let metrics_names: Vec<_> = extracted
2134 .project_metrics
2135 .into_iter()
2136 .map(|m| m.name)
2137 .collect();
2138
2139 insta::assert_debug_snapshot!(metrics_names, @r###"
2140 [
2141 MetricName(
2142 "d:transactions/measurements.frames_frozen@none",
2143 ),
2144 MetricName(
2145 "d:transactions/measurements.frames_frozen_rate@ratio",
2146 ),
2147 MetricName(
2148 "d:transactions/measurements.frames_slow@none",
2149 ),
2150 MetricName(
2151 "d:transactions/measurements.frames_slow_rate@ratio",
2152 ),
2153 MetricName(
2154 "d:transactions/measurements.frames_total@none",
2155 ),
2156 MetricName(
2157 "d:transactions/measurements.stall_percentage@ratio",
2158 ),
2159 MetricName(
2160 "d:transactions/measurements.stall_total_time@millisecond",
2161 ),
2162 MetricName(
2163 "c:transactions/usage@none",
2164 ),
2165 MetricName(
2166 "d:transactions/duration@millisecond",
2167 ),
2168 MetricName(
2169 "d:transactions/duration_light@millisecond",
2170 ),
2171 ]
2172 "###);
2173 }
2174
2175 #[test]
2176 fn test_conditional_tagging() {
2177 let event = Annotated::from_json(
2178 r#"{
2179 "type": "transaction",
2180 "platform": "javascript",
2181 "transaction": "foo",
2182 "start_timestamp": "2021-04-26T08:00:00+0100",
2183 "timestamp": "2021-04-26T08:00:02+0100",
2184 "measurements": {
2185 "lcp": {"value": 41, "unit": "millisecond"}
2186 }
2187 }"#,
2188 )
2189 .unwrap();
2190
2191 let config = TransactionMetricsConfig::new();
2192 let generic_tags: Vec<TagMapping> = serde_json::from_str(
2193 r#"[
2194 {
2195 "metrics": ["d:transactions/duration@millisecond"],
2196 "tags": [
2197 {
2198 "condition": {"op": "gte", "name": "event.duration", "value": 9001},
2199 "key": "satisfaction",
2200 "value": "frustrated"
2201 },
2202 {
2203 "condition": {"op": "gte", "name": "event.duration", "value": 666},
2204 "key": "satisfaction",
2205 "value": "tolerated"
2206 },
2207 {
2208 "condition": {"op": "and", "inner": []},
2209 "key": "satisfaction",
2210 "value": "satisfied"
2211 }
2212 ]
2213 }
2214 ]"#,
2215 )
2216 .unwrap();
2217 let generic_config = MetricExtractionConfig {
2218 version: 1,
2219 tags: generic_tags,
2220 ..Default::default()
2221 };
2222 let combined_config = CombinedMetricExtractionConfig::from(&generic_config);
2223
2224 let extractor = TransactionExtractor {
2225 config: &config,
2226 generic_config: Some(combined_config),
2227 transaction_from_dsc: Some("test_transaction"),
2228 sampling_decision: SamplingDecision::Keep,
2229 target_project_id: ProjectId::new(4711),
2230 };
2231
2232 let extracted = extractor.extract(event.value().unwrap()).unwrap();
2233 insta::assert_debug_snapshot!(extracted.project_metrics, @r###"
2234 [
2235 Bucket {
2236 timestamp: UnixTimestamp(1619420402),
2237 width: 0,
2238 name: MetricName(
2239 "d:transactions/measurements.lcp@millisecond",
2240 ),
2241 value: Distribution(
2242 [
2243 41.0,
2244 ],
2245 ),
2246 tags: {
2247 "measurement_rating": "good",
2248 "platform": "javascript",
2249 },
2250 metadata: BucketMetadata {
2251 merges: 1,
2252 received_at: Some(
2253 UnixTimestamp(0),
2254 ),
2255 extracted_from_indexed: false,
2256 },
2257 },
2258 Bucket {
2259 timestamp: UnixTimestamp(1619420402),
2260 width: 0,
2261 name: MetricName(
2262 "c:transactions/usage@none",
2263 ),
2264 value: Counter(
2265 1.0,
2266 ),
2267 tags: {},
2268 metadata: BucketMetadata {
2269 merges: 1,
2270 received_at: Some(
2271 UnixTimestamp(0),
2272 ),
2273 extracted_from_indexed: false,
2274 },
2275 },
2276 Bucket {
2277 timestamp: UnixTimestamp(1619420402),
2278 width: 0,
2279 name: MetricName(
2280 "d:transactions/duration@millisecond",
2281 ),
2282 value: Distribution(
2283 [
2284 2000.0,
2285 ],
2286 ),
2287 tags: {
2288 "platform": "javascript",
2289 "satisfaction": "tolerated",
2290 },
2291 metadata: BucketMetadata {
2292 merges: 1,
2293 received_at: Some(
2294 UnixTimestamp(0),
2295 ),
2296 extracted_from_indexed: false,
2297 },
2298 },
2299 Bucket {
2300 timestamp: UnixTimestamp(1619420402),
2301 width: 0,
2302 name: MetricName(
2303 "d:transactions/duration_light@millisecond",
2304 ),
2305 value: Distribution(
2306 [
2307 2000.0,
2308 ],
2309 ),
2310 tags: {},
2311 metadata: BucketMetadata {
2312 merges: 1,
2313 received_at: Some(
2314 UnixTimestamp(0),
2315 ),
2316 extracted_from_indexed: false,
2317 },
2318 },
2319 ]
2320 "###);
2321 }
2322}