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
29pub struct DogStatsDMapperConfiguration {
31 context_string_interner_bytes: NonZeroUsize,
33
34 cache_size: usize,
38
39 profiles: Vec<DogStatsDMapperProfile>,
41}
42
43pub struct DogStatsDMapperProfile {
45 pub name: String,
47
48 pub prefix: String,
50
51 pub mappings: Vec<DogStatsDMetricMapping>,
53}
54
55pub struct DogStatsDMetricMapping {
57 pub metric_match: String,
59
60 pub match_type: String,
62
63 pub name: String,
65
66 pub tags: HashMap<String, String>,
68}
69
70impl DogStatsDMapperConfiguration {
71 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 "" => 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 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 let metric_name = context.name();
221 let tags = context.tags();
222 let origin_tags = context.origin_tags();
223 let host = context.host();
226
227 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 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 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 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 .with_single_value::<DogStatsDMapper>("component struct")
332 .with_fixed_amount("string interner", self.context_string_interner_bytes.get());
335
336 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 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.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 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 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 let cases: Vec<(&str, Vec<DogStatsDMapperProfile>, &str)> = vec![
803 (
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 (
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 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 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 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 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 assert!(
1002 mapper.cache_len().unwrap() <= 2,
1003 "cache should not exceed configured capacity (got {})",
1004 mapper.cache_len().unwrap()
1005 );
1006 assert!(!(a && b && c), "at least one older entry must have been evicted");
1008 assert!(c, "the most-recently-inserted metric name should survive eviction");
1010 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 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}