@@ -189,11 +189,14 @@ where
189189/// (list of `{key, value_glob}` objects) into the shape `libdd-sampling`'s
190190/// `SamplingRuleConfig` accepts (a `{key: value}` map). Map-shape tags are
191191/// left untouched. Rules without `tags`, or with `tags` of an unexpected
192- /// type, are left untouched.
192+ /// type, are left untouched (libdatadog's parse will reject if necessary) .
193193///
194- /// Malformed list entries (missing `key` or `value_glob`, or non-string values)
195- /// are skipped rather than failing the whole update — RC schema validation is
196- /// the agent's job; the tracer should be lenient.
194+ /// If any list entry is malformed (missing `key`/`value_glob`, or non-string
195+ /// values), the rule's tags are left in their original (list) shape. This
196+ /// fails closed: libdatadog will reject the list-shape parse, the RC update
197+ /// is dropped, and the agent is informed via apply_state=3. We deliberately
198+ /// do not drop bad entries silently — doing so could broaden a tag-constrained
199+ /// rule into a less-constrained (or fully wildcard) rule.
197200fn normalize_rc_tags ( rules : & mut [ serde_json:: Value ] ) {
198201 for rule in rules {
199202 let Some ( obj) = rule. as_object_mut ( ) else {
@@ -207,19 +210,25 @@ fn normalize_rc_tags(rules: &mut [serde_json::Value]) {
207210 continue ;
208211 } ;
209212 let mut map = serde_json:: Map :: with_capacity ( entries. len ( ) ) ;
213+ let mut all_ok = true ;
210214 for entry in entries {
211215 let ( Some ( key) , Some ( value) ) = (
212216 entry. get ( "key" ) . and_then ( |v| v. as_str ( ) ) ,
213217 entry. get ( "value_glob" ) . and_then ( |v| v. as_str ( ) ) ,
214218 ) else {
215- continue ;
219+ all_ok = false ;
220+ break ;
216221 } ;
217222 map. insert (
218223 key. to_string ( ) ,
219224 serde_json:: Value :: String ( value. to_string ( ) ) ,
220225 ) ;
221226 }
222- obj. insert ( "tags" . to_string ( ) , serde_json:: Value :: Object ( map) ) ;
227+ if all_ok {
228+ obj. insert ( "tags" . to_string ( ) , serde_json:: Value :: Object ( map) ) ;
229+ }
230+ // else: leave the original list-shape tags in place; libdatadog's
231+ // parse will reject and the RC update is rejected as a whole.
223232 }
224233}
225234
@@ -835,7 +844,21 @@ impl ProductHandler for ApmTracingHandler {
835844
836845 let any_field_present =
837846 lib. tracing_sampling_rules . is_some ( ) || lib. tracing_sampling_rate . is_some ( ) ;
838- let rate: Option < f64 > = lib. tracing_sampling_rate . as_ref ( ) . and_then ( |v| v. as_f64 ( ) ) ;
847+
848+ // tracing_sampling_rate must be either null (clear) or a JSON number.
849+ // Any other present-but-non-numeric value (string, bool, object) is a
850+ // malformed payload — reject rather than silently treating it as a
851+ // clear, which would wipe an active remote sampling policy.
852+ let rate: Option < f64 > = match & lib. tracing_sampling_rate {
853+ None | Some ( serde_json:: Value :: Null ) => None ,
854+ Some ( serde_json:: Value :: Number ( n) ) => n. as_f64 ( ) ,
855+ Some ( other) => {
856+ return Err ( anyhow:: anyhow!(
857+ "tracing_sampling_rate must be a JSON number or null, got: {}" ,
858+ other
859+ ) ) ;
860+ }
861+ } ;
839862 let rules_value = match lib. tracing_sampling_rules {
840863 Some ( v) if !v. is_null ( ) => Some ( v) ,
841864 _ => None ,
@@ -2389,20 +2412,86 @@ mod tests {
23892412 }
23902413
23912414 #[ test]
2392- fn test_normalize_rc_tags_drops_malformed_list_entries ( ) {
2393- // A list entry missing `value_glob` or `key` is dropped silently —
2394- // we trust the agent / RC schema to deliver well-formed entries, but
2395- // we don't want one bad entry to kill the entire update.
2396- let mut rules: Vec < serde_json:: Value > = vec ! [ serde_json:: json!( {
2415+ fn test_normalize_rc_tags_leaves_malformed_list_untouched ( ) {
2416+ // If any list entry is malformed (missing key/value_glob), the rule's
2417+ // tags are left in their original list shape — libdatadog's parse will
2418+ // then reject the update as a whole. We must not drop bad entries
2419+ // silently, which could broaden a tag-constrained rule.
2420+ let original = serde_json:: json!( {
23972421 "sample_rate" : 0.5 ,
23982422 "tags" : [
23992423 { "key" : "env" , "value_glob" : "prod" } ,
24002424 { "key" : "region" }
24012425 ]
2402- } ) ] ;
2426+ } ) ;
2427+ let mut rules: Vec < serde_json:: Value > = vec ! [ original. clone( ) ] ;
24032428 normalize_rc_tags ( & mut rules) ;
2404- let tags = rules[ 0 ] [ "tags" ] . as_object ( ) . unwrap ( ) ;
2405- assert_eq ! ( tags. get( "env" ) . and_then( |v| v. as_str( ) ) , Some ( "prod" ) ) ;
2406- assert ! ( tags. get( "region" ) . is_none( ) ) ;
2429+ // Tags remain in the original (rejected) list shape.
2430+ assert_eq ! ( rules[ 0 ] , original) ;
2431+ }
2432+
2433+ #[ test]
2434+ fn test_handler_malformed_tags_rejects_update ( ) {
2435+ // Bug B fail-closed guard: a sampling rule with malformed list-shape
2436+ // tags must not be installed in a broadened form. With the prior
2437+ // override of sample_rate=0.5 in place, sending a rule with one bad
2438+ // tag entry must leave the prior override intact.
2439+ let config = build_config_for_handler ( ) ;
2440+ // 1. Install a working override.
2441+ let install = br#"{
2442+ "id": "rc-install",
2443+ "lib_config": {"tracing_sampling_rate": 0.5}
2444+ }"# ;
2445+ ApmTracingHandler . process_config ( install, & config) . unwrap ( ) ;
2446+ assert_eq ! ( config. trace_sampling_rules( ) . len( ) , 1 ) ;
2447+
2448+ // 2. Send a rule with a malformed tag entry.
2449+ let bad = br#"{
2450+ "id": "rc-bad-tags",
2451+ "lib_config": {
2452+ "tracing_sampling_rules": [
2453+ {
2454+ "sample_rate": 0.0,
2455+ "service": "svc",
2456+ "tags": [
2457+ {"key": "env", "value_glob": "prod"},
2458+ {"key": "region"}
2459+ ]
2460+ }
2461+ ]
2462+ }
2463+ }"# ;
2464+ // The libdatadog parse rejects list-shape tags, so the update fails
2465+ // internally and is logged at dd_debug! — process_config still returns
2466+ // Ok. The key invariant is that the prior remote override is not
2467+ // overwritten or cleared.
2468+ ApmTracingHandler . process_config ( bad, & config) . unwrap ( ) ;
2469+ let rules = config. trace_sampling_rules ( ) . to_vec ( ) ;
2470+ assert_eq ! ( rules. len( ) , 1 , "prior override must remain installed" ) ;
2471+ assert_eq ! ( rules[ 0 ] . sample_rate, 0.5 ) ;
2472+ }
2473+
2474+ #[ test]
2475+ fn test_handler_non_numeric_rate_rejects_update ( ) {
2476+ // A schema-drifted rate (e.g. string) must be rejected as a malformed
2477+ // payload, not silently treated as a clear that would wipe an active
2478+ // remote override.
2479+ let config = build_config_for_handler ( ) ;
2480+ let install = br#"{
2481+ "id": "rc-install",
2482+ "lib_config": {"tracing_sampling_rate": 0.5}
2483+ }"# ;
2484+ ApmTracingHandler . process_config ( install, & config) . unwrap ( ) ;
2485+ assert_eq ! ( config. trace_sampling_rules( ) . len( ) , 1 ) ;
2486+
2487+ let bad = br#"{
2488+ "id": "rc-bad-rate",
2489+ "lib_config": {"tracing_sampling_rate": "0.5"}
2490+ }"# ;
2491+ let result = ApmTracingHandler . process_config ( bad, & config) ;
2492+ assert ! ( result. is_err( ) , "non-numeric rate must be rejected" ) ;
2493+ // Prior override survives.
2494+ assert_eq ! ( config. trace_sampling_rules( ) . len( ) , 1 ) ;
2495+ assert_eq ! ( config. trace_sampling_rules( ) [ 0 ] . sample_rate, 0.5 ) ;
24072496 }
24082497}
0 commit comments