1use std::collections::HashMap;
2use std::num::NonZeroUsize;
3use std::str::FromStr;
4use std::sync::LazyLock;
5use std::time::Duration;
6
7use async_trait::async_trait;
8use bytesize::ByteSize;
9use memory_accounting::{MemoryBounds, MemoryBoundsBuilder};
10use regex::Regex;
11use saluki_config::GenericConfiguration;
12use saluki_context::{Context, ContextResolver, ContextResolverBuilder};
13use saluki_core::{
14 components::{
15 transforms::{SynchronousTransform, SynchronousTransformBuilder},
16 ComponentContext,
17 },
18 topology::EventsBuffer,
19};
20use saluki_error::{generic_error, ErrorContext, GenericError};
21use serde::{Deserialize, Serialize};
22use serde_with::{serde_as, DisplayFromStr, PickFirst};
23
24const MATCH_TYPE_WILDCARD: &str = "wildcard";
25const MATCH_TYPE_REGEX: &str = "regex";
26
27static ALLOWED_WILDCARD_MATCH_PATTERN: LazyLock<Regex> =
28 LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9\-_*.]+$").expect("Invalid regex in ALLOWED_WILDCARD_MATCH_PATTERN"));
29
30const fn default_context_string_interner_size() -> ByteSize {
31 ByteSize::kib(64)
32}
33#[serde_as]
35#[derive(Deserialize)]
36pub struct DogstatsDMapperConfiguration {
37 #[serde(
43 rename = "dogstatsd_mapper_string_interner_size",
44 default = "default_context_string_interner_size"
45 )]
46 context_string_interner_bytes: ByteSize,
47
48 #[serde_as(as = "PickFirst<(DisplayFromStr, _)>")]
50 #[serde(default)]
51 dogstatsd_mapper_profiles: MapperProfileConfigs,
52}
53
54#[derive(Clone, Debug, Default, Deserialize, Serialize)]
55struct MappingProfileConfig {
56 name: String,
57 prefix: String,
58 mappings: Vec<MetricMappingConfig>,
59}
60#[derive(Clone, Debug, Default, Deserialize, Serialize)]
61struct MapperProfileConfigs(pub Vec<MappingProfileConfig>);
62
63impl FromStr for MapperProfileConfigs {
64 type Err = serde_json::Error;
65
66 fn from_str(s: &str) -> Result<Self, Self::Err> {
67 let profiles: Vec<MappingProfileConfig> = serde_json::from_str(s)?;
68 Ok(MapperProfileConfigs(profiles))
69 }
70}
71
72impl MapperProfileConfigs {
73 fn build(
74 &self, context: ComponentContext, context_string_interner_bytes: ByteSize,
75 ) -> Result<MetricMapper, GenericError> {
76 let mut profiles = Vec::with_capacity(self.0.len());
77 for (i, config_profile) in self.0.iter().enumerate() {
78 if config_profile.name.is_empty() {
79 return Err(generic_error!("missing profile name"));
80 }
81 if config_profile.prefix.is_empty() {
82 return Err(generic_error!("missing prefix for profile: {}", config_profile.name));
83 }
84
85 let mut profile = MappingProfile {
86 prefix: config_profile.prefix.clone(),
87 mappings: Vec::with_capacity(config_profile.mappings.len()),
88 };
89
90 for mapping in &config_profile.mappings {
91 let match_type = match mapping.match_type.as_str() {
92 "" => MATCH_TYPE_WILDCARD,
94 MATCH_TYPE_WILDCARD => MATCH_TYPE_WILDCARD,
95 MATCH_TYPE_REGEX => MATCH_TYPE_REGEX,
96 unknown => {
97 return Err(generic_error!(
98 "profile: {}, mapping num {}: invalid match type `{}`, expected `wildcard` or `regex`",
99 config_profile.name,
100 i,
101 unknown,
102 ))
103 }
104 };
105 if mapping.name.is_empty() {
106 return Err(generic_error!(
107 "profile: {}, mapping num {}: name is required",
108 config_profile.name,
109 i
110 ));
111 }
112 if mapping.metric_match.is_empty() {
113 return Err(generic_error!(
114 "profile: {}, mapping num {}: match is required",
115 config_profile.name,
116 i
117 ));
118 }
119 let regex = build_regex(&mapping.metric_match, match_type)?;
120 profile.mappings.push(MetricMapping {
121 name: mapping.name.clone(),
122 tags: mapping.tags.clone(),
123 regex,
124 });
125 }
126 profiles.push(profile);
127 }
128
129 let context_string_interner_size = NonZeroUsize::new(context_string_interner_bytes.as_u64() as usize)
130 .ok_or_else(|| generic_error!("context_string_interner_size must be greater than 0"))
131 .unwrap();
132
133 let context_resolver =
134 ContextResolverBuilder::from_name(format!("{}/dsd_mapper/primary", context.component_id()))
135 .expect("resolver name is not empty")
136 .with_interner_capacity_bytes(context_string_interner_size)
137 .with_idle_context_expiration(Duration::from_secs(30))
138 .build();
139
140 Ok(MetricMapper {
141 context_resolver,
142 profiles,
143 })
144 }
145}
146
147fn build_regex(match_re: &str, match_type: &str) -> Result<Regex, GenericError> {
148 let mut pattern = match_re.to_owned();
149 if match_type == MATCH_TYPE_WILDCARD {
150 if !ALLOWED_WILDCARD_MATCH_PATTERN.is_match(&pattern) {
152 return Err(generic_error!(
153 "invalid wildcard match pattern `{}`, it does not match allowed match regex `{}`",
154 pattern,
155 ALLOWED_WILDCARD_MATCH_PATTERN.as_str()
156 ));
157 }
158 if pattern.contains("**") {
159 return Err(generic_error!(
160 "invalid wildcard match pattern `{}`, it should not contain consecutive `*`",
161 pattern
162 ));
163 }
164 pattern = pattern.replace(".", "\\.");
165 pattern = pattern.replace("*", "([^.]*)");
166 }
167
168 let final_pattern = format!("^{}$", pattern);
169
170 Regex::new(&final_pattern).with_error_context(|| {
171 format!(
172 "Failed to compile regular expression `{}` for `{}` match type",
173 final_pattern, match_type
174 )
175 })
176}
177
178#[derive(Clone, Debug, Default, Deserialize, Serialize)]
179struct MetricMappingConfig {
180 #[serde(rename = "match")]
182 metric_match: String,
183
184 #[serde(default)]
186 match_type: String,
187
188 name: String,
190
191 #[serde(default)]
193 tags: HashMap<String, String>,
194}
195
196struct MappingProfile {
197 prefix: String,
198 mappings: Vec<MetricMapping>,
199}
200
201struct MetricMapping {
202 name: String,
203 tags: HashMap<String, String>,
204 regex: Regex,
205}
206
207struct MetricMapper {
208 profiles: Vec<MappingProfile>,
209 context_resolver: ContextResolver,
210}
211
212impl MetricMapper {
213 fn try_map(&mut self, context: &Context) -> Option<Context> {
214 let metric_name = context.name();
215 let tags = context.tags();
216 let origin_tags = context.origin_tags();
217
218 for profile in &self.profiles {
219 if !metric_name.starts_with(&profile.prefix) && profile.prefix != "*" {
220 continue;
221 }
222
223 for mapping in &profile.mappings {
224 if let Some(captures) = mapping.regex.captures(metric_name) {
225 let mut name = String::new();
226 captures.expand(&mapping.name, &mut name);
227 let mut new_tags: Vec<String> = tags.into_iter().map(|tag| tag.as_str().to_owned()).collect();
228 for (tag_key, tag_value_expr) in &mapping.tags {
229 let mut expanded_value = String::new();
230 captures.expand(tag_value_expr, &mut expanded_value);
231 new_tags.push(format!("{}:{}", tag_key, expanded_value));
232 }
233 return self
234 .context_resolver
235 .resolve_with_origin_tags(&name, new_tags, origin_tags.clone());
236 }
237 }
238 }
239 None
240 }
241}
242
243impl DogstatsDMapperConfiguration {
244 pub fn from_configuration(config: &GenericConfiguration) -> Result<Self, GenericError> {
246 Ok(config.as_typed()?)
247 }
248}
249
250#[async_trait]
251impl SynchronousTransformBuilder for DogstatsDMapperConfiguration {
252 async fn build(&self, context: ComponentContext) -> Result<Box<dyn SynchronousTransform + Send>, GenericError> {
253 let metric_mapper = self
254 .dogstatsd_mapper_profiles
255 .build(context, self.context_string_interner_bytes)?;
256 Ok(Box::new(DogstatsDMapper { metric_mapper }))
257 }
258}
259
260impl MemoryBounds for DogstatsDMapperConfiguration {
261 fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
262 builder
263 .minimum()
264 .with_single_value::<DogstatsDMapper>("component struct")
266 .with_fixed_amount("string interner", self.context_string_interner_bytes.as_u64() as usize);
269 }
270}
271
272pub struct DogstatsDMapper {
273 metric_mapper: MetricMapper,
274}
275
276impl SynchronousTransform for DogstatsDMapper {
277 fn transform_buffer(&mut self, event_buffer: &mut EventsBuffer) {
278 for event in event_buffer {
279 if let Some(metric) = event.try_as_metric_mut() {
280 if let Some(new_context) = self.metric_mapper.try_map(metric.context()) {
281 *metric.context_mut() = new_context;
282 }
283 }
284 }
285 }
286}
287
288#[cfg(test)]
289mod tests {
290
291 use bytesize::ByteSize;
292 use saluki_context::Context;
293 use saluki_core::{components::ComponentContext, data_model::event::metric::Metric, topology::ComponentId};
294 use saluki_error::GenericError;
295 use serde_json::{json, Value};
296
297 use super::{MapperProfileConfigs, MetricMapper};
298
299 fn counter_metric(name: &'static str, tags: &[&'static str]) -> Metric {
300 let context = Context::from_static_parts(name, tags);
301 Metric::counter(context, 1.0)
302 }
303
304 fn mapper(json_data: Value) -> Result<MetricMapper, GenericError> {
305 let context = ComponentContext::transform(ComponentId::try_from("test_mapper").unwrap());
306 let mpc: MapperProfileConfigs = serde_json::from_value(json_data)?;
307 let context_string_interner_bytes = ByteSize::kib(64);
308 mpc.build(context, context_string_interner_bytes)
309 }
310
311 fn assert_tags(context: &Context, expected_tags: &[&str]) {
312 for tag in expected_tags {
313 assert!(context.tags().has_tag(tag), "missing tag: {}", tag);
314 }
315 assert_eq!(context.tags().len(), expected_tags.len(), "unexpected number of tags");
316 }
317
318 #[tokio::test]
319 async fn test_mapper_wildcard_simple() {
320 let json_data = json!([{
321 "name": "test",
322 "prefix": "test.",
323 "mappings": [
324 {
325 "match": "test.job.duration.*.*",
326 "name": "test.job.duration",
327 "tags": {
328 "job_type": "$1",
329 "job_name": "$2"
330 }
331 },
332 {
333 "match": "test.job.size.*.*",
334 "name": "test.job.size",
335 "tags": {
336 "foo": "$1",
337 "bar": "$2"
338 }
339 }
340 ]
341 }]);
342
343 let mut mapper = mapper(json_data).expect("should have parsed mapping config");
344 let metric = counter_metric("test.job.duration.my_job_type.my_job_name", &[]);
345 let context = mapper.try_map(metric.context()).expect("should have remapped");
346 assert_eq!(context.name(), "test.job.duration");
347 assert_tags(&context, &["job_type:my_job_type", "job_name:my_job_name"]);
348
349 let metric = counter_metric("test.job.size.my_job_type.my_job_name", &[]);
350 let context = mapper.try_map(metric.context()).expect("should have remapped");
351 assert_eq!(context.name(), "test.job.size");
352 assert_tags(&context, &["foo:my_job_type", "bar:my_job_name"]);
353
354 let metric = counter_metric("test.job.size.not_match", &[]);
355 assert!(mapper.try_map(metric.context()).is_none(), "should not have remapped");
356 }
357
358 #[tokio::test]
359 async fn test_partial_match() {
360 let json_data = json!([{
361 "name": "test",
362 "prefix": "test.",
363 "mappings": [
364 {
365 "match": "test.job.duration.*.*",
366 "name": "test.job.duration",
367 "tags": {
368 "job_type": "$1"
369 }
370 },
371 {
372 "match": "test.task.duration.*.*",
373 "name": "test.task.duration",
374 }
375 ]
376 }]);
377 let mut mapper = mapper(json_data).expect("should have parsed mapping config");
378 let metric = counter_metric("test.job.duration.my_job_type.my_job_name", &[]);
379 let context = mapper.try_map(metric.context()).expect("should have remapped");
380 assert_eq!(context.name(), "test.job.duration");
381 assert!(context.tags().has_tag("job_type:my_job_type"));
382
383 let metric = counter_metric("test.task.duration.my_job_type.my_job_name", &[]);
384 let context = mapper.try_map(metric.context()).expect("should have remapped");
385 assert_eq!(context.name(), "test.task.duration");
386 }
387
388 #[tokio::test]
389 async fn test_use_regex_expansion_alternative_syntax() {
390 let json_data = json!([{
391 "name": "test",
392 "prefix": "test.",
393 "mappings": [
394 {
395 "match": "test.job.duration.*.*",
396 "name": "test.job.duration",
397 "tags": {
398 "job_type": "${1}_x",
399 "job_name": "${2}_y"
400 }
401 }
402 ]
403 }]);
404
405 let mut mapper = mapper(json_data).expect("should have parsed mapping config");
406
407 let metric = counter_metric("test.job.duration.my_job_type.my_job_name", &[]);
408 let context = mapper.try_map(metric.context()).expect("should have remapped");
409 assert_eq!(context.name(), "test.job.duration");
410 assert_tags(&context, &["job_type:my_job_type_x", "job_name:my_job_name_y"]);
411 }
412
413 #[tokio::test]
414 async fn test_expand_name() {
415 let json_data = json!([{
416 "name": "test",
417 "prefix": "test.",
418 "mappings": [
419 {
420 "match": "test.job.duration.*.*",
421 "name": "test.hello.$2.$1",
422 "tags": {
423 "job_type": "$1",
424 "job_name": "$2"
425 }
426 }
427 ]
428 }]);
429
430 let mut mapper = mapper(json_data).expect("should have parsed mapping config");
431
432 let metric = counter_metric("test.job.duration.my_job_type.my_job_name", &[]);
433 let context = mapper.try_map(metric.context()).expect("should have remapped");
434 assert_eq!(context.name(), "test.hello.my_job_name.my_job_type");
435 assert_tags(&context, &["job_type:my_job_type", "job_name:my_job_name"]);
436 }
437
438 #[tokio::test]
439 async fn test_match_before_underscore() {
440 let json_data = json!([{
441 "name": "test",
442 "prefix": "test.",
443 "mappings": [
444 {
445 "match": "test.*_start",
446 "name": "test.start",
447 "tags": {
448 "job": "$1"
449 }
450 }
451 ]
452 }]);
453
454 let mut mapper = mapper(json_data).expect("should have parsed mapping config");
455
456 let metric = counter_metric("test.my_job_start", &[]);
457 let context = mapper.try_map(metric.context()).expect("should have remapped");
458 assert_eq!(context.name(), "test.start");
459 assert!(context.tags().has_tag("job:my_job"));
460 }
461
462 #[tokio::test]
463 async fn test_no_tags() {
464 let json_data = json!([{
465 "name": "test",
466 "prefix": "test.",
467 "mappings": [
468 {
469 "match": "test.my-worker.start",
470 "name": "test.worker.start"
471 },
472 {
473 "match": "test.my-worker.stop.*",
474 "name": "test.worker.stop"
475 }
476 ]
477 }]);
478
479 let mut mapper = mapper(json_data).expect("should have parsed mapping config");
480
481 let metric = counter_metric("test.my-worker.start", &[]);
482 let context = mapper.try_map(metric.context()).expect("should have remapped");
483 assert_eq!(context.name(), "test.worker.start");
484 assert!(context.tags().is_empty(), "Expected no tags");
485
486 let metric = counter_metric("test.my-worker.stop.worker-name", &[]);
487 let context = mapper.try_map(metric.context()).expect("should have remapped");
488 assert_eq!(context.name(), "test.worker.stop");
489 assert!(context.tags().is_empty(), "Expected no tags");
490 }
491
492 #[tokio::test]
493 async fn test_all_allowed_characters() {
494 let json_data = json!([{
495 "name": "test",
496 "prefix": "test.",
497 "mappings": [
498 {
499 "match": "test.abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ-01234567.*",
500 "name": "test.alphabet"
501 }
502 ]
503 }]);
504
505 let mut mapper = mapper(json_data).expect("should have parsed mapping config");
506
507 let metric = counter_metric(
508 "test.abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ-01234567.123",
509 &[],
510 );
511 let context = mapper.try_map(metric.context()).expect("should have remapped");
512 assert_eq!(context.name(), "test.alphabet");
513 assert!(context.tags().is_empty(), "Expected no tags");
514 }
515
516 #[tokio::test]
517 async fn test_regex_match_type() {
518 let json_data = json!([{
519 "name": "test",
520 "prefix": "test.",
521 "mappings": [
522 {
523 "match": "test\\.job\\.duration\\.(.*)",
524 "match_type": "regex",
525 "name": "test.job.duration",
526 "tags": {
527 "job_name": "$1"
528 }
529 },
530 {
531 "match": "test\\.task\\.duration\\.(.*)",
532 "match_type": "regex",
533 "name": "test.task.duration",
534 "tags": {
535 "task_name": "$1"
536 }
537 }
538 ]
539 }]);
540
541 let mut mapper = mapper(json_data).expect("should have parsed mapping config");
542 let metric = counter_metric("test.job.duration.my.funky.job$name-abc/123", &[]);
543 let context = mapper.try_map(metric.context()).expect("should have remapped");
544 assert_eq!(context.name(), "test.job.duration");
545 assert!(context.tags().has_tag("job_name:my.funky.job$name-abc/123"));
546
547 let metric = counter_metric("test.task.duration.MY_task_name", &[]);
548 let context = mapper.try_map(metric.context()).expect("should have remapped");
549 assert_eq!(context.name(), "test.task.duration");
550 assert!(context.tags().has_tag("task_name:MY_task_name"));
551 }
552
553 #[tokio::test]
554 async fn test_complex_regex_match_type() {
555 let json_data = json!([{
556 "name": "test",
557 "prefix": "test.",
558 "mappings": [
559 {
560 "match": "test\\.job\\.([a-z][0-9]-\\w+)\\.(.*)",
561 "match_type": "regex",
562 "name": "test.job",
563 "tags": {
564 "job_type": "$1",
565 "job_name": "$2"
566 }
567 }
568 ]
569 }]);
570
571 let mut mapper = mapper(json_data).expect("should have parsed mapping config");
572
573 let metric = counter_metric("test.job.a5-foo.bar", &[]);
574 let context = mapper.try_map(metric.context()).expect("should have remapped");
575 assert_eq!(context.name(), "test.job");
576 assert_tags(&context, &["job_type:a5-foo", "job_name:bar"]);
577
578 let metric = counter_metric("test.job.foo.bar-not-match", &[]);
579 assert!(mapper.try_map(metric.context()).is_none(), "should not have remapped");
580 }
581
582 #[tokio::test]
583 async fn test_profile_and_prefix() {
584 let json_data = json!([{
585 "name": "test",
586 "prefix": "foo.",
587 "mappings": [
588 {
589 "match": "foo.duration.*",
590 "name": "foo.duration",
591 "tags": {
592 "name": "$1"
593 }
594 }
595 ]
596 },
597 {
598 "name": "test",
599 "prefix": "bar.",
600 "mappings": [
601 {
602 "match": "bar.count.*",
603 "name": "bar.count",
604 "tags": {
605 "name": "$1"
606 }
607 },
608 {
609 "match": "foo.duration2.*",
610 "name": "foo.duration2",
611 "tags": {
612 "name": "$1"
613 }
614 }
615 ]
616 }]);
617
618 let mut mapper = mapper(json_data).expect("should have parsed mapping config");
619
620 let metric = counter_metric("foo.duration.foo_name1", &[]);
621 let context = mapper.try_map(metric.context()).expect("should have remapped");
622 assert_eq!(context.name(), "foo.duration");
623 assert!(context.tags().has_tag("name:foo_name1"));
624
625 let metric = counter_metric("foo.duration2.foo_name1", &[]);
626 assert!(
627 mapper.try_map(metric.context()).is_none(),
628 "should not have remapped due to wrong group"
629 );
630
631 let metric = counter_metric("bar.count.bar_name1", &[]);
632 let context = mapper.try_map(metric.context()).expect("should have remapped");
633 assert_eq!(context.name(), "bar.count");
634 assert!(context.tags().has_tag("name:bar_name1"));
635
636 let metric = counter_metric("z.not.mapped", &[]);
637 assert!(mapper.try_map(metric.context()).is_none(), "should not have remapped");
638 }
639
640 #[tokio::test]
641 async fn test_wildcard_prefix() {
642 let json_data = json!([{
643 "name": "test",
644 "prefix": "*",
645 "mappings": [
646 {
647 "match": "foo.duration.*",
648 "name": "foo.duration",
649 "tags": {
650 "name": "$1"
651 }
652 }
653 ]
654 }]);
655
656 let mut mapper = mapper(json_data).expect("should have parsed mapping config");
657
658 let metric = counter_metric("foo.duration.foo_name1", &[]);
659 let context = mapper.try_map(metric.context()).expect("should have remapped");
660 assert_eq!(context.name(), "foo.duration");
661 assert!(context.tags().has_tag("name:foo_name1"));
662 }
663
664 #[tokio::test]
665 async fn test_wildcard_prefix_order() {
666 let json_data = json!([{
667 "name": "test",
668 "prefix": "*",
669 "mappings": [
670 {
671 "match": "foo.duration.*",
672 "name": "foo.duration",
673 "tags": {
674 "name1": "$1"
675 }
676 }
677 ]
678 },
679 {
680 "name": "test",
681 "prefix": "*",
682 "mappings": [
683 {
684 "match": "foo.duration.*",
685 "name": "foo.duration",
686 "tags": {
687 "name2": "$1"
688 }
689 }
690 ]
691 }]);
692
693 let mut mapper = mapper(json_data).expect("should have parsed mapping config");
694 let metric = counter_metric("foo.duration.foo_name", &[]);
695 let context = mapper.try_map(metric.context()).expect("should have remapped");
696 assert_eq!(context.name(), "foo.duration");
697 assert!(context.tags().has_tag("name1:foo_name"));
698 assert!(
699 !context.tags().has_tag("name2:foo_name"),
700 "Only the first matching profile should apply"
701 );
702 }
703
704 #[tokio::test]
705 async fn test_multiple_profiles_order() {
706 let json_data = json!([{
707 "name": "test",
708 "prefix": "foo.",
709 "mappings": [
710 {
711 "match": "foo.*.duration.*",
712 "name": "foo.bar1.duration",
713 "tags": {
714 "bar": "$1",
715 "foo": "$2"
716 }
717 }
718 ]
719 },
720 {
721 "name": "test",
722 "prefix": "foo.bar.",
723 "mappings": [
724 {
725 "match": "foo.bar.duration.*",
726 "name": "foo.bar2.duration",
727 "tags": {
728 "foo_bar": "$1"
729 }
730 }
731 ]
732 }]);
733
734 let mut mapper = mapper(json_data).expect("should have parsed mapping config");
735
736 let metric = counter_metric("foo.bar.duration.foo_name", &[]);
737 let context = mapper.try_map(metric.context()).expect("should have remapped");
738 assert_eq!(context.name(), "foo.bar1.duration");
739 assert_tags(&context, &["bar:bar", "foo:foo_name"]);
740 assert!(
741 !context.tags().has_tag("foo_bar:foo_name"),
742 "Only the first matching profile should apply"
743 );
744 }
745
746 #[tokio::test]
747 async fn test_different_regex_expansion_syntax() {
748 let json_data = json!([{
749 "name": "test",
750 "prefix": "test.",
751 "mappings": [
752 {
753 "match": "test.user.(\\w+).action.(\\w+)",
754 "match_type": "regex",
755 "name": "test.user.action",
756 "tags": {
757 "user": "$1",
758 "action": "$2"
759 }
760 }
761 ]
762 }]);
763
764 let mut mapper = mapper(json_data).expect("should have parsed mapping config");
765 let metric = counter_metric("test.user.john_doe.action.login", &[]);
766 let context = mapper.try_map(metric.context()).expect("should have remapped");
767 assert_eq!(context.name(), "test.user.action");
768 assert_tags(&context, &["user:john_doe", "action:login"]);
769 }
770
771 #[tokio::test]
772 async fn test_retain_existing_tags() {
773 let json_data = json!([{
774 "name": "test",
775 "prefix": "test.",
776 "mappings": [
777 {
778 "match": "test.job.duration.*.*",
779 "name": "test.job.duration.$2",
780 "tags": {
781 "job_type": "$1",
782 "job_name": "$2"
783 }
784 },
785 ]
786 }]);
787 let mut mapper = mapper(json_data).expect("should have parsed mapping config");
788 let metric = counter_metric("test.job.duration.abc.def", &["foo:bar", "baz"]);
789 let context = mapper.try_map(metric.context()).expect("should have remapped");
790 assert_eq!(context.name(), "test.job.duration.def");
791 assert!(context.tags().has_tag("foo:bar"));
792 assert!(context.tags().has_tag("baz"));
793 }
794
795 #[test]
796 fn test_empty_name() {
797 let json_data = json!([{
798 "name": "test",
799 "prefix": "test.",
800 "mappings": [
801 {
802 "match": "test.job.duration.*.*",
803 "name": "",
804 "tags": {
805 "job_type": "$1"
806 }
807 }
808 ]
809 }]);
810 assert!(mapper(json_data).is_err())
811 }
812
813 #[test]
814 fn test_missing_name() {
815 let json_data = json!([{
816 "name": "test",
817 "prefix": "test.",
818 "mappings": [
819 {
820 "match": "test.job.duration.*.*",
821 "tags": {
822 "job_type": "$1",
823 "job_name": "$2"
824 }
825 }
826 ]
827 }]);
828 assert!(mapper(json_data).is_err());
829 }
830
831 #[test]
832 fn test_invalid_match_regex_brackets() {
833 let json_data = json!([{
834 "name": "test",
835 "prefix": "test.",
836 "mappings": [
837 {
838 "match": "test.[]duration.*.*", "name": "test.job.duration"
840 }
841 ]
842 }]);
843 assert!(mapper(json_data).is_err());
844 }
845
846 #[test]
847 fn test_invalid_match_regex_caret() {
848 let json_data = json!([{
849 "name": "test",
850 "prefix": "test.",
851 "mappings": [
852 {
853 "match": "^test.invalid.duration.*.*", "name": "test.job.duration"
855 }
856 ]
857 }]);
858 assert!(mapper(json_data).is_err());
859 }
860
861 #[test]
862 fn test_consecutive_wildcards() {
863 let json_data = json!([{
864 "name": "test",
865 "prefix": "test.",
866 "mappings": [
867 {
868 "match": "test.invalid.duration.**", "name": "test.job.duration"
870 }
871 ]
872 }]);
873 assert!(mapper(json_data).is_err());
874 }
875
876 #[test]
877 fn test_invalid_match_type() {
878 let json_data = json!([{
879 "name": "test",
880 "prefix": "test.",
881 "mappings": [
882 {
883 "match": "test.invalid.duration",
884 "match_type": "invalid", "name": "test.job.duration"
886 }
887 ]
888 }]);
889 assert!(mapper(json_data).is_err());
890 }
891
892 #[test]
893 fn test_missing_profile_name() {
894 let json_data = json!([{
895 "prefix": "test.",
897 "mappings": [
898 {
899 "match": "test.invalid.duration",
900 "match_type": "invalid",
901 "name": "test.job.duration"
902 }
903 ]
904 }]);
905 assert!(mapper(json_data).is_err());
906 }
907
908 #[test]
909 fn test_missing_profile_prefix() {
910 let json_data = json!([{
911 "name": "test",
912 "mappings": [
914 {
915 "match": "test.invalid.duration",
916 "match_type": "invalid",
917 "name": "test.job.duration"
918 }
919 ]
920 }]);
921 assert!(mapper(json_data).is_err());
922 }
923}