Skip to content

Commit 8044081

Browse files
ArunPiduguDDclaude
andcommitted
feat(tag_cardinality_limit transform): add tracking_scope setting for per-metric vs global tag tracking
When metrics do not have an explicit `per_metric_limits` entry, their tag values were always pooled into a single shared bucket. The new `tracking_scope` setting lets users opt into per-metric tracking buckets instead, providing isolation at the cost of higher memory. Default is `global` (current behavior); `per_metric` gives every distinct (namespace, name) its own bucket regardless of `per_metric_limits` membership. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 95756d7 commit 8044081

5 files changed

Lines changed: 125 additions & 9 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
The `tag_cardinality_limit` transform gained a new top-level `tracking_scope` setting
2+
that controls how tag tracking state is partitioned across metrics that do not have an
3+
explicit `per_metric_limits` entry:
4+
5+
- `global` (default — preserves existing behavior): all such metrics share a single
6+
tracking bucket, and the global `value_limit` caps the combined set of tag values
7+
across them.
8+
- `per_metric`: every distinct metric (namespace, name) gets its own tracking bucket
9+
regardless of whether it has a `per_metric_limits` entry, providing full isolation at
10+
the cost of higher memory.
11+
12+
authors: ArunPiduguDD

src/transforms/tag_cardinality_limit/config.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ pub struct Config {
3535
#[serde(flatten)]
3636
pub global: Inner,
3737

38+
/// Controls how tag tracking is partitioned for metrics that don't have a
39+
/// `per_metric_limits` entry.
40+
#[configurable(derived)]
41+
#[serde(default)]
42+
pub tracking_scope: TrackingScope,
43+
3844
/// Tag cardinality limits configuration per metric name.
3945
#[configurable(
4046
derived,
@@ -44,6 +50,25 @@ pub struct Config {
4450
pub per_metric_limits: HashMap<String, PerMetricConfig>,
4551
}
4652

53+
/// Controls how tag tracking state is partitioned across metrics that do not
54+
/// have an explicit `per_metric_limits` entry.
55+
#[configurable_component]
56+
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
57+
#[serde(rename_all = "snake_case")]
58+
pub enum TrackingScope {
59+
/// All metrics without an explicit `per_metric_limits` entry share a single
60+
/// tracking bucket. Tag values pool across metrics, and the global
61+
/// `value_limit` caps the combined set. Lower memory but cross-metric
62+
/// pollution.
63+
#[default]
64+
Global,
65+
66+
/// Every distinct metric (namespace, name) gets its own tracking bucket
67+
/// regardless of whether it has a `per_metric_limits` entry. Each metric is
68+
/// capped independently. Higher memory but full isolation.
69+
PerMetric,
70+
}
71+
4772
/// Configuration for the `tag_cardinality_limit` transform for a specific group of metrics.
4873
#[configurable_component]
4974
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -150,6 +175,7 @@ impl GenerateConfig for Config {
150175
limit_exceeded_action: default_limit_exceeded_action(),
151176
internal_metrics: InternalMetricsConfig::default(),
152177
},
178+
tracking_scope: TrackingScope::default(),
153179
per_metric_limits: HashMap::default(),
154180
})
155181
.unwrap()

src/transforms/tag_cardinality_limit/mod.rs

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ mod tag_value_set;
1515
#[cfg(test)]
1616
mod tests;
1717

18-
pub use config::{BloomFilterConfig, Config, Inner, LimitExceededAction, Mode, PerMetricConfig};
18+
pub use config::{
19+
BloomFilterConfig, Config, Inner, LimitExceededAction, Mode, PerMetricConfig, TrackingScope,
20+
};
1921
use tag_value_set::AcceptedTagValueSet;
2022

2123
use crate::event::metric::TagValueSet;
@@ -125,14 +127,20 @@ impl TagCardinalityLimit {
125127
let metric = event.as_mut_metric();
126128
let metric_name = metric.name().to_string();
127129
let metric_namespace = metric.namespace().map(|n| n.to_string());
128-
let has_per_metric_config = self.config.per_metric_limits.iter().any(|(name, config)| {
129-
*name == metric_name
130-
&& (config.namespace.is_none() || config.namespace == metric_namespace)
131-
});
132-
let metric_key = if has_per_metric_config {
133-
Some((metric_namespace, metric_name.clone()))
134-
} else {
135-
None
130+
let metric_key = match self.config.tracking_scope {
131+
TrackingScope::PerMetric => Some((metric_namespace, metric_name.clone())),
132+
TrackingScope::Global => {
133+
let has_per_metric_config =
134+
self.config.per_metric_limits.iter().any(|(name, config)| {
135+
*name == metric_name
136+
&& (config.namespace.is_none() || config.namespace == metric_namespace)
137+
});
138+
if has_per_metric_config {
139+
Some((metric_namespace, metric_name.clone()))
140+
} else {
141+
None
142+
}
143+
}
136144
};
137145
if let Some(tags_map) = metric.tags_mut() {
138146
match self

src/transforms/tag_cardinality_limit/tests.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ fn make_transform_hashset(
5757
mode: Mode::Exact,
5858
internal_metrics: InternalMetricsConfig::default(),
5959
},
60+
tracking_scope: TrackingScope::default(),
6061
per_metric_limits: HashMap::new(),
6162
}
6263
}
@@ -71,6 +72,7 @@ fn make_transform_bloom(value_limit: usize, limit_exceeded_action: LimitExceeded
7172
}),
7273
internal_metrics: InternalMetricsConfig::default(),
7374
},
75+
tracking_scope: TrackingScope::default(),
7476
per_metric_limits: HashMap::new(),
7577
}
7678
}
@@ -87,6 +89,7 @@ fn make_transform_hashset_with_per_metric_limits(
8789
mode: Mode::Exact,
8890
internal_metrics: InternalMetricsConfig::default(),
8991
},
92+
tracking_scope: TrackingScope::default(),
9093
per_metric_limits,
9194
}
9295
}
@@ -105,6 +108,7 @@ fn make_transform_bloom_with_per_metric_limits(
105108
}),
106109
internal_metrics: InternalMetricsConfig::default(),
107110
},
111+
tracking_scope: TrackingScope::default(),
108112
per_metric_limits,
109113
}
110114
}
@@ -595,3 +599,46 @@ async fn separate_value_limit_per_metric_name(config: Config) {
595599
})
596600
.await;
597601
}
602+
603+
/// With `tracking_scope: per_metric`, two metrics without explicit `per_metric_limits` entries
604+
/// each get their own tracking bucket, so one hitting the limit does not affect the other.
605+
#[test]
606+
fn tracking_scope_per_metric_isolates_metrics() {
607+
let mut config = make_transform_hashset(2, LimitExceededAction::DropEvent);
608+
config.tracking_scope = TrackingScope::PerMetric;
609+
let mut transform = TagCardinalityLimit::new(config);
610+
611+
// Fill metric_a's bucket to its limit (2 distinct values).
612+
let a1 = make_metric_with_name(metric_tags!("tag" => "v1"), "metric_a");
613+
let a2 = make_metric_with_name(metric_tags!("tag" => "v2"), "metric_a");
614+
// metric_b should be tracked in its own bucket — not affected by metric_a.
615+
let b1 = make_metric_with_name(metric_tags!("tag" => "v3"), "metric_b");
616+
let b2 = make_metric_with_name(metric_tags!("tag" => "v4"), "metric_b");
617+
// A 3rd unique value on metric_a should be rejected (its bucket is full).
618+
let a3 = make_metric_with_name(metric_tags!("tag" => "v5"), "metric_a");
619+
620+
assert_eq!(transform.transform_one(a1.clone()), Some(a1));
621+
assert_eq!(transform.transform_one(a2.clone()), Some(a2));
622+
assert_eq!(transform.transform_one(b1.clone()), Some(b1));
623+
assert_eq!(transform.transform_one(b2.clone()), Some(b2));
624+
assert_eq!(transform.transform_one(a3), None);
625+
}
626+
627+
/// With the default `tracking_scope: global`, metrics without explicit `per_metric_limits`
628+
/// entries share a single tracking bucket — so values from different metrics pool together.
629+
#[test]
630+
fn tracking_scope_global_pools_metrics() {
631+
// Default `tracking_scope` is `Global`.
632+
let config = make_transform_hashset(2, LimitExceededAction::DropEvent);
633+
let mut transform = TagCardinalityLimit::new(config);
634+
635+
let a1 = make_metric_with_name(metric_tags!("tag" => "v1"), "metric_a");
636+
// Different metric, but values pool into the shared bucket → 2/2 used.
637+
let b1 = make_metric_with_name(metric_tags!("tag" => "v2"), "metric_b");
638+
// 3rd unique value across the shared bucket → rejected.
639+
let a2 = make_metric_with_name(metric_tags!("tag" => "v3"), "metric_a");
640+
641+
assert_eq!(transform.transform_one(a1.clone()), Some(a1));
642+
assert_eq!(transform.transform_one(b1.clone()), Some(b1));
643+
assert_eq!(transform.transform_one(a2), None);
644+
}

website/cue/reference/components/transforms/generated/tag_cardinality_limit.cue

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,29 @@ generated: components: transforms: tag_cardinality_limit: configuration: {
139139
}
140140
}
141141
}
142+
tracking_scope: {
143+
description: """
144+
Controls how tag tracking is partitioned for metrics that don't have a
145+
`per_metric_limits` entry.
146+
"""
147+
required: false
148+
type: string: {
149+
default: "global"
150+
enum: {
151+
global: """
152+
All metrics without an explicit `per_metric_limits` entry share a single
153+
tracking bucket. Tag values pool across metrics, and the global
154+
`value_limit` caps the combined set. Lower memory but cross-metric
155+
pollution.
156+
"""
157+
per_metric: """
158+
Every distinct metric (namespace, name) gets its own tracking bucket
159+
regardless of whether it has a `per_metric_limits` entry. Each metric is
160+
capped independently. Higher memory but full isolation.
161+
"""
162+
}
163+
}
164+
}
142165
value_limit: {
143166
description: "How many distinct values to accept for any given key."
144167
required: false

0 commit comments

Comments
 (0)