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