Skip to main content

relay_dynamic_config/
metrics.rs

1//! Dynamic configuration for metrics extraction from sessions and transactions.
2
3use core::fmt;
4use std::collections::{BTreeMap, BTreeSet};
5use std::convert::Infallible;
6use std::str::FromStr;
7
8use relay_base_schema::data_category::DataCategory;
9use relay_common::glob2::LazyGlob;
10use relay_common::impl_str_serde;
11use relay_protocol::RuleCondition;
12use serde::{Deserialize, Serialize};
13
14use crate::project::ProjectConfig;
15
16/// Rule defining when a target tag should be set on a metric.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct TaggingRule {
20    // note: could add relay_sampling::RuleType here, but right now we only support transaction
21    // events
22    /// Condition that defines when to set the tag.
23    pub condition: RuleCondition,
24    /// Metrics on which the tag is set.
25    pub target_metrics: BTreeSet<String>,
26    /// Name of the tag that is set.
27    pub target_tag: String,
28    /// Value of the tag that is set.
29    pub tag_value: String,
30}
31
32/// Current version of metrics extraction.
33const SESSION_EXTRACT_VERSION: u16 = 3;
34const EXTRACT_ABNORMAL_MECHANISM_VERSION: u16 = 2;
35
36/// Configuration for metric extraction from sessions.
37#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
38#[serde(default, rename_all = "camelCase")]
39pub struct SessionMetricsConfig {
40    /// The revision of the extraction algorithm.
41    ///
42    /// Provided the revision is lower than or equal to the revision supported by this Relay,
43    /// metrics are extracted. If the revision is higher than what this Relay supports, it does not
44    /// extract metrics from sessions, and instead forwards them to the upstream.
45    ///
46    /// Version `0` (default) disables extraction.
47    version: u16,
48}
49
50impl SessionMetricsConfig {
51    /// Returns `true` if session metrics is enabled and compatible.
52    pub fn is_enabled(&self) -> bool {
53        self.version > 0 && self.version <= SESSION_EXTRACT_VERSION
54    }
55
56    /// Returns `true` if Relay should not extract metrics from sessions.
57    pub fn is_disabled(&self) -> bool {
58        !self.is_enabled()
59    }
60
61    /// Whether or not the abnormal mechanism should be extracted as a tag.
62    pub fn should_extract_abnormal_mechanism(&self) -> bool {
63        self.version >= EXTRACT_ABNORMAL_MECHANISM_VERSION
64    }
65}
66
67/// Configuration for extracting custom measurements from transaction payloads.
68#[derive(Default, Debug, Clone, Serialize, Deserialize)]
69#[serde(default, rename_all = "camelCase")]
70pub struct CustomMeasurementConfig {
71    /// The maximum number of custom measurements to extract. Defaults to zero.
72    limit: usize,
73}
74
75/// Combined view of global and project-specific metrics extraction configs.
76#[derive(Debug, Clone, Copy)]
77pub struct CombinedMetricExtractionConfig<'a> {
78    global: &'a MetricExtractionGroups,
79    project: &'a MetricExtractionConfig,
80}
81
82impl<'a> CombinedMetricExtractionConfig<'a> {
83    /// Empty config, used in tests and as a fallback.
84    pub const EMPTY: Self = Self {
85        global: MetricExtractionGroups::EMPTY,
86        project: &MetricExtractionConfig::empty(),
87    };
88
89    /// Creates a new combined view from two references.
90    pub fn new(global: &'a MetricExtractionGroups, project: &'a MetricExtractionConfig) -> Self {
91        for key in project.global_groups.keys() {
92            if !global.groups.contains_key(key) {
93                relay_log::error!(
94                    "Metrics group configured for project missing in global config: {key:?}"
95                )
96            }
97        }
98
99        Self { global, project }
100    }
101
102    /// Returns an iterator of metric specs.
103    pub fn metrics(&self) -> impl Iterator<Item = &MetricSpec> {
104        let project = self.project.metrics.iter();
105        let enabled_global = self
106            .enabled_groups()
107            .flat_map(|template| template.metrics.iter());
108
109        project.chain(enabled_global)
110    }
111
112    /// Returns an iterator of tag mappings.
113    pub fn tags(&self) -> impl Iterator<Item = &TagMapping> {
114        let project = self.project.tags.iter();
115        let enabled_global = self
116            .enabled_groups()
117            .flat_map(|template| template.tags.iter());
118
119        project.chain(enabled_global)
120    }
121
122    fn enabled_groups(&self) -> impl Iterator<Item = &MetricExtractionGroup> {
123        self.global.groups.iter().filter_map(|(key, template)| {
124            let is_enabled_by_override = self.project.global_groups.get(key).map(|c| c.is_enabled);
125            let is_enabled = is_enabled_by_override.unwrap_or(template.is_enabled);
126
127            is_enabled.then_some(template)
128        })
129    }
130}
131
132impl<'a> From<&'a MetricExtractionConfig> for CombinedMetricExtractionConfig<'a> {
133    /// Creates a combined config with an empty global component. Used in tests.
134    fn from(value: &'a MetricExtractionConfig) -> Self {
135        Self::new(MetricExtractionGroups::EMPTY, value)
136    }
137}
138
139/// Global groups for metric extraction.
140///
141/// Templates can be enabled or disabled by project configs.
142#[derive(Clone, Default, Debug, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct MetricExtractionGroups {
145    /// Mapping from group name to metrics specs & tags.
146    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
147    pub groups: BTreeMap<GroupKey, MetricExtractionGroup>,
148}
149
150impl MetricExtractionGroups {
151    /// Empty config, used in tests and as a fallback.
152    pub const EMPTY: &'static Self = &Self {
153        groups: BTreeMap::new(),
154    };
155
156    /// Returns `true` if the contained groups are empty.
157    pub fn is_empty(&self) -> bool {
158        self.groups.is_empty()
159    }
160}
161
162/// Group of metrics & tags that can be enabled or disabled as a group.
163#[derive(Clone, Debug, Serialize, Deserialize)]
164#[serde(rename_all = "camelCase")]
165pub struct MetricExtractionGroup {
166    /// Whether the set is enabled by default.
167    ///
168    /// Project configs can overwrite this flag to opt-in or out of a set.
169    pub is_enabled: bool,
170
171    /// A list of metric specifications to extract.
172    #[serde(default, skip_serializing_if = "Vec::is_empty")]
173    pub metrics: Vec<MetricSpec>,
174
175    /// A list of tags to add to previously extracted metrics.
176    ///
177    /// These tags add further tags to a range of metrics. If some metrics already have a matching
178    /// tag extracted, the existing tag is left unchanged.
179    #[serde(default, skip_serializing_if = "Vec::is_empty")]
180    pub tags: Vec<TagMapping>,
181}
182
183/// Configuration for generic extraction of metrics from all data categories.
184#[derive(Clone, Default, Debug, Serialize, Deserialize)]
185#[serde(rename_all = "camelCase")]
186pub struct MetricExtractionConfig {
187    /// Versioning of metrics extraction. Relay skips extraction if the version is not supported.
188    pub version: u16,
189
190    /// Configuration of global metric groups.
191    ///
192    /// The groups themselves are configured in [`crate::GlobalConfig`],
193    /// but can be enabled or disabled here.
194    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
195    pub global_groups: BTreeMap<GroupKey, MetricExtractionGroupOverride>,
196
197    /// A list of metric specifications to extract.
198    #[serde(default, skip_serializing_if = "Vec::is_empty")]
199    pub metrics: Vec<MetricSpec>,
200
201    /// A list of tags to add to previously extracted metrics.
202    ///
203    /// These tags add further tags to a range of metrics. If some metrics already have a matching
204    /// tag extracted, the existing tag is left unchanged.
205    #[serde(default, skip_serializing_if = "Vec::is_empty")]
206    pub tags: Vec<TagMapping>,
207
208    /// This config has been extended with fields from `conditional_tagging`.
209    ///
210    /// At the moment, Relay will parse `conditional_tagging` rules and insert them into the `tags`
211    /// mapping in this struct. If the flag is `true`, this has already happened and should not be
212    /// repeated.
213    ///
214    /// This is a temporary flag that will be removed once the transaction metric extraction version
215    /// is bumped to `2`.
216    #[serde(default)]
217    pub _conditional_tags_extended: bool,
218
219    /// This config has been extended with default span metrics.
220    ///
221    /// Relay checks for the span extraction flag and adds built-in metrics and tags to this struct.
222    /// If the flag is `true`, this has already happened and should not be repeated.
223    ///
224    /// This is a temporary flag that will be removed once the transaction metric extraction version
225    /// is bumped to `2`.
226    #[serde(default)]
227    pub _span_metrics_extended: bool,
228}
229
230impl MetricExtractionConfig {
231    /// The latest version for this config struct.
232    ///
233    /// This is the maximum version supported by this Relay instance.
234    pub const MAX_SUPPORTED_VERSION: u16 = 4;
235
236    /// Returns an empty `MetricExtractionConfig` with the latest version.
237    ///
238    /// As opposed to `default()`, this will be enabled once populated with specs.
239    pub const fn empty() -> Self {
240        Self {
241            version: Self::MAX_SUPPORTED_VERSION,
242            global_groups: BTreeMap::new(),
243            metrics: Vec::new(),
244            tags: Vec::new(),
245            _conditional_tags_extended: false,
246            _span_metrics_extended: false,
247        }
248    }
249
250    /// Returns `true` if the version of this metric extraction config is supported.
251    pub fn is_supported(&self) -> bool {
252        self.version <= Self::MAX_SUPPORTED_VERSION
253    }
254
255    /// Returns `true` if metric extraction is configured and compatible with this Relay.
256    pub fn is_enabled(&self) -> bool {
257        self.version > 0
258            && self.is_supported()
259            && !(self.metrics.is_empty() && self.tags.is_empty() && self.global_groups.is_empty())
260    }
261}
262
263/// Configures global metrics extraction groups.
264///
265/// Project configs can enable or disable globally defined groups.
266#[derive(Clone, Default, Debug, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase")]
268pub struct MetricExtractionGroupOverride {
269    /// `true` if a template should be enabled.
270    pub is_enabled: bool,
271}
272
273/// Enumeration of keys in [`MetricExtractionGroups`]. In JSON, this is simply a string.
274#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
275pub enum GroupKey {
276    /// Metric extracted for all plans.
277    SpanMetricsCommon,
278    /// "addon" metrics.
279    SpanMetricsAddons,
280    /// Metrics extracted from spans in the transaction namespace.
281    SpanMetricsTx,
282    /// Any other group defined by the upstream.
283    Other(String),
284}
285
286impl fmt::Display for GroupKey {
287    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
288        write!(
289            f,
290            "{}",
291            match self {
292                GroupKey::SpanMetricsCommon => "span_metrics_common",
293                GroupKey::SpanMetricsAddons => "span_metrics_addons",
294                GroupKey::SpanMetricsTx => "span_metrics_tx",
295                GroupKey::Other(s) => &s,
296            }
297        )
298    }
299}
300
301impl FromStr for GroupKey {
302    type Err = Infallible;
303
304    fn from_str(s: &str) -> Result<Self, Self::Err> {
305        Ok(match s {
306            "span_metrics_common" => GroupKey::SpanMetricsCommon,
307            "span_metrics_addons" => GroupKey::SpanMetricsAddons,
308            "span_metrics_tx" => GroupKey::SpanMetricsTx,
309            s => GroupKey::Other(s.to_owned()),
310        })
311    }
312}
313
314impl_str_serde!(GroupKey, "a metrics extraction group key");
315
316/// Specification for a metric to extract from some data.
317#[derive(Clone, Debug, Serialize, Deserialize)]
318#[serde(rename_all = "camelCase")]
319pub struct MetricSpec {
320    /// Category of data to extract this metric for.
321    pub category: DataCategory,
322
323    /// The Metric Resource Identifier (MRI) of the metric to extract.
324    pub mri: String,
325
326    /// A path to the field to extract the metric from.
327    ///
328    /// This value contains a fully qualified expression pointing at the data field in the payload
329    /// to extract the metric from. It follows the `Getter` syntax that is also used for dynamic
330    /// sampling.
331    ///
332    /// How the value is treated depends on the metric type:
333    ///
334    /// - **Counter** metrics are a special case, since the default product counters do not count
335    ///   any specific field but rather the occurrence of the event. As such, there is no value
336    ///   expression, and the field is set to `None`. Semantics of specifying remain undefined at
337    ///   this point.
338    /// - **Distribution** metrics require a numeric value. If the value at the specified path is
339    ///   not numeric, metric extraction will be skipped.
340    /// - **Set** metrics require a string value, which is then emitted into the set as unique
341    ///   value. Insertion of numbers and other types is undefined.
342    ///
343    /// If the field does not exist, extraction is skipped.
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub field: Option<String>,
346
347    /// An optional condition to meet before extraction.
348    ///
349    /// See [`RuleCondition`] for all available options to specify and combine conditions. If no
350    /// condition is specified, the metric is extracted unconditionally.
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub condition: Option<RuleCondition>,
353
354    /// A list of tags to add to the metric.
355    ///
356    /// Tags can be conditional, see [`TagSpec`] for configuration options. For this reason, it is
357    /// possible to list tag keys multiple times, each with different conditions. The first matching
358    /// condition will be applied.
359    #[serde(default, skip_serializing_if = "Vec::is_empty")]
360    pub tags: Vec<TagSpec>,
361}
362
363/// Mapping between extracted metrics and additional tags to extract.
364#[derive(Clone, Debug, Serialize, Deserialize)]
365#[serde(rename_all = "camelCase")]
366pub struct TagMapping {
367    /// A list of Metric Resource Identifiers (MRI) to apply tags to.
368    ///
369    /// Entries in this list can contain wildcards to match metrics with dynamic MRIs.
370    #[serde(default)]
371    pub metrics: Vec<LazyGlob>,
372
373    /// A list of tags to add to the metric.
374    ///
375    /// Tags can be conditional, see [`TagSpec`] for configuration options. For this reason, it is
376    /// possible to list tag keys multiple times, each with different conditions. The first matching
377    /// condition will be applied.
378    #[serde(default)]
379    pub tags: Vec<TagSpec>,
380}
381
382impl TagMapping {
383    /// Returns `true` if this mapping matches the provided MRI.
384    pub fn matches(&self, mri: &str) -> bool {
385        // TODO: Use a globset, instead.
386        self.metrics
387            .iter()
388            .any(|glob| glob.compiled().is_match(mri))
389    }
390}
391
392/// Configuration for a tag to add to a metric.
393///
394/// Tags values can be static if defined through `value` or dynamically queried from the payload if
395/// defined through `field`. These two options are mutually exclusive, behavior is undefined if both
396/// are specified.
397#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
398#[serde(rename_all = "camelCase")]
399pub struct TagSpec {
400    /// The key of the tag to extract.
401    pub key: String,
402
403    /// Path to a field containing the tag's value.
404    ///
405    /// It follows the `Getter` syntax to read data from the payload.
406    ///
407    /// Mutually exclusive with `value`.
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub field: Option<String>,
410
411    /// Literal value of the tag.
412    ///
413    /// Mutually exclusive with `field`.
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    pub value: Option<String>,
416
417    /// An optional condition to meet before extraction.
418    ///
419    /// See [`RuleCondition`] for all available options to specify and combine conditions. If no
420    /// condition is specified, the tag is added unconditionally, provided it is not already there.
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub condition: Option<RuleCondition>,
423}
424
425impl TagSpec {
426    /// Returns the source of tag values, either literal or a field.
427    pub fn source(&self) -> TagSource<'_> {
428        if let Some(ref field) = self.field {
429            TagSource::Field(field)
430        } else if let Some(ref value) = self.value {
431            TagSource::Literal(value)
432        } else {
433            TagSource::Unknown
434        }
435    }
436}
437
438/// Builder for [`TagSpec`].
439pub struct Tag {
440    key: String,
441}
442
443impl Tag {
444    /// Prepares a tag with a given tag name.
445    pub fn with_key(key: impl Into<String>) -> Self {
446        Self { key: key.into() }
447    }
448
449    /// Defines the field from which the tag value gets its data.
450    pub fn from_field(self, field_name: impl Into<String>) -> TagWithSource {
451        let Self { key } = self;
452        TagWithSource {
453            key,
454            field: Some(field_name.into()),
455            value: None,
456        }
457    }
458
459    /// Defines what value to set for a tag.
460    pub fn with_value(self, value: impl Into<String>) -> TagWithSource {
461        let Self { key } = self;
462        TagWithSource {
463            key,
464            field: None,
465            value: Some(value.into()),
466        }
467    }
468}
469
470/// Intermediate result of the tag spec builder.
471///
472/// Can be transformed into [`TagSpec`].
473pub struct TagWithSource {
474    key: String,
475    field: Option<String>,
476    value: Option<String>,
477}
478
479impl TagWithSource {
480    /// Defines a tag that is extracted unconditionally.
481    pub fn always(self) -> TagSpec {
482        let Self { key, field, value } = self;
483        TagSpec {
484            key,
485            field,
486            value,
487            condition: None,
488        }
489    }
490
491    /// Defines a tag that is extracted under the given condition.
492    pub fn when(self, condition: RuleCondition) -> TagSpec {
493        let Self { key, field, value } = self;
494        TagSpec {
495            key,
496            field,
497            value,
498            condition: Some(condition),
499        }
500    }
501}
502
503/// Specifies how to obtain the value of a tag in [`TagSpec`].
504#[derive(Clone, Debug, PartialEq)]
505pub enum TagSource<'a> {
506    /// A literal value.
507    Literal(&'a str),
508    /// Path to a field to evaluate.
509    Field(&'a str),
510    /// An unsupported or unknown source.
511    Unknown,
512}
513
514/// Converts the given tagging rules from `conditional_tagging` to the newer metric extraction
515/// config.
516pub fn convert_conditional_tagging(project_config: &mut ProjectConfig) {
517    // NOTE: This clones the rules so that they remain in the project state for old Relays that
518    // do not support generic metrics extraction. Once the migration is complete, this can be
519    // removed with a version bump of the transaction metrics config.
520    let rules = &project_config.metric_conditional_tagging;
521    if rules.is_empty() {
522        return;
523    }
524
525    let config = project_config
526        .metric_extraction
527        .get_or_insert_with(MetricExtractionConfig::empty);
528
529    if !config.is_supported() || config._conditional_tags_extended {
530        return;
531    }
532
533    config.tags.extend(TaggingRuleConverter {
534        rules: rules.iter().cloned().peekable(),
535        tags: Vec::new(),
536    });
537
538    config._conditional_tags_extended = true;
539    if config.version == 0 {
540        config.version = MetricExtractionConfig::MAX_SUPPORTED_VERSION;
541    }
542}
543
544struct TaggingRuleConverter<I: Iterator<Item = TaggingRule>> {
545    rules: std::iter::Peekable<I>,
546    tags: Vec<TagSpec>,
547}
548
549impl<I> Iterator for TaggingRuleConverter<I>
550where
551    I: Iterator<Item = TaggingRule>,
552{
553    type Item = TagMapping;
554
555    fn next(&mut self) -> Option<Self::Item> {
556        loop {
557            let old = self.rules.next()?;
558
559            self.tags.push(TagSpec {
560                key: old.target_tag,
561                field: None,
562                value: Some(old.tag_value),
563                condition: Some(old.condition),
564            });
565
566            // Optimization: Collect tags for consecutive tagging rules for the same set of metrics.
567            // Then, emit a single entry with all tag specs at once.
568            if self.rules.peek().map(|r| &r.target_metrics) == Some(&old.target_metrics) {
569                continue;
570            }
571
572            return Some(TagMapping {
573                metrics: old.target_metrics.into_iter().map(LazyGlob::new).collect(),
574                tags: std::mem::take(&mut self.tags),
575            });
576        }
577    }
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use similar_asserts::assert_eq;
584
585    #[test]
586    fn parse_tag_spec_value() {
587        let json = r#"{"key":"foo","value":"bar"}"#;
588        let spec: TagSpec = serde_json::from_str(json).unwrap();
589        assert_eq!(spec.source(), TagSource::Literal("bar"));
590    }
591
592    #[test]
593    fn parse_tag_spec_field() {
594        let json = r#"{"key":"foo","field":"bar"}"#;
595        let spec: TagSpec = serde_json::from_str(json).unwrap();
596        assert_eq!(spec.source(), TagSource::Field("bar"));
597    }
598
599    #[test]
600    fn parse_tag_spec_unsupported() {
601        let json = r#"{"key":"foo","somethingNew":"bar"}"#;
602        let spec: TagSpec = serde_json::from_str(json).unwrap();
603        assert_eq!(spec.source(), TagSource::Unknown);
604    }
605
606    #[test]
607    fn parse_tag_mapping() {
608        let json = r#"{"metrics": ["d:spans/*"], "tags": [{"key":"foo","field":"bar"}]}"#;
609        let mapping: TagMapping = serde_json::from_str(json).unwrap();
610        assert!(mapping.metrics[0].compiled().is_match("d:spans/foo"));
611    }
612
613    fn groups() -> MetricExtractionGroups {
614        serde_json::from_value::<MetricExtractionGroups>(serde_json::json!({
615            "groups": {
616                "group1": {
617                    "isEnabled": false,
618                    "metrics": [{
619                        "category": "transaction",
620                        "mri": "c:metric1/counter@none",
621                    }],
622                    "tags": [
623                        {
624                            "metrics": ["c:metric1/counter@none"],
625                            "tags": [{
626                                "key": "tag1",
627                                "value": "value1"
628                            }]
629                        }
630                    ]
631                },
632                "group2": {
633                    "isEnabled": true,
634                    "metrics": [{
635                        "category": "transaction",
636                        "mri": "c:metric2/counter@none",
637                    }],
638                    "tags": [
639                        {
640                            "metrics": ["c:metric2/counter@none"],
641                            "tags": [{
642                                "key": "tag2",
643                                "value": "value2"
644                            }]
645                        }
646                    ]
647                }
648            }
649        }))
650        .unwrap()
651    }
652
653    #[test]
654    fn metric_extraction_global_defaults() {
655        let global = groups();
656        let project: MetricExtractionConfig = serde_json::from_value(serde_json::json!({
657            "version": 1,
658            "global_templates": {}
659        }))
660        .unwrap();
661        let combined = CombinedMetricExtractionConfig::new(&global, &project);
662
663        assert_eq!(
664            combined
665                .metrics()
666                .map(|m| m.mri.as_str())
667                .collect::<Vec<_>>(),
668            vec!["c:metric2/counter@none"]
669        );
670        assert_eq!(
671            combined
672                .tags()
673                .map(|t| t.tags[0].key.as_str())
674                .collect::<Vec<_>>(),
675            vec!["tag2"]
676        );
677    }
678
679    #[test]
680    fn metric_extraction_override() {
681        let global = groups();
682        let project: MetricExtractionConfig = serde_json::from_value(serde_json::json!({
683            "version": 1,
684            "globalGroups": {
685                "group1": {"isEnabled": true},
686                "group2": {"isEnabled": false}
687            }
688        }))
689        .unwrap();
690        let combined = CombinedMetricExtractionConfig::new(&global, &project);
691
692        assert_eq!(
693            combined
694                .metrics()
695                .map(|m| m.mri.as_str())
696                .collect::<Vec<_>>(),
697            vec!["c:metric1/counter@none"]
698        );
699        assert_eq!(
700            combined
701                .tags()
702                .map(|t| t.tags[0].key.as_str())
703                .collect::<Vec<_>>(),
704            vec!["tag1"]
705        );
706    }
707}