Skip to content

chore(config): migrate the Datadog forwarder to typed config#1993

Merged
webern merged 4 commits into
m/pr5-cutoverfrom
m/forwarder-conf
Jul 5, 2026
Merged

chore(config): migrate the Datadog forwarder to typed config#1993
webern merged 4 commits into
m/pr5-cutoverfrom
m/forwarder-conf

Conversation

@webern

@webern webern commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Human Summary

Agents are thrashing hard on #1965 and they have a hard time understanding that we can't fix it right now. I tried to get everything else fixed.

AI Summary

Cuts the Datadog forwarder subsystem over from the raw GenericConfiguration map to the typed
SalukiConfiguration model. This is a tightly-coupled cluster, so it moves together in one PR.

Static construction (no more source serde, no restated defaults):

  • ForwarderConfiguration, EndpointConfiguration, ProxyConfiguration, and RetryConfiguration
    build field-by-field from shared.endpoints (and shared.metrics_encoding for the V3 settings).
  • DatadogForwarderConfiguration builds from the shared model; the Cluster Agent forwarder and the
    run.rs call sites are rewired to pass typed slices.

Runtime refresh (now reads a Live<SalukiConfiguration> view instead of a live
GenericConfiguration):

  • ResolvedEndpoint refreshes its API key from a typed source: the shared primary key, the
    Multi-Region Failover override key, or the dual-shipping endpoint keys by URL/index.
  • TransactionForwarder and ApiKeyValidator thread the live view; the retry policy's 403 secrets
    gate reads it too. ApiKeyValidator re-validates when any API-key source changes.

Config model:

  • secret_backend_command, secret_refresh_on_api_key_failure_interval, and run_path are
    witnessed keys (in the vendored schema), promoted from excluded: to inventory: and modeled as
    shared.secrets and shared.run_path, with consume_* bodies in the Datadog translator.

The ForwarderConfiguration and ProxyConfiguration config smoke tests are retired; equivalent
coverage now lives in the construction and translator tests.

Change Type

  • Non-functional (chore, refactoring, docs)

How did you test this PR?

  • make build-schema-overlay && make fmt (no changes)
  • make check-all
  • make check-docs
  • cargo nextest run for saluki-components (common::datadog, forwarders),
    agent-data-plane-config, agent-data-plane-config-system, and the agent-data-plane
    config/cli modules

References

@webern webern requested a review from a team as a code owner July 5, 2026 15:34
Copilot AI review requested due to automatic review settings July 5, 2026 15:34
@dd-octo-sts dd-octo-sts Bot added area/components Sources, transforms, and destinations. area/docs Reference documentation. forwarder/datadog Datadog forwarder. labels Jul 5, 2026
@datadog-datadog-prod-us1-2

This comment has been minimized.

@webern webern force-pushed the m/forwarder-conf branch from ab8a10a to c51c0c5 Compare July 5, 2026 15:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Migrates the Datadog forwarder subsystem from GenericConfiguration map-based lookups to the typed SalukiConfiguration/SharedConfiguration model, including live refresh via Live<T> projections. This aligns forwarder construction and runtime refresh behavior with the centralized config-inventory work (issue #1788) and reduces reliance on raw serde parsing/default restatement.

Changes:

  • Rewired Datadog + Cluster Agent forwarders and related runtime refresh paths to use typed config (SharedConfiguration) and live views (Live<SalukiConfiguration>).
  • Refactored endpoint/API-key refresh logic to be model-driven (primary, MRF override, additional endpoints) and updated validation/retry gating accordingly.
  • Promoted run_path and secrets-management keys into inventory, generated witness/typed schema fields, translator consumption, and docs updates.

Reviewed changes

Copilot reviewed 13 out of 15 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
lib/saluki-components/src/forwarders/datadog/mod.rs Builds forwarder config from typed shared model; threads optional live view for endpoint API-key refresh.
lib/saluki-components/src/forwarders/cluster_agent/mod.rs Switches forwarder config derivation to typed shared model for consistent defaults/proxy/retry behavior.
lib/saluki-components/src/common/datadog/validation.rs Updates API-key validation task to revalidate based on typed live projections of relevant key sources.
lib/saluki-components/src/common/datadog/retry.rs Rebuilds retry config from typed forwarder model and updates the 403 “secrets gate” to use live typed config.
lib/saluki-components/src/common/datadog/proxy.rs Builds proxy configuration from typed proxy model and removes raw-config deserialization paths.
lib/saluki-components/src/common/datadog/io.rs Threads typed live config into I/O forwarder and adjusts proxy setup to typed config shape.
lib/saluki-components/src/common/datadog/endpoints.rs Refactors endpoint resolution and API-key refresh to use typed model fields and Live<T> sources.
lib/saluki-components/src/common/datadog/config.rs Replaces serde-driven forwarder config with field-by-field construction from shared typed model.
lib/datadog-agent/config/src/generated/witness.rs Adds witnessed keys for run_path and secrets-management fields.
lib/datadog-agent/config/src/generated/datadog_configuration.rs Adds generated schema fields/defaults for run_path and secrets-management keys.
lib/datadog-agent/config/schema/schema_overlay.yaml Promotes run_path and secrets-management keys from excluded to inventory with metadata.
lib/agent-data-plane-config/src/shared.rs Extends shared typed config with secrets and run_path used by forwarder/retry logic.
lib/agent-data-plane-config-system/src/translators/datadog_translator.rs Consumes newly witnessed keys into the typed shared configuration.
docs/agent-data-plane/configuration/dogstatsd.md Documents new inventory keys in the configuration reference table.
bin/agent-data-plane/src/cli/run.rs Updates topology wiring to pass typed shared config + live views into forwarders.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +200 to 203
let classifier = if let Some(live) = live {
let gate: HttpRetryPredicate<B> =
Arc::new(move |response| response.status() == StatusCode::FORBIDDEN && secrets_in_use(&config));
Arc::new(move |response| response.status() == StatusCode::FORBIDDEN && secrets_in_use(&live));
StandardHttpClassifier::new().with_predicate(gate)
Comment on lines +894 to +896
fn consume_run_path(&mut self, value: String) {
self.config.shared.run_path = PathBuf::from(value);
}
Comment on lines +752 to +759
ApiKeySource::Additional { keys, url, index } => keys
.as_ref()?
.current()
.get(url)
.and_then(|list| list.get(*index))
.map(|key| key.trim())
.filter(|key| !key.is_empty())
.map(str::to_string),
Copilot AI review requested due to automatic review settings July 5, 2026 15:40

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ab8a10af9c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

api_key: endpoints.api_key.clone(),
api_key_refresh: ApiKeyRefresh::Primary,
site: endpoints.site.clone().unwrap_or_else(|| DEFAULT_SITE.to_string()),
dd_url: endpoints.dd_url.clone(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not let the generated dd_url default override site

When a user sets only site (for example datadoghq.eu) and leaves dd_url unset, the typed DatadogConfiguration still supplies the generated default dd_url of https://app.datadoghq.com. Copying that value here makes build_primary_endpoint treat dd_url as explicitly configured, so the site setting is ignored and traffic is routed to the default US endpoint. Before this migration, dd_url stayed None unless it was actually present in the raw config.

Useful? React with 👍 / 👎.

}

fn consume_run_path(&mut self, value: String) {
self.config.shared.run_path = PathBuf::from(value);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not derive retry paths from the run_path placeholder

When run_path is omitted, the generated Datadog model defaults it to the literal string ${run_path}; storing that verbatim means RetryConfiguration::from_model sees a non-empty run path and derives ${run_path}/transactions_to_retry whenever forwarder_storage_path is empty. In configurations that enable disk persistence via forwarder_storage_max_size_in_bytes without an explicit storage path, the retry queue will initialize under a literal placeholder path or fail instead of using a real runtime directory or leaving the path unset.

Useful? React with 👍 / 👎.

@pr-commenter

pr-commenter Bot commented Jul 5, 2026

Copy link
Copy Markdown

Binary Size Analysis (Agent Data Plane)

Baseline: 7b98c6f · Comparison: fb62d7d · diff
Analysis Configuration: stripped binaries · Pass/Fail Threshold: +5%
Sizes: 41.63 MiB (baseline) vs 41.72 MiB (comparison)
Size Change: +82.99 KiB (+0.19%)

✅ Binary size difference within threshold

Changes by Module
Module File Size Symbols
figment -617.70 KiB 377
serde_json +245.93 KiB 511
datadog_agent_config::generated::datadog_configuration +147.66 KiB 45
core +144.66 KiB 9312
resource_accounting::groups::Tracked +121.38 KiB 35
tracing -116.90 KiB 98
serde -72.26 KiB 67
agent_data_plane_config_system::saluki_env_overlay::PathRecorder +68.09 KiB 24
tokio +61.33 KiB 3340
tonic -45.00 KiB 353
serde_with -43.17 KiB 27
saluki_components::common::datadog -41.03 KiB 529
saluki_components::sources::dogstatsd -40.50 KiB 368
prost +33.22 KiB 263
http_body_util +33.00 KiB 157
agent_data_plane_config_system::translators::datadog_translator +31.38 KiB 25
hyper_util +29.30 KiB 39
agent_data_plane_config::SalukiConfiguration +28.62 KiB 1
alloc +25.99 KiB 1465
axum +25.87 KiB 333
Detailed Symbol Changes
    FILE SIZE        VM SIZE    
 --------------  -------------- 
  [NEW] +87.4Ki  [NEW] +87.1Ki    _<datadog_agent_config::generated::datadog_configuration::_::<impl serde_core::de::Deserialize for datadog_agent_config::generated::datadog_configuration::DatadogConfiguration>::deserialize::__Visitor as serde_core::de::Visitor>::visit_map::h59abc0a47db21533
  [NEW] +55.0Ki  [NEW] +54.9Ki    saluki_components::common::datadog::io::run_endpoint_io_loop::_{{closure}}::h619cfc6f7a2c29a2
  [NEW] +53.5Ki  [NEW] +53.3Ki    agent_data_plane::cli::run::handle_run_command::_{{closure}}::h329f79117c30c586
  [NEW] +40.9Ki  [NEW] +40.7Ki    _<saluki_components::forwarders::otlp::OtlpForwarder as saluki_core::components::forwarders::Forwarder>::run::_{{closure}}::h12001b40cfdbe9ad
  [NEW] +36.6Ki  [NEW] +36.4Ki    _<saluki_components::transforms::apm_stats::ApmStats as saluki_core::components::transforms::Transform>::run::_{{closure}}::hcfbaff7c8f04ca3a
  [NEW] +32.5Ki  [NEW] +32.3Ki    _<saluki_components::transforms::aggregate::Aggregate as saluki_core::components::transforms::Transform>::run::_{{closure}}::ha1b37f98738ba368
  [NEW] +32.1Ki  [NEW] +31.9Ki    saluki_components::sources::otlp::metrics::translator::OtlpMetricsTranslator::translate_metrics::h6f16bec49aa3e135
  [NEW] +31.8Ki  [NEW] +31.6Ki    agent_data_plane::internal::env::workload::RemoteAgentWorkloadProvider::from_configuration::_{{closure}}::hc1ab1506e0ba4151
  [NEW] +29.9Ki  [NEW] +29.8Ki    agent_data_plane::cli::dogstatsd::handle_dogstatsd_command::_{{closure}}::hd2948b2b886d3024
  [NEW] +28.6Ki  [NEW] +28.5Ki    _<agent_data_plane_config::SalukiConfiguration as core::clone::Clone>::clone::h8de0a5256ebf7df6
  [NEW] +26.5Ki  [NEW] +26.3Ki    saluki_components::sources::dogstatsd::drive_stream::_{{closure}}::h16dece6451b89a07
  [NEW] +26.4Ki  [NEW] +26.3Ki    agent_data_plane::cli::run::create_topology::_{{closure}}::h7b42595e5eb142f8
  [DEL] -26.6Ki  [DEL] -26.4Ki    _<saluki_components::sources::dogstatsd::DogStatsDConfiguration as saluki_core::components::sources::builder::SourceBuilder>::build::_{{closure}}::h2c2a33d551977c6d
  [DEL] -27.2Ki  [DEL] -26.9Ki    _<saluki_components::sources::dogstatsd::_::<impl serde_core::de::Deserialize for saluki_components::sources::dogstatsd::DogStatsDConfiguration>::deserialize::__Visitor as serde_core::de::Visitor>::visit_map::hfc578ba92a99992a
  [DEL] -28.6Ki  [DEL] -28.5Ki    saluki_components::sources::otlp::metrics::translator::OtlpMetricsTranslator::translate_metrics::h1e8351a6f731e9df
  [DEL] -28.6Ki  [DEL] -28.5Ki    agent_data_plane::cli::dogstatsd::handle_dogstatsd_command::_{{closure}}::h84db3f7ad161eab2
  [DEL] -32.6Ki  [DEL] -32.4Ki    _<saluki_components::transforms::aggregate::Aggregate as saluki_core::components::transforms::Transform>::run::_{{closure}}::h72e4b09fd816d94f
  [DEL] -38.7Ki  [DEL] -38.5Ki    _<saluki_components::forwarders::otlp::OtlpForwarder as saluki_core::components::forwarders::Forwarder>::run::_{{closure}}::h3cd6ee67f0471508
  [DEL] -44.7Ki  [DEL] -44.6Ki    agent_data_plane::cli::run::handle_run_command::_{{closure}}::h83501a7881036c5f
  [DEL] -56.8Ki  [DEL] -56.6Ki    saluki_components::common::datadog::io::run_endpoint_io_loop::_{{closure}}::h54fbb73bb30184e6
  -0.7%  -114Ki  -1.8%  -222Ki    [33748 Others]
  +0.2% +83.0Ki  -0.1% -26.0Ki    TOTAL

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 15 changed files in this pull request and generated 3 comments.

Comment on lines +754 to +761
ApiKeySource::Additional { keys, url, index } => keys
.as_ref()?
.current()
.get(url)
.and_then(|list| list.get(*index))
.map(|key| key.trim())
.filter(|key| !key.is_empty())
.map(str::to_string),
Comment on lines 214 to 218
fn secrets_in_use(live: &Live<SalukiConfiguration>) -> bool {
let config = live.current();
let secrets = &config.shared.secrets;
secrets.refresh_on_api_key_failure_interval > 0 || !secrets.backend_command.trim().is_empty()
}
Comment on lines +894 to +896
fn consume_run_path(&mut self, value: String) {
self.config.shared.run_path = PathBuf::from(value);
}
@pr-commenter

pr-commenter Bot commented Jul 5, 2026

Copy link
Copy Markdown

Regression Detector (Agent Data Plane)

Run ID: ec73dc69-a4fb-49b5-93c3-c8cf1b2d34e8
Baseline: 7b98c6f1 · Comparison: 07733e37 · diff

Optimization Goals: ✅ No significant changes detected

Fine details of change detection per experiment (3)

Experiments configured erratic: true are tagged (ignored) and skipped when determining which experiments regressed or improved. Experiments which are detected as erratic at runtime are tagged (erratic) to flag that the run's sample dispersion was high, but their regression / improvement signal still counts.

experiment goal Δ mean % links
quality_gates_rss_idle memory ⚪ +2.09 metrics profiles logs
quality_gates_rss_dsd_low memory ⚪ +1.33 metrics profiles logs
quality_gates_rss_dsd_medium memory ⚪ +0.87 metrics profiles logs
Bounds Checks: ✅ Passed (3)
experiment check replicates observed links
quality_gates_rss_dsd_low memory_usage 10/10 ✅ 43.4 MiB ≤ 50 MiB metrics profiles logs
quality_gates_rss_dsd_medium memory_usage 10/10 ✅ 65.4 MiB ≤ 75 MiB metrics profiles logs
quality_gates_rss_idle memory_usage 10/10 ✅ 29.5 MiB ≤ 40 MiB metrics profiles logs
Explanation

A change is flagged as a regression when |Δ mean %| > 5.00% in the regressing direction for its optimization goal AND SMP marks the experiment as a regression (is_regression: true). Improvements use the matching criteria for the improving direction. Experiments configured erratic: true (tagged (ignored)) are skipped outright; experiments detected as erratic at runtime (tagged (erratic)) still count, since that flag describes sample dispersion rather than directional certainty. The Δ mean % cell is colored accordingly: 🟢 = improvement, 🔴 = regression, ⚪ = neutral. Reduction in CPU or memory is an improvement; reduction in ingress throughput is a regression.

@webern webern left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code, reviewing on behalf of webern.

Overall this is a clean, faithful cutover: field-by-field construction, no restated defaults, tests rewritten to build from the typed model, and the runtime API-key refresh reworked into an explicit ApiKeySource that preserves the primary / MRF / additional-endpoint behaviors. I checked the promoted keys against the vendored schema (run_path, secret_backend_command, secret_refresh_on_api_key_failure_interval are all witnessed correctly), and the forwarder numeric defaults flowing through the generated DatadogConfiguration match the old serde defaults (concurrency 10, num_workers 1, timeout 20, buffer 100, backoff 2/2/64, recovery 2, flush ratio 0.5, disk ratio 0.8, outdated 10 days, capacity 900s).

There is one blocker and one narrower issue. Both were also flagged independently by Codex/Copilot and I agree with them.

Blocker: dd_url schema default overrides site (data routing regression)

consume_dd_url sets shared.endpoints.dd_url = non_empty(value), and the generated DatadogConfiguration.dd_url defaults to the schema value https://app.datadoghq.com. So when a user does not set dd_url, the typed model still carries dd_url = Some("https://app.datadoghq.com") rather than None.

EndpointConfiguration::from_model copies that through, and calculate_resolved_endpoint treats any Some(dd_url) as an explicit override that takes precedence over site:

let raw_endpoint = match override_url {
    Some(url) => url.to_string(),                 // dd_url wins, site ignored
    None => format!("https://app.{}", base_domain) // site path
};

Net effect: a deployment that sets only site (e.g. datadoghq.eu, us5.datadoghq.com, ap1.datadoghq.com) and leaves dd_url unset — the normal way non-US customers configure the intake — now has its primary endpoint silently routed to app.datadoghq.com. Before this PR the forwarder read the raw dd_url key, which stayed None when unset, so site was honored.

This is the root cause already documented in the TODO(#1965) comment on Endpoints: consume_dd_url can't distinguish "user set it to the default URL" from "schema default filled it in," so it can't safely populate dd_url. The forwarder was the last reader relying on the raw-config escape hatch, and this PR removes it.

Worth noting the test suite doesn't catch this because the forwarder tests build from SharedConfiguration::default() (where dd_url is None), not from a translated config where the schema default is applied. A test that sets only site and asserts the resolved primary endpoint would have surfaced it, and would be good to add alongside the fix.

Narrower: run_path placeholder can seed a literal retry-queue path

run_path's schema default is the literal template ${run_path}, and consume_run_path stores it verbatim (saluki does no ${...} expansion). RetryConfiguration::from_model then sees a non-empty run path and, when forwarder_storage_path is empty, derives ${run_path}/transactions_to_retry. Previously an unset run_path read as None and left the storage path empty.

This is gated behind forwarder_storage_max_size_in_bytes > 0, so it's inert in the default (disk-persistence-off) configuration. But a deployment that enables on-disk retry without an explicit forwarder_storage_path and without run_path would initialize the queue under a literal ${run_path} directory instead of leaving it unset. Treating ${run_path} (and empty/whitespace) as unset in consume_run_path would restore the prior behavior.

Secondary (perf, non-blocking)

The retry 403 gate clones the entire SalukiConfiguration on every evaluated 403 (secrets_in_uselive.current()). On a sustained-403 path that's a large clone per response; projecting to Live<Secrets> once when building the predicate would keep it to the small struct. Copilot also flagged the additional-endpoints api_key() refresh cloning the full map per request — comparable to the old per-call deserialize, so less of a change, but a borrowed read path on Live would help both. These are follow-ups, not merge blockers.

CI is green (only the ADP benchmark job pending).

@webern

webern commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up to my review: after reading #1965, I no longer consider the dd_url/site issue a blocker for this PR. It predates typed config, and fixing it correctly requires retaining configuration-source metadata at ingest, which is outside this cutover. We will fix the other two actionable findings here: treating the unresolved ${run_path} placeholder as unset, and projecting the 403 retry gate to Live<Secrets> so it does not clone the full configuration per response.

@webern webern force-pushed the m/forwarder-conf branch from 1f9eb25 to e589e86 Compare July 5, 2026 16:56
Copilot AI review requested due to automatic review settings July 5, 2026 16:56

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 17 changed files in this pull request and generated 2 comments.

Comment on lines +121 to +125
pub(crate) fn from_model(forwarder: &Forwarder, run_path: &Path) -> Self {
let mut storage_path = forwarder.storage_path.clone();
if storage_path.parent().is_none() && !run_path.as_os_str().is_empty() {
storage_path = run_path.join(RETRY_TXN_DIR);
}
Comment on lines +921 to +926
let trimmed = value.trim();
if trimmed.is_empty() || trimmed == "${run_path}" {
return;
}
self.config.shared.run_path = PathBuf::from(value);
}
@webern webern force-pushed the m/forwarder-conf branch from e589e86 to 07733e3 Compare July 5, 2026 17:12

@webern webern left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code, reviewing on behalf of webern — fresh clean-room pass on the current HEAD (07733e3).

First, the two items from the earlier round that the follow-up commits addressed, so they're off the table: consume_run_path now treats the ${run_path} placeholder (and empty/whitespace) as unset, and the 403 retry gate projects to Live<Secrets> instead of cloning the whole SalukiConfiguration per response. Both look good.

The rest of the cutover holds up well: field-by-field construction, no restated defaults, the forwarder numeric defaults flowing through the generated DatadogConfiguration still match the old serde defaults, and the ApiKeySource rework faithfully preserves the primary / MRF / additional-endpoint refresh behavior. The promoted keys (run_path, secret_backend_command, secret_refresh_on_api_key_failure_interval) are witnessed correctly against the vendored schema.

There is still one blocker, plus a second instance of the same root cause that hasn't been called out yet.

Blocker (still open): dd_url schema default shadows site

This is the same routing regression flagged last round, and it is unchanged at HEAD.

consume_dd_url sets shared.endpoints.dd_url = non_empty(value), and the generated DatadogConfiguration.dd_url defaults to the schema value https://app.datadoghq.com. drive() calls the consumer unconditionally, so when a user does not set dd_url the typed model still carries dd_url = Some("https://app.datadoghq.com") rather than None.

EndpointConfiguration::from_model copies that through, and calculate_resolved_endpoint treats any Some(dd_url) as an explicit override that beats site:

let raw_endpoint = match override_url {
    Some(url) => url.to_string(),                    // dd_url wins, site ignored
    None => format!("https://app.{}", base_domain),  // site path
};

Net effect: a deployment that sets only site (datadoghq.eu, us5.datadoghq.com, ap1.datadoghq.com, ...) and leaves dd_url unset — the normal way non-US1 customers point at their intake — now has its primary endpoint silently routed to app.datadoghq.com. Before this PR the forwarder read the raw dd_url key, which stayed None when unset, so site was honored. This is the last reader that relied on that raw escape hatch, so this cutover is what activates the regression.

TODO(#1965) on Endpoints already documents the underlying cause: the translator can't tell "user set dd_url to the default URL" from "schema default filled it in." One workable option here is that the dd_url default (https://app.datadoghq.com) and the default site resolution (datadoghq.com -> https://app.datadoghq.com) produce the same URL, so treating a dd_url equal to the schema default as unset in consume_dd_url would restore site precedence in the common case without hurting the coinciding-default case. Either way this needs to be resolved (or explicitly deferred with the routing consequence understood) before it merges.

The forwarder tests don't catch it because they build from SharedConfiguration::default() (where dd_url is None), not from a translated config where the schema default is applied. A test that sets only site and asserts the resolved primary endpoint would surface it.

Same root cause, not yet flagged: the deprecated forwarder_retry_queue_max_size fallback is now dead

queue_max_size_bytes() still prefers retry_queue_payloads_max_size and falls back to the deprecated retry_queue_max_size:

self.retry_queue_payloads_max_size
    .or(self.retry_queue_max_size)
    .unwrap_or(FORWARDER_RETRY_QUEUE_PAYLOADS_MAX_SIZE_BYTES)

That .or() chain depends on retry_queue_payloads_max_size being None when the user didn't set it. But consume_forwarder_retry_queue_payloads_max_size sets Some(...) unconditionally, and drive() always feeds it the generated default (15728640). So post-translation the model always carries retry_queue_payloads_max_size = Some(15 MiB), the .or() never reaches the deprecated key, and a config that sets only forwarder_retry_queue_max_size now silently gets 15 MiB instead of the configured value. Previously that field deserialized as None when unset (no serde default), so the fallback worked.

Impact is much narrower than dd_url — it only bites a deprecated-key-only config — but it's the same defect: an Option that used to encode "was it set" is now always Some(default) after drive(). The rewritten queue_max_size_bytes_fallback_behavior test hides it by building Forwarder::default() (payloads = None), a shape that never occurs after translation. Whatever the fix for dd_url, this one wants the same treatment or an explicit note that the deprecated fallback is being dropped.

Test loss worth relocating

The four deser_additional_endpoints_* tests were deleted, but AdditionalEndpoints's serde parsing (JSON-string maps via PickFirst<DisplayFromStr>, and the single-string-or-seq value shape) is still live — it's used by the not-yet-migrated DatadogMetricsConfiguration, as the retained #[serde_as] and the TODO on the type note. Deleting those tests leaves that live parsing path with no coverage. Since the code isn't going away yet, relocating the deser tests (rather than dropping them) would keep the coverage until the metrics encoder migrates.

Minor / non-blocking

  • Copilot's note that RetryConfiguration::from_model uses storage_path.parent().is_none() (also true for /) — worth tidying, but it matches the pre-cutover behavior, so it's not a regression from this PR.
  • Copilot's note that consume_run_path stores the untrimmed value after checking trimmed — cosmetic; a padded path was also possible before. Storing the trimmed value would be slightly cleaner.
  • Copilot's per-request clone of the whole additional_endpoints map in refreshed_api_key — reasonable follow-up, comparable to the old per-call deserialize, not a merge blocker.

Copilot AI review requested due to automatic review settings July 5, 2026 18:10
@webern webern force-pushed the m/forwarder-conf branch from 07733e3 to 775796f Compare July 5, 2026 18:10

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 16 changed files in this pull request and generated 1 comment.

if trimmed.is_empty() || trimmed == "${run_path}" {
return;
}
self.config.shared.run_path = PathBuf::from(value);
@webern webern force-pushed the m/forwarder-conf branch from 775796f to 5da11dc Compare July 5, 2026 18:21
@webern

webern commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

Claude Code, on behalf of webern — a follow-up now that the fixes have landed.

Two of the three points from my earlier review are resolved:

  • Retry-queue deprecated fallback — fixed. forwarder_retry_queue_payloads_max_size and
    forwarder_retry_queue_max_size are now absence-aware (saluki_overrides_default), so a config
    that sets only the deprecated key resolves to that value again instead of the 15 MiB default, with
    coverage that runs through translate() (the path that exposed the regression).

  • AdditionalEndpoints deserialization tests — withdrawn. Once this branch rebased onto chore(config): migrate metrics/service-checks/checks-IPC/MRF gateway to typed config #1990,
    additional_endpoints is witnessed as a plain HashMap<String, Vec<String>> and nothing
    deserializes through the AdditionalEndpoints serde impl anymore, so restoring those tests would
    only guard dead code. That commit was reverted and the now-unused serde_yaml dependency dropped.
    The behavior that actually needed protecting — the JSON-string and bare-string input shapes those
    serde impls used to accept (notably DD_ADDITIONAL_ENDPOINTS as a JSON string) — is preserved on
    the witnessed path in a separate PR into the base branch, fix(config): accept the wider additional_endpoints input forms #1994.

On the third point — dd_url overriding site — I was wrong to raise it as a blocker on this
PR. It's a known limitation of the current model (the translator can't yet distinguish an
operator-set dd_url from the schema default), tracked by #1965 and noted in the Endpoints TODO.
It's out of scope here and shouldn't hold up this cutover.

webern added a commit that referenced this pull request Jul 5, 2026
## Human Summary

Caught by AI when reviewing #1993, it looks like this was a regression
in the environment variable path only, which *should not* have affected
customers anyway, but it looks like now we handle JSON in environment
variable correctly if we happen to ever read that.

## AI Summary

Restores the wider `additional_endpoints` input shapes that were lost
when the metrics encoder and
forwarder moved off the component serde onto the typed model.

The old `AdditionalEndpoints` serde used `PickFirst<(DisplayFromStr,
_)>` plus `OneOrMany`, so it
accepted:

- the whole map as a JSON string — the form an environment variable
produces, e.g.
  `DD_ADDITIONAL_ENDPOINTS='{"https://app.datadoghq.com":["key"]}'`, and
- a bare string in place of a one-element key list for a host.

The generated `DatadogConfiguration` deserializes `additional_endpoints`
as a plain
`HashMap<String, Vec<String>>`, and the env overlay only materializes
scalar/space-separated-list
env values, so neither shape survives. Both now fail deserialization
outright — a dual-shipping
deployment configured through the environment fails to start.

This adds an `additional_endpoints` case to
`normalize_datadog_input_forms` (the same pass that
already restores the `dogstatsd_eol_required` and
`dogstatsd_mapper_profiles` env-var forms): parse
the JSON-string form into an object, then wrap any bare-string host
value into a one-element array.
Invalid JSON is left in place so the downstream deserializer still
surfaces the error.

## Change Type
- [x] Bug fix

## How did you test this PR?

- `cargo nextest run -p agent-data-plane-config-system` (added
`additional_endpoints_accepts_json_string_scalar_or_map`, covering the
JSON-string, JSON-string-with-scalar, native-scalar, and native-list
shapes; confirmed it fails without the fix with `invalid type: string
..., expected a map`).
- `make fmt`

## References

- Merges into `m/pr5-cutover`
webern added 4 commits July 5, 2026 20:32
Cut the Datadog forwarder subsystem over from the raw GenericConfiguration
map to the typed SalukiConfiguration model:

- ForwarderConfiguration, EndpointConfiguration, ProxyConfiguration, and
  RetryConfiguration now build field-by-field from the shared endpoints model
  (no source serde, no restated defaults).
- ResolvedEndpoint, TransactionForwarder, and ApiKeyValidator refresh API keys
  and secrets from a Live<SalukiConfiguration> view instead of a live
  GenericConfiguration; the reactive API-key sources are the primary key, the
  MRF override key, and the dual-shipping endpoint keys.
- DatadogForwarderConfiguration builds from the model plus a live view; the
  Cluster Agent forwarder and run.rs call sites are rewired accordingly.
- Home secret_backend_command, secret_refresh_on_api_key_failure_interval, and
  run_path as witnessed keys (promoted from excluded) under shared.secrets and
  shared.run_path.

Progresses #1788
…recated fallback

The forwarder retry-queue size keys forwarder_retry_queue_payloads_max_size and
forwarder_retry_queue_max_size are now flagged saluki_overrides_default in the schema
overlay, so the generator emits them as absence-aware Option<i64> instead of baking in
the schema's 15 MiB default. The translator maps the Option through (preserving the
max(0) clamp), leaving both model fields None when the operator does not set them.

This restores the deprecated-key fallback in RetryConfiguration::queue_max_size_bytes():
a config setting only forwarder_retry_queue_max_size now resolves to that value rather
than being clobbered by the schema default that drive() previously wrote unconditionally.

Adds translator-level tests that exercise the deserialize -> translate() path (the only
path that reproduced the regression).
The forwarder cutover moved additional_endpoints onto the typed model, and
#1990 did the same for the metrics encoder, so AdditionalEndpoints's serde
impl has no production caller. Its deserialization tests were the last consumer
of serde_yaml in this crate; drop the now-unused dependency.
Copilot AI review requested due to automatic review settings July 5, 2026 18:38
@webern webern force-pushed the m/forwarder-conf branch from 5da11dc to fb62d7d Compare July 5, 2026 18:38

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 18 changed files in this pull request and generated 4 comments.

Comment on lines +946 to +956
fn consume_run_path(&mut self, value: String) {
// The vendored schema default is the literal template `${run_path}`, which Saluki does not
// expand. Treat that placeholder (as well as empty or whitespace-only values) as unset,
// leaving `run_path` as the empty default so the forwarder does not derive a bogus
// `${run_path}/transactions_to_retry` storage path.
let trimmed = value.trim();
if trimmed.is_empty() || trimmed == "${run_path}" {
return;
}
self.config.shared.run_path = PathBuf::from(value);
}
Comment on lines +468 to +474
let api_key_source = ApiKeySource::Additional {
keys: live
.as_ref()
.map(|live| live.project(|c| &c.shared.endpoints.additional_endpoints)),
url: raw_endpoint.to_string(),
index,
};
Comment on lines 44 to 88
@@ -86,7 +84,7 @@ impl ForwarderBuilder for DatadogForwarderConfiguration {
let forwarder = TransactionForwarder::from_config(
context,
self.forwarder_config.clone(),
self.configuration.clone(),
self.live.clone(),
get_dd_endpoint_name,
Comment on lines +407 to +410
let saluki = config_system.config();
let dd_forwarder_config =
DatadogForwarderConfiguration::from_model(&saluki.shared, Some(config_system.live(|c| c)))
.error_context("Failed to configure Datadog forwarder.")?;
@webern webern merged commit cf794a0 into m/pr5-cutover Jul 5, 2026
79 of 81 checks passed
@webern webern deleted the m/forwarder-conf branch July 5, 2026 18:54
webern added a commit that referenced this pull request Jul 8, 2026
## Human Summary

Caught by AI when reviewing #1993, it looks like this was a regression
in the environment variable path only, which *should not* have affected
customers anyway, but it looks like now we handle JSON in environment
variable correctly if we happen to ever read that.

## AI Summary

Restores the wider `additional_endpoints` input shapes that were lost
when the metrics encoder and
forwarder moved off the component serde onto the typed model.

The old `AdditionalEndpoints` serde used `PickFirst<(DisplayFromStr,
_)>` plus `OneOrMany`, so it
accepted:

- the whole map as a JSON string — the form an environment variable
produces, e.g.
  `DD_ADDITIONAL_ENDPOINTS='{"https://app.datadoghq.com":["key"]}'`, and
- a bare string in place of a one-element key list for a host.

The generated `DatadogConfiguration` deserializes `additional_endpoints`
as a plain
`HashMap<String, Vec<String>>`, and the env overlay only materializes
scalar/space-separated-list
env values, so neither shape survives. Both now fail deserialization
outright — a dual-shipping
deployment configured through the environment fails to start.

This adds an `additional_endpoints` case to
`normalize_datadog_input_forms` (the same pass that
already restores the `dogstatsd_eol_required` and
`dogstatsd_mapper_profiles` env-var forms): parse
the JSON-string form into an object, then wrap any bare-string host
value into a one-element array.
Invalid JSON is left in place so the downstream deserializer still
surfaces the error.

## Change Type
- [x] Bug fix

## How did you test this PR?

- `cargo nextest run -p agent-data-plane-config-system` (added
`additional_endpoints_accepts_json_string_scalar_or_map`, covering the
JSON-string, JSON-string-with-scalar, native-scalar, and native-list
shapes; confirmed it fails without the fix with `invalid type: string
..., expected a map`).
- `make fmt`

## References

- Merges into `m/pr5-cutover`
webern added a commit that referenced this pull request Jul 8, 2026
Agents are thrashing hard on #1965 and they have a hard time
understanding that we can't fix it right now. I tried to get everything
else fixed.

Cuts the Datadog forwarder subsystem over from the raw
`GenericConfiguration` map to the typed
`SalukiConfiguration` model. This is a tightly-coupled cluster, so it
moves together in one PR.

Static construction (no more source serde, no restated defaults):

- `ForwarderConfiguration`, `EndpointConfiguration`,
`ProxyConfiguration`, and `RetryConfiguration`
build field-by-field from `shared.endpoints` (and
`shared.metrics_encoding` for the V3 settings).
- `DatadogForwarderConfiguration` builds from the shared model; the
Cluster Agent forwarder and the
  `run.rs` call sites are rewired to pass typed slices.

Runtime refresh (now reads a `Live<SalukiConfiguration>` view instead of
a live
`GenericConfiguration`):

- `ResolvedEndpoint` refreshes its API key from a typed source: the
shared primary key, the
Multi-Region Failover override key, or the dual-shipping endpoint keys
by URL/index.
- `TransactionForwarder` and `ApiKeyValidator` thread the live view; the
retry policy's 403 secrets
gate reads it too. `ApiKeyValidator` re-validates when any API-key
source changes.

Config model:

- `secret_backend_command`,
`secret_refresh_on_api_key_failure_interval`, and `run_path` are
witnessed keys (in the vendored schema), promoted from `excluded:` to
`inventory:` and modeled as
`shared.secrets` and `shared.run_path`, with `consume_*` bodies in the
Datadog translator.

The `ForwarderConfiguration` and `ProxyConfiguration` config smoke tests
are retired; equivalent
coverage now lives in the construction and translator tests.

- [x] Non-functional (chore, refactoring, docs)

- `make build-schema-overlay && make fmt` (no changes)
- `make check-all`
- `make check-docs`
- `cargo nextest run` for `saluki-components` (`common::datadog`,
`forwarders`),
`agent-data-plane-config`, `agent-data-plane-config-system`, and the
`agent-data-plane`
  config/cli modules

- Progresses #1788
- Merges into `m/pr5-cutover`
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/components Sources, transforms, and destinations. area/docs Reference documentation. forwarder/datadog Datadog forwarder.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants