saluki_core/observability/metrics/
remapper.rs1use stringtheory::MetaString;
9
10use crate::data_model::{event::metric::context::Context, tags::TagSet};
11
12#[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 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 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 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 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 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 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 pub fn with_continued_matching(mut self) -> Self {
124 self.continue_matching = true;
125 self
126 }
127
128 pub fn with_help_text(mut self, help_text: &'static str) -> Self {
134 self.help_text = Some(help_text);
135 self
136 }
137
138 pub const fn should_continue_matching(&self) -> bool {
140 self.continue_matching
141 }
142
143 pub const fn remapped_name(&self) -> &'static str {
145 self.new_name
146 }
147
148 pub const fn help_text(&self) -> Option<&'static str> {
150 self.help_text
151 }
152
153 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 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 new_tags.push(tag.clone().into_inner());
192 } else {
193 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
214pub struct RemappedMetric {
216 pub name: &'static str,
218
219 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 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 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}