Skip to main content

saluki_core/observability/metrics/
remapper.rs

1//! Remapper rules for translating source metrics into a different name and tag shape.
2//!
3//! A [`RemapperRule`] declares how to match a source metric (by name, optionally with a required
4//! tag set) and how the matched metric should be rewritten: a new name, a set of tags copied
5//! and/or renamed from the source, and an optional set of additional fixed tags. Rules also carry
6//! optional help text that the renderer emits in the Prometheus `# HELP` header.
7
8use saluki_context::{tags::TagSet, Context};
9use stringtheory::MetaString;
10
11/// A metric remapping rule.
12///
13/// Rules define the basic matching behavior (metric name, and optionally tags) as well as how to
14/// remap the new copy of the metric. This can include copying tags as-is from the source metric,
15/// copying specific tags over with a new name, and adding an additional fixed set of tags to the
16/// new metric.
17#[derive(Clone)]
18pub struct RemapperRule {
19    existing_name: &'static str,
20    existing_tags: &'static [&'static str],
21    new_name: &'static str,
22    remapped_tags: Vec<(&'static str, &'static str)>,
23    additional_tags: Vec<MetaString>,
24    continue_matching: bool,
25    help_text: Option<&'static str>,
26}
27
28impl RemapperRule {
29    /// Creates a new `RemapperRule` that matches a source metric by name only.
30    pub fn by_name(existing_name: &'static str, new_name: &'static str) -> Self {
31        Self {
32            existing_name,
33            existing_tags: &[],
34            new_name,
35            remapped_tags: Vec::new(),
36            additional_tags: Vec::new(),
37            continue_matching: false,
38            help_text: None,
39        }
40    }
41
42    /// Creates a new `RemapperRule` that matches a source metric by name and tags.
43    pub fn by_name_and_tags(
44        existing_name: &'static str, existing_tags: &'static [&'static str], new_name: &'static str,
45    ) -> Self {
46        Self {
47            existing_name,
48            existing_tags,
49            new_name,
50            remapped_tags: Vec::new(),
51            additional_tags: Vec::new(),
52            continue_matching: false,
53            help_text: None,
54        }
55    }
56
57    /// Adds a set of tags to remap from the source metric by changing their name.
58    ///
59    /// Remapped tags must be given in the form of `(source_tag, destination_tag)`. If a tag by the name `source_tag` is
60    /// found in the source metric, it's copied to the remapped metric with a name of `destination_tag`.
61    ///
62    /// This method is additive, so it can be called multiple times to add more remapped tags. Tag remapping is
63    /// order-dependent, so if a tag is configured to be remapped, or copied, multiple times, then the first match will
64    /// take precedence.
65    pub fn with_remapped_tags<I>(mut self, remapped_tags: I) -> Self
66    where
67        I: IntoIterator<Item = (&'static str, &'static str)>,
68    {
69        self.remapped_tags.extend(remapped_tags);
70        self
71    }
72
73    /// Adds a set of tags to remap from the source metric without changing their name.
74    ///
75    /// Remapped tags must be given in the form of `source_tag`. If a tag by the name `source_tag` is found in the
76    /// source metric, it's copied to the remapped metric with the same name.
77    ///
78    /// This method is additive, so it can be called multiple times to add more original tags. Tag remapping is
79    /// order-dependent, so if a tag is configured to be copied, or remapped, multiple times, then the first match will
80    /// take precedence.
81    pub fn with_original_tags<I>(mut self, original_tags: I) -> Self
82    where
83        I: IntoIterator<Item = &'static str>,
84    {
85        self.remapped_tags
86            .extend(original_tags.into_iter().map(|tag| (tag, tag)));
87        self
88    }
89
90    /// Adds a fixed set of tags to add to the remapped metric.
91    ///
92    /// Additional tags are given in the form of `tag`, which can be any valid tag value: bare or key/value.
93    ///
94    /// This method is additive, so it can be called multiple times to add more additional tags.
95    pub fn with_additional_tags<I>(mut self, additional_tags: I) -> Self
96    where
97        I: IntoIterator<Item = &'static str>,
98    {
99        self.additional_tags
100            .extend(additional_tags.into_iter().map(MetaString::from_static));
101        self
102    }
103
104    /// Allows later rules to also match the same source metric.
105    pub fn with_continued_matching(mut self) -> Self {
106        self.continue_matching = true;
107        self
108    }
109
110    /// Sets the Prometheus `# HELP` text that should be emitted alongside the remapped metric.
111    ///
112    /// Some downstream collectors (notably the Datadog Agent's OpenMetrics check) require a
113    /// specific help text on overlapped metric names. Setting this here keeps the help text next
114    /// to the rule that produces the name.
115    pub fn with_help_text(mut self, help_text: &'static str) -> Self {
116        self.help_text = Some(help_text);
117        self
118    }
119
120    /// Returns `true` if matching should continue after this rule matches.
121    pub const fn should_continue_matching(&self) -> bool {
122        self.continue_matching
123    }
124
125    /// Returns the remapped metric name produced by this rule.
126    pub const fn remapped_name(&self) -> &'static str {
127        self.new_name
128    }
129
130    /// Returns the help text associated with this rule, if any.
131    pub const fn help_text(&self) -> Option<&'static str> {
132        self.help_text
133    }
134
135    /// Attempts to match the given context against this rule.
136    ///
137    /// If the rule matches, returns a [`RemappedMetric`] containing the new metric name and tags.
138    pub fn try_match_no_context(&self, context: &Context) -> Option<RemappedMetric> {
139        if context.name() != self.existing_name {
140            return None;
141        }
142
143        let metric_tags = context.tags();
144        for existing_tag in self.existing_tags {
145            if !metric_tags.has_tag(existing_tag) {
146                return None;
147            }
148        }
149
150        let tags = self.build_remapped_tags(metric_tags);
151        Some(RemappedMetric {
152            name: self.new_name,
153            tags,
154        })
155    }
156
157    /// Builds the remapped tags for a matched metric.
158    fn build_remapped_tags(&self, metric_tags: &TagSet) -> Vec<MetaString> {
159        let mut new_tags = vec![];
160
161        for (original_tag_name, new_tag_name) in &self.remapped_tags {
162            if let Some(tag) = metric_tags.get_single_tag(original_tag_name) {
163                if original_tag_name == new_tag_name {
164                    // Just clone the tag since the name isn't changing.
165                    new_tags.push(tag.clone().into_inner());
166                } else {
167                    // Build our new tag if this one has a value.
168                    match tag.value() {
169                        Some(value) => {
170                            new_tags.push(MetaString::from(format!("{}:{}", new_tag_name, value)));
171                        }
172                        None => {
173                            new_tags.push(MetaString::from(*new_tag_name));
174                        }
175                    }
176                }
177            }
178        }
179
180        for additional_tag in &self.additional_tags {
181            new_tags.push(additional_tag.clone());
182        }
183
184        new_tags
185    }
186}
187
188/// A metric that has been remapped by a [`RemapperRule`].
189pub struct RemappedMetric {
190    /// The remapped metric name.
191    pub name: &'static str,
192
193    /// The remapped tags in `key:value` (or bare) format.
194    pub tags: Vec<MetaString>,
195}