saluki_components/encoders/datadog/logs/
mod.rs1use async_trait::async_trait;
2use chrono::{SecondsFormat, Utc};
3use http::{uri::PathAndQuery, HeaderValue, Method, Uri};
4use saluki_common::iter::ReusableDeduplicator;
5use saluki_context::tags::Tag;
6use saluki_core::accounting::{MemoryBounds, MemoryBoundsBuilder};
7use saluki_core::{
8 components::{encoders::*, ComponentContext},
9 data_model::{
10 event::{log::Log, Event, EventType},
11 payload::{HttpPayload, Payload, PayloadMetadata, PayloadType},
12 },
13 observability::ComponentMetricsExt as _,
14 topology::PayloadsDispatcher,
15};
16use saluki_error::{ErrorContext as _, GenericError};
17use saluki_io::compression::CompressionScheme;
18use saluki_metrics::MetricsBuilder;
19use serde_json::{Map as JsonMap, Value as JsonValue};
20use tracing::{error, warn};
21
22use crate::common::datadog::{
23 io::RB_BUFFER_CHUNK_SIZE,
24 request_builder::{EndpointEncoder, RequestBuilder},
25 telemetry::ComponentTelemetry,
26 DEFAULT_INTAKE_COMPRESSED_SIZE_LIMIT, DEFAULT_INTAKE_UNCOMPRESSED_SIZE_LIMIT,
27};
28
29const MAX_LOGS_PER_PAYLOAD: usize = 1000;
30
31static CONTENT_TYPE_JSON: HeaderValue = HeaderValue::from_static("application/json");
32
33#[derive(Debug)]
35pub struct DatadogLogsConfiguration {
36 compressor_kind: String,
38
39 zstd_level: i32,
43}
44
45impl DatadogLogsConfiguration {
46 pub fn new(compressor_kind: impl Into<String>, zstd_level: i32) -> Self {
48 Self {
49 compressor_kind: compressor_kind.into(),
50 zstd_level,
51 }
52 }
53}
54
55#[async_trait]
56impl IncrementalEncoderBuilder for DatadogLogsConfiguration {
57 type Output = DatadogLogs;
58
59 fn input_event_type(&self) -> EventType {
60 EventType::Log
61 }
62
63 fn output_payload_type(&self) -> PayloadType {
64 PayloadType::Http
65 }
66
67 async fn build(&self, context: ComponentContext) -> Result<Self::Output, GenericError> {
68 let metrics_builder = MetricsBuilder::from_component_context(&context);
69 let telemetry = ComponentTelemetry::from_builder(&metrics_builder);
70 let compression_scheme = CompressionScheme::new(&self.compressor_kind, self.zstd_level);
71
72 let mut request_builder =
73 RequestBuilder::new(LogsEndpointEncoder::new(), compression_scheme, RB_BUFFER_CHUNK_SIZE).await?;
74 request_builder.with_max_inputs_per_payload(MAX_LOGS_PER_PAYLOAD);
75
76 Ok(DatadogLogs {
77 request_builder,
78 telemetry,
79 })
80 }
81}
82
83impl MemoryBounds for DatadogLogsConfiguration {
84 fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
85 builder.minimum().with_single_value::<DatadogLogs>("component struct");
88 builder.firm().with_array::<Log>("logs buffer", MAX_LOGS_PER_PAYLOAD);
89 }
90}
91
92pub struct DatadogLogs {
93 request_builder: RequestBuilder<LogsEndpointEncoder>,
94 telemetry: ComponentTelemetry,
95}
96
97#[async_trait]
98impl IncrementalEncoder for DatadogLogs {
99 async fn process_event(&mut self, event: Event) -> Result<ProcessResult, GenericError> {
100 let log: Log = match event {
101 Event::Log(log) => log,
102 _ => return Ok(ProcessResult::Continue),
103 };
104 match self.request_builder.encode(log).await {
105 Ok(None) => Ok(ProcessResult::Continue),
106 Ok(Some(log)) => Ok(ProcessResult::FlushRequired(Event::Log(log))),
107 Err(e) => {
108 if e.is_recoverable() {
109 warn!(error = %e, "Failed to encode Datadog log due to recoverable error. Continuing...");
110
111 self.telemetry.events_dropped_encoder().increment(1);
113 Ok(ProcessResult::Continue)
114 } else {
115 Err(e).error_context("Failed to encode Datadog log due to unrecoverable error.")
116 }
117 }
118 }
119 }
120
121 async fn flush(&mut self, dispatcher: &PayloadsDispatcher) -> Result<(), GenericError> {
122 let maybe_requests = self.request_builder.flush().await;
123 for maybe_request in maybe_requests {
124 match maybe_request {
125 Ok((events, _data_points, request)) => {
126 let payload_meta = PayloadMetadata::from_event_count(events);
127 let http_payload = HttpPayload::new(payload_meta, request);
128 let payload = Payload::Http(http_payload);
129 dispatcher.dispatch(payload).await?;
130 }
131 Err(e) => error!(error = %e, "Failed to build Datadog logs payload. Continuing..."),
132 }
133 }
134
135 Ok(())
136 }
137}
138
139#[derive(Debug)]
140struct LogsEndpointEncoder {
141 tags_deduplicator: ReusableDeduplicator<Tag>,
142}
143
144impl LogsEndpointEncoder {
145 fn new() -> Self {
146 Self {
147 tags_deduplicator: ReusableDeduplicator::new(),
148 }
149 }
150
151 fn build_agent_json(&mut self, log: &Log) -> JsonValue {
152 let mut obj = JsonMap::new();
153
154 let mut message_inner = JsonMap::new();
156 message_inner.insert("message".to_string(), JsonValue::String(log.message().to_string()));
157 if !log.service().is_empty() {
158 message_inner.insert("service".to_string(), JsonValue::String(log.service().to_string()));
159 }
160 let message_str =
161 serde_json::to_string(&JsonValue::Object(message_inner)).unwrap_or_else(|_| log.message().to_string());
162 obj.insert("message".to_string(), JsonValue::String(message_str));
163
164 if let Some(status) = log.status() {
165 obj.insert("status".to_string(), JsonValue::String(status.as_str().to_string()));
166 }
167 if !log.hostname().is_empty() {
168 obj.insert("hostname".to_string(), JsonValue::String(log.hostname().to_string()));
169 }
170 if !log.service().is_empty() {
171 obj.insert("service".to_string(), JsonValue::String(log.service().to_string()));
172 }
173
174 if let Some(ddsource) = log.source().clone() {
175 obj.insert("ddsource".to_string(), JsonValue::String(ddsource.to_string()));
176 }
177
178 let tags_iter = self.tags_deduplicator.deduplicated(log.tags().into_iter());
180 let tags_vec: Vec<&str> = tags_iter.map(|t| t.as_str()).collect();
181 if !tags_vec.is_empty() {
182 obj.insert("ddtags".to_string(), JsonValue::String(tags_vec.join(",")));
183 }
184
185 let user_provided_timestamp = log.additional_properties().contains_key("timestamp")
187 || log.additional_properties().contains_key("@timestamp");
188 if !user_provided_timestamp {
189 let now_rfc3339 = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
190 obj.insert("@timestamp".to_string(), JsonValue::String(now_rfc3339));
191 }
192
193 for (k, v) in log.additional_properties() {
195 obj.insert(k.to_string(), v.clone());
196 }
197
198 JsonValue::Object(obj)
199 }
200}
201
202impl EndpointEncoder for LogsEndpointEncoder {
203 type Input = Log;
204 type EncodeError = serde_json::Error;
205
206 fn encoder_name() -> &'static str {
207 "logs"
208 }
209
210 fn compressed_size_limit(&self) -> usize {
211 DEFAULT_INTAKE_COMPRESSED_SIZE_LIMIT
212 }
213
214 fn uncompressed_size_limit(&self) -> usize {
215 DEFAULT_INTAKE_UNCOMPRESSED_SIZE_LIMIT
216 }
217
218 fn get_payload_prefix(&self) -> Option<&'static [u8]> {
219 Some(b"[")
220 }
221
222 fn get_payload_suffix(&self) -> Option<&'static [u8]> {
223 Some(b"]")
224 }
225
226 fn get_input_separator(&self) -> Option<&'static [u8]> {
227 Some(b",")
228 }
229
230 fn encode(&mut self, input: &Self::Input, buffer: &mut Vec<u8>) -> Result<(), Self::EncodeError> {
231 let json = self.build_agent_json(input);
232 serde_json::to_writer(buffer, &json)
233 }
234
235 fn endpoint_uri(&self) -> Uri {
236 PathAndQuery::from_static("/api/v2/logs").into()
237 }
238
239 fn endpoint_method(&self) -> Method {
240 Method::POST
241 }
242
243 fn content_type(&self) -> HeaderValue {
244 CONTENT_TYPE_JSON.clone()
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use std::collections::{BTreeSet, HashMap};
251
252 use saluki_context::tags::{Tag, TagSet};
253 use saluki_core::data_model::event::log::{Log, LogStatus};
254 use serde_json::json;
255 use stringtheory::MetaString;
256
257 use super::{JsonValue, LogsEndpointEncoder};
258
259 fn tag_set<const N: usize>(tags: [&'static str; N]) -> TagSet {
260 tags.into_iter().map(Tag::from_static).collect()
261 }
262
263 #[test]
264 fn build_agent_json_enriches_documented_fields() {
265 let mut encoder = LogsEndpointEncoder::new();
266 let log = Log::new("hello world")
267 .with_status(LogStatus::Error)
268 .with_source(MetaString::from_static("nginx"))
269 .with_hostname(MetaString::from_static("host-a"))
270 .with_service(MetaString::from_static("web"))
271 .with_tags(tag_set(["env:prod", "team:core"]));
272
273 let json = encoder.build_agent_json(&log);
274 let obj = json.as_object().expect("agent JSON should be an object");
275
276 let message = obj["message"].as_str().expect("message field should be a string");
278 let message_inner: JsonValue = serde_json::from_str(message).expect("message field should itself be JSON");
279 assert_eq!(json!("hello world"), message_inner["message"]);
280 assert_eq!(json!("web"), message_inner["service"]);
281
282 assert_eq!(json!("Error"), obj["status"]);
284 assert_eq!(json!("host-a"), obj["hostname"]);
285 assert_eq!(json!("web"), obj["service"]);
286 assert_eq!(json!("nginx"), obj["ddsource"]);
287
288 let ddtags = obj["ddtags"].as_str().expect("ddtags should be a string");
290 let ddtags = ddtags.split(',').collect::<BTreeSet<_>>();
291 assert_eq!(BTreeSet::from(["env:prod", "team:core"]), ddtags);
292 }
293
294 #[test]
295 fn build_agent_json_omits_empty_optional_fields() {
296 let mut encoder = LogsEndpointEncoder::new();
299 let json = encoder.build_agent_json(&Log::new("bare"));
300 let obj = json.as_object().expect("agent JSON should be an object");
301
302 assert!(!obj.contains_key("status"));
303 assert!(!obj.contains_key("hostname"));
304 assert!(!obj.contains_key("service"));
305 assert!(!obj.contains_key("ddsource"));
306 assert!(!obj.contains_key("ddtags"));
307
308 let message = obj["message"].as_str().expect("message field should be a string");
309 let message_inner: JsonValue = serde_json::from_str(message).expect("message field should itself be JSON");
310 assert_eq!(json!("bare"), message_inner["message"]);
311 assert!(message_inner.get("service").is_none());
312 }
313
314 #[test]
315 fn build_agent_json_adds_default_timestamp_unless_user_supplied() {
316 let mut encoder = LogsEndpointEncoder::new();
317
318 let json = encoder.build_agent_json(&Log::new("no ts"));
320 let ts = json["@timestamp"]
321 .as_str()
322 .expect("default @timestamp should be present");
323 assert!(ts.ends_with('Z'), "default timestamp should be UTC-suffixed: {ts}");
324 assert!(
325 chrono::DateTime::parse_from_rfc3339(ts).is_ok(),
326 "default timestamp should be RFC3339: {ts}"
327 );
328
329 let mut props = HashMap::new();
331 props.insert(MetaString::from_static("timestamp"), json!(1_234_567));
332 let json = encoder.build_agent_json(&Log::new("user ts").with_additional_properties(props));
333 assert!(
334 !json.as_object().unwrap().contains_key("@timestamp"),
335 "a user-provided `timestamp` should suppress the default `@timestamp`"
336 );
337 assert_eq!(json!(1_234_567), json["timestamp"]);
338
339 let mut props = HashMap::new();
341 props.insert(MetaString::from_static("@timestamp"), json!("2020-01-01T00:00:00Z"));
342 let json = encoder.build_agent_json(&Log::new("user @ts").with_additional_properties(props));
343 assert_eq!(json!("2020-01-01T00:00:00Z"), json["@timestamp"]);
344 }
345
346 #[test]
347 fn build_agent_json_additional_properties_win_over_encoder_fields() {
348 let mut encoder = LogsEndpointEncoder::new();
350 let mut props = HashMap::new();
351 props.insert(MetaString::from_static("hostname"), json!("override-host"));
352 props.insert(MetaString::from_static("custom"), json!(42));
353 let log = Log::new("msg")
354 .with_hostname(MetaString::from_static("original-host"))
355 .with_additional_properties(props);
356
357 let json = encoder.build_agent_json(&log);
358 assert_eq!(json!("override-host"), json["hostname"]);
359 assert_eq!(json!(42), json["custom"]);
360 }
361}