saluki_core/observability/metrics/
remapper.rs1use saluki_context::{tags::TagSet, Context};
9use stringtheory::MetaString;
10
11#[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 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 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 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 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 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 pub fn with_continued_matching(mut self) -> Self {
106 self.continue_matching = true;
107 self
108 }
109
110 pub fn with_help_text(mut self, help_text: &'static str) -> Self {
116 self.help_text = Some(help_text);
117 self
118 }
119
120 pub const fn should_continue_matching(&self) -> bool {
122 self.continue_matching
123 }
124
125 pub const fn remapped_name(&self) -> &'static str {
127 self.new_name
128 }
129
130 pub const fn help_text(&self) -> Option<&'static str> {
132 self.help_text
133 }
134
135 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 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 new_tags.push(tag.clone().into_inner());
166 } else {
167 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
188pub struct RemappedMetric {
190 pub name: &'static str,
192
193 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 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 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}