saluki_components/forwarders/datadog/
mod.rs1use agent_data_plane_config::shared::{Endpoints, MetricsEncoding};
2use async_trait::async_trait;
3use http::Uri;
4use saluki_common::buf::FrozenChunkedBytesBuffer;
5use saluki_config::GenericConfiguration;
6use saluki_core::accounting::{MemoryBounds, MemoryBoundsBuilder, UsageExpr};
7use saluki_core::{
8 components::{forwarders::*, ComponentContext},
9 data_model::payload::{PayloadMetadata, PayloadType},
10 observability::ComponentMetricsExt as _,
11};
12use saluki_error::GenericError;
13use saluki_metrics::MetricsBuilder;
14use stringtheory::MetaString;
15use tokio::select;
16use tracing::debug;
17
18use crate::common::datadog::{
19 config::ForwarderConfiguration,
20 io::TransactionForwarder,
21 protocol::MetricsPayloadInfo,
22 telemetry::ComponentTelemetry,
23 transaction::{Metadata, Transaction},
24 validation::ValidationReadiness,
25 DEFAULT_INTAKE_COMPRESSED_SIZE_LIMIT, METRICS_SERIES_V3_BETA_PATH, METRICS_SERIES_V3_PATH,
26 METRICS_SKETCHES_V3_PATH,
27};
28
29pub struct DatadogForwarderConfiguration {
35 forwarder_config: ForwarderConfiguration,
39
40 configuration: Option<GenericConfiguration>,
41}
42
43impl DatadogForwarderConfiguration {
44 pub fn from_configuration(config: &GenericConfiguration) -> Result<Self, GenericError> {
46 let forwarder_config = ForwarderConfiguration::from_configuration(config)?;
47 Ok(Self {
48 forwarder_config,
49 configuration: Some(config.clone()),
50 })
51 }
52
53 pub fn from_configuration_with_metrics_routing(
55 config: &GenericConfiguration, metrics: &MetricsEncoding, endpoints: &Endpoints,
56 ) -> Result<Self, GenericError> {
57 let mut config = Self::from_configuration(config)?;
58 config
59 .forwarder_config
60 .apply_typed_metrics_configuration(metrics, endpoints);
61 Ok(config)
62 }
63
64 pub fn with_endpoint_override_and_api_key_refresh_config_path(
69 mut self, dd_url: String, api_key: String, api_key_refresh_config_path: &'static str,
70 ) -> Self {
71 self.apply_endpoint_override(dd_url, api_key, api_key_refresh_config_path);
72
73 self
74 }
75
76 fn apply_endpoint_override(&mut self, dd_url: String, api_key: String, api_key_refresh_config_path: &'static str) {
77 let endpoint = self.forwarder_config.endpoint_mut();
81 endpoint.clear_additional_endpoints();
82 endpoint.set_dd_url(dd_url);
83 endpoint.set_api_key(api_key);
84 endpoint.set_api_key_refresh_config_path(api_key_refresh_config_path);
85 self.forwarder_config.clear_opw_metrics_endpoint();
86 }
87}
88
89#[async_trait]
90impl ForwarderBuilder for DatadogForwarderConfiguration {
91 fn input_payload_type(&self) -> PayloadType {
92 PayloadType::Http
93 }
94
95 async fn build(&self, context: ComponentContext) -> Result<Box<dyn Forwarder + Send>, GenericError> {
96 let metrics_builder = MetricsBuilder::from_component_context(&context);
97 let telemetry = ComponentTelemetry::from_builder(&metrics_builder);
98 let forwarder = TransactionForwarder::from_config(
99 context,
100 self.forwarder_config.clone(),
101 self.configuration.clone(),
102 get_dd_endpoint_name,
103 telemetry.clone(),
104 metrics_builder,
105 )?;
106
107 Ok(Box::new(Datadog { forwarder }))
108 }
109}
110
111impl MemoryBounds for DatadogForwarderConfiguration {
112 fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
113 builder
114 .minimum()
115 .with_single_value::<Datadog>("component struct")
116 .with_array::<Transaction<FrozenChunkedBytesBuffer>>("requests channel", 8);
117
118 builder
119 .firm()
120 .with_expr(UsageExpr::sum(
127 "in-flight requests",
128 UsageExpr::config(
129 "forwarder_retry_queue_payloads_max_size",
130 self.forwarder_config.retry().queue_max_size_bytes() as usize,
131 ),
132 UsageExpr::product(
133 "high priority queue",
134 UsageExpr::config(
135 "forwarder_high_prio_buffer_size",
136 self.forwarder_config.endpoint_buffer_size(),
137 ),
138 UsageExpr::constant("maximum compressed payload size", DEFAULT_INTAKE_COMPRESSED_SIZE_LIMIT),
141 ),
142 ));
143 }
144}
145
146pub struct Datadog {
147 forwarder: TransactionForwarder<FrozenChunkedBytesBuffer>,
148}
149
150#[async_trait]
151impl Forwarder for Datadog {
152 async fn run(mut self: Box<Self>, mut context: ForwarderContext) -> Result<(), GenericError> {
153 let Self { forwarder } = *self;
154
155 let mut health = context.take_health_handle();
156
157 let mut validation = forwarder.api_key_validator().spawn();
158
159 let forwarder = forwarder.spawn().await;
161
162 debug!("Datadog forwarder started.");
163
164 loop {
165 select! {
166 _ = health.live() => continue,
167 readiness = validation.wait_for_change() => match readiness {
168 ValidationReadiness::Ready => health.mark_ready(),
169 ValidationReadiness::NotReady => health.mark_not_ready(),
170 },
171 maybe_payload = context.payloads().next() => match maybe_payload {
172 Some(payload) => if let Some(http_payload) = payload.try_into_http_payload() {
173 let (payload_meta, request) = http_payload.into_parts();
174 let transaction_meta = transaction_metadata_from_payload_metadata(&payload_meta);
175 let transaction = Transaction::from_original(transaction_meta, request);
176
177 forwarder.send_transaction(transaction).await?;
178 }
179 None => break,
180 },
181 }
182 }
183
184 validation.abort();
186 forwarder.shutdown().await;
187
188 debug!("Datadog forwarder stopped.");
189
190 Ok(())
191 }
192}
193
194fn transaction_metadata_from_payload_metadata(payload_meta: &PayloadMetadata) -> Metadata {
195 let mut transaction_meta =
196 Metadata::from_event_and_data_point_count(payload_meta.event_count(), payload_meta.data_point_count());
197 transaction_meta.payload_info = payload_meta.get::<MetricsPayloadInfo>().copied();
198 transaction_meta
199}
200
201fn get_dd_endpoint_name(uri: &Uri) -> Option<MetaString> {
202 match uri.path() {
203 "/api/v2/logs" => Some(MetaString::from_static("logs_v2")),
204 "/api/v1/series" => Some(MetaString::from_static("series_v1")),
205 "/api/v2/series" => Some(MetaString::from_static("series_v2")),
206 METRICS_SERIES_V3_PATH => Some(MetaString::from_static("series_v3")),
207 METRICS_SERIES_V3_BETA_PATH => Some(MetaString::from_static("series_v3beta")),
208 "/api/beta/sketches" => Some(MetaString::from_static("sketches_v2")),
209 METRICS_SKETCHES_V3_PATH => Some(MetaString::from_static("sketches_v3")),
210 "/api/v1/check_run" => Some(MetaString::from_static("check_run_v1")),
211 "/api/v1/events_batch" => Some(MetaString::from_static("events_batch_v1")),
212 "/api/v0.2/traces" => Some(MetaString::from_static("traces_v0.2")),
213 _ => None,
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use saluki_config::ConfigurationLoader;
220 use serde_json::json;
221
222 use super::*;
223
224 #[test]
225 fn dd_endpoint_names_map_from_request_path() {
226 let cases: [(Uri, Option<&str>); 12] = [
229 (Uri::from_static("/api/v2/logs"), Some("logs_v2")),
230 (Uri::from_static("/api/v1/series"), Some("series_v1")),
231 (Uri::from_static("/api/v2/series"), Some("series_v2")),
232 (Uri::from_static(METRICS_SERIES_V3_PATH), Some("series_v3")),
233 (Uri::from_static(METRICS_SERIES_V3_BETA_PATH), Some("series_v3beta")),
234 (Uri::from_static("/api/beta/sketches"), Some("sketches_v2")),
235 (Uri::from_static(METRICS_SKETCHES_V3_PATH), Some("sketches_v3")),
236 (Uri::from_static("/api/v1/check_run"), Some("check_run_v1")),
237 (Uri::from_static("/api/v1/events_batch"), Some("events_batch_v1")),
238 (Uri::from_static("/api/v0.2/traces"), Some("traces_v0.2")),
239 (
240 Uri::from_static("https://app.datadoghq.com/api/v2/series"),
241 Some("series_v2"),
242 ),
243 (Uri::from_static("/api/v1/unknown"), None),
244 ];
245
246 for (uri, expected) in cases {
247 assert_eq!(
248 expected,
249 get_dd_endpoint_name(&uri).as_deref(),
250 "get_dd_endpoint_name({})",
251 uri.path()
252 );
253 }
254 }
255
256 #[test]
257 fn transaction_metadata_carries_counts_and_metrics_payload_info() {
258 let payload_meta = PayloadMetadata::from_event_and_data_point_count(3, 11);
260 let transaction_meta = transaction_metadata_from_payload_metadata(&payload_meta);
261 assert_eq!(3, transaction_meta.event_count);
262 assert_eq!(11, transaction_meta.data_point_count);
263 assert_eq!(None, transaction_meta.payload_info);
264
265 let payload_meta = PayloadMetadata::from_event_and_data_point_count(2, 7).with(MetricsPayloadInfo::v3_series());
267 let transaction_meta = transaction_metadata_from_payload_metadata(&payload_meta);
268 assert_eq!(2, transaction_meta.event_count);
269 assert_eq!(7, transaction_meta.data_point_count);
270 assert_eq!(Some(MetricsPayloadInfo::v3_series()), transaction_meta.payload_info);
271 }
272
273 #[tokio::test]
274 async fn endpoint_override_refreshes_from_mrf_api_key() {
275 let (generic_config, sender) = ConfigurationLoader::for_tests(
276 Some(json!({
277 "api_key": "primary-api-key",
278 "multi_region_failover": {
279 "api_key": "mrf-api-key"
280 }
281 })),
282 None,
283 true,
284 )
285 .await;
286 let sender = sender.expect("dynamic sender should exist");
287 sender
288 .send(saluki_config::dynamic::ConfigUpdate::Snapshot(json!({})))
289 .await
290 .expect("initial dynamic snapshot should be sent");
291 generic_config.ready().await;
292
293 let config = DatadogForwarderConfiguration::from_configuration(&generic_config)
294 .expect("DatadogForwarderConfiguration should parse")
295 .with_endpoint_override_and_api_key_refresh_config_path(
296 "http://mrf.example.test".to_string(),
297 "mrf-api-key".to_string(),
298 "multi_region_failover.api_key",
299 );
300
301 let mut endpoints = config
302 .forwarder_config
303 .build_routable_endpoints(config.configuration.clone())
304 .expect("endpoint should resolve");
305
306 assert_eq!(endpoints.len(), 1);
307 let (_, mut endpoint) = endpoints.pop().unwrap().into_parts();
308 assert_eq!(endpoint.cached_api_key(), "mrf-api-key");
309 assert!(endpoint.has_configuration());
310 assert_eq!(endpoint.api_key(), "mrf-api-key");
311
312 sender
313 .send(saluki_config::dynamic::ConfigUpdate::Partial {
314 key: "api_key".to_string(),
315 value: json!("rotated-primary-api-key"),
316 })
317 .await
318 .expect("primary API key update should be sent");
319 sender
320 .send(saluki_config::dynamic::ConfigUpdate::Partial {
321 key: "multi_region_failover.api_key".to_string(),
322 value: json!("rotated-mrf-api-key"),
323 })
324 .await
325 .expect("MRF API key update should be sent");
326
327 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
328 loop {
329 if endpoint.api_key() == "rotated-mrf-api-key" {
330 break;
331 }
332 assert!(
333 std::time::Instant::now() < deadline,
334 "timed out waiting for endpoint override to refresh from MRF API key"
335 );
336 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
337 }
338 }
339}