Skip to content

Commit 5289ef3

Browse files
committed
feat(metrics): add V3 shadow sampling
1 parent ca16da5 commit 5289ef3

8 files changed

Lines changed: 592 additions & 56 deletions

File tree

lib/saluki-components/src/common/datadog/config.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -890,6 +890,8 @@ mod config_smoke {
890890
structs::FORWARDER_CONFIGURATION,
891891
&[
892892
"serializer_experimental_use_v3_api.sketches.beta_route",
893+
"serializer_experimental_use_v3_api.sketches.shadow_sample_rate",
894+
"serializer_experimental_use_v3_api.sketches.shadow_sites",
893895
"serializer_experimental_use_v3_api.sketches.use_beta",
894896
],
895897
json!({ "api_key": "smoke-test-api-key" }),

lib/saluki-components/src/common/datadog/endpoints.rs

Lines changed: 121 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ use super::protocol::{MetricsPayloadInfo, MetricsProtocolVersion};
2020

2121
static DD_URL_REGEX: LazyLock<Regex> =
2222
LazyLock::new(|| Regex::new(r"^app(\.mrf)?(\.[a-z]{2}\d)?\.(datad(oghq|0g)\.(com|eu)|ddog-gov\.com)$").unwrap());
23+
static DD_SITE_FROM_HOSTNAME_REGEX: LazyLock<Regex> = LazyLock::new(|| {
24+
Regex::new(r"(?:^|\.)([a-z]{2,}\d{1,2}\.)?(datad(?:oghq|0g)\.(?:com|eu)|ddog-gov\.com)\.?$").unwrap()
25+
});
2326

2427
pub const DEFAULT_SITE: &str = "datadoghq.com";
2528

@@ -45,6 +48,9 @@ pub struct EndpointV3Settings {
4548

4649
/// Whether validation mode is enabled for sketches (send both V2 and V3).
4750
pub sketches_validation_mode: bool,
51+
52+
/// Whether this endpoint accepts sampled V3 beta series shadow payloads.
53+
pub series_shadow_mode: bool,
4854
}
4955

5056
impl EndpointV3Settings {
@@ -53,17 +59,22 @@ impl EndpointV3Settings {
5359
/// The `v3_series_endpoints` and `v3_sketches_endpoints` are lists of configured endpoint names.
5460
/// If the endpoint name matches any entry, V3 is enabled for that metric type.
5561
pub fn from_endpoint_url(
56-
configured_endpoint: &str, v3_series_endpoints: &[String], v3_sketches_endpoints: &[String],
57-
series_validate: bool, sketches_validate: bool,
62+
configured_endpoint: &str, resolved_endpoint: &Url, v3_series_endpoints: &[String],
63+
v3_sketches_endpoints: &[String], series_validate: bool, sketches_validate: bool,
64+
series_shadow_sites: &[String],
5865
) -> Self {
5966
let use_v3_series = v3_series_endpoints.iter().any(|e| configured_endpoint == e);
6067
let use_v3_sketches = v3_sketches_endpoints.iter().any(|e| configured_endpoint == e);
68+
let series_shadow_mode = !use_v3_series
69+
&& extract_site_from_url(resolved_endpoint.as_str())
70+
.is_some_and(|site| series_shadow_sites.iter().any(|shadow_site| shadow_site == &site));
6171

6272
Self {
6373
use_v3_series,
6474
use_v3_sketches,
6575
series_validation_mode: use_v3_series && series_validate,
6676
sketches_validation_mode: use_v3_sketches && sketches_validate,
77+
series_shadow_mode,
6778
}
6879
}
6980

@@ -100,8 +111,11 @@ impl EndpointV3Settings {
100111
if is_sketch {
101112
// V3 sketches: accept if V3 sketches is enabled
102113
self.use_v3_sketches
114+
} else if info.is_shadow() {
115+
// V3 shadow series: accept only when this V2-authoritative endpoint is shadow-enabled.
116+
self.series_shadow_mode
103117
} else {
104-
// V3 series: accept if V3 series is enabled
118+
// V3 series: accept if V3 series is enabled.
105119
self.use_v3_series
106120
}
107121
}
@@ -117,14 +131,25 @@ impl EndpointV3Settings {
117131
return false;
118132
};
119133

120-
if info.is_sketch() {
134+
if info.is_shadow() {
135+
self.series_shadow_mode
136+
} else if info.is_sketch() {
121137
self.sketches_validation_mode
122138
} else {
123139
self.series_validation_mode
124140
}
125141
}
126142
}
127143

144+
pub(crate) fn extract_site_from_url(raw_url: &str) -> Option<String> {
145+
let url = Url::parse(raw_url).ok()?;
146+
let hostname = url.host_str()?.trim_end_matches('.').to_ascii_lowercase();
147+
let captures = DD_SITE_FROM_HOSTNAME_REGEX.captures(&hostname)?;
148+
let datacenter = captures.get(1).map_or("", |m| m.as_str());
149+
let domain = captures.get(2)?.as_str();
150+
Some(format!("{datacenter}{domain}"))
151+
}
152+
128153
/// Error type for invalid endpoints.
129154
#[derive(Debug, Snafu)]
130155
#[snafu(context(suffix(false)))]
@@ -929,6 +954,7 @@ mod tests {
929954
use_v3_sketches: false,
930955
series_validation_mode: true,
931956
sketches_validation_mode: false,
957+
series_shadow_mode: false,
932958
};
933959

934960
assert!(settings.should_receive_validation_headers(Some(MetricsPayloadInfo::v2_series())));
@@ -938,6 +964,79 @@ mod tests {
938964
assert!(!settings.should_receive_validation_headers(None));
939965
}
940966

967+
#[test]
968+
fn extract_site_from_url_matches_datadog_domains() {
969+
assert_eq!(
970+
Some("datadoghq.com".to_string()),
971+
extract_site_from_url("https://1-2-3-agent.datadoghq.com/api/v2/series")
972+
);
973+
assert_eq!(
974+
Some("us3.datadoghq.com".to_string()),
975+
extract_site_from_url("https://intake.profile.us3.datadoghq.com/v1/input")
976+
);
977+
assert_eq!(None, extract_site_from_url("https://vector.example.test/api/v2/series"));
978+
}
979+
980+
#[test]
981+
fn shadow_payloads_are_endpoint_scoped() {
982+
let resolved = ResolvedEndpoint::from_raw_endpoint("https://app.datadoghq.com", "fake-api-key")
983+
.expect("endpoint should resolve");
984+
let settings = EndpointV3Settings::from_endpoint_url(
985+
resolved.configured_endpoint(),
986+
resolved.endpoint(),
987+
&[],
988+
&[],
989+
false,
990+
false,
991+
&["datadoghq.com".to_string()],
992+
);
993+
994+
assert!(settings.should_receive_payload(Some(MetricsPayloadInfo::v2_shadow_series())));
995+
assert!(settings.should_receive_payload(Some(MetricsPayloadInfo::v3_shadow_series())));
996+
assert!(!settings.should_receive_payload(Some(MetricsPayloadInfo::v3_series())));
997+
assert!(settings.should_receive_validation_headers(Some(MetricsPayloadInfo::v2_shadow_series())));
998+
assert!(settings.should_receive_validation_headers(Some(MetricsPayloadInfo::v3_shadow_series())));
999+
}
1000+
1001+
#[test]
1002+
fn shadow_payloads_require_allowed_site_and_v2_authoritative_endpoint() {
1003+
let us3 = ResolvedEndpoint::from_raw_endpoint("https://app.us3.datadoghq.com", "fake-api-key")
1004+
.expect("endpoint should resolve");
1005+
let settings = EndpointV3Settings::from_endpoint_url(
1006+
us3.configured_endpoint(),
1007+
us3.endpoint(),
1008+
&[],
1009+
&[],
1010+
false,
1011+
false,
1012+
&["datadoghq.com".to_string()],
1013+
);
1014+
assert!(!settings.should_receive_payload(Some(MetricsPayloadInfo::v3_shadow_series())));
1015+
1016+
let settings = EndpointV3Settings::from_endpoint_url(
1017+
us3.configured_endpoint(),
1018+
us3.endpoint(),
1019+
&[],
1020+
&[],
1021+
false,
1022+
false,
1023+
&["us3.datadoghq.com".to_string()],
1024+
);
1025+
assert!(settings.should_receive_payload(Some(MetricsPayloadInfo::v3_shadow_series())));
1026+
1027+
let v3_series_endpoints = vec![us3.configured_endpoint().to_string()];
1028+
let settings = EndpointV3Settings::from_endpoint_url(
1029+
us3.configured_endpoint(),
1030+
us3.endpoint(),
1031+
&v3_series_endpoints,
1032+
&[],
1033+
false,
1034+
false,
1035+
&["us3.datadoghq.com".to_string()],
1036+
);
1037+
assert!(!settings.should_receive_payload(Some(MetricsPayloadInfo::v3_shadow_series())));
1038+
}
1039+
9411040
#[test]
9421041
fn v3_endpoint_matching_uses_configured_endpoint_before_version_prefix() {
9431042
let resolved = ResolvedEndpoint::from_raw_endpoint("https://app.datadoghq.com", "fake-api-key")
@@ -949,10 +1048,12 @@ mod tests {
9491048
let v3_series_endpoints = vec!["https://app.datadoghq.com".to_string()];
9501049
let settings = EndpointV3Settings::from_endpoint_url(
9511050
resolved.configured_endpoint(),
1051+
resolved.endpoint(),
9521052
&v3_series_endpoints,
9531053
&[],
9541054
false,
9551055
false,
1056+
&["datadoghq.com".to_string()],
9561057
);
9571058

9581059
assert!(settings.use_v3_series);
@@ -961,12 +1062,16 @@ mod tests {
9611062
#[test]
9621063
fn v3_endpoint_matching_is_endpoint_based() {
9631064
let v3_series_endpoints = vec!["https://app.us".to_string()];
1065+
let resolved = ResolvedEndpoint::from_raw_endpoint("https://app.us5.datadoghq.com", "fake-api-key")
1066+
.expect("endpoint should resolve");
9641067
let settings = EndpointV3Settings::from_endpoint_url(
965-
"https://app.us5.datadoghq.com",
1068+
resolved.configured_endpoint(),
1069+
resolved.endpoint(),
9661070
&v3_series_endpoints,
9671071
&[],
9681072
false,
9691073
false,
1074+
&["datadoghq.com".to_string()],
9701075
);
9711076

9721077
assert!(!settings.use_v3_series);
@@ -975,8 +1080,17 @@ mod tests {
9751080
#[test]
9761081
fn v3_endpoint_matching_requires_exact_configured_endpoint() {
9771082
let v3_series_endpoints = vec!["app.datadoghq.com/".to_string()];
978-
let settings =
979-
EndpointV3Settings::from_endpoint_url("https://app.datadoghq.com", &v3_series_endpoints, &[], false, false);
1083+
let resolved = ResolvedEndpoint::from_raw_endpoint("https://app.datadoghq.com", "fake-api-key")
1084+
.expect("endpoint should resolve");
1085+
let settings = EndpointV3Settings::from_endpoint_url(
1086+
resolved.configured_endpoint(),
1087+
resolved.endpoint(),
1088+
&v3_series_endpoints,
1089+
&[],
1090+
false,
1091+
false,
1092+
&["datadoghq.com".to_string()],
1093+
);
9801094

9811095
assert!(!settings.use_v3_series);
9821096
}

lib/saluki-components/src/common/datadog/io.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,10 +368,12 @@ async fn run_endpoint_io_loop<B>(
368368
let v3_api = config.v3_api();
369369
let endpoint_v3_settings = EndpointV3Settings::from_endpoint_url(
370370
&configured_endpoint,
371+
endpoint.endpoint(),
371372
&v3_api.series.endpoints,
372373
&v3_api.sketches.endpoints,
373374
v3_api.series.validate,
374375
v3_api.sketches.validate,
376+
&v3_api.series.shadow_sites,
375377
);
376378
debug!(
377379
endpoint_url,

lib/saluki-components/src/common/datadog/protocol.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ fn default_v3_beta_series_route() -> String {
99
METRICS_SERIES_V3_BETA_PATH.to_owned()
1010
}
1111

12+
const fn default_v3_series_shadow_sample_rate() -> f64 {
13+
0.001
14+
}
15+
16+
fn default_v3_series_shadow_sites() -> Vec<String> {
17+
vec!["datadoghq.com".to_string()]
18+
}
19+
1220
/// The type of metrics payload.
1321
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
1422
pub enum MetricsPayloadType {
@@ -40,6 +48,10 @@ pub struct MetricsPayloadInfo {
4048

4149
/// The type of metrics (series or sketches).
4250
pub payload_type: MetricsPayloadType,
51+
52+
/// Whether this payload is part of sampled V3 shadow validation.
53+
#[serde(default)]
54+
pub shadow: bool,
4355
}
4456

4557
impl MetricsPayloadInfo {
@@ -48,6 +60,7 @@ impl MetricsPayloadInfo {
4860
Self {
4961
version: MetricsProtocolVersion::V2,
5062
payload_type: MetricsPayloadType::Series,
63+
shadow: false,
5164
}
5265
}
5366

@@ -56,6 +69,7 @@ impl MetricsPayloadInfo {
5669
Self {
5770
version: MetricsProtocolVersion::V2,
5871
payload_type: MetricsPayloadType::Sketches,
72+
shadow: false,
5973
}
6074
}
6175

@@ -64,6 +78,7 @@ impl MetricsPayloadInfo {
6478
Self {
6579
version: MetricsProtocolVersion::V3,
6680
payload_type: MetricsPayloadType::Series,
81+
shadow: false,
6782
}
6883
}
6984

@@ -72,13 +87,37 @@ impl MetricsPayloadInfo {
7287
Self {
7388
version: MetricsProtocolVersion::V3,
7489
payload_type: MetricsPayloadType::Sketches,
90+
shadow: false,
91+
}
92+
}
93+
94+
/// Creates a new V2 series payload info for sampled shadow validation.
95+
pub const fn v2_shadow_series() -> Self {
96+
Self {
97+
version: MetricsProtocolVersion::V2,
98+
payload_type: MetricsPayloadType::Series,
99+
shadow: true,
100+
}
101+
}
102+
103+
/// Creates a new V3 series payload info for sampled shadow validation.
104+
pub const fn v3_shadow_series() -> Self {
105+
Self {
106+
version: MetricsProtocolVersion::V3,
107+
payload_type: MetricsPayloadType::Series,
108+
shadow: true,
75109
}
76110
}
77111

78112
/// Returns true if this is a sketch payload.
79113
pub const fn is_sketch(&self) -> bool {
80114
matches!(self.payload_type, MetricsPayloadType::Sketches)
81115
}
116+
117+
/// Returns true if this payload is part of sampled shadow validation.
118+
pub const fn is_shadow(&self) -> bool {
119+
self.shadow
120+
}
82121
}
83122

84123
/// V3 API settings for a specific metric type (series or sketches).
@@ -109,6 +148,22 @@ pub struct V3ApiSettings {
109148
/// Defaults to `/api/intake/metrics/v3beta/series`.
110149
#[serde(default = "default_v3_beta_series_route")]
111150
pub beta_route: String,
151+
152+
/// Per-flush probability of sending a sampled V3 beta shadow payload.
153+
///
154+
/// This only applies to series metrics when V3 is not authoritative.
155+
///
156+
/// Defaults to `0.001`.
157+
#[serde(default = "default_v3_series_shadow_sample_rate")]
158+
pub shadow_sample_rate: f64,
159+
160+
/// Datadog sites eligible for sampled V3 beta shadow payloads.
161+
///
162+
/// This only applies to series metrics when V3 is not authoritative.
163+
///
164+
/// Defaults to `["datadoghq.com"]`.
165+
#[serde(default = "default_v3_series_shadow_sites")]
166+
pub shadow_sites: Vec<String>,
112167
}
113168

114169
impl Default for V3ApiSettings {
@@ -118,6 +173,8 @@ impl Default for V3ApiSettings {
118173
validate: false,
119174
use_beta: false,
120175
beta_route: default_v3_beta_series_route(),
176+
shadow_sample_rate: default_v3_series_shadow_sample_rate(),
177+
shadow_sites: default_v3_series_shadow_sites(),
121178
}
122179
}
123180
}

0 commit comments

Comments
 (0)