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#[serde_as]
43#[derive(Deserialize)]
44#[cfg_attr(test, derive(Debug, PartialEq, serde::Serialize))]
45pub struct DogStatsDMapperConfiguration {
46 #[serde(
52 rename = "dogstatsd_mapper_string_interner_size",
53 default = "default_context_string_interner_size"
54 )]
55 context_string_interner_bytes: ByteSize,
56
57 #[serde(
65 rename = "dogstatsd_mapper_cache_size",
66 default = "default_dogstatsd_mapper_cache_size"
67 )]
68 cache_size: usize,
69
70 #[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 "" => 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 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 #[serde(rename = "match")]
221 metric_match: String,
222
223 #[serde(default)]
225 match_type: String,
226
227 name: String,
229
230 #[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 let metric_name = context.name();
265 let tags = context.tags();
266 let origin_tags = context.origin_tags();
267 let host = context.host();
270
271 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 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 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 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 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 .with_single_value::<DogStatsDMapper>("component struct")
385 .with_fixed_amount("string interner", self.context_string_interner_bytes.as_u64() as usize);
388
389 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 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.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 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 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 let cases: Vec<(&str, Value, &str)> = vec![
818 (
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 (
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 (
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 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 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 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 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 assert!(
1021 mapper.cache_len().unwrap() <= 2,
1022 "cache should not exceed configured capacity (got {})",
1023 mapper.cache_len().unwrap()
1024 );
1025 assert!(!(a && b && c), "at least one older entry must have been evicted");
1027 assert!(c, "the most-recently-inserted metric name should survive eviction");
1029 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 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}