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::{MetricsEndpointRouting, MetricsPayloadInfo},
26    telemetry::ComponentTelemetry,
27    transaction::{Metadata, Transaction},
28    validation::ValidationReadiness,
29    DEFAULT_INTAKE_COMPRESSED_SIZE_LIMIT, METRICS_SERIES_V3_BETA_PATH, METRICS_SERIES_V3_PATH,
30    METRICS_SKETCHES_V3_PATH,
31};
32
33/// Datadog forwarder.
34///
35/// Forwards Datadog-specific payloads to the Datadog platform. Handles the standard Datadog Agent configuration,
36/// in terms of specifying additional endpoints, adding the necessary HTTP request headers for authentication,
37/// identification, and more.
38pub struct DatadogForwarderConfiguration {
39    /// Forwarder configuration settings.
40    ///
41    /// See [`ForwarderConfiguration`] for more information about the available settings.
42    forwarder_config: ForwarderConfiguration,
43
44    /// Live views the endpoints take their API keys from.
45    api_keys: LiveApiKeys,
46
47    /// Live view of the secrets settings the retry policy's gate reads.
48    secrets: Live<Secrets>,
49}
50
51impl DatadogForwarderConfiguration {
52    /// Creates a new `DatadogForwarderConfiguration` from the resolved shared configuration.
53    ///
54    /// `api_key` and `additional_endpoints` are the live views the endpoints take their API keys from, so that a key an
55    /// operator rotates while the process runs reaches the next request. `secrets` is the live view the retry policy's
56    /// gate reads to decide whether a rejected key is worth retrying.
57    pub fn from_configuration(
58        shared: &SharedConfiguration, api_key: Live<String>, additional_endpoints: Live<HashMap<String, Vec<String>>>,
59        secrets: Live<Secrets>,
60    ) -> Self {
61        Self {
62            forwarder_config: ForwarderConfiguration::from_configuration(shared),
63            api_keys: LiveApiKeys {
64                primary: Some(ApiKeyView::Required(api_key)),
65                additional: Some(additional_endpoints),
66            },
67            secrets,
68        }
69    }
70
71    /// Creates a new `DatadogForwarderConfiguration` that forwards to a single endpoint override.
72    ///
73    /// The override replaces the configured endpoints entirely, and `api_key_view` is the live view its key comes from.
74    /// Multi-Region Failover needs this: it has its own key, not the primary one, and configuration can leave that key
75    /// unset. `secrets` serves the same purpose as in [`from_configuration`](Self::from_configuration): it decides
76    /// whether the override's rejected key is worth retrying.
77    pub fn for_endpoint_override(
78        shared: &SharedConfiguration, dd_url: String, api_key: String, api_key_view: Live<Option<String>>,
79        secrets: Live<Secrets>,
80    ) -> Self {
81        let destination = SingleDestination {
82            url: dd_url,
83            api_key,
84            accepts_v3_series: true,
85        };
86
87        Self {
88            forwarder_config: ForwarderConfiguration::for_single_destination(shared, &destination),
89            api_keys: LiveApiKeys {
90                primary: Some(ApiKeyView::Optional(api_key_view)),
91                additional: None,
92            },
93            secrets,
94        }
95    }
96}
97
98#[async_trait]
99impl ForwarderBuilder for DatadogForwarderConfiguration {
100    fn input_payload_type(&self) -> PayloadType {
101        PayloadType::Http
102    }
103
104    async fn build(&self, context: BuildContext) -> Result<Box<dyn Forwarder + Send>, GenericError> {
105        let metrics_builder = MetricsBuilder::from_component_context(context.component_context());
106        let telemetry = ComponentTelemetry::from_builder(&metrics_builder);
107        let forwarder = TransactionForwarder::from_config(
108            context.component_context().clone(),
109            self.forwarder_config.clone(),
110            LiveForwarderConfiguration {
111                api_keys: self.api_keys.clone(),
112                secrets: self.secrets.clone(),
113            },
114            get_dd_endpoint_name,
115            telemetry.clone(),
116            metrics_builder,
117        )?;
118
119        Ok(Box::new(Datadog { forwarder }))
120    }
121}
122
123impl MemoryBounds for DatadogForwarderConfiguration {
124    fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
125        builder
126            .minimum()
127            .with_single_value::<Datadog>("component struct")
128            .with_array::<Transaction<FrozenChunkedBytesBuffer>>("requests channel", 8);
129
130        builder
131            .firm()
132            // TODO: This is a little wonky because we're accounting for the firm bound portion of connected encoders here, as this
133            // is the only place where we can calculate how many requests we'll hold on to in memory, which is what ultimately influences
134            // the firm usage.
135            //
136            // We're also cheating by knowing what the largest possible payload is that we'll potentially see, based on the limits on the
137            // Datadog encoders. This won't necessarily hold up for future sources/encoders, but is good enough for now.
138            .with_expr(UsageExpr::sum(
139                "in-flight requests",
140                UsageExpr::config(
141                    "forwarder_retry_queue_payloads_max_size",
142                    self.forwarder_config.retry().queue_max_size_bytes() as usize,
143                ),
144                UsageExpr::product(
145                    "high priority queue",
146                    UsageExpr::config(
147                        "forwarder_high_prio_buffer_size",
148                        self.forwarder_config.endpoint_buffer_size(),
149                    ),
150                    // TODO: The default compressed size limit just so happens to be the biggest one we currently default with on our side,
151                    // but it's not clear that this will always be the case.
152                    UsageExpr::constant("maximum compressed payload size", DEFAULT_INTAKE_COMPRESSED_SIZE_LIMIT),
153                ),
154            ));
155    }
156}
157
158pub struct Datadog {
159    forwarder: TransactionForwarder<FrozenChunkedBytesBuffer>,
160}
161
162#[async_trait]
163impl Forwarder for Datadog {
164    async fn run(mut self: Box<Self>, mut context: ForwarderContext) -> Result<(), GenericError> {
165        let Self { forwarder } = *self;
166
167        let mut health = context.take_health_handle();
168
169        // Both of these spawn supervised children of this component's supervisor, which is what stops them and
170        // bounds their drain once this run-future returns.
171        let mut validation = forwarder.api_key_validator().spawn();
172        let forwarder = forwarder.spawn();
173
174        debug!("Datadog forwarder started.");
175
176        loop {
177            select! {
178                _ = health.live() => continue,
179                readiness = validation.wait_for_change() => match readiness {
180                    ValidationReadiness::Ready => health.mark_ready(),
181                    ValidationReadiness::NotReady => health.mark_not_ready(),
182                },
183                maybe_payload = context.payloads().next() => match maybe_payload {
184                    Some(payload) => if let Some(http_payload) = payload.try_into_http_payload() {
185                        let (payload_meta, request) = http_payload.into_parts();
186                        let transaction_meta = transaction_metadata_from_payload_metadata(&payload_meta);
187                        let transaction = Transaction::from_original(transaction_meta, request);
188
189                        forwarder.send_transaction(transaction).await?;
190                    }
191                    None => break,
192                },
193            }
194        }
195
196        // Start the forwarder's drain. Dropping `validation` stops API key validation the same way. Neither is waited
197        // for here; the component's supervisor owns that, bounded by its shutdown budget.
198        forwarder.shutdown();
199        drop(validation);
200
201        debug!("Datadog forwarder stopped.");
202
203        Ok(())
204    }
205}
206
207fn transaction_metadata_from_payload_metadata(payload_meta: &PayloadMetadata) -> Metadata {
208    let mut transaction_meta =
209        Metadata::from_event_and_data_point_count(payload_meta.event_count(), payload_meta.data_point_count());
210    transaction_meta.payload_info = payload_meta.get::<MetricsPayloadInfo>().copied();
211    transaction_meta.metrics_endpoint_routing = payload_meta.get::<Arc<MetricsEndpointRouting>>().cloned();
212    transaction_meta
213}
214
215fn get_dd_endpoint_name(uri: &Uri) -> Option<MetaString> {
216    match uri.path() {
217        "/api/v2/logs" => Some(MetaString::from_static("logs_v2")),
218        "/api/v1/series" => Some(MetaString::from_static("series_v1")),
219        "/api/v2/series" => Some(MetaString::from_static("series_v2")),
220        METRICS_SERIES_V3_PATH => Some(MetaString::from_static("series_v3")),
221        METRICS_SERIES_V3_BETA_PATH => Some(MetaString::from_static("series_v3beta")),
222        "/api/beta/sketches" => Some(MetaString::from_static("sketches_v2")),
223        METRICS_SKETCHES_V3_PATH => Some(MetaString::from_static("sketches_v3")),
224        "/api/v1/check_run" => Some(MetaString::from_static("check_run_v1")),
225        "/api/v1/events_batch" => Some(MetaString::from_static("events_batch_v1")),
226        "/api/v0.2/traces" => Some(MetaString::from_static("traces_v0.2")),
227        _ => None,
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use agent_data_plane_config::SalukiConfiguration;
234    use saluki_core::components::test_util::TestComponentSupervisor;
235
236    use super::*;
237    use crate::common::datadog::{
238        api_key::ApiKeyRefresher,
239        test_util::{shared_configuration, LiveConfiguration},
240    };
241
242    #[test]
243    fn dd_endpoint_names_map_from_request_path() {
244        // Each intake path maps to the telemetry endpoint name the forwarder tags transactions with; the query and
245        // authority are ignored (only the path matters), and unknown paths map to `None`.
246        let cases: [(Uri, Option<&str>); 12] = [
247            (Uri::from_static("/api/v2/logs"), Some("logs_v2")),
248            (Uri::from_static("/api/v1/series"), Some("series_v1")),
249            (Uri::from_static("/api/v2/series"), Some("series_v2")),
250            (Uri::from_static(METRICS_SERIES_V3_PATH), Some("series_v3")),
251            (Uri::from_static(METRICS_SERIES_V3_BETA_PATH), Some("series_v3beta")),
252            (Uri::from_static("/api/beta/sketches"), Some("sketches_v2")),
253            (Uri::from_static(METRICS_SKETCHES_V3_PATH), Some("sketches_v3")),
254            (Uri::from_static("/api/v1/check_run"), Some("check_run_v1")),
255            (Uri::from_static("/api/v1/events_batch"), Some("events_batch_v1")),
256            (Uri::from_static("/api/v0.2/traces"), Some("traces_v0.2")),
257            (
258                Uri::from_static("https://app.datadoghq.com/api/v2/series"),
259                Some("series_v2"),
260            ),
261            (Uri::from_static("/api/v1/unknown"), None),
262        ];
263
264        for (uri, expected) in cases {
265            assert_eq!(
266                expected,
267                get_dd_endpoint_name(&uri).as_deref(),
268                "get_dd_endpoint_name({})",
269                uri.path()
270            );
271        }
272    }
273
274    #[test]
275    fn transaction_metadata_carries_counts_and_metrics_payload_info() {
276        // A non-metrics payload propagates the counts and leaves metrics metadata unset.
277        let payload_meta = PayloadMetadata::from_event_and_data_point_count(3, 11);
278        let transaction_meta = transaction_metadata_from_payload_metadata(&payload_meta);
279        assert_eq!(3, transaction_meta.event_count);
280        assert_eq!(11, transaction_meta.data_point_count);
281        assert_eq!(None, transaction_meta.payload_info);
282        assert_eq!(None, transaction_meta.metrics_endpoint_routing);
283
284        // A metrics payload preserves protocol info and shares routing through dispatch and transaction clones.
285        let endpoint_routing = Arc::new(MetricsEndpointRouting::Only(
286            ["https://secondary.example.com".to_string()].into(),
287        ));
288        let payload_meta = PayloadMetadata::from_event_and_data_point_count(2, 7)
289            .with(MetricsPayloadInfo::v3_series())
290            .with(Arc::clone(&endpoint_routing));
291        let transaction_meta = transaction_metadata_from_payload_metadata(&payload_meta);
292        assert_eq!(2, transaction_meta.event_count);
293        assert_eq!(7, transaction_meta.data_point_count);
294        assert_eq!(Some(MetricsPayloadInfo::v3_series()), transaction_meta.payload_info);
295        assert!(Arc::ptr_eq(
296            transaction_meta.metrics_endpoint_routing.as_ref().unwrap(),
297            &endpoint_routing
298        ));
299        let cloned_meta = transaction_meta.clone();
300        assert!(Arc::ptr_eq(
301            cloned_meta.metrics_endpoint_routing.as_ref().unwrap(),
302            &endpoint_routing
303        ));
304    }
305
306    #[tokio::test]
307    async fn an_endpoint_override_follows_the_failover_key_and_not_the_primary_one() {
308        let mut live_config = SalukiConfiguration::default();
309        live_config.shared.endpoints.api_key = "primary-api-key".to_string();
310        live_config.domains.multi_region_failover.api_key = Some("mrf-api-key".to_string());
311        let live = LiveConfiguration::new(live_config.clone());
312
313        let config = DatadogForwarderConfiguration::for_endpoint_override(
314            &shared_configuration(),
315            "http://mrf.example.test".to_string(),
316            "mrf-api-key".to_string(),
317            live.live(|config| &config.domains.multi_region_failover.api_key),
318            live.live(|config| &config.shared.secrets),
319        );
320
321        let endpoints = config
322            .forwarder_config
323            .build_routable_endpoints()
324            .expect("endpoint should resolve");
325        assert_eq!(endpoints.len(), 1);
326        assert_eq!("mrf-api-key", &*endpoints[0].endpoint().api_key());
327
328        let refresher = ApiKeyRefresher::new(&endpoints, &config.api_keys)
329            .expect("the destination should follow the failover view");
330
331        // The refresher spawns on the ambient supervisor, and the supervisor is what keeps it running for the rest of
332        // the test.
333        let supervisor = TestComponentSupervisor::start("test_forwarder").await;
334        supervisor.scope(async move { refresher.spawn() }).await;
335
336        // Rotate the primary key first: the override does not follow it.
337        live_config.shared.endpoints.api_key = "rotated-primary-api-key".to_string();
338        live.store(live_config.clone());
339
340        live_config.domains.multi_region_failover.api_key = Some("rotated-mrf-api-key".to_string());
341        live.store(live_config);
342
343        let endpoint = endpoints[0].endpoint();
344        tokio::time::timeout(std::time::Duration::from_secs(2), async {
345            while &*endpoint.api_key() != "rotated-mrf-api-key" {
346                tokio::task::yield_now().await;
347            }
348        })
349        .await
350        .expect("the endpoint override should follow the failover key");
351    }
352}