1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct TaggingRule {
20 pub condition: RuleCondition,
24 pub target_metrics: BTreeSet<String>,
26 pub target_tag: String,
28 pub tag_value: String,
30}
31
32const SESSION_EXTRACT_VERSION: u16 = 3;
34const EXTRACT_ABNORMAL_MECHANISM_VERSION: u16 = 2;
35
36#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
38#[serde(default, rename_all = "camelCase")]
39pub struct SessionMetricsConfig {
40 version: u16,
48}
49
50impl SessionMetricsConfig {
51 pub fn is_enabled(&self) -> bool {
53 self.version > 0 && self.version <= SESSION_EXTRACT_VERSION
54 }
55
56 pub fn is_disabled(&self) -> bool {
58 !self.is_enabled()
59 }
60
61 pub fn should_extract_abnormal_mechanism(&self) -> bool {
63 self.version >= EXTRACT_ABNORMAL_MECHANISM_VERSION
64 }
65}
66
67#[derive(Default, Debug, Clone, Serialize, Deserialize)]
69#[serde(default, rename_all = "camelCase")]
70pub struct CustomMeasurementConfig {
71 limit: usize,
73}
74
75#[derive(Debug, Clone, Copy)]
77pub struct CombinedMetricExtractionConfig<'a> {
78 global: &'a MetricExtractionGroups,
79 project: &'a MetricExtractionConfig,
80}
81
82impl<'a> CombinedMetricExtractionConfig<'a> {
83 pub const EMPTY: Self = Self {
85 global: MetricExtractionGroups::EMPTY,
86 project: &MetricExtractionConfig::empty(),
87 };
88
89 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 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 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 fn from(value: &'a MetricExtractionConfig) -> Self {
135 Self::new(MetricExtractionGroups::EMPTY, value)
136 }
137}
138
139#[derive(Clone, Default, Debug, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct MetricExtractionGroups {
145 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
147 pub groups: BTreeMap<GroupKey, MetricExtractionGroup>,
148}
149
150impl MetricExtractionGroups {
151 pub const EMPTY: &'static Self = &Self {
153 groups: BTreeMap::new(),
154 };
155
156 pub fn is_empty(&self) -> bool {
158 self.groups.is_empty()
159 }
160}
161
162#[derive(Clone, Debug, Serialize, Deserialize)]
164#[serde(rename_all = "camelCase")]
165pub struct MetricExtractionGroup {
166 pub is_enabled: bool,
170
171 #[serde(default, skip_serializing_if = "Vec::is_empty")]
173 pub metrics: Vec<MetricSpec>,
174
175 #[serde(default, skip_serializing_if = "Vec::is_empty")]
180 pub tags: Vec<TagMapping>,
181}
182
183#[derive(Clone, Default, Debug, Serialize, Deserialize)]
185#[serde(rename_all = "camelCase")]
186pub struct MetricExtractionConfig {
187 pub version: u16,
189
190 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
195 pub global_groups: BTreeMap<GroupKey, MetricExtractionGroupOverride>,
196
197 #[serde(default, skip_serializing_if = "Vec::is_empty")]
199 pub metrics: Vec<MetricSpec>,
200
201 #[serde(default, skip_serializing_if = "Vec::is_empty")]
206 pub tags: Vec<TagMapping>,
207
208 #[serde(default)]
217 pub _conditional_tags_extended: bool,
218
219 #[serde(default)]
227 pub _span_metrics_extended: bool,
228}
229
230impl MetricExtractionConfig {
231 pub const MAX_SUPPORTED_VERSION: u16 = 4;
235
236 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 pub fn is_supported(&self) -> bool {
252 self.version <= Self::MAX_SUPPORTED_VERSION
253 }
254
255 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#[derive(Clone, Default, Debug, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase")]
268pub struct MetricExtractionGroupOverride {
269 pub is_enabled: bool,
271}
272
273#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
275pub enum GroupKey {
276 SpanMetricsCommon,
278 SpanMetricsAddons,
280 SpanMetricsTx,
282 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#[derive(Clone, Debug, Serialize, Deserialize)]
318#[serde(rename_all = "camelCase")]
319pub struct MetricSpec {
320 pub category: DataCategory,
322
323 pub mri: String,
325
326 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub field: Option<String>,
346
347 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub condition: Option<RuleCondition>,
353
354 #[serde(default, skip_serializing_if = "Vec::is_empty")]
360 pub tags: Vec<TagSpec>,
361}
362
363#[derive(Clone, Debug, Serialize, Deserialize)]
365#[serde(rename_all = "camelCase")]
366pub struct TagMapping {
367 #[serde(default)]
371 pub metrics: Vec<LazyGlob>,
372
373 #[serde(default)]
379 pub tags: Vec<TagSpec>,
380}
381
382impl TagMapping {
383 pub fn matches(&self, mri: &str) -> bool {
385 self.metrics
387 .iter()
388 .any(|glob| glob.compiled().is_match(mri))
389 }
390}
391
392#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
398#[serde(rename_all = "camelCase")]
399pub struct TagSpec {
400 pub key: String,
402
403 #[serde(default, skip_serializing_if = "Option::is_none")]
409 pub field: Option<String>,
410
411 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub value: Option<String>,
416
417 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub condition: Option<RuleCondition>,
423}
424
425impl TagSpec {
426 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
438pub struct Tag {
440 key: String,
441}
442
443impl Tag {
444 pub fn with_key(key: impl Into<String>) -> Self {
446 Self { key: key.into() }
447 }
448
449 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 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
470pub struct TagWithSource {
474 key: String,
475 field: Option<String>,
476 value: Option<String>,
477}
478
479impl TagWithSource {
480 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 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#[derive(Clone, Debug, PartialEq)]
505pub enum TagSource<'a> {
506 Literal(&'a str),
508 Field(&'a str),
510 Unknown,
512}
513
514pub fn convert_conditional_tagging(project_config: &mut ProjectConfig) {
517 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 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}