1use std::collections::HashSet;
5use std::sync::{Arc, OnceLock};
6
7use agent_data_plane_config::{Live, SalukiConfiguration};
8use arc_swap::ArcSwap;
9use datadog_agent_config::{apply_env_overlay, DatadogConfiguration, EnvOverlayMode, TranslateErrors};
10use saluki_config::dynamic::ConfigUpdate;
11use saluki_config::{upsert, ConfigurationError, GenericConfiguration};
12use serde::Deserialize;
13use serde_json::Value;
14use snafu::Snafu;
15use tokio::sync::{mpsc, watch};
16use tracing::{debug, warn};
17
18use crate::saluki_env_overlay;
19use crate::saluki_only::SalukiOnly;
20use crate::translators::DatadogTranslator;
21
22#[derive(Debug, Snafu)]
24pub enum Error {
25 #[snafu(context(false), display("{source}"))]
27 Source {
28 source: ConfigurationError,
30 },
31
32 #[snafu(context(false), display("{source}"))]
34 Deserialize {
35 source: serde_json::Error,
37 },
38
39 #[snafu(display("configuration stream closed before the initial snapshot"))]
41 StreamClosed,
42
43 #[snafu(display("failed to build the configuration base: {message}"))]
45 Base {
46 message: String,
48 },
49
50 #[snafu(display("{source}"))]
52 Translate {
53 source: TranslateErrors,
55 },
56}
57
58type Result<T> = std::result::Result<T, Error>;
59
60pub struct ConfigurationSystem {
69 raw_map: GenericConfiguration,
70 current: Arc<ArcSwap<SalukiConfiguration>>,
71 tick: Arc<watch::Sender<()>>,
75}
76
77impl ConfigurationSystem {
78 pub(crate) async fn connected(
92 mut agent_rx: mpsc::Receiver<ConfigUpdate>, compat_tx: mpsc::Sender<ConfigUpdate>,
93 compat_map: GenericConfiguration, base: Value, overlay: EnvOverlayMode,
94 ) -> Result<Self> {
95 let first = agent_rx.recv().await.ok_or(Error::StreamClosed)?;
97
98 let mut agent = Value::Null;
101 fold(&mut agent, &first);
102 forward(&compat_tx, first).await;
103 compat_map.ready().await;
104
105 let merged = deep_merge(base.clone(), agent.clone());
110 let config = translate_strict(&merged, overlay)?;
111
112 let current = Arc::new(ArcSwap::from_pointee(config));
113 let (tick, _) = watch::channel(());
116 let tick = Arc::new(tick);
117
118 tokio::spawn(agent_loop(
119 agent_rx,
120 compat_tx,
121 base,
122 agent,
123 Arc::clone(¤t),
124 Arc::clone(&tick),
125 overlay,
126 ));
127
128 Ok(Self {
129 raw_map: compat_map,
130 current,
131 tick,
132 })
133 }
134
135 pub(crate) fn standalone(compat_map: GenericConfiguration, config: SalukiConfiguration) -> Self {
139 let current = Arc::new(ArcSwap::from_pointee(config));
140 let (tick, _) = watch::channel(());
141 Self {
142 raw_map: compat_map,
143 current,
144 tick: Arc::new(tick),
145 }
146 }
147
148 pub fn live<T>(&self, project: impl for<'a> Fn(&'a SalukiConfiguration) -> &'a T + Send + Sync + 'static) -> Live<T>
151 where
152 T: Clone + PartialEq + 'static,
153 {
154 Live::new_dynamic(Arc::clone(&self.current), self.tick.subscribe(), project)
155 }
156
157 pub fn config(&self) -> arc_swap::Guard<Arc<SalukiConfiguration>> {
161 self.current.load()
162 }
163
164 pub fn current_handle(&self) -> Arc<ArcSwap<SalukiConfiguration>> {
167 Arc::clone(&self.current)
168 }
169
170 pub fn raw_map(&self) -> GenericConfiguration {
172 self.raw_map.clone()
173 }
174}
175
176async fn agent_loop(
183 mut agent_rx: mpsc::Receiver<ConfigUpdate>, compat_tx: mpsc::Sender<ConfigUpdate>, base: Value, mut agent: Value,
184 current: Arc<ArcSwap<SalukiConfiguration>>, tick: Arc<watch::Sender<()>>, overlay: EnvOverlayMode,
185) {
186 while let Some(update) = agent_rx.recv().await {
187 let mut tentative = agent.clone();
191 fold(&mut tentative, &update);
192 let merged = deep_merge(base.clone(), tentative.clone());
193 match translate_strict(&merged, overlay) {
194 Ok(config) => {
195 agent = tentative;
196 current.store(Arc::new(config));
197 tick.send_replace(());
198 debug!("Applied configuration update.");
199 }
200 Err(e) => warn!(
201 error = %e,
202 "Rejected configuration update; keeping the last-known-good typed configuration. The \
203 compatibility map still receives this update, so the two representations may now \
204 differ, so that divergence indicates a defect in the typed configuration system, not \
205 in the configuration itself."
206 ),
207 }
208 forward(&compat_tx, update).await;
213 }
214}
215
216fn fold(agent: &mut Value, update: &ConfigUpdate) {
222 match update {
223 ConfigUpdate::Snapshot(state) => *agent = state.clone(),
224 ConfigUpdate::Partial { key, value } => {
225 if agent.is_null() {
226 *agent = Value::Object(serde_json::Map::new());
227 }
228 upsert(agent, key, value.clone());
229 }
230 }
231}
232
233async fn forward(compat_tx: &mpsc::Sender<ConfigUpdate>, update: ConfigUpdate) {
235 let _ = compat_tx.send(update).await;
236}
237
238pub(crate) fn translate_strict(merged: &Value, overlay: EnvOverlayMode) -> Result<SalukiConfiguration> {
244 let Sources { datadog, saluki } = deserialize_sources(merged, overlay)?;
245 let (config, errors) = translate(&datadog, &saluki);
246 if let Some(errors) = errors {
247 return Err(Error::Translate { source: errors });
248 }
249 Ok(config)
250}
251
252fn deep_merge(base: Value, overlay: Value) -> Value {
267 merge_at(base, overlay, &mut Vec::new(), section_paths())
268}
269
270fn merge_at(base: Value, overlay: Value, path: &mut Vec<String>, sections: &HashSet<Vec<String>>) -> Value {
274 match (base, overlay) {
275 (Value::Object(mut base_map), Value::Object(overlay_map)) => {
276 for (key, overlay_value) in overlay_map {
277 path.push(key.clone());
278 let merged = match base_map.remove(&key) {
279 Some(base_value) if sections.contains(path.as_slice()) => {
280 merge_at(base_value, overlay_value, path, sections)
281 }
282 _ => overlay_value,
283 };
284 path.pop();
285 base_map.insert(key, merged);
286 }
287 Value::Object(base_map)
288 }
289 (_, overlay) => overlay,
290 }
291}
292
293fn section_paths() -> &'static HashSet<Vec<String>> {
297 static SECTIONS: OnceLock<HashSet<Vec<String>>> = OnceLock::new();
298 SECTIONS.get_or_init(|| {
299 let mut sections = HashSet::new();
300 let mut add_prefixes = |segments: Vec<String>| {
301 for end in 1..segments.len() {
302 sections.insert(segments[..end].to_vec());
303 }
304 };
305 for path in datadog_agent_config::datadog_leaf_paths() {
306 add_prefixes(path.iter().map(|segment| (*segment).to_string()).collect());
307 }
308 for path in saluki_env_overlay::leaf_paths() {
309 add_prefixes(path.to_vec());
310 }
311 sections
312 })
313}
314
315struct Sources {
317 datadog: DatadogConfiguration,
318 saluki: SalukiOnly,
319}
320
321fn deserialize_sources(merged: &Value, overlay: EnvOverlayMode) -> Result<Sources> {
332 let mut merged = merged.clone();
333 saluki_env_overlay::apply(&mut merged, overlay);
334 let saluki = SalukiOnly::deserialize(&merged)?;
335 apply_env_overlay(&mut merged, overlay);
336 let datadog = DatadogConfiguration::deserialize(&merged)?;
337 Ok(Sources { datadog, saluki })
338}
339
340fn translate(datadog: &DatadogConfiguration, saluki: &SalukiOnly) -> (SalukiConfiguration, Option<TranslateErrors>) {
348 let (mut config, errors) = DatadogTranslator::new(datadog).translate();
349 saluki.seed(&mut config);
350 (config, errors)
351}
352
353#[cfg(test)]
354mod tests {
355 use std::time::Duration;
356
357 use agent_data_plane_config::domains::dogstatsd::OriginTagCardinality;
358 use agent_data_plane_config::{Live, SalukiConfiguration};
359 use datadog_agent_config::{DatadogConfiguration, EnvOverlayMode};
360 use saluki_config::dynamic::ConfigUpdate;
361 use saluki_config::ConfigurationLoader;
362 use serde_json::{json, Value};
363 use tokio::sync::mpsc;
364
365 use super::{deep_merge, translate, translate_strict, ConfigurationSystem, Error, SalukiOnly};
366
367 async fn standalone_system(
369 file: Option<Value>, env: Option<&[(String, String)]>, overlay: EnvOverlayMode,
370 ) -> Result<ConfigurationSystem, Error> {
371 let (compat_map, _) = ConfigurationLoader::for_tests(file, env, false).await;
372 let base = compat_map.as_typed::<Value>().expect("base extracts");
373 let config = translate_strict(&base, overlay)?;
374 Ok(ConfigurationSystem::standalone(compat_map, config))
375 }
376
377 async fn connected_system(
381 base: Value, overlay: EnvOverlayMode,
382 ) -> (ConfigurationSystem, mpsc::Sender<ConfigUpdate>) {
383 let (agent_tx, agent_rx) = mpsc::channel(100);
384 let (compat_map, compat_tx) = ConfigurationLoader::for_tests(None, None, true).await;
385 let compat_tx = compat_tx.expect("dynamic sender exists");
386 agent_tx.send(ConfigUpdate::Snapshot(json!({}))).await.unwrap();
387 let system = ConfigurationSystem::connected(agent_rx, compat_tx, compat_map, base, overlay)
388 .await
389 .expect("system builds");
390 (system, agent_tx)
391 }
392
393 async fn await_config(system: &ConfigurationSystem, what: &str, predicate: impl Fn(&SalukiConfiguration) -> bool) {
395 tokio::time::timeout(Duration::from_secs(2), async {
396 while !predicate(&system.config()) {
397 tokio::time::sleep(Duration::from_millis(5)).await;
398 }
399 })
400 .await
401 .unwrap_or_else(|_| panic!("timed out waiting for {what}"));
402 }
403
404 #[tokio::test]
405 async fn startup_current_reflects_translation() {
406 let system = standalone_system(
407 Some(json!({ "log_level": "warn", "dogstatsd_port": 9125 })),
408 None,
409 EnvOverlayMode::Fallback,
410 )
411 .await
412 .expect("system builds");
413 let config = system.config();
414
415 assert_eq!(config.control.logging.level, "warn");
416 assert_eq!(config.domains.dogstatsd.listeners.port, 9125);
417 }
418
419 #[tokio::test]
420 async fn connected_stream_translates_metrics_v3_routing_configuration() {
421 let (system, agent_tx) = connected_system(
422 json!({
423 "data_plane": {
424 "metrics": {
425 "v3": {
426 "series": {
427 "enabled": true
428 }
429 }
430 }
431 }
432 }),
433 EnvOverlayMode::Fallback,
434 )
435 .await;
436
437 assert_eq!(
438 system.config().shared.metrics_encoding.v3_series_mode.mode,
439 "datadog_only"
440 );
441
442 agent_tx
443 .send(ConfigUpdate::Snapshot(json!({
444 "serializer_compressor_kind": "zstd",
445 "serializer_experimental_use_v3_api": {
446 "compression_level": 7,
447 "series": {
448 "endpoints": ["https://app.us3.datadoghq.com"],
449 "validate": true,
450 "use_beta": true,
451 "beta_route": "/api/intake/metrics/custom/series",
452 "shadow_sample_rate": 0.25,
453 "shadow_sites": ["us3.datadoghq.com"]
454 }
455 },
456 "use_v2_api": {
457 "series": false
458 },
459 "use_v3_api": {
460 "series": {
461 "enabled": "false",
462 "endpoints": {
463 "https://app.datadoghq.com": "true"
464 }
465 }
466 },
467 "observability_pipelines_worker": {
468 "metrics": {
469 "enabled": true,
470 "url": "https://opw.example.com",
471 "use_v3_api": {
472 "series": true
473 }
474 }
475 }
476 })))
477 .await
478 .unwrap();
479
480 await_config(&system, "the streamed metrics V3 routing configuration", |config| {
481 config.shared.metrics_encoding.v3_series_mode.mode == "false"
482 && config
483 .shared
484 .metrics_encoding
485 .v3_series_mode
486 .endpoint_modes
487 .get("https://app.datadoghq.com")
488 .is_some_and(|mode| mode == "true")
489 })
490 .await;
491
492 let config = system.config();
493 let metrics = &config.shared.metrics_encoding;
494 assert!(!metrics.use_v2_series_api);
495 assert_eq!(metrics.v3_api.compression_level, 7);
496 assert_eq!(metrics.v3_api.series.endpoints, vec!["https://app.us3.datadoghq.com"]);
497 assert!(metrics.v3_api.series.validate);
498 assert!(metrics.v3_api.series.use_beta);
499 assert_eq!(metrics.v3_api.series.beta_route, "/api/intake/metrics/custom/series");
500 assert_eq!(metrics.v3_api.series.shadow_sample_rate, 0.25);
501 assert_eq!(metrics.v3_api.series.shadow_sites, vec!["us3.datadoghq.com"]);
502
503 let opw = &config.shared.endpoints.opw_intake;
504 assert!(opw.enabled);
505 assert_eq!(opw.url, "https://opw.example.com");
506 assert!(opw.use_v3_series);
507 }
508
509 #[tokio::test]
510 async fn flat_env_key_overlays_onto_nested_datadog_slot() {
511 let system = standalone_system(
515 Some(json!({
516 "autoscaling_failover_enabled": true,
517 "autoscaling_failover_metrics": "container.memory.usage container.cpu.usage",
518 })),
519 None,
520 EnvOverlayMode::Fallback,
521 )
522 .await
523 .expect("system builds");
524 let config = system.config();
525
526 assert!(config.shared.autoscaling_failover.enabled);
527 assert_eq!(
528 config.shared.autoscaling_failover.metrics,
529 vec!["container.memory.usage".to_string(), "container.cpu.usage".to_string()]
530 );
531 }
532
533 #[tokio::test]
534 async fn saluki_only_dotted_env_key_seeds_the_model() {
535 let system = standalone_system(
539 None,
540 Some(&[("DATA_PLANE_STANDALONE_MODE".to_string(), "true".to_string())]),
541 EnvOverlayMode::Fallback,
542 )
543 .await
544 .expect("system builds");
545
546 assert!(system.config().control.standalone_mode);
547 }
548
549 #[tokio::test]
550 async fn disabled_overlay_leaves_flat_env_key_unread() {
551 let system = standalone_system(
554 Some(json!({ "autoscaling_failover_enabled": true })),
555 None,
556 EnvOverlayMode::Disabled,
557 )
558 .await
559 .expect("system builds");
560
561 assert!(!system.config().shared.autoscaling_failover.enabled);
562 }
563
564 #[tokio::test]
565 async fn load_fails_on_translation_invalid_startup_config() {
566 let result = standalone_system(
569 Some(json!({ "dogstatsd_tag_cardinality": "bogus" })),
570 None,
571 EnvOverlayMode::Fallback,
572 )
573 .await;
574
575 assert!(matches!(result, Err(Error::Translate { .. })));
576 }
577
578 #[test]
579 fn zero_otlp_trace_interner_size_is_rejected() {
580 let error = translate_strict(
583 &json!({ "otlp_config": { "traces": { "string_interner_size": 0 } } }),
584 EnvOverlayMode::Fallback,
585 )
586 .expect_err("zero trace interner size should fail translation");
587
588 assert!(matches!(error, Error::Deserialize { .. }));
589 assert!(error.to_string().contains("value of bytes must be greater than zero"));
590 }
591
592 #[test]
593 fn oversized_otlp_trace_interner_size_is_rejected() {
594 let error = translate_strict(
595 &json!({ "otlp_config": { "traces": { "string_interner_size": "2GiB" } } }),
596 EnvOverlayMode::Fallback,
597 )
598 .expect_err("oversized trace interner should fail translation");
599
600 assert!(matches!(error, Error::Deserialize { .. }));
601 assert!(error.to_string().contains("must not exceed 1073741824 bytes"));
602 }
603
604 #[test]
605 fn positive_otlp_trace_interner_size_is_accepted() {
606 let config = translate_strict(
607 &json!({ "otlp_config": { "traces": { "string_interner_size": "512KiB" } } }),
608 EnvOverlayMode::Fallback,
609 )
610 .expect("positive trace interner size should translate");
611
612 assert_eq!(config.domains.otlp.traces.string_interner_size.get(), 512 * 1024);
613 }
614
615 #[tokio::test]
616 async fn standalone_loads_numeric_byte_size() {
617 let system = standalone_system(
621 Some(json!({ "dogstatsd_log_file_max_size": 10485760 })),
622 None,
623 EnvOverlayMode::Fallback,
624 )
625 .await
626 .expect("numeric byte size boots");
627
628 assert_eq!(system.config().domains.dogstatsd.debug_log.log_file_max_size, 10485760);
629 }
630
631 #[tokio::test]
632 async fn translation_invalid_update_is_rejected_keeping_last_known_good() {
633 let (system, agent_tx) = connected_system(
634 json!({ "log_level": "warn", "dogstatsd_tag_cardinality": "high" }),
635 EnvOverlayMode::Fallback,
636 )
637 .await;
638 assert_eq!(
639 system.config().domains.dogstatsd.origin.tag_cardinality,
640 OriginTagCardinality::High
641 );
642
643 agent_tx
646 .send(ConfigUpdate::Partial {
647 key: "dogstatsd_tag_cardinality".to_string(),
648 value: json!("bogus"),
649 })
650 .await
651 .unwrap();
652 agent_tx
653 .send(ConfigUpdate::Partial {
654 key: "log_level".to_string(),
655 value: json!("error"),
656 })
657 .await
658 .unwrap();
659
660 await_config(&system, "the later valid update to take effect", |c| {
661 c.control.logging.level == "error"
662 })
663 .await;
664 assert_eq!(
667 system.config().domains.dogstatsd.origin.tag_cardinality,
668 OriginTagCardinality::High
669 );
670 }
671
672 #[tokio::test]
673 async fn converges_to_latest_value_under_burst() {
674 let (system, agent_tx) = connected_system(json!({ "log_level": "info" }), EnvOverlayMode::Fallback).await;
675
676 let burst = [
677 "warn", "error", "debug", "trace", "info", "warn", "error", "debug", "trace", "info", "warn", "error",
678 "debug", "trace", "info", "warn", "error", "debug", "trace",
679 ];
680 for (i, level) in burst.iter().enumerate() {
681 agent_tx
682 .send(ConfigUpdate::Partial {
683 key: "log_level".to_string(),
684 value: json!(level),
685 })
686 .await
687 .unwrap();
688 if i == burst.len() / 2 {
692 agent_tx
693 .send(ConfigUpdate::Partial {
694 key: "dogstatsd_tag_cardinality".to_string(),
695 value: json!("bogus"),
696 })
697 .await
698 .unwrap();
699 agent_tx
700 .send(ConfigUpdate::Partial {
701 key: "dogstatsd_tag_cardinality".to_string(),
702 value: json!("high"),
703 })
704 .await
705 .unwrap();
706 }
707 }
708 let final_level = "error";
709 agent_tx
710 .send(ConfigUpdate::Partial {
711 key: "log_level".to_string(),
712 value: json!(final_level),
713 })
714 .await
715 .unwrap();
716
717 await_config(
718 &system,
719 "the current configuration to converge to the final value",
720 |c| c.control.logging.level == final_level,
721 )
722 .await;
723 assert_eq!(system.config().control.logging.level, final_level);
724 }
725
726 #[tokio::test]
727 async fn live_view_observes_debug_log_update() {
728 let (system, agent_tx) = connected_system(
729 json!({ "dogstatsd_metrics_stats_enable": false }),
730 EnvOverlayMode::Fallback,
731 )
732 .await;
733 let mut view = system.live(|c| &c.domains.dogstatsd.debug_log);
734 assert!(!view.metrics_stats_enable);
735
736 agent_tx
737 .send(ConfigUpdate::Partial {
738 key: "dogstatsd_metrics_stats_enable".to_string(),
739 value: json!(true),
740 })
741 .await
742 .unwrap();
743
744 let updated = tokio::time::timeout(Duration::from_secs(2), view.changed())
745 .await
746 .expect("view observes the debug-log update");
747 assert!(updated.metrics_stats_enable);
748 assert!(view.metrics_stats_enable);
750 }
751
752 #[tokio::test]
753 async fn field_view_wakes_on_its_field() {
754 let (system, agent_tx) = connected_system(
757 json!({ "dogstatsd_metrics_stats_enable": false }),
758 EnvOverlayMode::Fallback,
759 )
760 .await;
761 let mut stats = system.live(|c| &c.domains.dogstatsd.debug_log.metrics_stats_enable);
762 assert!(!*stats);
763
764 agent_tx
765 .send(ConfigUpdate::Partial {
766 key: "dogstatsd_metrics_stats_enable".to_string(),
767 value: json!(true),
768 })
769 .await
770 .unwrap();
771
772 let updated = tokio::time::timeout(Duration::from_secs(2), stats.changed())
773 .await
774 .expect("field view observes its field's update");
775 assert!(updated);
776 assert!(*stats);
777 }
778
779 #[tokio::test]
780 async fn fixed_view_never_changes() {
781 let mut view: Live<bool> = Live::new_fixed(true);
782 assert!(*view);
783 assert!(tokio::time::timeout(Duration::from_millis(100), view.changed())
785 .await
786 .is_err());
787 }
788
789 #[tokio::test]
790 async fn live_views_reflect_startup_configuration() {
791 let system = standalone_system(
792 Some(json!({ "dogstatsd_metrics_stats_enable": true })),
793 None,
794 EnvOverlayMode::Fallback,
795 )
796 .await
797 .expect("system builds");
798 let config = system.config();
799
800 let debug_log = system.live(|c| &c.domains.dogstatsd.debug_log);
801 assert_eq!(&*debug_log, &config.domains.dogstatsd.debug_log);
802
803 let prefix_filter = system.live(|c| &c.domains.dogstatsd.prefix_filter);
804 assert_eq!(&*prefix_filter, &config.domains.dogstatsd.prefix_filter);
805
806 let multi_region_failover = system.live(|c| &c.domains.multi_region_failover);
807 assert_eq!(&*multi_region_failover, &config.domains.multi_region_failover);
808 }
809
810 #[test]
811 fn translate_small_map_through_witness_and_seed() {
812 let datadog: DatadogConfiguration = serde_json::from_value(json!({
815 "api_key": "abc",
816 "dd_url": "https://custom.example.com",
817 "dogstatsd_port": 9125,
818 "dogstatsd_tag_cardinality": "high",
819 "expected_tags_duration": "15s",
820 "telemetry": { "dogstatsd_origin": true },
821 }))
822 .expect("datadog source deserializes");
823
824 let saluki: SalukiOnly = serde_json::from_value(json!({
826 "dogstatsd_tcp_port": 8126,
827 }))
828 .expect("saluki-only source deserializes");
829
830 let (config, errors) = translate(&datadog, &saluki);
831 assert!(errors.is_none(), "translation of a valid map records no error");
832
833 assert_eq!(config.domains.dogstatsd.listeners.port, 9125);
835 assert_eq!(
837 config.domains.dogstatsd.origin.tag_cardinality,
838 OriginTagCardinality::High
839 );
840 assert_eq!(config.shared.tags.expected_tags_duration, Duration::from_secs(15));
842 assert!(config.domains.dogstatsd.telemetry.origin_breakdown);
844 assert_eq!(config.shared.endpoints.api_key, "abc");
846 assert_eq!(
847 config.shared.endpoints.dd_url.as_deref(),
848 Some("https://custom.example.com")
849 );
850 assert_eq!(config.domains.dogstatsd.listeners.tcp_port, 8126);
852 }
853
854 #[test]
857 fn merge_replaces_a_map_typed_leaf_wholesale() {
858 let base = json!({ "additional_endpoints": { "https://a.example.com": ["k1"] } });
859 let overlay = json!({ "additional_endpoints": { "https://b.example.com": ["k2"] } });
860
861 let merged = deep_merge(base, overlay);
862
863 assert_eq!(
864 merged,
865 json!({ "additional_endpoints": { "https://b.example.com": ["k2"] } })
866 );
867 }
868
869 #[test]
872 fn merge_recurses_into_a_section_and_replaces_leaves_within_it() {
873 let base = json!({ "apm_config": { "compute_stats_by_span_kind": true, "enable_rare_sampler": true } });
874 let overlay = json!({ "apm_config": { "enable_rare_sampler": false } });
875
876 let merged = deep_merge(base, overlay);
877
878 assert_eq!(
879 merged,
880 json!({ "apm_config": { "compute_stats_by_span_kind": true, "enable_rare_sampler": false } })
881 );
882 }
883
884 #[test]
886 fn merge_replaces_scalar_and_array_leaves() {
887 let base = json!({ "dogstatsd_port": 8125, "dogstatsd_mapper_profiles": ["a"] });
888 let overlay = json!({ "dogstatsd_port": 9125, "dogstatsd_mapper_profiles": ["b"] });
889
890 let merged = deep_merge(base, overlay);
891
892 assert_eq!(
893 merged,
894 json!({ "dogstatsd_port": 9125, "dogstatsd_mapper_profiles": ["b"] })
895 );
896 }
897}