relay_server/metrics_extraction/transactions/
mod.rs

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
28/// Placeholder for transaction names in metrics that is used when SDKs are likely sending high
29/// cardinality data such as raw URLs.
30const PLACEHOLDER_UNPARAMETERIZED: &str = "<< unparameterized >>";
31
32/// Tags we set on metrics for performance score measurements (e.g. `score.lcp.weight`).
33///
34/// These are a subset of "universal" tags.
35const 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
45/// Extract HTTP method
46/// See <https://github.com/getsentry/snuba/blob/2e038c13a50735d58cc9397a29155ab5422a62e5/snuba/datasets/errors_processor.py#L64-L67>.
47fn 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
53/// Extract the browser name from the [`BrowserContext`] context.
54fn extract_browser_name(event: &Event) -> Option<String> {
55    let browser = event.context::<BrowserContext>()?;
56    browser.name.value().cloned()
57}
58
59/// Extract the OS name from the [`OsContext`] context.
60fn extract_os_name(event: &Event) -> Option<String> {
61    let os = event.context::<OsContext>()?;
62    os.name.value().cloned()
63}
64
65/// Extract the GEO country code from the [`relay_event_schema::protocol::User`] context.
66fn 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        // For now, we hope that custom transaction names set by users are low-cardinality.
75        TransactionSource::Custom => true,
76
77        // "url" are raw URLs, potentially containing identifiers.
78        TransactionSource::Url => false,
79
80        // These four are names of software components, which we assume to be low-cardinality.
81        TransactionSource::Route
82        | TransactionSource::View
83        | TransactionSource::Component
84        | TransactionSource::Task => true,
85
86        // We know now that the rules to remove high cardinality were applied, so we assume
87        // low-cardinality now.
88        TransactionSource::Sanitized => true,
89
90        // Explicit `Unknown` is used to mark a legacy SDK that does not send the transaction name,
91        // but we assume sends low-cardinality data. See `is_high_cardinality_transaction`.
92        TransactionSource::Unknown => true,
93
94        // Any other value would be an SDK bug or users manually configuring the
95        // source, assume high-cardinality and drop.
96        TransactionSource::Other(_) => false,
97    }
98}
99
100/// Decide whether we want to keep the transaction name.
101/// High-cardinality sources are excluded to protect our metrics infrastructure.
102/// Note that this will produce a discrepancy between metrics and raw transaction data.
103pub 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
137/// These are the tags that are added to extracted low cardinality metrics.
138fn 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
145/// These are the tags that are added to all extracted metrics.
146fn 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    // The platform tag should not increase dimensionality in most cases, because most
162    // transactions are specific to one platform.
163    // NOTE: we might want to reconsider normalization a little and include the
164    // `relay_event_normalization::is_valid_platform` into normalization.
165    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        // We assume that the trace context status is automatically set to unknown inside of the
174        // normalization step.
175        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    // The product only uses the subregion for web data at the moment
201    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        // XXX(slow): event tags are a flat array
222        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/// Metrics extracted from an envelope.
240///
241/// Metric extraction derives pre-computed metrics (time series data) from payload items in
242/// envelopes. Depending on their semantics, these metrics can be ingested into the same project as
243/// the envelope or a different project.
244#[derive(Debug, Default)]
245pub struct ExtractedMetrics {
246    /// Metrics associated with the project of the envelope.
247    pub project_metrics: Vec<Bucket>,
248
249    /// Metrics associated with the project of the trace parent.
250    pub sampling_metrics: Vec<Bucket>,
251}
252
253/// A utility that extracts metrics from transactions.
254pub 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        // Measurements
286        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                // We treat a measurement as "performance score" if its name is the name of another
304                // measurement prefixed by `score.`.
305                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        // Breakdowns
342        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                            // The only reason we do not emit total.time as a metric is that is was not
348                            // on the allowlist in sentry before, and nobody seems to be missing it.
349                            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        // Internal usage counter
374        metrics
375            .project_metrics
376            .push(TransactionMetric::Usage.into_metric(timestamp));
377
378        // Duration
379        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            // Lower cardinality duration
391            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        // Count the transaction towards the root
421        metrics.sampling_metrics.push(
422            TransactionMetric::CountPerRootProject {
423                tags: root_counter_tags,
424            }
425            .into_metric(timestamp),
426        );
427
428        // User
429        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        // Apply shared tags from generic metric extraction. Transaction metrics will adopt generic
442        // metric extraction, after which this is done automatically.
443        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        // Normalize first, to make sure the units are correct:
1000        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        // Normalize first, to make sure the units are correct:
1175        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        // Normalize first, to make sure the units are correct:
1386        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        // Status is normalized upstream in the normalization step.
1636        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    /// Helper function to check if the transaction name is set correctly
1808    fn extract_transaction_name(json: &str) -> Option<String> {
1809        let mut event = Annotated::<Event>::from_json(json).unwrap();
1810
1811        // Logic from `set_default_transaction_source` was previously duplicated in
1812        // `extract_transaction_metrics`. Add it here such that tests can remain.
1813        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 first, to make sure that the metrics were computed:
2120        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}