saluki_components/forwarders/datadog/
mod.rs1use std::{collections::HashMap, sync::Arc};
2
3use agent_data_plane_config::shared::{Secrets, SharedConfiguration};
4use agent_data_plane_config::Live;
5use async_trait::async_trait;
6use http::Uri;
7use saluki_common::buf::FrozenChunkedBytesBuffer;
8use saluki_core::accounting::{MemoryBounds, MemoryBoundsBuilder, UsageExpr};
9use saluki_core::{
10 components::{forwarders::*, BuildContext},
11 data_model::payload::{PayloadMetadata, PayloadType},
12 observability::ComponentMetricsExt as _,
13};
14use saluki_error::GenericError;
15use saluki_metrics::MetricsBuilder;
16use stringtheory::MetaString;
17use tokio::select;
18use tracing::debug;
19
20use crate::common::datadog::{
21 api_key::{ApiKeyView, LiveApiKeys},
22 config::ForwarderConfiguration,
23 endpoints::SingleDestination,
24 io::{LiveForwarderConfiguration, TransactionForwarder},
25 protocol::MetricsPayloadInfo,
26 routing::{MetricsRoutingTargets, RoutingTargetCatalog},
27 telemetry::ComponentTelemetry,
28 transaction::{Metadata, Transaction},
29 validation::ValidationReadiness,
30 DEFAULT_INTAKE_COMPRESSED_SIZE_LIMIT, METRICS_SERIES_V3_BETA_PATH, METRICS_SERIES_V3_PATH,
31 METRICS_SKETCHES_V3_PATH,
32};
33
34pub struct DatadogForwarderConfiguration {
40 forwarder_config: ForwarderConfiguration,
44
45 api_keys: LiveApiKeys,
47
48 secrets: Live<Secrets>,
50}
51
52impl DatadogForwarderConfiguration {
53 pub fn from_configuration(
59 shared: &SharedConfiguration, api_key: Live<String>, additional_endpoints: Live<HashMap<String, Vec<String>>>,
60 secrets: Live<Secrets>,
61 ) -> Self {
62 Self {
63 forwarder_config: ForwarderConfiguration::from_configuration(shared),
64 api_keys: LiveApiKeys {
65 primary: Some(ApiKeyView::Required(api_key)),
66 additional: Some(additional_endpoints),
67 },
68 secrets,
69 }
70 }
71
72 pub fn for_endpoint_override(
79 shared: &SharedConfiguration, dd_url: String, api_key: String, api_key_view: Live<Option<String>>,
80 secrets: Live<Secrets>,
81 ) -> Self {
82 let destination = SingleDestination {
83 url: dd_url,
84 api_key,
85 accepts_v3_series: true,
86 };
87
88 Self {
89 forwarder_config: ForwarderConfiguration::for_single_destination(shared, &destination),
90 api_keys: LiveApiKeys {
91 primary: Some(ApiKeyView::Optional(api_key_view)),
92 additional: None,
93 },
94 secrets,
95 }
96 }
97
98 pub fn with_routing_targets(mut self, routing_targets: Arc<RoutingTargetCatalog>) -> Self {
104 self.forwarder_config = self.forwarder_config.with_routing_targets(routing_targets);
105 self
106 }
107}
108
109#[async_trait]
110impl ForwarderBuilder for DatadogForwarderConfiguration {
111 fn input_payload_type(&self) -> PayloadType {
112 PayloadType::Http
113 }
114
115 async fn build(&self, context: BuildContext) -> Result<Box<dyn Forwarder + Send>, GenericError> {
116 let metrics_builder = MetricsBuilder::from_component_context(context.component_context());
117 let telemetry = ComponentTelemetry::from_builder(&metrics_builder);
118 let forwarder = TransactionForwarder::from_config(
119 context.component_context().clone(),
120 self.forwarder_config.clone(),
121 LiveForwarderConfiguration {
122 api_keys: self.api_keys.clone(),
123 secrets: self.secrets.clone(),
124 },
125 get_dd_endpoint_name,
126 telemetry.clone(),
127 metrics_builder,
128 )?;
129
130 Ok(Box::new(Datadog { forwarder }))
131 }
132}
133
134impl MemoryBounds for DatadogForwarderConfiguration {
135 fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
136 builder
137 .minimum()
138 .with_single_value::<Datadog>("component struct")
139 .with_array::<Transaction<FrozenChunkedBytesBuffer>>("requests channel", 8);
140
141 builder
142 .firm()
143 .with_expr(UsageExpr::sum(
150 "in-flight requests",
151 UsageExpr::config(
152 "forwarder_retry_queue_payloads_max_size",
153 self.forwarder_config.retry().queue_max_size_bytes() as usize,
154 ),
155 UsageExpr::product(
156 "high priority queue",
157 UsageExpr::config(
158 "forwarder_high_prio_buffer_size",
159 self.forwarder_config.endpoint_buffer_size(),
160 ),
161 UsageExpr::constant("maximum compressed payload size", DEFAULT_INTAKE_COMPRESSED_SIZE_LIMIT),
164 ),
165 ));
166 }
167}
168
169pub struct Datadog {
170 forwarder: TransactionForwarder<FrozenChunkedBytesBuffer>,
171}
172
173#[async_trait]
174impl Forwarder for Datadog {
175 async fn run(mut self: Box<Self>, mut context: ForwarderContext) -> Result<(), GenericError> {
176 let Self { forwarder } = *self;
177
178 let mut health = context.take_health_handle();
179
180 let mut validation = forwarder.api_key_validator().spawn();
183 let forwarder = forwarder.spawn();
184
185 debug!("Datadog forwarder started.");
186
187 loop {
188 select! {
189 _ = health.live() => continue,
190 readiness = validation.wait_for_change() => match readiness {
191 ValidationReadiness::Ready => health.mark_ready(),
192 ValidationReadiness::NotReady => health.mark_not_ready(),
193 },
194 maybe_payload = context.payloads().next() => match maybe_payload {
195 Some(payload) => if let Some(http_payload) = payload.try_into_http_payload() {
196 let (payload_meta, request) = http_payload.into_parts();
197 let transaction_meta = transaction_metadata_from_payload_metadata(&payload_meta);
198 let transaction = Transaction::from_original(transaction_meta, request);
199
200 forwarder.send_transaction(transaction).await?;
201 }
202 None => break,
203 },
204 }
205 }
206
207 forwarder.shutdown();
211 drop(validation);
212
213 debug!("Datadog forwarder stopped.");
214
215 Ok(())
216 }
217}
218
219fn transaction_metadata_from_payload_metadata(payload_meta: &PayloadMetadata) -> Metadata {
220 let mut transaction_meta =
221 Metadata::from_event_and_data_point_count(payload_meta.event_count(), payload_meta.data_point_count());
222 transaction_meta.payload_info = payload_meta.get::<MetricsPayloadInfo>().copied();
223 transaction_meta.metrics_routing_targets = payload_meta.get::<Arc<MetricsRoutingTargets>>().cloned();
224 transaction_meta
225}
226
227fn get_dd_endpoint_name(uri: &Uri) -> Option<MetaString> {
228 match uri.path() {
229 "/api/v2/logs" => Some(MetaString::from_static("logs_v2")),
230 "/api/v1/series" => Some(MetaString::from_static("series_v1")),
231 "/api/v2/series" => Some(MetaString::from_static("series_v2")),
232 METRICS_SERIES_V3_PATH => Some(MetaString::from_static("series_v3")),
233 METRICS_SERIES_V3_BETA_PATH => Some(MetaString::from_static("series_v3beta")),
234 "/api/beta/sketches" => Some(MetaString::from_static("sketches_v2")),
235 METRICS_SKETCHES_V3_PATH => Some(MetaString::from_static("sketches_v3")),
236 "/api/v1/check_run" => Some(MetaString::from_static("check_run_v1")),
237 "/api/v1/events_batch" => Some(MetaString::from_static("events_batch_v1")),
238 "/api/v0.2/traces" => Some(MetaString::from_static("traces_v0.2")),
239 _ => None,
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use agent_data_plane_config::SalukiConfiguration;
246 use saluki_core::runtime::test_util::TestSupervisor;
247
248 use super::*;
249 use crate::common::datadog::{
250 api_key::ApiKeyRefresher,
251 test_util::{shared_configuration, LiveConfiguration},
252 };
253
254 #[test]
255 fn dd_endpoint_names_map_from_request_path() {
256 let cases: [(Uri, Option<&str>); 12] = [
259 (Uri::from_static("/api/v2/logs"), Some("logs_v2")),
260 (Uri::from_static("/api/v1/series"), Some("series_v1")),
261 (Uri::from_static("/api/v2/series"), Some("series_v2")),
262 (Uri::from_static(METRICS_SERIES_V3_PATH), Some("series_v3")),
263 (Uri::from_static(METRICS_SERIES_V3_BETA_PATH), Some("series_v3beta")),
264 (Uri::from_static("/api/beta/sketches"), Some("sketches_v2")),
265 (Uri::from_static(METRICS_SKETCHES_V3_PATH), Some("sketches_v3")),
266 (Uri::from_static("/api/v1/check_run"), Some("check_run_v1")),
267 (Uri::from_static("/api/v1/events_batch"), Some("events_batch_v1")),
268 (Uri::from_static("/api/v0.2/traces"), Some("traces_v0.2")),
269 (
270 Uri::from_static("https://app.datadoghq.com/api/v2/series"),
271 Some("series_v2"),
272 ),
273 (Uri::from_static("/api/v1/unknown"), None),
274 ];
275
276 for (uri, expected) in cases {
277 assert_eq!(
278 expected,
279 get_dd_endpoint_name(&uri).as_deref(),
280 "get_dd_endpoint_name({})",
281 uri.path()
282 );
283 }
284 }
285
286 #[test]
287 fn transaction_metadata_carries_counts_and_metrics_payload_info() {
288 let payload_meta = PayloadMetadata::from_event_and_data_point_count(3, 11);
290 let transaction_meta = transaction_metadata_from_payload_metadata(&payload_meta);
291 assert_eq!(3, transaction_meta.event_count);
292 assert_eq!(11, transaction_meta.data_point_count);
293 assert_eq!(None, transaction_meta.payload_info);
294 assert_eq!(None, transaction_meta.metrics_routing_targets);
295
296 let catalog = RoutingTargetCatalog::new("https://primary.example.com", &HashMap::new()).unwrap();
298 let routing_targets = Arc::new(MetricsRoutingTargets::new(catalog.select(|_| true)));
299 let payload_meta = PayloadMetadata::from_event_and_data_point_count(2, 7)
300 .with(MetricsPayloadInfo::v3_series())
301 .with(Arc::clone(&routing_targets));
302 let transaction_meta = transaction_metadata_from_payload_metadata(&payload_meta);
303 assert_eq!(2, transaction_meta.event_count);
304 assert_eq!(7, transaction_meta.data_point_count);
305 assert_eq!(Some(MetricsPayloadInfo::v3_series()), transaction_meta.payload_info);
306 assert!(Arc::ptr_eq(
307 transaction_meta.metrics_routing_targets.as_ref().unwrap(),
308 &routing_targets
309 ));
310 let cloned_meta = transaction_meta.clone();
311 assert!(Arc::ptr_eq(
312 cloned_meta.metrics_routing_targets.as_ref().unwrap(),
313 &routing_targets
314 ));
315 }
316
317 #[tokio::test]
318 async fn an_endpoint_override_follows_the_failover_key_and_not_the_primary_one() {
319 let mut live_config = SalukiConfiguration::default();
320 live_config.shared.endpoints.api_key = "primary-api-key".to_string();
321 live_config.domains.multi_region_failover.api_key = Some("mrf-api-key".to_string());
322 let live = LiveConfiguration::new(live_config.clone());
323
324 let config = DatadogForwarderConfiguration::for_endpoint_override(
325 &shared_configuration(),
326 "http://mrf.example.test".to_string(),
327 "mrf-api-key".to_string(),
328 live.live(|config| &config.domains.multi_region_failover.api_key),
329 live.live(|config| &config.shared.secrets),
330 );
331
332 let endpoints = config
333 .forwarder_config
334 .build_routable_endpoints()
335 .expect("endpoint should resolve");
336 assert_eq!(endpoints.len(), 1);
337 assert_eq!("mrf-api-key", &*endpoints[0].endpoint().api_key());
338
339 let refresher = ApiKeyRefresher::new(&endpoints, &config.api_keys)
340 .expect("the destination should follow the failover view");
341
342 let supervisor = TestSupervisor::start("test_forwarder").await;
345 supervisor.scope(async move { refresher.spawn() }).await;
346
347 live_config.shared.endpoints.api_key = "rotated-primary-api-key".to_string();
349 live.store(live_config.clone());
350
351 live_config.domains.multi_region_failover.api_key = Some("rotated-mrf-api-key".to_string());
352 live.store(live_config);
353
354 let endpoint = endpoints[0].endpoint();
355 tokio::time::timeout(std::time::Duration::from_secs(2), async {
356 while &*endpoint.api_key() != "rotated-mrf-api-key" {
357 tokio::task::yield_now().await;
358 }
359 })
360 .await
361 .expect("the endpoint override should follow the failover key");
362 }
363}