saluki_components/forwarders/datadog/
mod.rs

1use agent_data_plane_config::shared::SharedConfiguration;
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    endpoints::SingleDestination,
21    io::TransactionForwarder,
22    protocol::MetricsPayloadInfo,
23    telemetry::ComponentTelemetry,
24    transaction::{Metadata, Transaction},
25    validation::ValidationReadiness,
26    DEFAULT_INTAKE_COMPRESSED_SIZE_LIMIT, METRICS_SERIES_V3_BETA_PATH, METRICS_SERIES_V3_PATH,
27    METRICS_SKETCHES_V3_PATH,
28};
29
30/// Datadog forwarder.
31///
32/// Forwards Datadog-specific payloads to the Datadog platform. Handles the standard Datadog Agent configuration,
33/// in terms of specifying additional endpoints, adding the necessary HTTP request headers for authentication,
34/// identification, and more.
35pub struct DatadogForwarderConfiguration {
36    /// Forwarder configuration settings.
37    ///
38    /// See [`ForwarderConfiguration`] for more information about the available settings.
39    forwarder_config: ForwarderConfiguration,
40
41    /// Live configuration, from which endpoints refresh their API keys.
42    configuration: GenericConfiguration,
43}
44
45impl DatadogForwarderConfiguration {
46    /// Creates a new `DatadogForwarderConfiguration` from the resolved shared configuration.
47    ///
48    /// `config` is retained so that endpoints can refresh their API keys as configuration changes.
49    pub fn from_configuration(shared: &SharedConfiguration, config: &GenericConfiguration) -> Self {
50        Self {
51            forwarder_config: ForwarderConfiguration::from_configuration(shared, config),
52            configuration: config.clone(),
53        }
54    }
55
56    /// Creates a new `DatadogForwarderConfiguration` that forwards to a single endpoint override.
57    ///
58    /// The override replaces the configured endpoints entirely, and its API key refreshes from
59    /// `api_key_refresh_config_path` rather than the primary `api_key`, as Multi-Region Failover
60    /// requires.
61    pub fn for_endpoint_override(
62        shared: &SharedConfiguration, config: &GenericConfiguration, dd_url: String, api_key: String,
63        api_key_refresh_config_path: &'static str,
64    ) -> Self {
65        let destination = SingleDestination {
66            url: dd_url,
67            api_key,
68            api_key_refresh_config_path: Some(api_key_refresh_config_path),
69            accepts_v3_series: true,
70        };
71
72        Self {
73            forwarder_config: ForwarderConfiguration::for_single_destination(shared, config, &destination),
74            configuration: config.clone(),
75        }
76    }
77}
78
79#[async_trait]
80impl ForwarderBuilder for DatadogForwarderConfiguration {
81    fn input_payload_type(&self) -> PayloadType {
82        PayloadType::Http
83    }
84
85    async fn build(&self, context: ComponentContext) -> Result<Box<dyn Forwarder + Send>, GenericError> {
86        let metrics_builder = MetricsBuilder::from_component_context(&context);
87        let telemetry = ComponentTelemetry::from_builder(&metrics_builder);
88        let forwarder = TransactionForwarder::from_config(
89            context,
90            self.forwarder_config.clone(),
91            Some(self.configuration.clone()),
92            get_dd_endpoint_name,
93            telemetry.clone(),
94            metrics_builder,
95        )?;
96
97        Ok(Box::new(Datadog { forwarder }))
98    }
99}
100
101impl MemoryBounds for DatadogForwarderConfiguration {
102    fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
103        builder
104            .minimum()
105            .with_single_value::<Datadog>("component struct")
106            .with_array::<Transaction<FrozenChunkedBytesBuffer>>("requests channel", 8);
107
108        builder
109            .firm()
110            // TODO: This is a little wonky because we're accounting for the firm bound portion of connected encoders here, as this
111            // is the only place where we can calculate how many requests we'll hold on to in memory, which is what ultimately influences
112            // the firm usage.
113            //
114            // We're also cheating by knowing what the largest possible payload is that we'll potentially see, based on the limits on the
115            // Datadog encoders. This won't necessarily hold up for future sources/encoders, but is good enough for now.
116            .with_expr(UsageExpr::sum(
117                "in-flight requests",
118                UsageExpr::config(
119                    "forwarder_retry_queue_payloads_max_size",
120                    self.forwarder_config.retry().queue_max_size_bytes() as usize,
121                ),
122                UsageExpr::product(
123                    "high priority queue",
124                    UsageExpr::config(
125                        "forwarder_high_prio_buffer_size",
126                        self.forwarder_config.endpoint_buffer_size(),
127                    ),
128                    // TODO: The default compressed size limit just so happens to be the biggest one we currently default with on our side,
129                    // but it's not clear that this will always be the case.
130                    UsageExpr::constant("maximum compressed payload size", DEFAULT_INTAKE_COMPRESSED_SIZE_LIMIT),
131                ),
132            ));
133    }
134}
135
136pub struct Datadog {
137    forwarder: TransactionForwarder<FrozenChunkedBytesBuffer>,
138}
139
140#[async_trait]
141impl Forwarder for Datadog {
142    async fn run(mut self: Box<Self>, mut context: ForwarderContext) -> Result<(), GenericError> {
143        let Self { forwarder } = *self;
144
145        let mut health = context.take_health_handle();
146
147        let mut validation = forwarder.api_key_validator().spawn();
148
149        // Spawn our forwarder task to handle sending requests.
150        let forwarder = forwarder.spawn().await;
151
152        debug!("Datadog forwarder started.");
153
154        loop {
155            select! {
156                _ = health.live() => continue,
157                readiness = validation.wait_for_change() => match readiness {
158                    ValidationReadiness::Ready => health.mark_ready(),
159                    ValidationReadiness::NotReady => health.mark_not_ready(),
160                },
161                maybe_payload = context.payloads().next() => match maybe_payload {
162                    Some(payload) => if let Some(http_payload) = payload.try_into_http_payload() {
163                        let (payload_meta, request) = http_payload.into_parts();
164                        let transaction_meta = transaction_metadata_from_payload_metadata(&payload_meta);
165                        let transaction = Transaction::from_original(transaction_meta, request);
166
167                        forwarder.send_transaction(transaction).await?;
168                    }
169                    None => break,
170                },
171            }
172        }
173
174        // Shutdown the forwarder gracefully.
175        validation.abort();
176        forwarder.shutdown().await;
177
178        debug!("Datadog forwarder stopped.");
179
180        Ok(())
181    }
182}
183
184fn transaction_metadata_from_payload_metadata(payload_meta: &PayloadMetadata) -> Metadata {
185    let mut transaction_meta =
186        Metadata::from_event_and_data_point_count(payload_meta.event_count(), payload_meta.data_point_count());
187    transaction_meta.payload_info = payload_meta.get::<MetricsPayloadInfo>().copied();
188    transaction_meta
189}
190
191fn get_dd_endpoint_name(uri: &Uri) -> Option<MetaString> {
192    match uri.path() {
193        "/api/v2/logs" => Some(MetaString::from_static("logs_v2")),
194        "/api/v1/series" => Some(MetaString::from_static("series_v1")),
195        "/api/v2/series" => Some(MetaString::from_static("series_v2")),
196        METRICS_SERIES_V3_PATH => Some(MetaString::from_static("series_v3")),
197        METRICS_SERIES_V3_BETA_PATH => Some(MetaString::from_static("series_v3beta")),
198        "/api/beta/sketches" => Some(MetaString::from_static("sketches_v2")),
199        METRICS_SKETCHES_V3_PATH => Some(MetaString::from_static("sketches_v3")),
200        "/api/v1/check_run" => Some(MetaString::from_static("check_run_v1")),
201        "/api/v1/events_batch" => Some(MetaString::from_static("events_batch_v1")),
202        "/api/v0.2/traces" => Some(MetaString::from_static("traces_v0.2")),
203        _ => None,
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use saluki_config::ConfigurationLoader;
210    use serde_json::json;
211
212    use super::*;
213    use crate::common::datadog::test_util::shared_configuration;
214
215    #[test]
216    fn dd_endpoint_names_map_from_request_path() {
217        // Each intake path maps to the telemetry endpoint name the forwarder tags transactions with; the query and
218        // authority are ignored (only the path matters), and unknown paths map to `None`.
219        let cases: [(Uri, Option<&str>); 12] = [
220            (Uri::from_static("/api/v2/logs"), Some("logs_v2")),
221            (Uri::from_static("/api/v1/series"), Some("series_v1")),
222            (Uri::from_static("/api/v2/series"), Some("series_v2")),
223            (Uri::from_static(METRICS_SERIES_V3_PATH), Some("series_v3")),
224            (Uri::from_static(METRICS_SERIES_V3_BETA_PATH), Some("series_v3beta")),
225            (Uri::from_static("/api/beta/sketches"), Some("sketches_v2")),
226            (Uri::from_static(METRICS_SKETCHES_V3_PATH), Some("sketches_v3")),
227            (Uri::from_static("/api/v1/check_run"), Some("check_run_v1")),
228            (Uri::from_static("/api/v1/events_batch"), Some("events_batch_v1")),
229            (Uri::from_static("/api/v0.2/traces"), Some("traces_v0.2")),
230            (
231                Uri::from_static("https://app.datadoghq.com/api/v2/series"),
232                Some("series_v2"),
233            ),
234            (Uri::from_static("/api/v1/unknown"), None),
235        ];
236
237        for (uri, expected) in cases {
238            assert_eq!(
239                expected,
240                get_dd_endpoint_name(&uri).as_deref(),
241                "get_dd_endpoint_name({})",
242                uri.path()
243            );
244        }
245    }
246
247    #[test]
248    fn transaction_metadata_carries_counts_and_metrics_payload_info() {
249        // A non-metrics payload propagates the event/data-point counts and leaves `payload_info` as `None`.
250        let payload_meta = PayloadMetadata::from_event_and_data_point_count(3, 11);
251        let transaction_meta = transaction_metadata_from_payload_metadata(&payload_meta);
252        assert_eq!(3, transaction_meta.event_count);
253        assert_eq!(11, transaction_meta.data_point_count);
254        assert_eq!(None, transaction_meta.payload_info);
255
256        // A metrics payload additionally copies the `MetricsPayloadInfo` extension through unchanged.
257        let payload_meta = PayloadMetadata::from_event_and_data_point_count(2, 7).with(MetricsPayloadInfo::v3_series());
258        let transaction_meta = transaction_metadata_from_payload_metadata(&payload_meta);
259        assert_eq!(2, transaction_meta.event_count);
260        assert_eq!(7, transaction_meta.data_point_count);
261        assert_eq!(Some(MetricsPayloadInfo::v3_series()), transaction_meta.payload_info);
262    }
263
264    #[tokio::test]
265    async fn endpoint_override_refreshes_from_mrf_api_key() {
266        let (generic_config, sender) = ConfigurationLoader::for_tests(
267            Some(json!({
268                "api_key": "primary-api-key",
269                "multi_region_failover": {
270                    "api_key": "mrf-api-key"
271                }
272            })),
273            None,
274            true,
275        )
276        .await;
277        let sender = sender.expect("dynamic sender should exist");
278        sender
279            .send(saluki_config::dynamic::ConfigUpdate::snapshot([]))
280            .await
281            .expect("initial dynamic snapshot should be sent");
282        generic_config.ready().await;
283
284        let config = DatadogForwarderConfiguration::for_endpoint_override(
285            &shared_configuration(),
286            &generic_config,
287            "http://mrf.example.test".to_string(),
288            "mrf-api-key".to_string(),
289            "multi_region_failover.api_key",
290        );
291
292        let mut endpoints = config
293            .forwarder_config
294            .build_routable_endpoints(Some(config.configuration.clone()))
295            .expect("endpoint should resolve");
296
297        assert_eq!(endpoints.len(), 1);
298        let (_, mut endpoint) = endpoints.pop().unwrap().into_parts();
299        assert_eq!(endpoint.cached_api_key(), "mrf-api-key");
300        assert!(endpoint.has_configuration());
301        assert_eq!(endpoint.api_key(), "mrf-api-key");
302
303        sender
304            .send(saluki_config::dynamic::ConfigUpdate::Partial(
305                saluki_config::dynamic::ConfigSetting::explicit("api_key", json!("rotated-primary-api-key")),
306            ))
307            .await
308            .expect("primary API key update should be sent");
309        sender
310            .send(saluki_config::dynamic::ConfigUpdate::Partial(
311                saluki_config::dynamic::ConfigSetting::explicit(
312                    "multi_region_failover.api_key",
313                    json!("rotated-mrf-api-key"),
314                ),
315            ))
316            .await
317            .expect("MRF API key update should be sent");
318
319        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
320        loop {
321            if endpoint.api_key() == "rotated-mrf-api-key" {
322                break;
323            }
324            assert!(
325                std::time::Instant::now() < deadline,
326                "timed out waiting for endpoint override to refresh from MRF API key"
327            );
328            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
329        }
330    }
331}