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        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        // XXX(slow): event tags are a flat array
223        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/// Metrics extracted from an envelope.
241///
242/// Metric extraction derives pre-computed metrics (time series data) from payload items in
243/// envelopes. Depending on their semantics, these metrics can be ingested into the same project as
244/// the envelope or a different project.
245#[derive(Debug, Default)]
246pub struct ExtractedMetrics {
247    /// Metrics associated with the project of the envelope.
248    pub project_metrics: Vec<Bucket>,
249
250    /// Metrics associated with the project of the trace parent.
251    pub sampling_metrics: Vec<Bucket>,
252}
253
254/// A utility that extracts metrics from transactions.
255pub 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        // Measurements
287        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                // We treat a measurement as "performance score" if its name is the name of another
305                // measurement prefixed by `score.`.
306                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        // Breakdowns
343        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                            // The only reason we do not emit total.time as a metric is that is was not
349                            // on the allowlist in sentry before, and nobody seems to be missing it.
350                            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        // Internal usage counter
375        metrics
376            .project_metrics
377            .push(TransactionMetric::Usage.into_metric(timestamp));
378
379        // Duration
380        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            // Lower cardinality duration
392            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        // Count the transaction towards the root
422        metrics.sampling_metrics.push(
423            TransactionMetric::CountPerRootProject {
424                tags: root_counter_tags,
425            }
426            .into_metric(timestamp),
427        );
428
429        // User
430        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        // Apply shared tags from generic metric extraction. Transaction metrics will adopt generic
443        // metric extraction, after which this is done automatically.
444        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        // Normalize first, to make sure the units are correct:
1001        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        // Normalize first, to make sure the units are correct:
1176        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        // Normalize first, to make sure the units are correct:
1387        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        // Status is normalized upstream in the normalization step.
1637        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    /// Helper function to check if the transaction name is set correctly
1809    fn extract_transaction_name(json: &str) -> Option<String> {
1810        let mut event = Annotated::<Event>::from_json(json).unwrap();
1811
1812        // Logic from `set_default_transaction_source` was previously duplicated in
1813        // `extract_transaction_metrics`. Add it here such that tests can remain.
1814        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 first, to make sure that the metrics were computed:
2121        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}