Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog.d/datadog_logs_truncate_oversized.enhancement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
The `datadog_logs` sink can now optionally truncate logs that exceed Datadog's
per-log size limit. Configure `truncate_oversized_logs` to set the encoded log
and retained message limits, mark shortened messages, tag reduced logs, and
preserve standard Datadog fields. Logs that cannot be reduced below the
configured limit are dropped.

authors: bruceg
2 changes: 2 additions & 0 deletions lib/vector-common/src/internal_event/metric_name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ pub enum CounterName {
MemoryEnrichmentTableTtlExpirations,
MemoryEnrichmentTableTtlExpirationsTotal,
ComponentCpuUsageNsTotal,
DatadogLogsEventsTruncatedTotal,
DatadogLogsReservedAttributeConflictsTotal,
// Data-plane counter names emitted by the `host_metrics` source.
CpuSecondsTotal,
Expand Down Expand Up @@ -503,6 +504,7 @@ impl CounterName {
"memory_enrichment_table_ttl_expirations_total"
}
Self::ComponentCpuUsageNsTotal => "component_cpu_usage_ns_total",
Self::DatadogLogsEventsTruncatedTotal => "datadog_logs_events_truncated_total",
Self::DatadogLogsReservedAttributeConflictsTotal => {
"datadog_logs_reserved_attribute_conflicts_total"
}
Expand Down
20 changes: 20 additions & 0 deletions src/internal_events/datadog_logs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ use vector_lib::{
};
use vrl::path::OwnedTargetPath;

#[derive(Debug, NamedInternalEvent)]
pub struct DatadogLogsEventTruncated {
pub max_log_bytes: usize,
pub max_message_bytes: usize,
pub original_encoded_size: usize,
}

impl InternalEvent for DatadogLogsEventTruncated {
fn emit(self) {
warn!(
message = "Truncated a Datadog log event that exceeded the per-log size limit.",
max_log_bytes = self.max_log_bytes,
max_message_bytes = self.max_message_bytes,
original_encoded_size = self.original_encoded_size,
internal_log_rate_limit = true,
);
counter!(CounterName::DatadogLogsEventsTruncatedTotal).increment(1);
}
}

#[derive(Debug, NamedInternalEvent)]
pub struct DatadogLogsReservedAttributeConflict<'a> {
pub meaning: &'static str,
Expand Down
121 changes: 119 additions & 2 deletions src/sinks/datadog/logs/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ use crate::{
// of escaped double-quotes -- but we believe this should be very rare in
// practice.
pub const MAX_PAYLOAD_BYTES: usize = 5_000_000;
pub(super) const DEFAULT_MAX_LOG_BYTES: usize = 1_000_000;
pub(super) const DEFAULT_MAX_MESSAGE_BYTES: usize = 900_000;
pub const BATCH_HEADROOM_BYTES: usize = 750_000;
pub const BATCH_MAX_EVENTS: usize = 1_000;
pub const BATCH_DEFAULT_TIMEOUT_SECS: f64 = 5.0;
Expand All @@ -48,6 +50,23 @@ impl SinkBatchSettings for DatadogLogsDefaultBatchSettings {
const TIMEOUT_SECS: f64 = BATCH_DEFAULT_TIMEOUT_SECS;
}

/// Options for truncating logs that exceed Datadog's per-log size limit.
#[configurable_component]
#[derive(Clone, Copy, Debug, Derivative)]
#[derivative(Default)]
#[serde(deny_unknown_fields)]
pub struct DatadogLogsTruncationConfig {
/// Maximum encoded size, in bytes, of a log before truncation is applied.
#[derivative(Default(value = "default_max_log_bytes()"))]
#[serde(default = "default_max_log_bytes")]
pub max_log_bytes: usize,

/// Maximum number of message bytes to retain before appending the truncation marker.
#[derivative(Default(value = "default_max_message_bytes()"))]
#[serde(default = "default_max_message_bytes")]
pub max_message_bytes: usize,
}

/// Configuration for the `datadog_logs` sink.
#[configurable_component(sink("datadog_logs", "Publish log events to Datadog."))]
#[derive(Clone, Debug, Derivative)]
Expand Down Expand Up @@ -81,17 +100,34 @@ pub struct DatadogLogsConfig {
/// to not set it above 5,000,000 (5 MB, the standard Datadog API limit). Increase
/// this when targeting a compatible endpoint that accepts larger payloads. The batch
/// goal is derived as `max_payload_bytes - 750,000` bytes; events larger than the
/// batch goal are sent alone in their batch. Events exceeding `max_payload_bytes` are
/// dropped.
/// batch goal are sent alone in their batch. Single events that still exceed
/// `max_payload_bytes` after optional truncation are dropped.
#[derivative(Default(value = "default_max_payload_bytes()"))]
#[serde(default = "default_max_payload_bytes")]
pub max_payload_bytes: Option<usize>,

/// Truncate logs whose encoded JSON exceeds `max_log_bytes`.
///
/// When a message is shortened, at most `max_message_bytes` raw bytes are retained and
/// `...TRUNCATED...` is appended. Every reduced log is tagged with `truncated:single_line`.
/// If the log remains oversized after the initial message cap, non-standard fields are removed
/// before the message is shortened further to account for JSON encoding. Logs that still
/// exceed the limit, or have no string message to truncate, are dropped.
pub truncate_oversized_logs: Option<DatadogLogsTruncationConfig>,
}

const fn default_max_payload_bytes() -> Option<usize> {
Some(MAX_PAYLOAD_BYTES)
}

const fn default_max_log_bytes() -> usize {
DEFAULT_MAX_LOG_BYTES
}

const fn default_max_message_bytes() -> usize {
DEFAULT_MAX_MESSAGE_BYTES
}

const fn default_compression() -> Option<Compression> {
Some(Compression::zstd_default())
}
Expand Down Expand Up @@ -175,6 +211,7 @@ impl DatadogLogsConfig {
self.max_payload_bytes.unwrap_or(MAX_PAYLOAD_BYTES),
)
.compression(self.compression.or_else(default_compression).unwrap())
.truncation(self.truncate_oversized_logs)
.build();

Ok(VectorSink::from_event_streamsink(sink))
Expand Down Expand Up @@ -253,6 +290,27 @@ impl ValidatedSink for DatadogLogsConfig {
.into());
}

if let Some(truncation) = self.truncate_oversized_logs {
let max_payload_bytes = self.max_payload_bytes.unwrap_or(MAX_PAYLOAD_BYTES);
// A single log is wrapped in `[` and `]`, so leave two bytes for the JSON array.
let maximum_log_bytes = max_payload_bytes - 2;
if !(1..=maximum_log_bytes).contains(&truncation.max_log_bytes) {
return Err(format!(
"truncate_oversized_logs.max_log_bytes ({}) must be between 1 and {}",
truncation.max_log_bytes, maximum_log_bytes
)
.into());
}
let maximum_message_bytes = truncation.max_log_bytes - 1;
if !(1..=maximum_message_bytes).contains(&truncation.max_message_bytes) {
return Err(format!(
"truncate_oversized_logs.max_message_bytes ({}) must be between 1 and {}",
truncation.max_message_bytes, maximum_message_bytes
)
.into());
}
}

let batch_goal_bytes =
self.max_payload_bytes.unwrap_or(MAX_PAYLOAD_BYTES) - BATCH_HEADROOM_BYTES;

Expand Down Expand Up @@ -309,6 +367,65 @@ mod test {
crate::test_util::test_generate_config::<DatadogLogsConfig>();
}

#[test]
fn truncate_oversized_logs_accepts_empty_configuration() {
let config = serde_yaml::from_str::<DatadogLogsConfig>(indoc::indoc! {r#"
default_api_key: "test_key"
truncate_oversized_logs: {}
"#})
.expect("truncation configuration should deserialize");

let truncation = config
.truncate_oversized_logs
.expect("truncation should be enabled");
assert_eq!(truncation.max_log_bytes, 1_000_000);
assert_eq!(truncation.max_message_bytes, 900_000);
}

#[test]
fn validate_rejects_invalid_truncation_limits() {
for max_message_bytes in [0, DEFAULT_MAX_LOG_BYTES] {
let config: DatadogLogsConfig = serde_yaml::from_str(&format!(
r#"
default_api_key: "test_key"
truncate_oversized_logs:
max_message_bytes: {max_message_bytes}
"#
))
.unwrap();

assert!(config.validate().is_err());
}
}

#[test]
fn validate_rejects_truncation_limit_without_array_wrapper_room() {
let config: DatadogLogsConfig = serde_yaml::from_str(indoc::indoc! {r#"
default_api_key: "test_key"
max_payload_bytes: 800000
truncate_oversized_logs:
max_log_bytes: 799999
max_message_bytes: 700000
"#})
.unwrap();

assert!(config.validate().is_err());
}

#[test]
fn validate_accepts_custom_log_limit_above_datadog_default() {
let config: DatadogLogsConfig = serde_yaml::from_str(indoc::indoc! {r#"
default_api_key: "test_key"
max_payload_bytes: 3000000
truncate_oversized_logs:
max_log_bytes: 2000000
max_message_bytes: 1800000
"#})
.unwrap();

assert!(config.validate().is_ok());
}

#[test]
fn validate_produces_usable_batch_settings() {
let config = DatadogLogsConfig::default();
Expand Down
3 changes: 3 additions & 0 deletions src/sinks/datadog/logs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@
//! Datadog Log API. The log API is relatively generous in terms of its
//! constraints, except that:
//!
//! * an individual log over 1 MB is accepted but truncated
//! * a 'payload' is comprised of no more than 1,000 array members
//! * a 'payload' may not be more than 5Mb in size, uncompressed and
//! * a 'payload' may not mix API keys
//!
//! The sink can optionally perform per-log truncation before sending.
//!
//! Otherwise per [the
//! docs](https://docs.datadoghq.com/api/latest/logs/#send-logs) there aren't
//! other major constraints we have to follow in this implementation. The sink
Expand Down
Loading
Loading