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}
196
197#[cfg(test)]
198mod tests {
199    use saluki_context::Context;
200
201    use super::*;
202
203    struct MatchCase {
204        description: &'static str,
205        rule: RemapperRule,
206        context: Context,
207        expected_name: Option<&'static str>,
208    }
209
210    #[test]
211    fn matches_by_name_and_required_tags() {
212        let cases = [
213            MatchCase {
214                description: "by_name matches on the metric name alone",
215                rule: RemapperRule::by_name("src.metric", "dst.metric"),
216                context: Context::from_static_parts("src.metric", &["env:prod"]),
217                expected_name: Some("dst.metric"),
218            },
219            MatchCase {
220                description: "by_name rejects a different metric name",
221                rule: RemapperRule::by_name("src.metric", "dst.metric"),
222                context: Context::from_static_parts("other.metric", &["env:prod"]),
223                expected_name: None,
224            },
225            MatchCase {
226                description: "by_name_and_tags matches when every required tag is present",
227                rule: RemapperRule::by_name_and_tags("src.metric", &["env:prod", "role:api"], "dst.metric"),
228                context: Context::from_static_parts("src.metric", &["env:prod", "role:api", "extra:1"]),
229                expected_name: Some("dst.metric"),
230            },
231            MatchCase {
232                description: "by_name_and_tags rejects when a required tag has a different value",
233                rule: RemapperRule::by_name_and_tags("src.metric", &["env:prod"], "dst.metric"),
234                context: Context::from_static_parts("src.metric", &["env:dev"]),
235                expected_name: None,
236            },
237            MatchCase {
238                description: "by_name_and_tags rejects when only one of several required tags is present",
239                rule: RemapperRule::by_name_and_tags("src.metric", &["env:prod", "role:api"], "dst.metric"),
240                context: Context::from_static_parts("src.metric", &["env:prod"]),
241                expected_name: None,
242            },
243        ];
244
245        for case in cases {
246            let actual = case
247                .rule
248                .try_match_no_context(&case.context)
249                .map(|remapped| remapped.name);
250            assert_eq!(actual, case.expected_name, "case: {}", case.description);
251        }
252    }
253
254    fn remapped_tags(rule: &RemapperRule, context: &Context) -> Vec<String> {
255        rule.try_match_no_context(context)
256            .expect("rule should match")
257            .tags
258            .iter()
259            .map(|tag| tag.as_ref().to_string())
260            .collect()
261    }
262
263    #[test]
264    fn copies_original_tags_and_renames_remapped_tags() {
265        // `with_original_tags` copies a tag unchanged; `with_remapped_tags` copies its value under a new
266        // key. Output order follows the rule's configured order, not the source metric's tag order.
267        let rule = RemapperRule::by_name("src.metric", "dst.metric")
268            .with_original_tags(["region"])
269            .with_remapped_tags([("host", "hostname")]);
270        let context = Context::from_static_parts("src.metric", &["region:us-east-1", "host:web01"]);
271
272        assert_eq!(remapped_tags(&rule, &context), ["region:us-east-1", "hostname:web01"]);
273    }
274
275    #[test]
276    fn appends_additional_fixed_tags_after_copied_tags() {
277        let rule = RemapperRule::by_name("src.metric", "dst.metric")
278            .with_original_tags(["region"])
279            .with_additional_tags(["source:internal"]);
280        let context = Context::from_static_parts("src.metric", &["region:us-east-1"]);
281
282        assert_eq!(remapped_tags(&rule, &context), ["region:us-east-1", "source:internal"]);
283    }
284
285    #[test]
286    fn skips_remapped_tags_absent_from_the_source_metric() {
287        // The `host` tag isn't present on the source metric, so it contributes no remapped tag.
288        let rule = RemapperRule::by_name("src.metric", "dst.metric").with_remapped_tags([("host", "hostname")]);
289        let context = Context::from_static_parts("src.metric", &["region:us-east-1"]);
290
291        assert!(remapped_tags(&rule, &context).is_empty());
292    }
293
294    #[test]
295    fn exposes_continue_matching_and_help_text_accessors() {
296        let rule = RemapperRule::by_name("src.metric", "dst.metric");
297        assert_eq!(rule.remapped_name(), "dst.metric");
298        assert!(!rule.should_continue_matching());
299        assert_eq!(rule.help_text(), None);
300
301        let rule = rule.with_continued_matching().with_help_text("some help text");
302        assert!(rule.should_continue_matching());
303        assert_eq!(rule.help_text(), Some("some help text"));
304    }
305}