Skip to content
6 changes: 6 additions & 0 deletions changelog.d/15659_aggregate_set_interval_ms.enhancement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
The `aggregate` transform now has a `set_interval_ms` option. When enabled, each flushed
incremental metric gets `interval_ms` set to the configured flush interval, so sinks such as
`datadog_metrics` send aggregated counters as rates instead of counts. With `event_time`, these
metrics also get the start of their bucket as their timestamp.

authors: gremlinops
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
The `datadog_metrics` sink now converts a counter's `interval_ms` to seconds without integer
division. Counters with an interval under one second were sent as rates with infinite or `NaN`
values, and intervals that are not whole seconds overstated the rate. The rate value now uses
the exact interval, and the interval field is rounded to the nearest whole second (at least 1).
When the sink combines counters with an interval that share a series and second, it now also
combines their intervals, so the rate is not overstated.

authors: gremlinops
84 changes: 77 additions & 7 deletions src/sinks/datadog/metrics/encoder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,26 @@ fn sketch_to_proto_message(
})
}

/// A metric's interval, in the two forms the series wire formats need.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct SeriesInterval {
/// Whole seconds for the wire `interval` field, rounded to the nearest second and at least 1.
pub(super) whole_secs: u32,
/// Exact seconds, used to scale a rate's value to per-second.
pub(super) exact_secs: f64,
}

/// Converts a metric's millisecond `interval_ms`, if any, into a [`SeriesInterval`].
pub(super) fn series_interval(metric: &Metric) -> Option<SeriesInterval> {
metric.interval_ms().map(|interval_ms| {
let exact_secs = f64::from(interval_ms.get()) / 1000.0;
SeriesInterval {
whole_secs: (exact_secs.round() as u32).max(1),
exact_secs,
}
})
}

/// A metric's tags, split into the three pieces the series wire formats send separately.
pub(super) struct SeriesTags {
/// Remaining tags, encoded as sorted `key:value` (or bare `key`) strings.
Expand Down Expand Up @@ -646,16 +666,15 @@ fn series_to_proto_message(

let timestamp = encode_timestamp(metric.timestamp());

// our internal representation is in milliseconds but the expected output is in seconds
let maybe_interval = metric.interval_ms().map(|i| i.get() / 1000);
let maybe_interval = series_interval(metric);

let (points, metric_type) = match metric.value() {
MetricValue::Counter { value } => {
if let Some(interval) = maybe_interval {
// When an interval is defined, it implies the value should be in a per-second form,
// so we need to get back to seconds from our milliseconds-based interval, and then
// divide our value by that amount as well.
let value = *value / (interval as f64);
let value = *value / interval.exact_secs;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve interval coverage when collapsing rate points

When sub-second interval counters reach the Datadog sink, the default 2-second batch can contain multiple windows whose timestamps fall in the same whole second. sort_and_collapse_counters_by_series_and_timestamp sums those values but leaves the surviving metric's interval_ms unchanged, so two 500 ms windows become one summed value that this line still divides by 0.5, emitting a 2x rate in both V2 and V3. Make the collapse rate-aware by preserving the total covered duration (or avoid collapsing these interval-bearing windows) before applying the exact-seconds scaling.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9a4c15a. When both collapsed counters carry an interval and a timestamp, the collapse now uses MetricData::update, which sums the values and merges the windows into one span. Without timestamps it sums the intervals. Tests cover both orders and the no-timestamp case.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverted in caa499c to keep this PR focused

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve collapsed rate duration before exact scaling

When a batch contains two interval-bearing counters for one series whose timestamps fall in the same whole second (for example, adjacent 500 ms windows), the sink's collapse sums the values but leaves one 500 ms interval, and this division emits twice the true rate in both V2 and V3. Fresh evidence since the earlier resolved thread is that this squashed target removed the interval-aware merge: src/sinks/datadog/metrics/sink.rs:230-250 still only adds the values. Preserve the total covered duration (or avoid collapsing these points) before applying the exact divisor.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly fixed in f7407f8. The case set_interval_ms could still produce was untimestamped input: the sink gives every untimestamped metric in a batch the same send time, so two windows could collapse. Those metrics now get their window start, one interval apart, so they never share a second. What remains is input that already carries sub-second intervals, or timestamps that put two windows in the same second; before this PR, sub-second intervals were sent as inf. The interval-aware collapse for that is in the follow-up PR.

(
vec![ddmetric_proto::metric_payload::MetricPoint { value, timestamp }],
ddmetric_proto::metric_payload::MetricType::Rate,
Expand Down Expand Up @@ -703,7 +722,7 @@ fn series_to_proto_message(
// unit is omitted
unit: "".to_string(),
source_type_name,
interval: maybe_interval.unwrap_or(0) as i64,
interval: maybe_interval.map_or(0, |interval| i64::from(interval.whole_secs)),
metadata,
})
}
Expand Down Expand Up @@ -873,9 +892,10 @@ mod tests {
};

use super::{
DatadogMetricsEncoder, EncoderError, ddmetric_proto, encode_proto_key_and_message,
encode_tags, encode_timestamp, get_sketch_payload_sketches_field_number,
max_compressed_size, series_to_proto_message, sketch_to_proto_message,
DatadogMetricsEncoder, EncoderError, SeriesInterval, ddmetric_proto,
encode_proto_key_and_message, encode_tags, encode_timestamp,
get_sketch_payload_sketches_field_number, max_compressed_size, series_interval,
series_to_proto_message, sketch_to_proto_message,
};
use crate::sinks::{
datadog::metrics::{
Expand Down Expand Up @@ -1207,6 +1227,56 @@ mod tests {
}
}

#[test]
fn series_interval_rounds_wire_field_and_keeps_exact_seconds() {
let cases = [
(1, 1, 0.001),
(500, 1, 0.5),
(1_000, 1, 1.0),
(1_499, 1, 1.499),
(1_500, 2, 1.5),
(10_000, 10, 10.0),
(u32::MAX, 4_294_967, 4_294_967.295),
];
for (interval_ms, whole_secs, exact_secs) in cases {
let metric = get_simple_rate_counter(1.0, interval_ms);
assert_eq!(
series_interval(&metric),
Some(SeriesInterval {
whole_secs,
exact_secs
}),
"interval_ms = {interval_ms}"
);
}
assert_eq!(series_interval(&get_simple_counter()), None);
}

#[test]
fn encode_counter_with_fractional_second_interval_as_rate() {
for (value, interval_ms, expected_value, expected_interval) in
[(10.0, 500, 20.0, 1), (3.0, 1_500, 2.0, 2)]
{
let series_proto = series_to_proto_message(
&get_simple_rate_counter(value, interval_ms),
&None,
log_schema(),
DEFAULT_DD_ORIGIN_PRODUCT_VALUE,
)
.unwrap();
assert_eq!(series_proto.r#type, 2, "interval_ms = {interval_ms}");
assert_eq!(
series_proto.interval, expected_interval,
"interval_ms = {interval_ms}"
);
assert_eq!(series_proto.points.len(), 1);
assert_eq!(
series_proto.points[0].value, expected_value,
"interval_ms = {interval_ms}"
);
}
}

#[test]
fn encode_non_rate_metric_with_interval() {
// It is possible that the Agent sends Gauges with an interval set. This
Expand Down
29 changes: 24 additions & 5 deletions src/sinks/datadog/metrics/encoder_v3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ use super::{
config::DatadogMetricsEndpoint,
encoder::{
EncoderError, FinishError, ORIGIN_CATEGORY_VALUE, ORIGIN_PRODUCT_VALUE, SeriesTags,
generate_origin_metadata, split_series_tags,
generate_origin_metadata, series_interval, split_series_tags,
},
};
use crate::sinks::util::{
Expand Down Expand Up @@ -180,7 +180,7 @@ fn encode_metric_to_v3(
) -> Result<(), EncoderError> {
// Mirrors V2's `series_to_proto_message`: a Counter with an interval is sent as a
// per-second-scaled Rate, not a raw Count.
let maybe_interval = metric.interval_ms().map(|i| i.get() / 1000);
let maybe_interval = series_interval(metric);

let metric_type = match metric.value() {
MetricValue::Counter { .. } if maybe_interval.is_some() => V3MetricType::Rate,
Expand Down Expand Up @@ -261,9 +261,9 @@ fn encode_metric_to_v3(
}

// Interval — matches V2, which always stamps the interval field on the message
// (`interval: maybe_interval.unwrap_or(0)`), even though only Rate uses it to scale the value.
// when an interval is present, even though only Rate uses it to scale the value.
if let Some(interval) = maybe_interval {
builder.set_interval(interval.into());
builder.set_interval(interval.whole_secs.into());
}

// Note: `unit` is intentionally never set — V2 always sends it empty (see
Expand All @@ -275,7 +275,7 @@ fn encode_metric_to_v3(
match metric.value() {
MetricValue::Counter { value } => {
let value = match maybe_interval {
Some(interval) => *value / (interval as f64),
Some(interval) => *value / interval.exact_secs,
None => *value,
};
builder.add_point(timestamp, value);
Expand Down Expand Up @@ -520,6 +520,25 @@ mod tests {
);
}

#[test]
fn v3_counter_with_sub_second_interval_scales_by_exact_seconds() {
let encode = |value: f64, interval_ms: u32| {
let mut enc = DatadogMetricsV3Encoder::new(
DatadogMetricsEndpoint::Series(SeriesApiVersion::V3),
None,
);
let metric = counter("rate.counter", value)
.with_timestamp(DateTime::from_timestamp(1_700_000_000, 0))
.with_interval_ms(NonZeroU32::new(interval_ms));
enc.try_encode(metric).unwrap();
enc.finish().unwrap().0.into_payload()
};

// Both are a rate of 1/s with a wire interval of 1 second.
assert_eq!(encode(0.5, 500), encode(1.0, 1_000));
assert_ne!(encode(1.0, 500), encode(1.0, 1_000));
}

#[test]
fn v3_non_series_metric_values_are_rejected() {
// `AggregatedSummary` is split into counters/gauges, and `Distribution`/
Expand Down
88 changes: 72 additions & 16 deletions src/sinks/datadog/metrics/sink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,30 +239,47 @@ fn sort_and_collapse_counters_by_series_and_timestamp(mut metrics: Vec<Metric>)
}

// Only aggregate counters. All other types can be skipped.
if let (
MetricValue::Counter { value: left_value },
MetricValue::Counter { value: right_value },
) = (left.value(), right.value_mut())
{
// NOTE: The docs for `dedup_by` specify that if `left`/`right` are equal, then
// `left` is the element that gets removed.
*right_value += left_value;
right
.metadata_mut()
.merge_finalizers(left.metadata_mut().take_finalizers());

true
} else {
false
let (MetricValue::Counter { value: left_value }, MetricValue::Counter { .. }) =
(left.value(), right.value())
else {
return false;
};
let left_value = *left_value;

// NOTE: The docs for `dedup_by` specify that if `left`/`right` are equal, then
// `left` is the element that gets removed.
match (left.interval_ms(), right.interval_ms()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Coalesce rate windows before splitting sink batches

When subsecond windows for one series land in different sink batches (for example with batch.max_events = 1, a small max_bytes, or a busy batch at its limit), this rate-aware merge cannot see both: run_inner performs batched_partitioned at src/sinks/datadog/metrics/sink.rs:120-130 before invoking this function at lines 137-147, and encode_timestamp at encoder.rs:765-769 truncates both points to the same second. The sink documents Datadog's last-write-wins behavior for that case at lines 132-134, so unequal 500 ms windows are sent separately and one window is lost instead of being combined into the correct per-second rate. Fresh evidence beyond the earlier intra-batch collapse report is this batching order; coalesce by encoded second before batching or retain merge state across batches.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of scope for this PR after caa499c: set_interval_ms now requires whole seconds, and whole-second windows from one producer never share an encoded second. Sub-second intervals from other sources hitting this across batches is pre-existing sink behavior

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to my reply above: whole-second windows can share an encoded second. Metrics without a timestamp (for example from the statsd source) stayed untimestamped through aggregate, and the sink gives all of them in one batch the same send time (2-second default batch timeout). So with interval_ms at or below 2000, two windows could be combined and sent at twice the rate. Fixed in f7407f8: with set_interval_ms, untimestamped aggregates now get their window start, and consecutive windows start exactly one interval apart, so they never share a second. Sub-second windows from other sources stay with the follow-up PR.

// Rates are scaled by their interval, so the collapsed counter must also cover both
// windows. `update` merges the windows from the timestamps and sums the values.
(Some(_), Some(_)) if left.timestamp().is_some() && right.timestamp().is_some() => {
if !right.data_mut().update(left.data()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent overflow when collapsing long rate intervals

When two timestamped rate counters in the same encoded second have an interval near u32::MAX and timestamps differing by even 1 ms, this new call reaches MetricData::update, whose merge at lib/vector-core/src/event/metric/data.rs:104-119 performs unchecked delta_t + interval. Since this commit explicitly accepts u32::MAX, debug builds panic and release builds wrap the interval (for exactly u32::MAX + 1, it becomes None), causing the sink to emit a wildly incorrect rate or even a count; use checked/saturating interval arithmetic or avoid this generic merge.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 497f5d8. MetricData::update now uses checked_add for the window and returns false on overflow, the same way it already handles a timestamp delta that does not fit in u32. That also covers the aggregate transform's existing record_sum path. Added a test with u32::MAX intervals

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverted in caa499c to keep this PR focused

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve every window during rate collapse

When a batch contains at least three interval-bearing counters for one series in the same whole second and they arrive out of event-time order, this pairwise update can shrink an already-merged window. For example, consecutive windows [600,900), [0,300), and [300,600) ms are comparator-equal; the first update can produce [0,900), but the next MetricData::update replaces that end with the later operand's end and produces [0,600), so the encoder divides all three values by 0.6 seconds and overstates the rate by 50%. Fresh evidence since the earlier collapse finding is that the current MetricData::update fix is non-associative after it changes the survivor's timestamp; merge the minimum start and maximum end (with checked arithmetic) so the result is order-independent.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The collapse change this refers to was reverted in caa499c, so this no longer applies here

return false;
}
}
(left_interval, right_interval) => {
if let MetricValue::Counter { value } = right.value_mut() {
*value += left_value;
}
if let (Some(left_interval), Some(right_interval)) = (left_interval, right_interval)
{
right.data_mut().time.interval_ms =
Some(right_interval.saturating_add(left_interval.get()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject unrepresentable untimestamped interval sums

When two untimestamped rate counters share a series and their intervals sum past u32::MAX (which is accepted for each input), this branch still sums both values but silently caps the duration. V2 and V3 then divide by that capped interval, so two back-to-back u32::MAX windows are emitted at twice their intended rate. Fresh evidence beyond the earlier timestamped-overflow finding is that this no-timestamp arm bypasses the new checked MetricData::update; use checked addition and avoid or otherwise correctly rescale an unrepresentable collapse.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The collapse change this refers to was reverted in caa499c, so it no longer applies here. The checked interval sums are saved for the follow-up PR.

}
}
}
right
.metadata_mut()
.merge_finalizers(left.metadata_mut().take_finalizers());

true
});

metrics
}

#[cfg(test)]
mod tests {
use std::{collections::HashSet, time::Duration};
use std::{collections::HashSet, num::NonZeroU32, time::Duration};

use chrono::{DateTime, Utc};
use proptest::prelude::*;
Expand Down Expand Up @@ -366,6 +383,45 @@ mod tests {
assert_eq!(expected, actual);
}

fn create_rate_counter(
value: f64,
interval_ms: u32,
timestamp: Option<DateTime<Utc>>,
) -> Metric {
create_counter("rate", value)
.with_interval_ms(NonZeroU32::new(interval_ms))
.with_timestamp(timestamp)
}

#[test]
fn collapse_rate_counters_merges_their_windows() {
let start = DateTime::from_timestamp_millis(1_700_000_000_000);
let half_second_later = DateTime::from_timestamp_millis(1_700_000_000_500);
for input in [
vec![
create_rate_counter(1.0, 500, start),
create_rate_counter(3.0, 500, half_second_later),
],
vec![
create_rate_counter(3.0, 500, half_second_later),
create_rate_counter(1.0, 500, start),
],
] {
let actual = sort_and_collapse_counters_by_series_and_timestamp(input);
assert_eq!(vec![create_rate_counter(4.0, 1_000, start)], actual);
}
}

#[test]
fn collapse_rate_counters_without_timestamps_sums_their_intervals() {
let input = vec![
create_rate_counter(1.0, 500, None),
create_rate_counter(3.0, 500, None),
];
let actual = sort_and_collapse_counters_by_series_and_timestamp(input);
assert_eq!(vec![create_rate_counter(4.0, 1_000, None)], actual);
}

#[test]
fn collapse_identical_metrics_counter_unsorted() {
let gauge_value = 1.0;
Expand Down
14 changes: 14 additions & 0 deletions src/transforms/aggregate/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@ pub struct AggregateConfig {
/// they are processed. Omit this block to keep the default system-time behavior.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub event_time: Option<EventTimeConfig>,

/// Set `interval_ms` on each aggregated incremental metric to the flush interval.
///
/// Downstream components can then treat an aggregated counter as a rate over the flush
/// interval. For example, the `datadog_metrics` sink sends counters that have an interval
/// as rates. Absolute metrics and metrics that pass through unchanged are not modified.
///
/// With `event_time`, the timestamp of these metrics is also set to the start of their
/// bucket, so the timestamp and the interval describe the same window.
///
/// When enabled, `interval_ms` must not be greater than 4294967295 (about 49.7 days).
#[serde(default)]
pub set_interval_ms: bool,
}

/// Settings for event-time aggregation windows.
Expand Down Expand Up @@ -150,6 +163,7 @@ impl Default for AggregateConfig {
interval_ms: default_interval_ms(),
mode: default_mode(),
event_time: None,
set_interval_ms: false,
}
}
}
Expand Down
19 changes: 19 additions & 0 deletions src/transforms/aggregate/event_time.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@ impl Aggregate {
.collect();

for bucket_key in buckets_to_flush {
let bucket_output_start = output.len();
if let Some(bucket_map) = self.event_time_buckets.remove(&bucket_key) {
// Diff mode must retain `bucket_map` to subtract against the
// next flush, so it iterates by reference and per-entry clones
Expand Down Expand Up @@ -507,6 +508,7 @@ impl Aggregate {
}
}
}
self.set_bucket_start_timestamps(&mut output[bucket_output_start..], bucket_key);

// Advance the watermark to the *exclusive end* of the highest
// flushed bucket so subsequent events for that window (or any
Expand All @@ -521,4 +523,21 @@ impl Aggregate {
emit!(AggregateFlushed);
}
}

/// With `set_interval_ms`, an incremental bucket metric describes the window
/// `[bucket_key, bucket_key + interval_ms)`, so its timestamp must be the window start.
fn set_bucket_start_timestamps(&self, flushed: &mut [Event], bucket_key: BucketKey) {
if self.output_interval_ms.is_none() {
return;
}
let Some(bucket_start) = DateTime::<Utc>::from_timestamp_millis(bucket_key) else {
return;
};
for event in flushed {
let metric = event.as_mut_metric();
if metric.kind() == MetricKind::Incremental {
metric.data_mut().time.timestamp = Some(bucket_start);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stamp event-time rates at the bucket end

When event_time and set_interval_ms are enabled, this assignment stamps the rate at the bucket start, but the checked Datadog E2E intake model in tests/e2e/datadog/metrics/series.rs:31-35 interprets a rate as (timestamp - interval, timestamp). A bucket [0, 10) is therefore submitted as [-10, 0] rather than [0, 10]. Fresh evidence since the earlier comment is that the current fix now explicitly writes bucket_start; stamp bucket_key + interval_ms instead.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this one is right. get_time_bucket in tests/e2e/datadog/metrics/series.rs only builds the key the e2e test uses to compare Agent and Vector payloads; both sides go through it, and the file notes their timestamps are not guaranteed to line up. The Datadog Agent itself stamps rate points with the bucket start: calculateBucketStart and Append(bucketTimestamp) in pkg/aggregator/time_sampler.go, then Points: {Ts: timestamp, Value: value / interval} in pkg/metrics/counter.go. Vector's datadog_agent source also keeps that timestamp as-is. So I kept the bucket start.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moot after caa499c: the bucket-start stamping was reverted along with the other timestamp changes.

}
}
}
}
Loading
Loading