Skip to main content

saluki_components/forwarders/datadog/
mod.rs

1use 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
34/// Datadog forwarder.
35///
36/// Forwards Datadog-specific payloads to the Datadog platform. Handles the standard Datadog Agent configuration,
37/// in terms of specifying additional endpoints, adding the necessary HTTP request headers for authentication,
38/// identification, and more.
39pub struct DatadogForwarderConfiguration {
40    /// Forwarder configuration settings.
41    ///
42    /// See [`ForwarderConfiguration`] for more information about the available settings.
43    forwarder_config: ForwarderConfiguration,
44
45    /// Live views the endpoints take their API keys from.
46    api_keys: LiveApiKeys,
47
48    /// Live view of the secrets settings the retry policy's gate reads.
49    secrets: Live<Secrets>,
50}
51
52impl DatadogForwarderConfiguration {
53    /// Creates a new `DatadogForwarderConfiguration` from the resolved shared configuration.
54    ///
55    /// `api_key` and `additional_endpoints` are the live views the endpoints take their API keys from, so that a key an
56    /// operator rotates while the process runs reaches the next request. `secrets` is the live view the retry policy's
57    /// gate reads to decide whether a rejected key is worth retrying.
58    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    /// Creates a new `DatadogForwarderConfiguration` that forwards to a single endpoint override.
73    ///
74    /// The override replaces the configured endpoints entirely, and `api_key_view` is the live view its key comes from.
75    /// Multi-Region Failover needs this: it has its own key, not the primary one, and configuration can leave that key
76    /// unset. `secrets` serves the same purpose as in [`from_configuration`](Self::from_configuration): it decides
77    /// whether the override's rejected key is worth retrying.
78    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    /// Sets the routing targets that metrics payloads sent to this forwarder may be addressed to.
99    ///
100    /// Every encoder that addresses its payloads with [`MetricsRoutingTargets`] must share this catalog, and the catalog
101    /// must be built from the same endpoint configuration as this forwarder. Without a catalog, a payload addressed to
102    /// specific targets reaches no endpoint.
103    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            // TODO: This is a little wonky because we're accounting for the firm bound portion of connected encoders here, as this
144            // is the only place where we can calculate how many requests we'll hold on to in memory, which is what ultimately influences
145            // the firm usage.
146            //
147            // We're also cheating by knowing what the largest possible payload is that we'll potentially see, based on the limits on the
148            // Datadog encoders. This won't necessarily hold up for future sources/encoders, but is good enough for now.
149            .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                    // TODO: The default compressed size limit just so happens to be the biggest one we currently default with on our side,
162                    // but it's not clear that this will always be the case.
163                    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        // Both of these calls spawn supervised children of this component. When this run-future returns, the children
181        // stop, and their drain has a time limit.
182        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        // Start the forwarder's drain. Dropping `validation` stops API key validation the same way. This component does
208        // not wait for either of them here. But this component does not finish until its children finish. The shutdown
209        // timeout of the component bounds this wait.
210        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        // Each intake path maps to the telemetry endpoint name the forwarder tags transactions with; the query and
257        // authority are ignored (only the path matters), and unknown paths map to `None`.
258        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        // A non-metrics payload propagates the counts and leaves metrics metadata unset.
289        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        // A metrics payload preserves protocol info and shares routing through dispatch and transaction clones.
297        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        // The refresher spawns on the ambient supervisor, and the supervisor is what keeps it running for the rest of
343        // the test.
344        let supervisor = TestSupervisor::start("test_forwarder").await;
345        supervisor.scope(async move { refresher.spawn() }).await;
346
347        // Rotate the primary key first: the override does not follow it.
348        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}