Skip to content
Open
5 changes: 5 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,5 @@
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.

authors: gremlinops
11 changes: 11 additions & 0 deletions src/transforms/aggregate/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ 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.
///
/// 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 +160,7 @@ impl Default for AggregateConfig {
interval_ms: default_interval_ms(),
mode: default_mode(),
event_time: None,
set_interval_ms: false,
}
}
}
Expand Down
200 changes: 200 additions & 0 deletions src/transforms/aggregate/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ fn system_time_config(mode: AggregationMode) -> AggregateConfig {
interval_ms: 1000,
mode,
event_time: None,
set_interval_ms: false,
}
}

Expand All @@ -199,6 +200,7 @@ fn event_time_config(interval_ms: u64, mode: AggregationMode) -> AggregateConfig
interval_ms,
mode,
event_time: Some(EventTimeConfig::default()),
set_interval_ms: false,
}
}

Expand All @@ -213,6 +215,7 @@ fn event_time_config_with(
interval_ms,
mode,
event_time: Some(event_time),
set_interval_ms: false,
}
}

Expand Down Expand Up @@ -1699,3 +1702,200 @@ fn event_time_count_counts_mixed_kinds_in_same_bucket() {
assert_eq!(out.len(), 1);
assert_counter(&out[0], 2.0);
}

fn with_set_interval_ms(config: AggregateConfig) -> AggregateConfig {
AggregateConfig {
set_interval_ms: true,
..config
}
}

fn interval_ms_of(event: &Event) -> Option<u32> {
event.as_metric().interval_ms().map(|i| i.get())
}

#[test]
fn set_interval_ms_parses_and_defaults_to_false() {
use crate::config::GenerateConfig;

let generated = AggregateConfig::generate_config();
assert_eq!(
generated.get("set_interval_ms"),
Some(&serde_json::Value::Bool(false))
);

let cfg: AggregateConfig = toml::from_str("interval_ms = 5000").unwrap();
assert!(!cfg.set_interval_ms);

let cfg: AggregateConfig = toml::from_str(
r#"
interval_ms = 5000
set_interval_ms = true
"#,
)
.unwrap();
assert!(cfg.set_interval_ms);
}

#[test]
fn set_interval_ms_rejects_interval_above_u32() {
let too_large = u64::from(u32::MAX) + 1;

let err = Aggregate::new(&with_set_interval_ms(event_time_config(
too_large,
AggregationMode::Auto,
)))
.expect_err("interval_ms above u32::MAX must be rejected with set_interval_ms");
assert!(
err.to_string().contains("set_interval_ms"),
"error should mention set_interval_ms, got: {err}"
);

Aggregate::new(&event_time_config(too_large, AggregationMode::Auto))
.expect("the u32 limit only applies when set_interval_ms is enabled");
Aggregate::new(&with_set_interval_ms(event_time_config(
u64::from(u32::MAX),
AggregationMode::Auto,
)))
.expect("u32::MAX is the largest accepted interval with set_interval_ms");
}

#[test]
fn set_interval_ms_disabled_leaves_interval_unset() {
let mut agg = Aggregate::new(&system_time_config(AggregationMode::Auto)).unwrap();
agg.record(make_metric(
"counter_a",
MetricKind::Incremental,
MetricValue::Counter { value: 1.0 },
));

let mut out = vec![];
agg.flush_into(&mut out);
assert_eq!(1, out.len());
assert_eq!(interval_ms_of(&out[0]), None);
}

#[test]
fn set_interval_ms_sets_interval_on_flushed_incremental_metrics() {
let mut agg = Aggregate::new(&with_set_interval_ms(system_time_config(
AggregationMode::Auto,
)))
.unwrap();
agg.record(make_metric(
"counter_a",
MetricKind::Incremental,
MetricValue::Counter { value: 42.0 },
));
agg.record(make_metric(
"counter_a",
MetricKind::Incremental,
MetricValue::Counter { value: 43.0 },
));
agg.record(make_metric(
"gauge_a",
MetricKind::Absolute,
MetricValue::Gauge { value: 7.0 },
));

let mut out = vec![];
agg.flush_into(&mut out);
assert_eq!(2, out.len());
for event in &out {
match event.as_metric().series().name.name.as_str() {
"counter_a" => {
assert_counter(event, 85.0);
assert_eq!(interval_ms_of(event), Some(1000));
}
"gauge_a" => {
assert_gauge(event, 7.0);
assert_eq!(
interval_ms_of(event),
None,
"absolute metrics keep no interval"
);
}
other => panic!("Unexpected metric name in aggregate output: {other}"),
}
}

// The final flush on shutdown sets the interval too.
agg.record(make_metric(
"counter_a",
MetricKind::Incremental,
MetricValue::Counter { value: 1.0 },
));
let out = flush_final(&mut agg);
assert_eq!(1, out.len());
assert_eq!(interval_ms_of(&out[0]), Some(1000));
}

#[test]
fn set_interval_ms_overwrites_input_interval() {
let mut agg = Aggregate::new(&with_set_interval_ms(system_time_config(
AggregationMode::Sum,
)))
.unwrap();
let mut counter = make_metric(
"counter_a",
MetricKind::Incremental,
MetricValue::Counter { value: 1.0 },
);
counter.as_mut_metric().data_mut().time.interval_ms = std::num::NonZeroU32::new(250);
agg.record(counter);

let mut out = vec![];
agg.flush_into(&mut out);
assert_eq!(1, out.len());
assert_eq!(interval_ms_of(&out[0]), Some(1000));
}

#[test]
fn set_interval_ms_does_not_touch_passthrough_or_existing_output() {
let mut agg = Aggregate::new(&with_set_interval_ms(system_time_config(
AggregationMode::Latest,
)))
.unwrap();
let passthrough = make_metric(
"counter_a",
MetricKind::Incremental,
MetricValue::Counter { value: 1.0 },
);
let passed = agg
.record(passthrough.clone())
.expect("Latest passes incremental metrics through");
assert_eq!(interval_ms_of(&passed), None);

// Events already in the output buffer before a flush are left alone.
let mut out = vec![passed];
agg.flush_into(&mut out);
assert_eq!(1, out.len());
assert_eq!(out[0], passthrough);
}

#[test]
fn set_interval_ms_sets_interval_on_event_time_buckets() {
let interval_ms = 10_000;
let mut agg = Aggregate::new(&with_set_interval_ms(event_time_config(
interval_ms,
AggregationMode::Auto,
)))
.unwrap();
let base_time = open_bucket_timestamp(interval_ms);
agg.record(make_metric_with_timestamp(
"counter_a",
MetricKind::Incremental,
MetricValue::Counter { value: 2.0 },
base_time,
));
agg.record(make_metric_with_timestamp(
"counter_a",
MetricKind::Incremental,
MetricValue::Counter { value: 3.0 },
base_time + chrono::Duration::milliseconds(100),
));

let out = flush_final(&mut agg);
assert_eq!(1, out.len());
assert_counter(&out[0], 5.0);
assert_eq!(interval_ms_of(&out[0]), Some(10_000));
}
35 changes: 35 additions & 0 deletions src/transforms/aggregate/transform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use super::{AggregateConfig, AggregationMode};

use std::{
collections::{BTreeMap, HashMap, hash_map::Entry},
num::NonZeroU32,
pin::Pin,
time::Duration,
};
Expand Down Expand Up @@ -95,6 +96,8 @@ pub struct Aggregate {
BTreeMap<BucketKey, HashMap<MetricSeries, Vec<MetricEntry>>>,
pub(crate) watermark: Option<BucketKey>,
pub(crate) config: AggregateConfig,
/// Interval to set on flushed incremental metrics; `Some` only when `set_interval_ms` is enabled.
output_interval_ms: Option<NonZeroU32>,
}

/// Upper bound for any millisecond-valued duration field that is later cast
Expand Down Expand Up @@ -129,6 +132,19 @@ impl Aggregate {
.into());
}
}
let output_interval_ms = if config.set_interval_ms {
let interval_ms = u32::try_from(config.interval_ms).map_err(|_| {
format!(
"`interval_ms` ({}) exceeds the maximum supported value of {} ms when `set_interval_ms` is enabled",
config.interval_ms,
u32::MAX
)
})?;
// Non-zero was validated above.
NonZeroU32::new(interval_ms)
} else {
None
};

Ok(Self {
interval: Duration::from_millis(config.interval_ms),
Expand All @@ -139,6 +155,7 @@ impl Aggregate {
event_time_multi_buckets: Default::default(),
watermark: None,
config: *config,
output_interval_ms,
})
}

Expand Down Expand Up @@ -324,11 +341,13 @@ impl Aggregate {
}
}
pub fn flush_into(&mut self, output: &mut Vec<Event>) {
let start = output.len();
if self.config.is_event_time() {
self.flush_event_time_buckets(output, false);
} else {
self.flush_system_time(output);
}
self.set_output_interval(&mut output[start..]);
}

/// Final flush invoked when the input stream closes. In event-time mode
Expand All @@ -337,11 +356,27 @@ impl Aggregate {
/// shutdown or topology reload, matching system-time semantics where
/// `flush_system_time` always empties `self.map`.
pub fn flush_final(&mut self, output: &mut Vec<Event>) {
let start = output.len();
if self.config.is_event_time() {
self.flush_event_time_buckets(output, true);
} else {
self.flush_system_time(output);
}
self.set_output_interval(&mut output[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 Avoid a full interval on partial final flushes

When the input closes or a topology reload happens partway through the default system-time window, flush_final drains the partial aggregate and this call still stamps the entire configured interval. Thus a transform stopped one second after the previous flush with interval_ms = 10000 sends its count as a 10-second rate (the Datadog encoder divides by 10), underreporting that final window by 10x. Track the elapsed final-window duration or leave the interval unset on an incomplete final flush.

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.

Deferred, see "Not in this PR" in the description. The count is kept: the point is the window's count divided by the interval, so rate × interval gives the count back; a partial window only reads low per second. Fixing it needs the real window length, which neither this PR nor the follow-up tracks yet. Leaving the interval off that point instead would send it as a count while the rest of the metric is a rate.

}

/// Overwrites `interval_ms` on flushed incremental metrics with the flush interval, so a
/// counter aggregated over the window can be read as a rate over that window.
fn set_output_interval(&self, flushed: &mut [Event]) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Both encoder.rs::series_to_proto_message and encoder_v3.rs::encode_metric_to_v3 calculate interval_ms / 1000 using integer division. Can we also handle fractional-second intervals in the Datadog rate path?

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.

Yeah... With interval_ms < 1000, interval_ms / 1000 is 0, so value / 0.0 sends inf (or NaN for a zero counter). With non-whole seconds such as 1500, it divides by 1 and overstates the rate by 1.5x.

Proposal for both series_to_proto_message and encode_metric_to_v3:

  • Scale the value with the exact interval: value / (interval_ms as f64 / 1000.0).
  • The wire interval field is whole seconds in both protocols (int64 interval in v2, uint64 intervals in v3), so send max(1, round(interval_ms / 1000)) there instead of truncating to 0.

Tradeoff: for a non-whole-second interval the point value is exact, but Datadog's as_count() (rate × interval) uses the rounded field, so a 500 ms window would read back as 2x its true count. The alternative is to keep the field and the scaling consistent (both rounded), which makes as_count() consistent but the rate inexact. I prefer the exact rate, because rates are what the type means, but I can fix to do either.

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 62d0f91

let Some(interval_ms) = self.output_interval_ms else {
return;
};
for event in flushed {
let metric = event.as_mut_metric();
if metric.kind() == MetricKind::Incremental {
metric.data_mut().time.interval_ms = Some(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.

P2 Badge Align event-time rate timestamps to bucket boundaries

When event_time and set_interval_ms are both enabled, bucket aggregation retains an input sample timestamp while this assignment only adds the full bucket width. For a 10-second bucket [0, 10) whose last sample is at second 5, the Datadog encoder forwards a rate point at 5 with interval 10, which its intake semantics interpret as the window [-5, 5], misplacing the aggregate and overlapping adjacent buckets. Stamp the appropriate bucket boundary when attaching the interval so the timestamp and duration describe the same window.

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.

Valid, fixed in 9a4c15a, but the reason differs: the Datadog Agent stamps count and rate series with the bucket start, and Vector's MetricData::update also treats the timestamp as the window start. So with event_time and set_interval_ms, incremental bucket metrics now get the bucket start as their timestamp. Behavior without the flag and for absolute metrics is unchanged. System-time mode keeps its current timestamps, since the exact window start is not known there

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.

P2 Badge Stamp system-time rates at the flush boundary

When set_interval_ms is used in the default system-time mode and inputs carry timestamps, record_sum retains the latest sample timestamp while this code attaches the full flush interval without replacing that timestamp. The Datadog E2E model in tests/e2e/datadog/metrics/series.rs:31-35 treats a rate timestamp as the interval end, so a 10-second flush window [0, 10) whose last sample arrived at second 5 is reported for [-5, 5]; capture and stamp the flush boundary on these outputs.

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 ac4ea38. Opposite of what I said on the earlier thread about system-time mode. With set_interval_ms, a system-time flush now stamps its incremental metrics with the window start: the transform's creation time for the first window, then the time of the previous flush. That matches the bucket-start convention used in event-time mode and by the Datadog Agent. Without the flag, timestamps are unchanged.

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.

P2 Badge Delay the first rate flush until a full interval elapses

When system-time mode starts with buffered input and set_interval_ms is enabled, tokio::time::interval's first tick is immediately ready; because the select! at lines 498-517 is unbiased, it can record a ready counter before choosing that tick and then flush it almost immediately. This assignment still labels that near-zero startup window with the full configured interval (and a timestamp from transform construction), so, for example, 10 counts with a 10-second configuration are encoded as 1/s even if they were collected into this window over only milliseconds. Start the timer at now + interval or avoid stamping an early first flush.

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.

Fair point, but I'm leaving it out of this PR. The immediate first tick is existing aggregate behavior, and changing the flush schedule would affect every user of the transform, not only set_interval_ms. No counts are lost: as_count() (rate × interval) still returns the true total for that first point; only its per-second reading is low for the startup window. The timestamp part no longer applies, since window-start stamping was reverted in caa499c

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 interval-bearing aggregates at the window start

When set_interval_ms is enabled for timestamped inputs, this assignment pairs the full window width with the retained sample timestamp rather than the aggregation window start; for example, an event-time [0,10) bucket whose latest sample is at t=5 is represented as a [5,15) interval under MetricData::update's start-timestamp convention (lib/vector-core/src/event/metric/data.rs:111-119), and default system-time mode has the same shift. Fresh evidence since the earlier timestamp threads is that this squashed target removed both boundary stamps: event_time.rs:449-451 and flush_system_time now reconstruct metrics without changing the timestamp. Stamp the corresponding bucket/flush start when setting the interval.

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.

Deferred to the follow-up PR, see "Not in this PR" in the description. Since f7407f8, metrics without a timestamp get their window start. Metrics that already have a timestamp keep it, so a rate can be placed later than the window it covers. The per-window value is not affected.

}
}
}

fn flush_system_time(&mut self, output: &mut Vec<Event>) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,17 @@ generated: components: transforms: aggregate: configuration: {
}
}
}
set_interval_ms: {
description: """
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.

When enabled, `interval_ms` must not be greater than 4294967295 (about 49.7 days).
"""
required: false
type: bool: default: false
}
}
Loading