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 required tag keys) and how the matched metric should be rewritten: a new name, a set
5//! of tags copied and/or renamed from the source, and an optional set of additional fixed tags.
6//! Rules also carry optional help text that the renderer emits in the Prometheus `# HELP` header.
7
8use stringtheory::MetaString;
9
10use crate::data_model::{event::metric::context::Context, tags::TagSet};
11
12/// A metric remapping rule.
13///
14/// Rules define the basic matching behavior (metric name, and optionally tags) as well as how to
15/// remap the new copy of the metric. This can include copying tags as-is from the source metric,
16/// copying specific tags over with a new name, and adding an additional fixed set of tags to the
17/// new metric.
18#[derive(Clone)]
19pub struct RemapperRule {
20    existing_name: &'static str,
21    existing_tags: &'static [&'static str],
22    required_tag_keys: Vec<&'static str>,
23    new_name: &'static str,
24    remapped_tags: Vec<(&'static str, &'static str)>,
25    additional_tags: Vec<MetaString>,
26    continue_matching: bool,
27    help_text: Option<&'static str>,
28}
29
30impl RemapperRule {
31    /// Creates a new `RemapperRule` that matches a source metric by name only.
32    pub fn by_name(existing_name: &'static str, new_name: &'static str) -> Self {
33        Self {
34            existing_name,
35            existing_tags: &[],
36            required_tag_keys: Vec::new(),
37            new_name,
38            remapped_tags: Vec::new(),
39            additional_tags: Vec::new(),
40            continue_matching: false,
41            help_text: None,
42        }
43    }
44
45    /// Creates a new `RemapperRule` that matches a source metric by name and tags.
46    pub fn by_name_and_tags(
47        existing_name: &'static str, existing_tags: &'static [&'static str], new_name: &'static str,
48    ) -> Self {
49        Self {
50            existing_name,
51            existing_tags,
52            required_tag_keys: Vec::new(),
53            new_name,
54            remapped_tags: Vec::new(),
55            additional_tags: Vec::new(),
56            continue_matching: false,
57            help_text: None,
58        }
59    }
60
61    /// Adds a set of tag names that must be present on the source metric, with any value, for this rule to match.
62    ///
63    /// This is useful when the same metric name is emitted both with and without a given tag (for example, an
64    /// aggregate series alongside per-dimension series), and only the tagged series should be remapped.
65    ///
66    /// This method is additive, so it can be called multiple times to add more required tag keys.
67    pub fn with_required_tag_keys<I>(mut self, required_tag_keys: I) -> Self
68    where
69        I: IntoIterator<Item = &'static str>,
70    {
71        self.required_tag_keys.extend(required_tag_keys);
72        self
73    }
74
75    /// Adds a set of tags to remap from the source metric by changing their name.
76    ///
77    /// Remapped tags must be given in the form of `(source_tag, destination_tag)`. If a tag by the name `source_tag` is
78    /// found in the source metric, it's copied to the remapped metric with a name of `destination_tag`.
79    ///
80    /// This method is additive, so it can be called multiple times to add more remapped tags. Tag remapping is
81    /// order-dependent, so if a tag is configured to be remapped, or copied, multiple times, then the first match will
82    /// take precedence.
83    pub fn with_remapped_tags<I>(mut self, remapped_tags: I) -> Self
84    where
85        I: IntoIterator<Item = (&'static str, &'static str)>,
86    {
87        self.remapped_tags.extend(remapped_tags);
88        self
89    }
90
91    /// Adds a set of tags to remap from the source metric without changing their name.
92    ///
93    /// Remapped tags must be given in the form of `source_tag`. If a tag by the name `source_tag` is found in the
94    /// source metric, it's copied to the remapped metric with the same name.
95    ///
96    /// This method is additive, so it can be called multiple times to add more original tags. Tag remapping is
97    /// order-dependent, so if a tag is configured to be copied, or remapped, multiple times, then the first match will
98    /// take precedence.
99    pub fn with_original_tags<I>(mut self, original_tags: I) -> Self
100    where
101        I: IntoIterator<Item = &'static str>,
102    {
103        self.remapped_tags
104            .extend(original_tags.into_iter().map(|tag| (tag, tag)));
105        self
106    }
107
108    /// Adds a fixed set of tags to add to the remapped metric.
109    ///
110    /// Additional tags are given in the form of `tag`, which can be any valid tag value: bare or key/value.
111    ///
112    /// This method is additive, so it can be called multiple times to add more additional tags.
113    pub fn with_additional_tags<I>(mut self, additional_tags: I) -> Self
114    where
115        I: IntoIterator<Item = &'static str>,
116    {
117        self.additional_tags
118            .extend(additional_tags.into_iter().map(MetaString::from_static));
119        self
120    }
121
122    /// Allows later rules to also match the same source metric.
123    pub fn with_continued_matching(mut self) -> Self {
124        self.continue_matching = true;
125        self
126    }
127
128    /// Sets the Prometheus `# HELP` text that should be emitted alongside the remapped metric.
129    ///
130    /// Some downstream collectors (notably the Datadog Agent's OpenMetrics check) require a
131    /// specific help text on overlapped metric names. Setting this here keeps the help text next
132    /// to the rule that produces the name.
133    pub fn with_help_text(mut self, help_text: &'static str) -> Self {
134        self.help_text = Some(help_text);
135        self
136    }
137
138    /// Returns `true` if matching should continue after this rule matches.
139    pub const fn should_continue_matching(&self) -> bool {
140        self.continue_matching
141    }
142
143    /// Returns the remapped metric name produced by this rule.
144    pub const fn remapped_name(&self) -> &'static str {
145        self.new_name
146    }
147
148    /// Returns the help text associated with this rule, if any.
149    pub const fn help_text(&self) -> Option<&'static str> {
150        self.help_text
151    }
152
153    /// Attempts to match the given context against this rule.
154    ///
155    /// If the rule matches, returns a [`RemappedMetric`] containing the new metric name and tags.
156    pub fn try_match_no_context(&self, context: &Context) -> Option<RemappedMetric> {
157        if context.name() != self.existing_name {
158            return None;
159        }
160
161        let metric_tags = context.tags();
162        for existing_tag in self.existing_tags {
163            if !metric_tags.has_tag(existing_tag) {
164                return None;
165            }
166        }
167
168        if self
169            .required_tag_keys
170            .iter()
171            .any(|required_tag_key| metric_tags.get_single_tag(required_tag_key).is_none())
172        {
173            return None;
174        }
175
176        let tags = self.build_remapped_tags(metric_tags);
177        Some(RemappedMetric {
178            name: self.new_name,
179            tags,
180        })
181    }
182
183    /// Builds the remapped tags for a matched metric.
184    fn build_remapped_tags(&self, metric_tags: &TagSet) -> Vec<MetaString> {
185        let mut new_tags = vec![];
186
187        for (original_tag_name, new_tag_name) in &self.remapped_tags {
188            if let Some(tag) = metric_tags.get_single_tag(original_tag_name) {
189                if original_tag_name == new_tag_name {
190                    // Just clone the tag since the name isn't changing.
191                    new_tags.push(tag.clone().into_inner());
192                } else {
193                    // Build our new tag if this one has a value.
194                    match tag.value() {
195                        Some(value) => {
196                            new_tags.push(MetaString::from(format!("{}:{}", new_tag_name, value)));
197                        }
198                        None => {
199                            new_tags.push(MetaString::from(*new_tag_name));
200                        }
201                    }
202                }
203            }
204        }
205
206        for additional_tag in &self.additional_tags {
207            new_tags.push(additional_tag.clone());
208        }
209
210        new_tags
211    }
212}
213
214/// A metric that has been remapped by a [`RemapperRule`].
215pub struct RemappedMetric {
216    /// The remapped metric name.
217    pub name: &'static str,
218
219    /// The remapped tags in `key:value` (or bare) format.
220    pub tags: Vec<MetaString>,
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::data_model::event::metric::context::Context;
227
228    struct MatchCase {
229        description: &'static str,
230        rule: RemapperRule,
231        context: Context,
232        expected_name: Option<&'static str>,
233    }
234
235    #[test]
236    fn matches_by_name_and_required_tags() {
237        let cases = [
238            MatchCase {
239                description: "by_name matches on the metric name alone",
240                rule: RemapperRule::by_name("src.metric", "dst.metric"),
241                context: Context::from_static_parts("src.metric", &["env:prod"]),
242                expected_name: Some("dst.metric"),
243            },
244            MatchCase {
245                description: "by_name rejects a different metric name",
246                rule: RemapperRule::by_name("src.metric", "dst.metric"),
247                context: Context::from_static_parts("other.metric", &["env:prod"]),
248                expected_name: None,
249            },
250            MatchCase {
251                description: "by_name_and_tags matches when every required tag is present",
252                rule: RemapperRule::by_name_and_tags("src.metric", &["env:prod", "role:api"], "dst.metric"),
253                context: Context::from_static_parts("src.metric", &["env:prod", "role:api", "extra:1"]),
254                expected_name: Some("dst.metric"),
255            },
256            MatchCase {
257                description: "by_name_and_tags rejects when a required tag has a different value",
258                rule: RemapperRule::by_name_and_tags("src.metric", &["env:prod"], "dst.metric"),
259                context: Context::from_static_parts("src.metric", &["env:dev"]),
260                expected_name: None,
261            },
262            MatchCase {
263                description: "by_name_and_tags rejects when only one of several required tags is present",
264                rule: RemapperRule::by_name_and_tags("src.metric", &["env:prod", "role:api"], "dst.metric"),
265                context: Context::from_static_parts("src.metric", &["env:prod"]),
266                expected_name: None,
267            },
268            MatchCase {
269                description: "with_required_tag_keys matches when the tag key is present with any value",
270                rule: RemapperRule::by_name("src.metric", "dst.metric").with_required_tag_keys(["domain"]),
271                context: Context::from_static_parts("src.metric", &["domain:example.com"]),
272                expected_name: Some("dst.metric"),
273            },
274            MatchCase {
275                description: "with_required_tag_keys rejects when the tag key is absent",
276                rule: RemapperRule::by_name("src.metric", "dst.metric").with_required_tag_keys(["domain"]),
277                context: Context::from_static_parts("src.metric", &["component_id:forwarder"]),
278                expected_name: None,
279            },
280        ];
281
282        for case in cases {
283            let actual = case
284                .rule
285                .try_match_no_context(&case.context)
286                .map(|remapped| remapped.name);
287            assert_eq!(actual, case.expected_name, "case: {}", case.description);
288        }
289    }
290
291    fn remapped_tags(rule: &RemapperRule, context: &Context) -> Vec<String> {
292        rule.try_match_no_context(context)
293            .expect("rule should match")
294            .tags
295            .iter()
296            .map(|tag| tag.as_ref().to_string())
297            .collect()
298    }
299
300    #[test]
301    fn copies_original_tags_and_renames_remapped_tags() {
302        // `with_original_tags` copies a tag unchanged; `with_remapped_tags` copies its value under a new
303        // key. Output order follows the rule's configured order, not the source metric's tag order.
304        let rule = RemapperRule::by_name("src.metric", "dst.metric")
305            .with_original_tags(["region"])
306            .with_remapped_tags([("host", "hostname")]);
307        let context = Context::from_static_parts("src.metric", &["region:us-east-1", "host:web01"]);
308
309        assert_eq!(remapped_tags(&rule, &context), ["region:us-east-1", "hostname:web01"]);
310    }
311
312    #[test]
313    fn appends_additional_fixed_tags_after_copied_tags() {
314        let rule = RemapperRule::by_name("src.metric", "dst.metric")
315            .with_original_tags(["region"])
316            .with_additional_tags(["source:internal"]);
317        let context = Context::from_static_parts("src.metric", &["region:us-east-1"]);
318
319        assert_eq!(remapped_tags(&rule, &context), ["region:us-east-1", "source:internal"]);
320    }
321
322    #[test]
323    fn skips_remapped_tags_absent_from_the_source_metric() {
324        // The `host` tag isn't present on the source metric, so it contributes no remapped tag.
325        let rule = RemapperRule::by_name("src.metric", "dst.metric").with_remapped_tags([("host", "hostname")]);
326        let context = Context::from_static_parts("src.metric", &["region:us-east-1"]);
327
328        assert!(remapped_tags(&rule, &context).is_empty());
329    }
330
331    #[test]
332    fn exposes_continue_matching_and_help_text_accessors() {
333        let rule = RemapperRule::by_name("src.metric", "dst.metric");
334        assert_eq!(rule.remapped_name(), "dst.metric");
335        assert!(!rule.should_continue_matching());
336        assert_eq!(rule.help_text(), None);
337
338        let rule = rule.with_continued_matching().with_help_text("some help text");
339        assert!(rule.should_continue_matching());
340        assert_eq!(rule.help_text(), Some("some help text"));
341    }
342}