saluki_components/transforms/dogstatsd_mapper/
mod.rs

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/// DogstatsD mapper transform.
34#[serde_as]
35#[derive(Deserialize)]
36pub struct DogstatsDMapperConfiguration {
37    /// Total size of the string interner used for contexts, in bytes.
38    ///
39    /// This controls the amount of memory that will be pre-allocated for the purpose
40    /// of interning mapped metric names and tags, which can help to avoid unnecessary
41    /// allocations and allocator fragmentation.
42    #[serde(
43        rename = "dogstatsd_mapper_string_interner_size",
44        default = "default_context_string_interner_size"
45    )]
46    context_string_interner_bytes: ByteSize,
47
48    /// Configuration related to metric mapping.
49    #[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                    // Default to wildcard when not set.
93                    "" => 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        // Check it against the allowed wildcard pattern
151        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    // The metric name to extract groups from with the Wildcard or Regex match logic.
181    #[serde(rename = "match")]
182    metric_match: String,
183
184    // The type of match to apply to the `metric_match`. Either wildcard or regex.
185    #[serde(default)]
186    match_type: String,
187
188    // The new metric name to send to Datadog with the tags defined in the same group.
189    name: String,
190
191    // Map with the tag key and tag values collected from the `match_type` to inline.
192    #[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    /// Creates a new `DogstatsDMapperConfiguration` from the given configuration.
245    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            // Capture the size of the heap allocation when the component is built.
265            .with_single_value::<DogstatsDMapper>("component struct")
266            // We also allocate the backing storage for the string interner up front, which is used by our context
267            // resolver.
268            .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.*.*", // Invalid regex
839                    "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.*.*", // Invalid regex
854                    "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.**", // Consecutive *
869                    "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", // Invalid match_type
885                    "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            // "name" is missing here
896            "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            // "prefix" is missing here
913            "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}