saluki_components/encoders/datadog/events/
mod.rs1use 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#[derive(Deserialize, Facet)]
61#[cfg_attr(test, derive(Debug, PartialEq, serde::Serialize))]
62pub struct DatadogEventsConfiguration {
63 #[serde(rename = "serializer_max_payload_size", default = "default_max_payload_size")]
71 max_payload_size: usize,
72
73 #[serde(
81 rename = "serializer_max_uncompressed_payload_size",
82 default = "default_max_uncompressed_payload_size"
83 )]
84 max_uncompressed_payload_size: usize,
85
86 #[serde(
90 rename = "serializer_compressor_kind",
91 default = "default_serializer_compressor_kind"
92 )]
93 compressor_kind: String,
94
95 #[serde(rename = "data_plane_serializer_zstd_compressor_level", default)]
98 data_plane_zstd_compressor_level: Option<i32>,
99
100 #[serde(rename = "serializer_zstd_compressor_level", default)]
103 serializer_zstd_compressor_level: Option<i32>,
104
105 #[serde(default = "default_log_payloads")]
111 log_payloads: bool,
112}
113
114impl DatadogEventsConfiguration {
115 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 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 builder.minimum().with_single_value::<DatadogEvents>("component struct");
172
173 builder
174 .firm()
175 .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 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 output_stream.write_tag(EVENTS_FIELD_NUMBER, WireType::LengthDelimited)?;
289
290 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 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 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}