saluki_components/transforms/dogstatsd_mapper/
mod.rs

1use std::collections::HashMap;
2use std::num::NonZeroUsize;
3use std::sync::LazyLock;
4use std::time::Duration;
5
6use async_trait::async_trait;
7use regex::Regex;
8use saluki_common::cache::{Cache, CacheBuilder};
9use saluki_context::tags::SharedTagSet;
10use saluki_context::tags::TagSet;
11use saluki_context::{Context, ContextResolver, ContextResolverBuilder};
12use saluki_core::accounting::{MemoryBounds, MemoryBoundsBuilder};
13use saluki_core::{
14    components::{
15        transforms::{SynchronousTransform, SynchronousTransformBuilder},
16        BuildContext,
17    },
18    topology::EventsBuffer,
19};
20use saluki_error::{generic_error, ErrorContext, GenericError};
21use stringtheory::MetaString;
22
23const MATCH_TYPE_WILDCARD: &str = "wildcard";
24const MATCH_TYPE_REGEX: &str = "regex";
25
26static ALLOWED_WILDCARD_MATCH_PATTERN: LazyLock<Regex> =
27    LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9\-_*.]+$").expect("Invalid regex in ALLOWED_WILDCARD_MATCH_PATTERN"));
28
29/// DogStatsD mapper transform.
30pub struct DogStatsDMapperConfiguration {
31    /// Total size of the string interner used for contexts, in bytes.
32    context_string_interner_bytes: NonZeroUsize,
33
34    /// Maximum number of mapped results to cache.
35    ///
36    /// When set to `0`, the cache is disabled.
37    cache_size: usize,
38
39    /// Metric mapping profiles.
40    profiles: Vec<DogStatsDMapperProfile>,
41}
42
43/// A DogStatsD metric mapping profile.
44pub struct DogStatsDMapperProfile {
45    /// Profile name used in configuration errors.
46    pub name: String,
47
48    /// Metric-name prefix this profile applies to.
49    pub prefix: String,
50
51    /// Metric mappings evaluated for this profile.
52    pub mappings: Vec<DogStatsDMetricMapping>,
53}
54
55/// A metric-name mapping in a [`DogStatsDMapperProfile`].
56pub struct DogStatsDMetricMapping {
57    /// Pattern matched against the incoming metric name.
58    pub metric_match: String,
59
60    /// Pattern type: `wildcard`, `regex`, or empty for the default wildcard behavior.
61    pub match_type: String,
62
63    /// Metric name emitted after a match.
64    pub name: String,
65
66    /// Tags emitted after expanding capture groups.
67    pub tags: HashMap<String, String>,
68}
69
70impl DogStatsDMapperConfiguration {
71    /// Creates a DogStatsD mapper configuration.
72    pub fn new(
73        context_string_interner_bytes: NonZeroUsize, cache_size: usize, profiles: Vec<DogStatsDMapperProfile>,
74    ) -> Self {
75        Self {
76            context_string_interner_bytes,
77            cache_size,
78            profiles,
79        }
80    }
81
82    fn build_mapper(&self, context: BuildContext) -> Result<MetricMapper, GenericError> {
83        let mut profiles = Vec::with_capacity(self.profiles.len());
84        for config_profile in &self.profiles {
85            if config_profile.name.is_empty() {
86                return Err(generic_error!("missing profile name"));
87            }
88            if config_profile.prefix.is_empty() {
89                return Err(generic_error!("missing prefix for profile: {}", config_profile.name));
90            }
91
92            let mut profile = MappingProfile {
93                prefix: config_profile.prefix.clone(),
94                mappings: Vec::with_capacity(config_profile.mappings.len()),
95            };
96
97            for (mapping_index, mapping) in config_profile.mappings.iter().enumerate() {
98                let match_type = match mapping.match_type.as_str() {
99                    // Default to wildcard when not set.
100                    "" => MATCH_TYPE_WILDCARD,
101                    MATCH_TYPE_WILDCARD => MATCH_TYPE_WILDCARD,
102                    MATCH_TYPE_REGEX => MATCH_TYPE_REGEX,
103                    unknown => {
104                        return Err(generic_error!(
105                            "profile: {}, mapping num {}: invalid match type `{}`, expected `wildcard` or `regex`",
106                            config_profile.name,
107                            mapping_index,
108                            unknown,
109                        ))
110                    }
111                };
112                if mapping.name.is_empty() {
113                    return Err(generic_error!(
114                        "profile: {}, mapping num {}: name is required",
115                        config_profile.name,
116                        mapping_index
117                    ));
118                }
119                if mapping.metric_match.is_empty() {
120                    return Err(generic_error!(
121                        "profile: {}, mapping num {}: match is required",
122                        config_profile.name,
123                        mapping_index
124                    ));
125                }
126                let regex = build_regex(&mapping.metric_match, match_type)?;
127                profile.mappings.push(MetricMapping {
128                    name: mapping.name.clone(),
129                    tags: mapping.tags.clone(),
130                    regex,
131                });
132            }
133            profiles.push(profile);
134        }
135
136        let context_resolver =
137            ContextResolverBuilder::from_name(format!("{}/dsd_mapper/primary", context.component_id()))
138                .expect("resolver name is not empty")
139                .with_interner_capacity_bytes(self.context_string_interner_bytes)
140                .with_idle_context_expiration(Duration::from_secs(30))
141                .build();
142
143        let cache = match NonZeroUsize::new(self.cache_size) {
144            Some(capacity) => Some(
145                CacheBuilder::from_identifier(format!("{}/dsd_mapper/result_cache", context.component_id()))?
146                    .with_capacity(capacity)
147                    .build(),
148            ),
149            None => None,
150        };
151
152        Ok(MetricMapper {
153            context_resolver,
154            profiles,
155            cache,
156        })
157    }
158}
159
160fn build_regex(match_re: &str, match_type: &str) -> Result<Regex, GenericError> {
161    let mut pattern = match_re.to_owned();
162    if match_type == MATCH_TYPE_WILDCARD {
163        // Check it against the allowed wildcard pattern
164        if !ALLOWED_WILDCARD_MATCH_PATTERN.is_match(&pattern) {
165            return Err(generic_error!(
166                "invalid wildcard match pattern `{}`, it does not match allowed match regex `{}`",
167                pattern,
168                ALLOWED_WILDCARD_MATCH_PATTERN.as_str()
169            ));
170        }
171        if pattern.contains("**") {
172            return Err(generic_error!(
173                "invalid wildcard match pattern `{}`, it should not contain consecutive `*`",
174                pattern
175            ));
176        }
177        pattern = pattern.replace(".", "\\.");
178        pattern = pattern.replace("*", "([^.]*)");
179    }
180
181    let final_pattern = format!("^{}$", pattern);
182
183    Regex::new(&final_pattern).with_error_context(|| {
184        format!(
185            "Failed to compile regular expression `{}` for `{}` match type",
186            final_pattern, match_type
187        )
188    })
189}
190
191struct MappingProfile {
192    prefix: String,
193    mappings: Vec<MetricMapping>,
194}
195
196struct MetricMapping {
197    name: String,
198    tags: HashMap<String, String>,
199    regex: Regex,
200}
201
202#[derive(Clone)]
203struct CachedMapResult {
204    name: MetaString,
205    extra_tags: SharedTagSet,
206}
207
208struct MetricMapper {
209    profiles: Vec<MappingProfile>,
210    context_resolver: ContextResolver,
211    cache: Option<Cache<MetaString, Option<CachedMapResult>>>,
212}
213
214impl MetricMapper {
215    fn try_map(&mut self, context: &Context) -> Option<Context> {
216        // TODO: We should really be able to immutably borrow both the incoming tag set and the cached extra tags and
217        // chain them together for our call into `resolve_with_origin_tags`, avoiding any allocations... but we need
218        // some supporting work on the `TagSet` side to make it possible.
219
220        let metric_name = context.name();
221        let tags = context.tags();
222        let origin_tags = context.origin_tags();
223        // TODO: If host-bearing remaps show measurable allocation overhead, preserve the context's underlying host
224        // representation through the resolver instead of rematerializing it from `&str`.
225        let host = context.host();
226
227        // See if we have a cached result for this metric name.
228        if let Some(cache) = &self.cache {
229            if let Some(cached) = cache.get(metric_name) {
230                return match cached {
231                    None => None,
232                    Some(result) => {
233                        let mut merged_tags = tags.clone();
234                        merged_tags.merge_shared(&result.extra_tags);
235
236                        self.context_resolver.resolve_with_optional_host_and_origin_tags(
237                            result.name.clone(),
238                            host,
239                            merged_tags,
240                            origin_tags.clone(),
241                        )
242                    }
243                };
244            }
245        }
246
247        // Slow path: iterate profiles and run regexes.
248        let mut new_name = String::new();
249        let mut expanded_tag_value = String::new();
250
251        for profile in &self.profiles {
252            if !metric_name.starts_with(&profile.prefix) && profile.prefix != "*" {
253                continue;
254            }
255
256            for mapping in &profile.mappings {
257                if let Some(captures) = mapping.regex.captures(metric_name) {
258                    new_name.clear();
259                    captures.expand(&mapping.name, &mut new_name);
260
261                    let mut extra_tags = TagSet::with_capacity(mapping.tags.len());
262                    for (tag_key, tag_value_expr) in &mapping.tags {
263                        expanded_tag_value.clear();
264                        expanded_tag_value.push_str(tag_key);
265                        expanded_tag_value.push(':');
266                        captures.expand(tag_value_expr, &mut expanded_tag_value);
267
268                        extra_tags.insert_tag(expanded_tag_value.as_str());
269                    }
270
271                    // Freeze the tags here so they can be shared / cached.
272                    let extra_tags = extra_tags.into_shared();
273
274                    let mut merged_tags = tags.clone();
275                    merged_tags.merge_shared(&extra_tags);
276
277                    let resolved = self.context_resolver.resolve_with_optional_host_and_origin_tags(
278                        new_name.as_str(),
279                        host,
280                        merged_tags,
281                        origin_tags.clone(),
282                    )?;
283
284                    if let Some(cache) = &self.cache {
285                        cache.insert(
286                            metric_name.clone(),
287                            Some(CachedMapResult {
288                                name: resolved.name().clone(),
289                                extra_tags,
290                            }),
291                        );
292                    }
293                    return Some(resolved);
294                }
295            }
296        }
297
298        // We also cache "negative" results -- no match for this metric in the configured profiles -- to save ourselves some work.
299        if let Some(cache) = &self.cache {
300            cache.insert(metric_name.clone(), None);
301        }
302        None
303    }
304
305    #[cfg(test)]
306    fn cache_len(&self) -> Option<usize> {
307        self.cache.as_ref().map(|c| c.len())
308    }
309
310    #[cfg(test)]
311    fn cache_contains(&self, metric_name: &str) -> bool {
312        self.cache
313            .as_ref()
314            .is_some_and(|c| c.get(&MetaString::from(metric_name)).is_some())
315    }
316}
317
318#[async_trait]
319impl SynchronousTransformBuilder for DogStatsDMapperConfiguration {
320    async fn build(&self, context: BuildContext) -> Result<Box<dyn SynchronousTransform + Send>, GenericError> {
321        let metric_mapper = self.build_mapper(context)?;
322        Ok(Box::new(DogStatsDMapper { metric_mapper }))
323    }
324}
325
326impl MemoryBounds for DogStatsDMapperConfiguration {
327    fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
328        let mut min = builder.minimum();
329        min
330            // Capture the size of the heap allocation when the component is built.
331            .with_single_value::<DogStatsDMapper>("component struct")
332            // We also allocate the backing storage for the string interner up front, which is used by our context
333            // resolver.
334            .with_fixed_amount("string interner", self.context_string_interner_bytes.get());
335
336        // Account for the per-name result cache when enabled.
337        if self.cache_size > 0 {
338            min.with_array::<(MetaString, Option<CachedMapResult>)>("mapper result cache", self.cache_size);
339        }
340    }
341}
342
343pub struct DogStatsDMapper {
344    metric_mapper: MetricMapper,
345}
346
347impl SynchronousTransform for DogStatsDMapper {
348    fn transform_buffer(&mut self, event_buffer: &mut EventsBuffer) {
349        for event in event_buffer {
350            if let Some(metric) = event.try_as_metric_mut() {
351                if let Some(new_context) = self.metric_mapper.try_map(metric.context()) {
352                    *metric.context_mut() = new_context;
353                }
354            }
355        }
356    }
357}
358
359#[cfg(test)]
360mod tests {
361
362    use std::collections::HashMap;
363    use std::num::NonZeroUsize;
364
365    use saluki_context::{Context, ContextResolverBuilder};
366    use saluki_core::{
367        components::{transforms::SynchronousTransform, BuildContext},
368        data_model::event::{metric::Metric, Event},
369        topology::EventsBuffer,
370    };
371    use saluki_error::GenericError;
372
373    use super::{
374        DogStatsDMapper, DogStatsDMapperConfiguration, DogStatsDMapperProfile, DogStatsDMetricMapping, MetricMapper,
375    };
376
377    macro_rules! metric_mapping {
378        (@match_type) => {
379            String::new()
380        };
381        (@match_type $match_type:expr) => {
382            $match_type.to_string()
383        };
384        ({
385            "match": $metric_match:expr,
386            $("match_type": $match_type:expr,)?
387            "name": $name:expr
388            $(, "tags": { $($tag_name:literal: $tag_value:expr),* $(,)? })?
389        }) => {
390            DogStatsDMetricMapping {
391                metric_match: $metric_match.to_string(),
392                match_type: metric_mapping!(@match_type $($match_type)?),
393                name: $name.to_string(),
394                tags: HashMap::from([$($(($tag_name.to_string(), $tag_value.to_string())),*)?]),
395            }
396        };
397    }
398
399    macro_rules! mapper_profiles {
400        ([$({
401            "name": $name:expr,
402            "prefix": $prefix:expr,
403            "mappings": [$($mapping:tt),* $(,)?]
404        }),* $(,)?]) => {
405            vec![$(DogStatsDMapperProfile {
406                name: $name.to_string(),
407                prefix: $prefix.to_string(),
408                mappings: vec![$(metric_mapping!($mapping)),*],
409            }),*]
410        };
411    }
412
413    fn counter_metric(name: &'static str, tags: &[&'static str]) -> Metric {
414        let context = Context::from_static_parts(name, tags);
415        Metric::counter(context, 1.0)
416    }
417
418    fn mapper(profiles: Vec<DogStatsDMapperProfile>) -> Result<MetricMapper, GenericError> {
419        mapper_with_cache(profiles, 1000)
420    }
421
422    fn mapper_with_cache(
423        profiles: Vec<DogStatsDMapperProfile>, cache_size: usize,
424    ) -> Result<MetricMapper, GenericError> {
425        let config =
426            DogStatsDMapperConfiguration::new(NonZeroUsize::new(64 * 1024).expect("not zero"), cache_size, profiles);
427        config.build_mapper(BuildContext::test_transform("test_mapper"))
428    }
429
430    fn assert_tags(context: &Context, expected_tags: &[&str]) {
431        for tag in expected_tags {
432            assert!(context.tags().has_tag(tag), "missing tag: {}", tag);
433        }
434        assert_eq!(context.tags().len(), expected_tags.len(), "unexpected number of tags");
435    }
436
437    #[track_caller]
438    fn assert_tags_for_case(context: &Context, expected_tags: &[&str], case: &str, input: &str) {
439        for tag in expected_tags {
440            assert!(
441                context.tags().has_tag(tag),
442                "[{case}] input {input:?}: missing tag {tag:?}"
443            );
444        }
445        assert_eq!(
446            context.tags().len(),
447            expected_tags.len(),
448            "[{case}] input {input:?}: unexpected number of tags"
449        );
450    }
451
452    fn simple_mapping_profile() -> Vec<DogStatsDMapperProfile> {
453        mapper_profiles!([{
454            "name": "test",
455            "prefix": "test.",
456            "mappings": [
457                {
458                    "match": "test.job.duration.*.*",
459                    "name": "test.job.duration",
460                    "tags": {
461                        "job_type": "$1",
462                        "job_name": "$2"
463                    }
464                }
465            ]
466        }])
467    }
468
469    #[tokio::test]
470    async fn config_driven_mappings_produce_expected_output() {
471        // Each case builds one mapper from `config`, then checks a series of inputs against it. A check is
472        // `(input_name, input_tags, expected)`, where `expected` is `Some((mapped_name, mapped_tags))` when the metric
473        // should be remapped, or `None` when it must pass through unmapped.
474        struct MapperCase {
475            description: &'static str,
476            config: Vec<DogStatsDMapperProfile>,
477            #[allow(clippy::type_complexity)]
478            checks: Vec<(
479                &'static str,
480                &'static [&'static str],
481                Option<(&'static str, &'static [&'static str])>,
482            )>,
483        }
484
485        let cases = vec![
486            MapperCase {
487                description: "wildcard mappings with capture-group tags",
488                config: mapper_profiles!([{
489                    "name": "test",
490                    "prefix": "test.",
491                    "mappings": [
492                        { "match": "test.job.duration.*.*", "name": "test.job.duration", "tags": { "job_type": "$1", "job_name": "$2" } },
493                        { "match": "test.job.size.*.*", "name": "test.job.size", "tags": { "foo": "$1", "bar": "$2" } }
494                    ]
495                }]),
496                checks: vec![
497                    (
498                        "test.job.duration.my_job_type.my_job_name",
499                        &[],
500                        Some(("test.job.duration", &["job_type:my_job_type", "job_name:my_job_name"])),
501                    ),
502                    (
503                        "test.job.size.my_job_type.my_job_name",
504                        &[],
505                        Some(("test.job.size", &["foo:my_job_type", "bar:my_job_name"])),
506                    ),
507                    ("test.job.size.not_match", &[], None),
508                ],
509            },
510            MapperCase {
511                description: "partial mapping, second mapping has no tags",
512                config: mapper_profiles!([{
513                    "name": "test",
514                    "prefix": "test.",
515                    "mappings": [
516                        { "match": "test.job.duration.*.*", "name": "test.job.duration", "tags": { "job_type": "$1" } },
517                        { "match": "test.task.duration.*.*", "name": "test.task.duration" }
518                    ]
519                }]),
520                checks: vec![
521                    (
522                        "test.job.duration.my_job_type.my_job_name",
523                        &[],
524                        Some(("test.job.duration", &["job_type:my_job_type"])),
525                    ),
526                    (
527                        "test.task.duration.my_job_type.my_job_name",
528                        &[],
529                        Some(("test.task.duration", &[])),
530                    ),
531                ],
532            },
533            MapperCase {
534                description: "regex expansion with ${n} syntax",
535                config: mapper_profiles!([{
536                    "name": "test",
537                    "prefix": "test.",
538                    "mappings": [
539                        { "match": "test.job.duration.*.*", "name": "test.job.duration", "tags": { "job_type": "${1}_x", "job_name": "${2}_y" } }
540                    ]
541                }]),
542                checks: vec![(
543                    "test.job.duration.my_job_type.my_job_name",
544                    &[],
545                    Some((
546                        "test.job.duration",
547                        &["job_type:my_job_type_x", "job_name:my_job_name_y"],
548                    )),
549                )],
550            },
551            MapperCase {
552                description: "capture groups expanded into the metric name",
553                config: mapper_profiles!([{
554                    "name": "test",
555                    "prefix": "test.",
556                    "mappings": [
557                        { "match": "test.job.duration.*.*", "name": "test.hello.$2.$1", "tags": { "job_type": "$1", "job_name": "$2" } }
558                    ]
559                }]),
560                checks: vec![(
561                    "test.job.duration.my_job_type.my_job_name",
562                    &[],
563                    Some((
564                        "test.hello.my_job_name.my_job_type",
565                        &["job_type:my_job_type", "job_name:my_job_name"],
566                    )),
567                )],
568            },
569            MapperCase {
570                description: "wildcard matches a segment before an underscore",
571                config: mapper_profiles!([{
572                    "name": "test",
573                    "prefix": "test.",
574                    "mappings": [
575                        { "match": "test.*_start", "name": "test.start", "tags": { "job": "$1" } }
576                    ]
577                }]),
578                checks: vec![("test.my_job_start", &[], Some(("test.start", &["job:my_job"])))],
579            },
580            MapperCase {
581                description: "mappings without any tags",
582                config: mapper_profiles!([{
583                    "name": "test",
584                    "prefix": "test.",
585                    "mappings": [
586                        { "match": "test.my-worker.start", "name": "test.worker.start" },
587                        { "match": "test.my-worker.stop.*", "name": "test.worker.stop" }
588                    ]
589                }]),
590                checks: vec![
591                    ("test.my-worker.start", &[], Some(("test.worker.start", &[]))),
592                    ("test.my-worker.stop.worker-name", &[], Some(("test.worker.stop", &[]))),
593                ],
594            },
595            MapperCase {
596                description: "all allowed wildcard characters",
597                config: mapper_profiles!([{
598                    "name": "test",
599                    "prefix": "test.",
600                    "mappings": [
601                        { "match": "test.abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ-01234567.*", "name": "test.alphabet" }
602                    ]
603                }]),
604                checks: vec![(
605                    "test.abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ-01234567.123",
606                    &[],
607                    Some(("test.alphabet", &[])),
608                )],
609            },
610            MapperCase {
611                description: "regex match type",
612                config: mapper_profiles!([{
613                    "name": "test",
614                    "prefix": "test.",
615                    "mappings": [
616                        { "match": "test\\.job\\.duration\\.(.*)", "match_type": "regex", "name": "test.job.duration", "tags": { "job_name": "$1" } },
617                        { "match": "test\\.task\\.duration\\.(.*)", "match_type": "regex", "name": "test.task.duration", "tags": { "task_name": "$1" } }
618                    ]
619                }]),
620                checks: vec![
621                    (
622                        "test.job.duration.my.funky.job$name-abc/123",
623                        &[],
624                        Some(("test.job.duration", &["job_name:my.funky.job$name-abc/123"])),
625                    ),
626                    (
627                        "test.task.duration.MY_task_name",
628                        &[],
629                        Some(("test.task.duration", &["task_name:MY_task_name"])),
630                    ),
631                ],
632            },
633            MapperCase {
634                description: "complex regex match type",
635                config: mapper_profiles!([{
636                    "name": "test",
637                    "prefix": "test.",
638                    "mappings": [
639                        { "match": "test\\.job\\.([a-z][0-9]-\\w+)\\.(.*)", "match_type": "regex", "name": "test.job", "tags": { "job_type": "$1", "job_name": "$2" } }
640                    ]
641                }]),
642                checks: vec![
643                    (
644                        "test.job.a5-foo.bar",
645                        &[],
646                        Some(("test.job", &["job_type:a5-foo", "job_name:bar"])),
647                    ),
648                    ("test.job.foo.bar-not-match", &[], None),
649                ],
650            },
651            MapperCase {
652                description: "multiple profiles matched by prefix",
653                config: mapper_profiles!([
654                    {
655                        "name": "test",
656                        "prefix": "foo.",
657                        "mappings": [ { "match": "foo.duration.*", "name": "foo.duration", "tags": { "name": "$1" } } ]
658                    },
659                    {
660                        "name": "test",
661                        "prefix": "bar.",
662                        "mappings": [
663                            { "match": "bar.count.*", "name": "bar.count", "tags": { "name": "$1" } },
664                            { "match": "foo.duration2.*", "name": "foo.duration2", "tags": { "name": "$1" } }
665                        ]
666                    }
667                ]),
668                checks: vec![
669                    (
670                        "foo.duration.foo_name1",
671                        &[],
672                        Some(("foo.duration", &["name:foo_name1"])),
673                    ),
674                    // `foo.duration2` only exists under the `bar.` prefix, so it can't be reached by a `foo.` metric.
675                    ("foo.duration2.foo_name1", &[], None),
676                    ("bar.count.bar_name1", &[], Some(("bar.count", &["name:bar_name1"]))),
677                    ("z.not.mapped", &[], None),
678                ],
679            },
680            MapperCase {
681                description: "wildcard prefix matches any metric",
682                config: mapper_profiles!([{
683                    "name": "test",
684                    "prefix": "*",
685                    "mappings": [ { "match": "foo.duration.*", "name": "foo.duration", "tags": { "name": "$1" } } ]
686                }]),
687                checks: vec![(
688                    "foo.duration.foo_name1",
689                    &[],
690                    Some(("foo.duration", &["name:foo_name1"])),
691                )],
692            },
693            MapperCase {
694                description: "only the first matching wildcard-prefixed profile applies",
695                config: mapper_profiles!([
696                    {
697                        "name": "test",
698                        "prefix": "*",
699                        "mappings": [ { "match": "foo.duration.*", "name": "foo.duration", "tags": { "name1": "$1" } } ]
700                    },
701                    {
702                        "name": "test",
703                        "prefix": "*",
704                        "mappings": [ { "match": "foo.duration.*", "name": "foo.duration", "tags": { "name2": "$1" } } ]
705                    }
706                ]),
707                // The single expected tag (and exact tag count) proves the second profile's `name2` tag was not applied.
708                checks: vec![(
709                    "foo.duration.foo_name",
710                    &[],
711                    Some(("foo.duration", &["name1:foo_name"])),
712                )],
713            },
714            MapperCase {
715                description: "only the first matching profile applies across differing prefixes",
716                config: mapper_profiles!([
717                    {
718                        "name": "test",
719                        "prefix": "foo.",
720                        "mappings": [ { "match": "foo.*.duration.*", "name": "foo.bar1.duration", "tags": { "bar": "$1", "foo": "$2" } } ]
721                    },
722                    {
723                        "name": "test",
724                        "prefix": "foo.bar.",
725                        "mappings": [ { "match": "foo.bar.duration.*", "name": "foo.bar2.duration", "tags": { "foo_bar": "$1" } } ]
726                    }
727                ]),
728                // The exact tag count proves the second profile's `foo_bar` tag was not applied.
729                checks: vec![(
730                    "foo.bar.duration.foo_name",
731                    &[],
732                    Some(("foo.bar1.duration", &["bar:bar", "foo:foo_name"])),
733                )],
734            },
735            MapperCase {
736                description: "regex expansion with (\\w+) groups",
737                config: mapper_profiles!([{
738                    "name": "test",
739                    "prefix": "test.",
740                    "mappings": [
741                        { "match": "test.user.(\\w+).action.(\\w+)", "match_type": "regex", "name": "test.user.action", "tags": { "user": "$1", "action": "$2" } }
742                    ]
743                }]),
744                checks: vec![(
745                    "test.user.john_doe.action.login",
746                    &[],
747                    Some(("test.user.action", &["user:john_doe", "action:login"])),
748                )],
749            },
750            MapperCase {
751                description: "existing metric tags are retained alongside mapped tags",
752                config: mapper_profiles!([{
753                    "name": "test",
754                    "prefix": "test.",
755                    "mappings": [
756                        { "match": "test.job.duration.*.*", "name": "test.job.duration.$2", "tags": { "job_type": "$1", "job_name": "$2" } }
757                    ]
758                }]),
759                checks: vec![(
760                    "test.job.duration.abc.def",
761                    &["foo:bar", "baz"],
762                    Some((
763                        "test.job.duration.def",
764                        &["foo:bar", "baz", "job_type:abc", "job_name:def"],
765                    )),
766                )],
767            },
768        ];
769
770        for case in cases {
771            let mut mapper = mapper(case.config)
772                .unwrap_or_else(|e| panic!("[{}] config should parse and build: {e}", case.description));
773
774            for (input_name, input_tags, expected) in case.checks {
775                let metric = counter_metric(input_name, input_tags);
776                match (mapper.try_map(metric.context()), expected) {
777                    (Some(context), Some((expected_name, expected_tags))) => {
778                        assert_eq!(
779                            context.name(),
780                            expected_name,
781                            "[{}] wrong mapped name for input {input_name:?}",
782                            case.description
783                        );
784                        assert_tags_for_case(&context, expected_tags, case.description, input_name);
785                    }
786                    (None, None) => {}
787                    (mapped, expected) => panic!(
788                        "[{}] input {input_name:?}: expected remap={}, got remap={}",
789                        case.description,
790                        expected.is_some(),
791                        mapped.is_some()
792                    ),
793                }
794            }
795        }
796    }
797
798    #[test]
799    fn invalid_mapper_configurations_are_rejected() {
800        // Each case is `(description, config, expected_error_substring)`. Empty required values reach the
801        // component's validation; missing fields are rejected at the configuration boundary.
802        let cases: Vec<(&str, Vec<DogStatsDMapperProfile>, &str)> = vec![
803            // Custom (present-but-empty) validation branches.
804            (
805                "profile with an empty name",
806                mapper_profiles!([{ "name": "", "prefix": "test.", "mappings": [] }]),
807                "missing profile name",
808            ),
809            (
810                "profile with an empty prefix",
811                mapper_profiles!([{ "name": "test", "prefix": "", "mappings": [] }]),
812                "missing prefix for profile: test",
813            ),
814            (
815                "mapping with an empty match",
816                mapper_profiles!([{ "name": "test", "prefix": "test.", "mappings": [{ "match": "", "name": "test.mapped" }] }]),
817                "match is required",
818            ),
819            (
820                "mapping with an empty name",
821                mapper_profiles!([{ "name": "test", "prefix": "test.", "mappings": [{ "match": "test.job.duration.*.*", "name": "", "tags": { "job_type": "$1" } }] }]),
822                "name is required",
823            ),
824            (
825                "second mapping with an empty name",
826                mapper_profiles!([{
827                    "name": "test",
828                    "prefix": "test.",
829                    "mappings": [
830                        { "match": "test.valid", "name": "mapped" },
831                        { "match": "test.invalid", "name": "" }
832                    ]
833                }]),
834                "mapping num 1: name is required",
835            ),
836            // Match compilation / type validation.
837            (
838                "wildcard match with disallowed characters",
839                mapper_profiles!([{ "name": "test", "prefix": "test.", "mappings": [{ "match": "test.[]duration.*.*", "name": "test.job.duration" }] }]),
840                "does not match allowed match regex",
841            ),
842            (
843                "wildcard match anchored with a caret",
844                mapper_profiles!([{ "name": "test", "prefix": "test.", "mappings": [{ "match": "^test.invalid.duration.*.*", "name": "test.job.duration" }] }]),
845                "does not match allowed match regex",
846            ),
847            (
848                "wildcard match with consecutive wildcards",
849                mapper_profiles!([{ "name": "test", "prefix": "test.", "mappings": [{ "match": "test.invalid.duration.**", "name": "test.job.duration" }] }]),
850                "consecutive",
851            ),
852            (
853                "unknown match type",
854                mapper_profiles!([{ "name": "test", "prefix": "test.", "mappings": [{ "match": "test.invalid.duration", "match_type": "invalid", "name": "test.job.duration" }] }]),
855                "invalid match type",
856            ),
857        ];
858
859        for (description, config, expected_substring) in cases {
860            let err = mapper(config)
861                .err()
862                .unwrap_or_else(|| panic!("[{description}] configuration should be rejected"));
863            let message = err.to_string();
864            assert!(
865                message.contains(expected_substring),
866                "[{description}] error {message:?} should contain {expected_substring:?}"
867            );
868        }
869    }
870
871    #[tokio::test]
872    async fn transform_buffer_remaps_matching_metrics_and_passes_others_through() {
873        // Drives the public `SynchronousTransform::transform_buffer` entry point (every other test exercises the
874        // internal `try_map`). Matching metrics are remapped in place; non-matching metrics pass through untouched.
875        let mut transform = DogStatsDMapper {
876            metric_mapper: mapper(simple_mapping_profile()).expect("config should parse and build"),
877        };
878
879        let mut events = EventsBuffer::default();
880        assert!(events
881            .try_push(Event::Metric(counter_metric("test.job.duration.my_type.my_name", &[])))
882            .is_none());
883        assert!(events
884            .try_push(Event::Metric(counter_metric("unrelated.metric", &["keep:me"])))
885            .is_none());
886
887        transform.transform_buffer(&mut events);
888
889        let metrics: Vec<Metric> = events.into_iter().filter_map(Event::try_into_metric).collect();
890        assert_eq!(metrics.len(), 2);
891
892        // The matching metric is remapped in place (order is preserved).
893        assert_eq!(metrics[0].context().name(), "test.job.duration");
894        assert_tags(metrics[0].context(), &["job_type:my_type", "job_name:my_name"]);
895
896        // The non-matching metric is left untouched.
897        assert_eq!(metrics[1].context().name(), "unrelated.metric");
898        assert_tags(metrics[1].context(), &["keep:me"]);
899    }
900
901    #[tokio::test]
902    async fn mapper_preserves_host_context_dimension() {
903        let profiles = mapper_profiles!([{
904          "name": "test",
905          "prefix": "test.",
906          "mappings": [
907            {
908              "match": "test.job.duration.*",
909              "name": "test.job.duration",
910              "tags": {
911                "job_name": "$1"
912              }
913            }
914          ]
915        }]);
916
917        let mut resolver = ContextResolverBuilder::for_tests().build();
918        let context_a = resolver
919            .resolve_with_host("test.job.duration.worker", "host-a", &[] as &[&str], None)
920            .expect("context should resolve");
921        let context_b = resolver
922            .resolve_with_host("test.job.duration.worker", "host-b", &[] as &[&str], None)
923            .expect("context should resolve");
924
925        let mut mapper = mapper(profiles).expect("should have built mapper");
926        let mapped_a = mapper.try_map(&context_a).expect("should have remapped");
927        let mapped_b = mapper.try_map(&context_b).expect("should have remapped");
928
929        assert_ne!(mapped_a, mapped_b);
930        assert_eq!(mapped_a.host(), Some("host-a"));
931        assert_eq!(mapped_b.host(), Some("host-b"));
932        assert_eq!(mapped_a.name(), "test.job.duration");
933        assert_tags(&mapped_a, &["job_name:worker"]);
934        assert_tags(&mapped_b, &["job_name:worker"]);
935    }
936
937    #[tokio::test]
938    async fn cache_hit_returns_same_result_as_miss() {
939        let mut mapper = mapper_with_cache(simple_mapping_profile(), 1000).expect("should have parsed mapping config");
940        assert_eq!(mapper.cache_len(), Some(0));
941
942        let metric = counter_metric("test.job.duration.my_type.my_name", &[]);
943        let first = mapper.try_map(metric.context()).expect("should have remapped");
944        assert_eq!(mapper.cache_len(), Some(1));
945
946        let metric = counter_metric("test.job.duration.my_type.my_name", &[]);
947        let second = mapper.try_map(metric.context()).expect("should have remapped");
948        assert_eq!(mapper.cache_len(), Some(1));
949
950        assert_eq!(first.name(), second.name());
951        assert_eq!(first.name(), "test.job.duration");
952        assert_tags(&first, &["job_type:my_type", "job_name:my_name"]);
953        assert_tags(&second, &["job_type:my_type", "job_name:my_name"]);
954    }
955
956    #[tokio::test]
957    async fn negative_results_are_cached() {
958        let mut mapper = mapper_with_cache(simple_mapping_profile(), 1000).expect("should have parsed mapping config");
959
960        let metric = counter_metric("unrelated.metric.name", &[]);
961        assert!(mapper.try_map(metric.context()).is_none());
962        assert_eq!(mapper.cache_len(), Some(1));
963
964        let metric = counter_metric("unrelated.metric.name", &[]);
965        assert!(mapper.try_map(metric.context()).is_none());
966        assert_eq!(mapper.cache_len(), Some(1));
967    }
968
969    #[tokio::test]
970    async fn cache_disabled_when_size_is_zero() {
971        let mut mapper = mapper_with_cache(simple_mapping_profile(), 0).expect("should have parsed mapping config");
972        assert_eq!(mapper.cache_len(), None);
973
974        let metric = counter_metric("test.job.duration.my_type.my_name", &[]);
975        let context = mapper.try_map(metric.context()).expect("should have remapped");
976        assert_eq!(context.name(), "test.job.duration");
977        assert_tags(&context, &["job_type:my_type", "job_name:my_name"]);
978
979        assert!(mapper
980            .try_map(counter_metric("unrelated.metric", &[]).context())
981            .is_none());
982        assert_eq!(mapper.cache_len(), None);
983    }
984
985    #[tokio::test]
986    async fn cache_evicts_older_entry_and_retains_newest_within_capacity() {
987        let mut mapper = mapper_with_cache(simple_mapping_profile(), 2).expect("should have parsed mapping config");
988
989        // Insert three distinct metric names into a capacity-2 result cache, in order a, b, then c.
990        for suffix in ["a", "b", "c"] {
991            let name = format!("test.job.duration.t.{}", suffix);
992            let metric = counter_metric(Box::leak(name.into_boxed_str()), &[]);
993            mapper.try_map(metric.context()).expect("should have remapped");
994        }
995
996        let a = mapper.cache_contains("test.job.duration.t.a");
997        let b = mapper.cache_contains("test.job.duration.t.b");
998        let c = mapper.cache_contains("test.job.duration.t.c");
999
1000        // The cache must respect its configured capacity...
1001        assert!(
1002            mapper.cache_len().unwrap() <= 2,
1003            "cache should not exceed configured capacity (got {})",
1004            mapper.cache_len().unwrap()
1005        );
1006        // ...eviction must actually have happened (three distinct names cannot all fit in a capacity-2 cache)...
1007        assert!(!(a && b && c), "at least one older entry must have been evicted");
1008        // ...the most-recently-inserted name ("c") must be the entry that survives eviction...
1009        assert!(c, "the most-recently-inserted metric name should survive eviction");
1010        // ...and with "c" retained at capacity 2, at most one of the two older names may remain.
1011        assert!(
1012            !(a && b),
1013            "only one older entry may coexist with the newest entry at capacity 2"
1014        );
1015    }
1016
1017    #[tokio::test]
1018    async fn flood_of_identical_names_populates_single_cache_entry() {
1019        // Many profiles, only the last one matches the test metric. A flood of identical
1020        // names should be served from the cache after the first call.
1021        let mut profiles: Vec<DogStatsDMapperProfile> = (0..50)
1022            .map(|i| DogStatsDMapperProfile {
1023                name: format!("noise-{i}"),
1024                prefix: format!("noise{i}."),
1025                mappings: vec![DogStatsDMetricMapping {
1026                    metric_match: format!("noise{i}.*"),
1027                    match_type: String::new(),
1028                    name: "noise.mapped".to_string(),
1029                    tags: HashMap::new(),
1030                }],
1031            })
1032            .collect();
1033        profiles.push(DogStatsDMapperProfile {
1034            name: "real".to_string(),
1035            prefix: "real.".to_string(),
1036            mappings: vec![DogStatsDMetricMapping {
1037                metric_match: "real.metric.*".to_string(),
1038                match_type: String::new(),
1039                name: "real.mapped".to_string(),
1040                tags: [("x".to_string(), "$1".to_string())].into(),
1041            }],
1042        });
1043
1044        let mut mapper = mapper_with_cache(profiles, 16).expect("should have built mapper");
1045
1046        for _ in 0..10_000 {
1047            let metric = counter_metric("real.metric.flood", &[]);
1048            let context = mapper.try_map(metric.context()).expect("should have remapped");
1049            assert_eq!(context.name(), "real.mapped");
1050        }
1051
1052        assert_eq!(
1053            mapper.cache_len(),
1054            Some(1),
1055            "flood of identical names should populate exactly one cache entry"
1056        );
1057    }
1058}