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