Skip to content

Commit de5c779

Browse files
authored
feat(datadog_logs): optionally truncate oversized log events (#26512)
1 parent 0baa4d2 commit de5c779

10 files changed

Lines changed: 859 additions & 72 deletions

File tree

‎Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ serde_json = { version = "1.0.150", default-features = false, features = ["prese
223223
serde_path_to_error = "0.1.14"
224224
serde_with = { version = "3.21.0", default-features = false, features = ["std", "macros", "chrono_0_4"] }
225225
serde_yaml = { version = "0.9.34", default-features = false }
226+
simdutf8 = { version = "0.1.5", default-features = false }
226227
snafu = { version = "0.9.0", default-features = false, features = ["futures", "std"] }
227228
socket2 = { version = "0.6.3", default-features = false }
228229
tempfile = "3.27.0"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
The `datadog_logs` sink can now optionally truncate logs that exceed Datadog's
2+
per-log size limit. Configure `truncate_oversized_logs` to set the encoded log
3+
limit, mark shortened messages, and tag reduced logs. Logs whose non-message
4+
fields leave no room for a truncated message, or have no string message, are
5+
forwarded unchanged when they fit the payload limit, allowing the Datadog
6+
intake to truncate them.
7+
8+
authors: bruceg

‎lib/vector-common/src/internal_event/metric_name.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ pub enum CounterName {
109109
MemoryEnrichmentTableTtlExpirations,
110110
MemoryEnrichmentTableTtlExpirationsTotal,
111111
ComponentCpuUsageNsTotal,
112+
DatadogLogsEventsTruncatedTotal,
112113
DatadogLogsReservedAttributeConflictsTotal,
113114
// Data-plane counter names emitted by the `host_metrics` source.
114115
CpuSecondsTotal,
@@ -503,6 +504,7 @@ impl CounterName {
503504
"memory_enrichment_table_ttl_expirations_total"
504505
}
505506
Self::ComponentCpuUsageNsTotal => "component_cpu_usage_ns_total",
507+
Self::DatadogLogsEventsTruncatedTotal => "datadog_logs_events_truncated_total",
506508
Self::DatadogLogsReservedAttributeConflictsTotal => {
507509
"datadog_logs_reserved_attribute_conflicts_total"
508510
}

‎src/internal_events/datadog_logs.rs‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,24 @@ use vector_lib::{
44
};
55
use vrl::path::OwnedTargetPath;
66

7+
#[derive(Debug, NamedInternalEvent)]
8+
pub struct DatadogLogsEventTruncated {
9+
pub max_log_bytes: usize,
10+
pub original_encoded_size: usize,
11+
}
12+
13+
impl InternalEvent for DatadogLogsEventTruncated {
14+
fn emit(self) {
15+
debug!(
16+
message = "Truncated a Datadog log event that exceeded the per-log size limit.",
17+
max_log_bytes = self.max_log_bytes,
18+
original_encoded_size = self.original_encoded_size,
19+
internal_log_rate_limit = true,
20+
);
21+
counter!(CounterName::DatadogLogsEventsTruncatedTotal).increment(1);
22+
}
23+
}
24+
725
#[derive(Debug, NamedInternalEvent)]
826
pub struct DatadogLogsReservedAttributeConflict<'a> {
927
pub meaning: &'static str,

‎src/sinks/datadog/logs/config.rs‎

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ use crate::{
3434
// of escaped double-quotes -- but we believe this should be very rare in
3535
// practice.
3636
pub const MAX_PAYLOAD_BYTES: usize = 5_000_000;
37+
pub(super) const DEFAULT_MAX_LOG_BYTES: usize = 1_000_000;
3738
pub const BATCH_HEADROOM_BYTES: usize = 750_000;
3839
pub const BATCH_MAX_EVENTS: usize = 1_000;
3940
pub const BATCH_DEFAULT_TIMEOUT_SECS: f64 = 5.0;
@@ -48,6 +49,18 @@ impl SinkBatchSettings for DatadogLogsDefaultBatchSettings {
4849
const TIMEOUT_SECS: f64 = BATCH_DEFAULT_TIMEOUT_SECS;
4950
}
5051

52+
/// Options for truncating logs that exceed Datadog's per-log size limit.
53+
#[configurable_component]
54+
#[derive(Clone, Copy, Debug, Derivative)]
55+
#[derivative(Default)]
56+
#[serde(deny_unknown_fields)]
57+
pub struct DatadogLogsTruncationConfig {
58+
/// Maximum encoded size, in bytes, of a log before truncation is applied.
59+
#[derivative(Default(value = "default_max_log_bytes()"))]
60+
#[serde(default = "default_max_log_bytes")]
61+
pub max_log_bytes: usize,
62+
}
63+
5164
/// Configuration for the `datadog_logs` sink.
5265
#[configurable_component(sink("datadog_logs", "Publish log events to Datadog."))]
5366
#[derive(Clone, Debug, Derivative)]
@@ -81,17 +94,29 @@ pub struct DatadogLogsConfig {
8194
/// to not set it above 5,000,000 (5 MB, the standard Datadog API limit). Increase
8295
/// this when targeting a compatible endpoint that accepts larger payloads. The batch
8396
/// goal is derived as `max_payload_bytes - 750,000` bytes; events larger than the
84-
/// batch goal are sent alone in their batch. Events exceeding `max_payload_bytes` are
85-
/// dropped.
97+
/// batch goal are sent alone in their batch. Single events that still exceed
98+
/// `max_payload_bytes` after optional truncation are dropped.
8699
#[derivative(Default(value = "default_max_payload_bytes()"))]
87100
#[serde(default = "default_max_payload_bytes")]
88101
pub max_payload_bytes: Option<usize>,
102+
103+
/// Attempt to truncate logs whose encoded JSON exceeds `max_log_bytes`.
104+
///
105+
/// The message is shortened to the largest size that fits and `...TRUNCATED...` is appended.
106+
/// Every reduced log is tagged with `truncated:single_line`. Logs with no string message, or
107+
/// whose non-message fields leave no room for a truncated message, are sent unchanged if they
108+
/// fit `max_payload_bytes`; the Datadog intake can further truncate them.
109+
pub truncate_oversized_logs: Option<DatadogLogsTruncationConfig>,
89110
}
90111

91112
const fn default_max_payload_bytes() -> Option<usize> {
92113
Some(MAX_PAYLOAD_BYTES)
93114
}
94115

116+
const fn default_max_log_bytes() -> usize {
117+
DEFAULT_MAX_LOG_BYTES
118+
}
119+
95120
const fn default_compression() -> Option<Compression> {
96121
Some(Compression::zstd_default())
97122
}
@@ -175,6 +200,7 @@ impl DatadogLogsConfig {
175200
self.max_payload_bytes.unwrap_or(MAX_PAYLOAD_BYTES),
176201
)
177202
.compression(self.compression.or_else(default_compression).unwrap())
203+
.truncation(self.truncate_oversized_logs)
178204
.build();
179205

180206
Ok(VectorSink::from_event_streamsink(sink))
@@ -253,6 +279,19 @@ impl ValidatedSink for DatadogLogsConfig {
253279
.into());
254280
}
255281

282+
if let Some(truncation) = self.truncate_oversized_logs {
283+
let max_payload_bytes = self.max_payload_bytes.unwrap_or(MAX_PAYLOAD_BYTES);
284+
// A single log is wrapped in `[` and `]`, so leave two bytes for the JSON array.
285+
let maximum_log_bytes = max_payload_bytes - 2;
286+
if !(1..=maximum_log_bytes).contains(&truncation.max_log_bytes) {
287+
return Err(format!(
288+
"truncate_oversized_logs.max_log_bytes ({}) must be between 1 and {}",
289+
truncation.max_log_bytes, maximum_log_bytes
290+
)
291+
.into());
292+
}
293+
}
294+
256295
let batch_goal_bytes =
257296
self.max_payload_bytes.unwrap_or(MAX_PAYLOAD_BYTES) - BATCH_HEADROOM_BYTES;
258297

@@ -309,6 +348,46 @@ mod test {
309348
crate::test_util::test_generate_config::<DatadogLogsConfig>();
310349
}
311350

351+
#[test]
352+
fn truncate_oversized_logs_accepts_empty_configuration() {
353+
let config = serde_yaml::from_str::<DatadogLogsConfig>(indoc::indoc! {r#"
354+
default_api_key: "test_key"
355+
truncate_oversized_logs: {}
356+
"#})
357+
.expect("truncation configuration should deserialize");
358+
359+
let truncation = config
360+
.truncate_oversized_logs
361+
.expect("truncation should be enabled");
362+
assert_eq!(truncation.max_log_bytes, 1_000_000);
363+
}
364+
365+
#[test]
366+
fn validate_rejects_truncation_limit_without_array_wrapper_room() {
367+
let config: DatadogLogsConfig = serde_yaml::from_str(indoc::indoc! {r#"
368+
default_api_key: "test_key"
369+
max_payload_bytes: 800000
370+
truncate_oversized_logs:
371+
max_log_bytes: 799999
372+
"#})
373+
.unwrap();
374+
375+
assert!(config.validate().is_err());
376+
}
377+
378+
#[test]
379+
fn validate_accepts_custom_log_limit_above_datadog_default() {
380+
let config: DatadogLogsConfig = serde_yaml::from_str(indoc::indoc! {r#"
381+
default_api_key: "test_key"
382+
max_payload_bytes: 3000000
383+
truncate_oversized_logs:
384+
max_log_bytes: 2000000
385+
"#})
386+
.unwrap();
387+
388+
assert!(config.validate().is_ok());
389+
}
390+
312391
#[test]
313392
fn validate_produces_usable_batch_settings() {
314393
let config = DatadogLogsConfig::default();

‎src/sinks/datadog/logs/mod.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@
55
//! Datadog Log API. The log API is relatively generous in terms of its
66
//! constraints, except that:
77
//!
8+
//! * an individual log over 1 MB is accepted but truncated
89
//! * a 'payload' is comprised of no more than 1,000 array members
910
//! * a 'payload' may not be more than 5Mb in size, uncompressed and
1011
//! * a 'payload' may not mix API keys
1112
//!
13+
//! The sink can optionally perform per-log truncation before sending.
14+
//!
1215
//! Otherwise per [the
1316
//! docs](https://docs.datadoghq.com/api/latest/logs/#send-logs) there aren't
1417
//! other major constraints we have to follow in this implementation. The sink

0 commit comments

Comments
 (0)