Skip to content

Commit cdac292

Browse files
bm1549claude
andcommitted
feat(sampling): accept Remote Config list-shape tags natively
SamplingRuleConfig::tags now accepts both the map shape ({"env": "prod"}) and the Remote Config wire shape ([{"key": "env", "value_glob": "prod"}]). List entries missing "key" or "value_glob" produce a deserialization error — we don't silently drop entries because doing so could broaden a tag- constrained rule. This removes the per-tracer normalize_rc_tags workaround. The dd-trace-rs caller stops normalizing in a follow-up PR once this lands in a libdatadog release. Co-Authored-By: Claude Opus 4.7 <[email protected]>
1 parent 118d260 commit cdac292

1 file changed

Lines changed: 121 additions & 2 deletions

File tree

libdd-sampling/src/sampling_rule_config.rs

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,13 @@ pub struct SamplingRuleConfig {
2525
#[serde(default)]
2626
pub resource: Option<String>,
2727

28-
/// Tags that must match (key-value pairs)
29-
#[serde(default)]
28+
/// Tags that must match (key-value pairs).
29+
///
30+
/// Accepts either the map shape `{"env": "prod"}` or the Remote Config
31+
/// wire shape `[{"key": "env", "value_glob": "prod"}]`. Internally both
32+
/// normalize to the map shape; the list-shape entries are required to
33+
/// have both `key` and `value_glob` (missing either rejects the rule).
34+
#[serde(default, deserialize_with = "deserialize_tags")]
3035
pub tags: HashMap<String, String>,
3136

3237
/// Where this rule comes from (customer, dynamic, default).
@@ -61,6 +66,61 @@ fn default_provenance() -> String {
6166
"default".to_string()
6267
}
6368

69+
/// Deserializes the `tags` field, accepting either:
70+
/// - map shape: `{"env": "prod", "region": "us-east-1"}`
71+
/// - list shape: `[{"key": "env", "value_glob": "prod"}, ...]`
72+
///
73+
/// A list entry missing `key` or `value_glob` produces a deserialization
74+
/// error; we never silently drop entries because that could broaden a
75+
/// tag-constrained sampling rule.
76+
fn deserialize_tags<'de, D>(deserializer: D) -> Result<HashMap<String, String>, D::Error>
77+
where
78+
D: serde::Deserializer<'de>,
79+
{
80+
use serde::de::{MapAccess, SeqAccess, Visitor};
81+
use std::fmt;
82+
83+
#[derive(serde::Deserialize)]
84+
struct ListEntry {
85+
key: String,
86+
value_glob: String,
87+
}
88+
89+
struct TagsVisitor;
90+
91+
impl<'de> Visitor<'de> for TagsVisitor {
92+
type Value = HashMap<String, String>;
93+
94+
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
95+
f.write_str("a map of string to string or a list of {key, value_glob} objects")
96+
}
97+
98+
fn visit_map<M>(self, mut access: M) -> Result<Self::Value, M::Error>
99+
where
100+
M: MapAccess<'de>,
101+
{
102+
let mut map = HashMap::with_capacity(access.size_hint().unwrap_or(0));
103+
while let Some((k, v)) = access.next_entry::<String, String>()? {
104+
map.insert(k, v);
105+
}
106+
Ok(map)
107+
}
108+
109+
fn visit_seq<S>(self, mut access: S) -> Result<Self::Value, S::Error>
110+
where
111+
S: SeqAccess<'de>,
112+
{
113+
let mut map = HashMap::with_capacity(access.size_hint().unwrap_or(0));
114+
while let Some(entry) = access.next_element::<ListEntry>()? {
115+
map.insert(entry.key, entry.value_glob);
116+
}
117+
Ok(map)
118+
}
119+
}
120+
121+
deserializer.deserialize_any(TagsVisitor)
122+
}
123+
64124
#[derive(Debug, Default, Clone, PartialEq)]
65125
pub struct ParsedSamplingRules {
66126
pub rules: Vec<SamplingRuleConfig>,
@@ -181,6 +241,65 @@ mod tests {
181241
assert_eq!(original, restored);
182242
}
183243

244+
#[test]
245+
fn test_sampling_rule_config_tags_accepts_map_shape() {
246+
// Already supported — guard against regression.
247+
let json = r#"{
248+
"sample_rate": 0.5,
249+
"service": "svc",
250+
"tags": {"env": "prod", "region": "us-east-1"}
251+
}"#;
252+
let cfg: SamplingRuleConfig = serde_json::from_str(json).unwrap();
253+
assert_eq!(cfg.tags.get("env").map(String::as_str), Some("prod"));
254+
assert_eq!(
255+
cfg.tags.get("region").map(String::as_str),
256+
Some("us-east-1")
257+
);
258+
}
259+
260+
#[test]
261+
fn test_sampling_rule_config_tags_accepts_rc_list_shape() {
262+
// Remote Config wire shape: list of {key, value_glob} entries.
263+
let json = r#"{
264+
"sample_rate": 0.5,
265+
"service": "svc",
266+
"tags": [
267+
{"key": "env", "value_glob": "prod"},
268+
{"key": "region", "value_glob": "us-east-1"}
269+
]
270+
}"#;
271+
let cfg: SamplingRuleConfig = serde_json::from_str(json).unwrap();
272+
assert_eq!(cfg.tags.get("env").map(String::as_str), Some("prod"));
273+
assert_eq!(
274+
cfg.tags.get("region").map(String::as_str),
275+
Some("us-east-1")
276+
);
277+
}
278+
279+
#[test]
280+
fn test_sampling_rule_config_tags_list_with_malformed_entry_rejects() {
281+
// A list entry missing `value_glob` must reject the whole rule rather
282+
// than silently dropping the entry — silently dropping a constraint could
283+
// broaden a tag-constrained rule and produce a security-relevant change
284+
// in sampling decisions.
285+
let json = r#"{
286+
"sample_rate": 0.5,
287+
"tags": [
288+
{"key": "env", "value_glob": "prod"},
289+
{"key": "region"}
290+
]
291+
}"#;
292+
let result: Result<SamplingRuleConfig, _> = serde_json::from_str(json);
293+
assert!(result.is_err(), "expected deserialization to fail");
294+
}
295+
296+
#[test]
297+
fn test_sampling_rule_config_tags_absent_defaults_to_empty() {
298+
let json = r#"{"sample_rate": 0.5}"#;
299+
let cfg: SamplingRuleConfig = serde_json::from_str(json).unwrap();
300+
assert!(cfg.tags.is_empty());
301+
}
302+
184303
#[test]
185304
fn test_sampling_rule_config_display() {
186305
let config = SamplingRuleConfig {

0 commit comments

Comments
 (0)