@@ -21,10 +21,31 @@ static DD_URL_REGEX: LazyLock<Regex> =
2121
2222pub const DEFAULT_SITE : & str = "datadoghq.com" ;
2323
24+ /// The primary endpoint URL that is constructed when both `site` and `dd_url` are at their defaults.
25+ ///
26+ /// The Core Agent sends `dd_url` at this value even when the operator only configured `site`.
27+ /// A `dd_url` equal to this constant carries no override intent and must not shadow `site`.
28+ const DEFAULT_PRIMARY_ENDPOINT : & str = "https://app.datadoghq.com" ;
29+
2430fn default_site ( ) -> String {
2531 DEFAULT_SITE . to_owned ( )
2632}
2733
34+ /// Deserializes an optional `dd_url`, treating the schema-default URL as absent.
35+ ///
36+ /// The Core Agent always sends `dd_url` at its schema default (`https://app.datadoghq.com`) even
37+ /// when the operator only configured `site`. Filtering here, at deserialization, ensures that a
38+ /// value equal to the default is treated as `None`, allowing `site` to determine the endpoint. This
39+ /// only affects the serde path; programmatic callers such as `set_dd_url` bypass serde and are
40+ /// unaffected.
41+ fn deserialize_dd_url < ' de , D > ( deserializer : D ) -> Result < Option < String > , D :: Error >
42+ where
43+ D : serde:: Deserializer < ' de > ,
44+ {
45+ let val = Option :: < String > :: deserialize ( deserializer) ?;
46+ Ok ( val. filter ( |url| url. as_str ( ) != DEFAULT_PRIMARY_ENDPOINT ) )
47+ }
48+
2849/// Error type for invalid endpoints.
2950#[ derive( Debug , Snafu ) ]
3051#[ snafu( context( suffix( false ) ) ) ]
@@ -136,7 +157,7 @@ pub struct EndpointConfiguration {
136157 /// which are both useful when proxying traffic to an intermediate destination before forwarding to Datadog.
137158 ///
138159 /// Defaults to unset.
139- #[ serde( default , alias = "url" ) ]
160+ #[ serde( default , alias = "url" , deserialize_with = "deserialize_dd_url" ) ]
140161 dd_url : Option < String > ,
141162
142163 /// Enables sending data to multiple endpoints and/or with multiple API keys via dual shipping.
@@ -638,4 +659,63 @@ mod tests {
638659 . expect ( "error calculating override API endpoint" ) ;
639660 assert_eq ! ( expected_endpoint, resolved. endpoint( ) . to_string( ) ) ;
640661 }
662+
663+ #[ test]
664+ fn deserialize_dd_url_filters_default_value ( ) {
665+ // The default dd_url (what the Agent sends when operator only configured site) should
666+ // deserialize to None so that site takes precedence.
667+ let config_str = r#"{"api_key": "test-key", "dd_url": "https://app.datadoghq.com"}"# ;
668+ let config: EndpointConfiguration = serde_json:: from_str ( config_str) . expect ( "deserialization should succeed" ) ;
669+ assert_eq ! ( None , config. dd_url) ;
670+ }
671+
672+ #[ test]
673+ fn deserialize_dd_url_preserves_explicit_override ( ) {
674+ // A dd_url that differs from the default should deserialize as-is.
675+ let config_str = r#"{"api_key": "test-key", "dd_url": "https://proxy.internal.example.com:3128"}"# ;
676+ let config: EndpointConfiguration = serde_json:: from_str ( config_str) . expect ( "deserialization should succeed" ) ;
677+ assert_eq ! (
678+ Some ( "https://proxy.internal.example.com:3128" . to_string( ) ) ,
679+ config. dd_url
680+ ) ;
681+ }
682+
683+ #[ test]
684+ fn set_dd_url_is_not_filtered ( ) {
685+ // Programmatic calls to set_dd_url bypass serde and are never filtered, even if set to the default value.
686+ // This is important for MRF and other override paths that may explicitly set the default URL.
687+ let config_str = r#"{"api_key": "test-key", "site": "datadoghq.eu"}"# ;
688+ let mut config: EndpointConfiguration =
689+ serde_json:: from_str ( config_str) . expect ( "deserialization should succeed" ) ;
690+ config. set_dd_url ( "https://app.datadoghq.com" . to_string ( ) ) ;
691+ assert_eq ! ( Some ( "https://app.datadoghq.com" . to_string( ) ) , config. dd_url) ;
692+ }
693+
694+ #[ test]
695+ fn site_takes_precedence_when_dd_url_is_default ( ) {
696+ // When dd_url is at its default (sent by Agent with source="default"), site should determine the endpoint.
697+ // This is tested through deserialization so the default-filtering occurs.
698+ let config_str = r#"{"api_key": "test-key", "site": "datadoghq.eu", "dd_url": "https://app.datadoghq.com"}"# ;
699+ let config: EndpointConfiguration = serde_json:: from_str ( config_str) . expect ( "deserialization should succeed" ) ;
700+
701+ let resolved = config
702+ . build_primary_endpoint ( None )
703+ . expect ( "error building primary endpoint" ) ;
704+ // The site path applies a version prefix, so assert the host resolves to the eu site rather than the
705+ // default US1 intake.
706+ let host = resolved. endpoint ( ) . host_str ( ) . unwrap ( ) ;
707+ assert ! ( host. ends_with( "datadoghq.eu" ) , "expected eu site, got {host}" ) ;
708+ }
709+
710+ #[ test]
711+ fn explicit_dd_url_overrides_site ( ) {
712+ // A dd_url that diverges from the default should take precedence over site.
713+ let config_str = r#"{"api_key": "test-key", "site": "datadoghq.eu", "dd_url": "https://dogpound.io/"}"# ;
714+ let config: EndpointConfiguration = serde_json:: from_str ( config_str) . expect ( "deserialization should succeed" ) ;
715+
716+ let resolved = config
717+ . build_primary_endpoint ( None )
718+ . expect ( "error building primary endpoint" ) ;
719+ assert_eq ! ( "dogpound.io" , resolved. endpoint( ) . host_str( ) . unwrap( ) ) ;
720+ }
641721}
0 commit comments