saluki_components/forwarders/datadog/
mod.rs

1use 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
29/// Datadog forwarder.
30///
31/// Forwards Datadog-specific payloads to the Datadog platform. Handles the standard Datadog Agent configuration,
32/// in terms of specifying additional endpoints, adding the necessary HTTP request headers for authentication,
33/// identification, and more.
34pub struct DatadogForwarderConfiguration {
35    /// Forwarder configuration settings.
36    ///
37    /// See [`ForwarderConfiguration`] for more information about the available settings.
38    forwarder_config: ForwarderConfiguration,
39
40    configuration: Option<GenericConfiguration>,
41}
42
43impl DatadogForwarderConfiguration {
44    /// Creates a new `DatadogForwarderConfiguration` from the given configuration.
45    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    /// Creates a forwarder using authoritative typed metrics-routing configuration.
54    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    /// Overrides the default endpoint and refreshes its API key from the given config path.
65    ///
66    /// This is for override endpoints whose API key does not refresh from the top-level `api_key`
67    /// config path, such as Multi-Region Failover.
68    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        // Clear any existing additional endpoints, and set the new DD URL and API key.
78        //
79        // This ensures that the only endpoint we'll send to is this one.
80        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            // TODO: This is a little wonky because we're accounting for the firm bound portion of connected encoders here, as this
121            // is the only place where we can calculate how many requests we'll hold on to in memory, which is what ultimately influences
122            // the firm usage.
123            //
124            // We're also cheating by knowing what the largest possible payload is that we'll potentially see, based on the limits on the
125            // Datadog encoders. This won't necessarily hold up for future sources/encoders, but is good enough for now.
126            .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                    // TODO: The default compressed size limit just so happens to be the biggest one we currently default with on our side,
139                    // but it's not clear that this will always be the case.
140                    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        // Spawn our forwarder task to handle sending requests.
160        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        // Shutdown the forwarder gracefully.
185        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        // Each intake path maps to the telemetry endpoint name the forwarder tags transactions with; the query and
227        // authority are ignored (only the path matters), and unknown paths map to `None`.
228        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        // A non-metrics payload propagates the event/data-point counts and leaves `payload_info` as `None`.
259        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        // A metrics payload additionally copies the `MetricsPayloadInfo` extension through unchanged.
266        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}