Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
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 this option,
`interval_ms` must be a whole number of seconds.

authors: gremlinops
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
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).

authors: gremlinops
85 changes: 78 additions & 7 deletions src/sinks/datadog/metrics/encoder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,27 @@ 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 ms = interval_ms.get();
let rounded_secs = ms / 1000 + u32::from(ms % 1000 >= 500);
SeriesInterval {
whole_secs: rounded_secs.max(1),
exact_secs: f64::from(ms) / 1000.0,
}
})
}

/// 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 +667,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 👍 / 👎.

(
vec![ddmetric_proto::metric_payload::MetricPoint { value, timestamp }],
ddmetric_proto::metric_payload::MetricType::Rate,
Expand Down Expand Up @@ -703,7 +723,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 +893,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 +1228,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
12 changes: 12 additions & 0 deletions src/transforms/aggregate/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ 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 be a whole number of seconds (a multiple of 1000) and
/// 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 +161,7 @@ impl Default for AggregateConfig {
interval_ms: default_interval_ms(),
mode: default_mode(),
event_time: None,
set_interval_ms: false,
}
}
}
Expand Down
Loading
Loading