saluki_components/encoders/datadog/events/
mod.rs

1use async_trait::async_trait;
2use datadog_protos::events as proto;
3use facet::Facet;
4use http::{uri::PathAndQuery, HeaderValue, Method, Uri};
5use protobuf::{rt::WireType, CodedOutputStream};
6use saluki_common::iter::ReusableDeduplicator;
7use saluki_config::GenericConfiguration;
8use saluki_context::tags::Tag;
9use saluki_core::accounting::{MemoryBounds, MemoryBoundsBuilder};
10use saluki_core::{
11    components::{encoders::*, ComponentContext},
12    data_model::{
13        event::{eventd::EventD, Event, EventType},
14        payload::{HttpPayload, Payload, PayloadMetadata, PayloadType},
15    },
16    observability::ComponentMetricsExt as _,
17    topology::PayloadsDispatcher,
18};
19use saluki_error::{ErrorContext as _, GenericError};
20use saluki_io::compression::CompressionScheme;
21use saluki_metrics::MetricsBuilder;
22use serde::Deserialize;
23use tracing::{debug, error, warn};
24
25use crate::common::datadog::{
26    clamp_payload_limits,
27    io::RB_BUFFER_CHUNK_SIZE,
28    request_builder::{EndpointEncoder, RequestBuilder},
29    resolve_zstd_compressor_level,
30    telemetry::ComponentTelemetry,
31    DEFAULT_INTAKE_COMPRESSED_SIZE_LIMIT, DEFAULT_INTAKE_UNCOMPRESSED_SIZE_LIMIT,
32    DEFAULT_SERIALIZER_COMPRESSED_SIZE_LIMIT, DEFAULT_SERIALIZER_UNCOMPRESSED_SIZE_LIMIT,
33};
34
35const DEFAULT_SERIALIZER_COMPRESSOR_KIND: &str = "zstd";
36const MAX_EVENTS_PER_PAYLOAD: usize = 100;
37const EVENTS_FIELD_NUMBER: u32 = 1;
38
39static CONTENT_TYPE_PROTOBUF: HeaderValue = HeaderValue::from_static("application/x-protobuf");
40
41fn default_serializer_compressor_kind() -> String {
42    DEFAULT_SERIALIZER_COMPRESSOR_KIND.to_owned()
43}
44
45const fn default_max_payload_size() -> usize {
46    DEFAULT_SERIALIZER_COMPRESSED_SIZE_LIMIT
47}
48
49const fn default_max_uncompressed_payload_size() -> usize {
50    DEFAULT_SERIALIZER_UNCOMPRESSED_SIZE_LIMIT
51}
52
53const fn default_log_payloads() -> bool {
54    false
55}
56
57/// Datadog Events incremental encoder.
58///
59/// Generates Datadog Events payloads for the Datadog platform.
60#[derive(Deserialize, Facet)]
61#[cfg_attr(test, derive(Debug, PartialEq, serde::Serialize))]
62pub struct DatadogEventsConfiguration {
63    /// Maximum compressed size, in bytes, of an events payload.
64    ///
65    /// This uses the same generic event payload setting as the Datadog Agent. ADP sends events to
66    /// `/api/v1/events_batch`, so the effective value is clamped to that endpoint's global intake limit of 3,200,000
67    /// bytes. If set to `0`, every non-empty compressed payload exceeds the limit and is dropped during flush.
68    ///
69    /// Defaults to 2,621,440 bytes.
70    #[serde(rename = "serializer_max_payload_size", default = "default_max_payload_size")]
71    max_payload_size: usize,
72
73    /// Maximum uncompressed size, in bytes, of an events payload.
74    ///
75    /// This uses the same generic event payload setting as the Datadog Agent. ADP sends events to
76    /// `/api/v1/events_batch`, so the effective value is clamped to that endpoint's global intake limit of 62,914,560
77    /// bytes. Values smaller than the minimum endpoint framing size prevent the request builder from starting.
78    ///
79    /// Defaults to 4,194,304 bytes.
80    #[serde(
81        rename = "serializer_max_uncompressed_payload_size",
82        default = "default_max_uncompressed_payload_size"
83    )]
84    max_uncompressed_payload_size: usize,
85
86    /// Compression kind to use for the request payloads.
87    ///
88    /// Defaults to `zstd`.
89    #[serde(
90        rename = "serializer_compressor_kind",
91        default = "default_serializer_compressor_kind"
92    )]
93    compressor_kind: String,
94
95    /// ADP-specific zstd compression level, taking precedence over `serializer_zstd_compressor_level`.
96    /// See [`resolve_zstd_compressor_level`] for how the effective level is determined.
97    #[serde(rename = "data_plane_serializer_zstd_compressor_level", default)]
98    data_plane_zstd_compressor_level: Option<i32>,
99
100    /// The Core Agent's zstd compression level, used only when set to a non-default value (not 1).
101    /// See [`resolve_zstd_compressor_level`] for how the effective level is determined.
102    #[serde(rename = "serializer_zstd_compressor_level", default)]
103    serializer_zstd_compressor_level: Option<i32>,
104
105    /// Whether to log event payload contents before encoding.
106    ///
107    /// This logs decoded event objects, not the encoded HTTP body.
108    ///
109    /// Defaults to `false`.
110    #[serde(default = "default_log_payloads")]
111    log_payloads: bool,
112}
113
114impl DatadogEventsConfiguration {
115    /// Creates a new `DatadogEventsConfiguration` from the given configuration.
116    pub fn from_configuration(config: &GenericConfiguration) -> Result<Self, GenericError> {
117        Ok(config.as_typed()?)
118    }
119}
120
121#[async_trait]
122impl IncrementalEncoderBuilder for DatadogEventsConfiguration {
123    type Output = DatadogEvents;
124
125    fn input_event_type(&self) -> EventType {
126        EventType::EventD
127    }
128
129    fn output_payload_type(&self) -> PayloadType {
130        PayloadType::Http
131    }
132
133    async fn build(&self, context: ComponentContext) -> Result<Self::Output, GenericError> {
134        let metrics_builder = MetricsBuilder::from_component_context(&context);
135        let telemetry = ComponentTelemetry::from_builder(&metrics_builder);
136        let zstd_compressor_level = resolve_zstd_compressor_level(
137            self.data_plane_zstd_compressor_level,
138            self.serializer_zstd_compressor_level,
139        );
140        let compression_scheme = CompressionScheme::new(&self.compressor_kind, zstd_compressor_level);
141
142        // Create our request builder.
143        let mut request_builder =
144            RequestBuilder::new(EventsEndpointEncoder::new(), compression_scheme, RB_BUFFER_CHUNK_SIZE).await?;
145        let (uncompressed_limit, compressed_limit) = clamp_payload_limits(
146            self.max_uncompressed_payload_size,
147            self.max_payload_size,
148            DEFAULT_INTAKE_UNCOMPRESSED_SIZE_LIMIT,
149            DEFAULT_INTAKE_COMPRESSED_SIZE_LIMIT,
150        );
151        request_builder.with_len_limits(uncompressed_limit, compressed_limit)?;
152        request_builder.with_max_inputs_per_payload(MAX_EVENTS_PER_PAYLOAD);
153
154        Ok(DatadogEvents {
155            request_builder,
156            telemetry,
157            log_payloads: self.log_payloads,
158        })
159    }
160}
161
162impl MemoryBounds for DatadogEventsConfiguration {
163    fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
164        // TODO: How do we properly represent the requests we can generate that may be sitting around in-flight?
165        //
166        // Theoretically, we'll end up being limited by the size of the downstream forwarder's interconnect, and however
167        // many payloads it will buffer internally... so realistically the firm limit boils down to the forwarder itself
168        // but we'll have a hard time in the forwarder knowing the maximum size of any given payload being sent in, which
169        // then makes it hard to calculate a proper firm bound even though we know the rest of the values required to
170        // calculate the firm bound.
171        builder.minimum().with_single_value::<DatadogEvents>("component struct");
172
173        builder
174            .firm()
175            // Capture the size of the "split re-encode" buffer in the request builder, which is where we keep owned
176            // versions of events that we encode in case we need to actually re-encode them during a split operation.
177            .with_array::<EventD>("events split re-encode buffer", MAX_EVENTS_PER_PAYLOAD);
178    }
179}
180
181pub struct DatadogEvents {
182    request_builder: RequestBuilder<EventsEndpointEncoder>,
183    telemetry: ComponentTelemetry,
184    log_payloads: bool,
185}
186
187#[async_trait]
188impl IncrementalEncoder for DatadogEvents {
189    async fn process_event(&mut self, event: Event) -> Result<ProcessResult, GenericError> {
190        let eventd = match event.try_into_eventd() {
191            Some(eventd) => eventd,
192            None => return Ok(ProcessResult::Continue),
193        };
194
195        if self.log_payloads {
196            debug!(event = ?eventd, "Flushing event.");
197        }
198
199        match self.request_builder.encode(eventd).await {
200            Ok(None) => Ok(ProcessResult::Continue),
201            Ok(Some(eventd)) => Ok(ProcessResult::FlushRequired(Event::EventD(eventd))),
202            Err(e) => {
203                if e.is_recoverable() {
204                    warn!(error = %e, "Failed to encode Datadog event due to recoverable error. Continuing...");
205
206                    // TODO: Get the actual number of events dropped from the error itself.
207                    self.telemetry.events_dropped_encoder().increment(1);
208
209                    Ok(ProcessResult::Continue)
210                } else {
211                    Err(e).error_context("Failed to encode Datadog event due to unrecoverable error.")
212                }
213            }
214        }
215    }
216
217    async fn flush(&mut self, dispatcher: &PayloadsDispatcher) -> Result<(), GenericError> {
218        let maybe_requests = self.request_builder.flush().await;
219        for maybe_request in maybe_requests {
220            match maybe_request {
221                Ok((events, _data_points, request)) => {
222                    let payload_meta = PayloadMetadata::from_event_count(events);
223                    let http_payload = HttpPayload::new(payload_meta, request);
224                    let payload = Payload::Http(http_payload);
225
226                    dispatcher.dispatch(payload).await?;
227                }
228                Err(e) => error!(error = %e, "Failed to build Datadog events payload. Continuing..."),
229            }
230        }
231
232        Ok(())
233    }
234}
235
236#[derive(Debug)]
237struct EventsEndpointEncoder {
238    tags_deduplicator: ReusableDeduplicator<Tag>,
239}
240
241impl EventsEndpointEncoder {
242    fn new() -> Self {
243        Self {
244            tags_deduplicator: ReusableDeduplicator::new(),
245        }
246    }
247}
248
249impl EndpointEncoder for EventsEndpointEncoder {
250    type Input = EventD;
251    type EncodeError = protobuf::Error;
252
253    fn encoder_name() -> &'static str {
254        "events"
255    }
256
257    fn compressed_size_limit(&self) -> usize {
258        DEFAULT_INTAKE_COMPRESSED_SIZE_LIMIT
259    }
260
261    fn uncompressed_size_limit(&self) -> usize {
262        DEFAULT_INTAKE_UNCOMPRESSED_SIZE_LIMIT
263    }
264
265    fn encode(&mut self, input: &Self::Input, buffer: &mut Vec<u8>) -> Result<(), Self::EncodeError> {
266        encode_and_write_eventd(input, buffer, &mut self.tags_deduplicator)
267    }
268
269    fn endpoint_uri(&self) -> Uri {
270        PathAndQuery::from_static("/api/v1/events_batch").into()
271    }
272
273    fn endpoint_method(&self) -> Method {
274        Method::POST
275    }
276
277    fn content_type(&self) -> HeaderValue {
278        CONTENT_TYPE_PROTOBUF.clone()
279    }
280}
281
282fn encode_and_write_eventd(
283    eventd: &EventD, buf: &mut Vec<u8>, tags_deduplicator: &mut ReusableDeduplicator<Tag>,
284) -> Result<(), protobuf::Error> {
285    let mut output_stream = CodedOutputStream::vec(buf);
286
287    // Write the field tag.
288    output_stream.write_tag(EVENTS_FIELD_NUMBER, WireType::LengthDelimited)?;
289
290    // Write the message.
291    let encoded_eventd = encode_eventd(eventd, tags_deduplicator);
292    output_stream.write_message_no_tag(&encoded_eventd)
293}
294
295fn encode_eventd(eventd: &EventD, tags_deduplicator: &mut ReusableDeduplicator<Tag>) -> proto::Event {
296    let mut event = proto::Event::new();
297    event.set_title(eventd.title().into());
298    event.set_text(eventd.text().into());
299
300    if let Some(timestamp) = eventd.timestamp() {
301        event.set_ts(timestamp as i64);
302    }
303
304    if let Some(priority) = eventd.priority() {
305        event.set_priority(priority.as_str().into());
306    }
307
308    if let Some(alert_type) = eventd.alert_type() {
309        event.set_alert_type(alert_type.as_str().into());
310    }
311
312    if let Some(hostname) = eventd.hostname() {
313        event.set_host(hostname.into());
314    }
315
316    if let Some(aggregation_key) = eventd.aggregation_key() {
317        event.set_aggregation_key(aggregation_key.into());
318    }
319
320    if let Some(source_type_name) = eventd.source_type_name() {
321        event.set_source_type_name(source_type_name.into());
322    }
323
324    let chained_tags = eventd.tags().into_iter().chain(eventd.origin_tags());
325    let deduplicated_tags = tags_deduplicator.deduplicated(chained_tags);
326
327    event.set_tags(deduplicated_tags.map(|tag| tag.as_str().into()).collect());
328
329    event
330}
331
332#[cfg(test)]
333mod tests {
334    use std::collections::BTreeSet;
335
336    use saluki_common::iter::ReusableDeduplicator;
337    use saluki_context::tags::{Tag, TagSet};
338    use saluki_core::data_model::event::eventd::{AlertType, EventD, Priority};
339    use stringtheory::MetaString;
340
341    use super::encode_eventd;
342
343    fn tag_set<const N: usize>(tags: [&'static str; N]) -> TagSet {
344        tags.into_iter().map(Tag::from_static).collect()
345    }
346
347    #[test]
348    fn encode_eventd_maps_all_documented_fields() {
349        let eventd = EventD::new("deploy", "release rolled out")
350            .with_timestamp(1_700_000_000u64)
351            .with_priority(Priority::Low)
352            .with_alert_type(AlertType::Error)
353            .with_hostname(MetaString::from_static("host-a"))
354            .with_aggregation_key(MetaString::from_static("deploy-key"))
355            .with_source_type_name(MetaString::from_static("my-source"));
356
357        let mut tags_deduplicator = ReusableDeduplicator::new();
358        let encoded = encode_eventd(&eventd, &mut tags_deduplicator);
359
360        assert_eq!("deploy", encoded.title());
361        assert_eq!("release rolled out", encoded.text());
362        assert_eq!(1_700_000_000, encoded.ts());
363        assert_eq!("low", encoded.priority());
364        assert_eq!("error", encoded.alert_type());
365        assert_eq!("host-a", encoded.host());
366        assert_eq!("deploy-key", encoded.aggregation_key());
367        assert_eq!("my-source", encoded.source_type_name());
368    }
369
370    #[test]
371    fn encode_eventd_applies_defaults_and_skips_empty_string_fields() {
372        // `EventD::new` defaults the priority to `normal` and the alert type to `info`. An unset timestamp and the
373        // empty host/aggregation-key/source-type fields are treated as absent and left at their protobuf defaults.
374        let eventd = EventD::new("title-only", "body");
375        let mut tags_deduplicator = ReusableDeduplicator::new();
376        let encoded = encode_eventd(&eventd, &mut tags_deduplicator);
377
378        assert_eq!("title-only", encoded.title());
379        assert_eq!("body", encoded.text());
380        assert_eq!(0, encoded.ts());
381        assert_eq!("normal", encoded.priority());
382        assert_eq!("info", encoded.alert_type());
383        assert_eq!("", encoded.host());
384        assert_eq!("", encoded.aggregation_key());
385        assert_eq!("", encoded.source_type_name());
386        assert!(encoded.tags().is_empty());
387    }
388
389    #[test]
390    fn encode_eventd_deduplicates_tags_across_origin_tags() {
391        // Event tags and origin tags are chained then deduplicated, so an overlapping tag is written only once.
392        let eventd = EventD::new("dedup", "body")
393            .with_tags(tag_set(["env:prod", "team:core"]))
394            .with_origin_tags(tag_set(["env:prod", "region:us"]));
395        let mut tags_deduplicator = ReusableDeduplicator::new();
396        let encoded = encode_eventd(&eventd, &mut tags_deduplicator);
397
398        let tags = encoded.tags().iter().map(String::as_str).collect::<BTreeSet<_>>();
399        assert_eq!(BTreeSet::from(["env:prod", "team:core", "region:us"]), tags);
400        assert_eq!(
401            3,
402            encoded.tags().len(),
403            "the overlapping `env:prod` tag should not be duplicated"
404        );
405    }
406}
407
408#[cfg(test)]
409mod config_smoke {
410    use datadog_agent_config_testing::config_registry::structs;
411    use datadog_agent_config_testing::run_config_smoke_tests;
412    use serde_json::json;
413
414    use super::DatadogEventsConfiguration;
415    use crate::config::{DatadogRemapper, KEY_ALIASES};
416
417    #[tokio::test]
418    async fn smoke_test() {
419        run_config_smoke_tests(
420            structs::DATADOG_EVENTS_CONFIGURATION,
421            &[],
422            json!({}),
423            |cfg| {
424                cfg.as_typed::<DatadogEventsConfiguration>()
425                    .expect("DatadogEventsConfiguration should deserialize")
426            },
427            KEY_ALIASES,
428            DatadogRemapper::from_env_vars,
429        )
430        .await
431    }
432}