Skip to content

Commit 9465cec

Browse files
authored
enhancement(observability)!: Time-weight buffer utilization means (#24697)
* enhancement(observability): Time-weight buffer utilization means Under a fully loaded pipeline, the typical source will batch its received events into arrays, the size of which is currently capped at 1,000 events. The channel buffers for transforms, however, have a capacity limit of 100 events. This means their actual utilization under non-trivial load will bounce between 0 and 1,000 events, and the weighted mean value will just hover around 500. This is true both when infrequent batches are sent and when the pipeline is at capacity, making that mean value useless. This change adds a duration-weighting calculation to the mean that weights values held for longer times more than those held for shorter times. This will cause the above two situations to reflect much different values in the mean. * Adjustments to docs
1 parent 5ce1198 commit 9465cec

13 files changed

Lines changed: 236 additions & 55 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
The `*buffer_utilization_mean` metrics have been enhanced to use time-weighted
2+
averaging which make them more representative of the actual buffer utilization
3+
over time.
4+
5+
This change is breaking due to the replacement of the existing
6+
`buffer_utilization_ewma_alpha` config option with
7+
`buffer_utilization_ewma_half_life_seconds`.
8+
9+
authors: bruceg

lib/vector-buffers/src/topology/builder.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,13 +191,13 @@ impl<T: Bufferable> TopologyBuilder<T> {
191191
when_full: WhenFull,
192192
receiver_span: &Span,
193193
metadata: Option<ChannelMetricMetadata>,
194-
ewma_alpha: Option<f64>,
194+
ewma_half_life_seconds: Option<f64>,
195195
) -> (BufferSender<T>, BufferReceiver<T>) {
196196
let usage_handle = BufferUsageHandle::noop();
197197
usage_handle.set_buffer_limits(None, Some(max_events.get()));
198198

199199
let limit = MemoryBufferSize::MaxEvents(max_events);
200-
let (sender, receiver) = limited(limit, metadata, ewma_alpha);
200+
let (sender, receiver) = limited(limit, metadata, ewma_half_life_seconds);
201201

202202
let mode = match when_full {
203203
WhenFull::Overflow => WhenFull::Block,

lib/vector-buffers/src/topology/channel/limited_queue.rs

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use std::{
77
Arc,
88
atomic::{AtomicUsize, Ordering},
99
},
10+
time::Instant,
1011
};
1112

1213
#[cfg(test)]
@@ -17,10 +18,12 @@ use crossbeam_queue::{ArrayQueue, SegQueue};
1718
use futures::Stream;
1819
use metrics::{Gauge, Histogram, gauge, histogram};
1920
use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore, TryAcquireError};
20-
use vector_common::stats::EwmaGauge;
21+
use vector_common::stats::TimeEwmaGauge;
2122

2223
use crate::{InMemoryBufferable, config::MemoryBufferSize};
2324

25+
pub const DEFAULT_EWMA_HALF_LIFE_SECONDS: f64 = 5.0;
26+
2427
/// Error returned by `LimitedSender::send` when the receiver has disconnected.
2528
#[derive(Debug, PartialEq, Eq)]
2629
pub struct SendError<T>(pub T);
@@ -110,7 +113,7 @@ impl ChannelMetricMetadata {
110113
struct Metrics {
111114
histogram: Histogram,
112115
gauge: Gauge,
113-
mean_gauge: EwmaGauge,
116+
mean_gauge: TimeEwmaGauge,
114117
// We hold a handle to the max gauge to avoid it being dropped by the metrics collector, but
115118
// since the value is static, we never need to update it. The compiler detects this as an unused
116119
// field, so we need to suppress the warning here.
@@ -127,8 +130,10 @@ impl Metrics {
127130
fn new(
128131
limit: MemoryBufferSize,
129132
metadata: ChannelMetricMetadata,
130-
ewma_alpha: Option<f64>,
133+
ewma_half_life_seconds: Option<f64>,
131134
) -> Self {
135+
let ewma_half_life_seconds =
136+
ewma_half_life_seconds.unwrap_or(DEFAULT_EWMA_HALF_LIFE_SECONDS);
132137
let ChannelMetricMetadata { prefix, output } = metadata;
133138
let (legacy_suffix, gauge_suffix, max_value) = match limit {
134139
MemoryBufferSize::MaxEvents(max_events) => (
@@ -157,7 +162,7 @@ impl Metrics {
157162
Self {
158163
histogram: histogram!(histogram_name, "output" => label_value.clone()),
159164
gauge: gauge!(gauge_name, "output" => label_value.clone()),
160-
mean_gauge: EwmaGauge::new(mean_gauge_handle, ewma_alpha),
165+
mean_gauge: TimeEwmaGauge::new(mean_gauge_handle, ewma_half_life_seconds),
161166
max_gauge,
162167
legacy_max_gauge,
163168
#[cfg(test)]
@@ -173,7 +178,7 @@ impl Metrics {
173178
Self {
174179
histogram: histogram!(histogram_name),
175180
gauge: gauge!(gauge_name),
176-
mean_gauge: EwmaGauge::new(mean_gauge_handle, ewma_alpha),
181+
mean_gauge: TimeEwmaGauge::new(mean_gauge_handle, ewma_half_life_seconds),
177182
max_gauge,
178183
legacy_max_gauge,
179184
#[cfg(test)]
@@ -183,10 +188,10 @@ impl Metrics {
183188
}
184189

185190
#[expect(clippy::cast_precision_loss)]
186-
fn record(&self, value: usize) {
191+
fn record(&self, value: usize, reference: Instant) {
187192
self.histogram.record(value as f64);
188193
self.gauge.set(value as f64);
189-
self.mean_gauge.record(value as f64);
194+
self.mean_gauge.record(value as f64, reference);
190195
#[cfg(test)]
191196
if let Ok(mut recorded) = self.recorded_values.lock() {
192197
recorded.push(value);
@@ -221,10 +226,11 @@ impl<T: Send + Sync + Debug + 'static> Inner<T> {
221226
fn new(
222227
limit: MemoryBufferSize,
223228
metric_metadata: Option<ChannelMetricMetadata>,
224-
ewma_alpha: Option<f64>,
229+
ewma_half_life_seconds: Option<f64>,
225230
) -> Self {
226231
let read_waker = Arc::new(Notify::new());
227-
let metrics = metric_metadata.map(|metadata| Metrics::new(limit, metadata, ewma_alpha));
232+
let metrics =
233+
metric_metadata.map(|metadata| Metrics::new(limit, metadata, ewma_half_life_seconds));
228234
match limit {
229235
MemoryBufferSize::MaxEvents(max_events) => Inner {
230236
data: Arc::new(ArrayQueue::new(max_events.get())),
@@ -256,7 +262,7 @@ impl<T: Send + Sync + Debug + 'static> Inner<T> {
256262
// acquired fewer permits than their true size, `size` is the correct utilization since
257263
// the queue must have been empty for the oversized acquire to succeed.
258264
let utilization = size.max(self.used_capacity());
259-
metrics.record(utilization);
265+
metrics.record(utilization, Instant::now());
260266
}
261267
self.data.push((permits, item));
262268
self.read_waker.notify_one();
@@ -275,7 +281,7 @@ impl<T> Inner<T> {
275281
// been released yet, used_capacity is stable against racing senders acquiring those
276282
// permits.
277283
let utilization = self.used_capacity().saturating_sub(permit.num_permits());
278-
metrics.record(utilization);
284+
metrics.record(utilization, Instant::now());
279285
}
280286
// Release permits after recording so a waiting sender cannot enqueue a new item
281287
// before this pop's utilization measurement is taken.
@@ -446,9 +452,9 @@ impl<T> Drop for LimitedReceiver<T> {
446452
pub fn limited<T: InMemoryBufferable + fmt::Debug>(
447453
limit: MemoryBufferSize,
448454
metric_metadata: Option<ChannelMetricMetadata>,
449-
ewma_alpha: Option<f64>,
455+
ewma_half_life_seconds: Option<f64>,
450456
) -> (LimitedSender<T>, LimitedReceiver<T>) {
451-
let inner = Inner::new(limit, metric_metadata, ewma_alpha);
457+
let inner = Inner::new(limit, metric_metadata, ewma_half_life_seconds);
452458

453459
let sender = LimitedSender {
454460
inner: inner.clone(),

lib/vector-buffers/src/topology/channel/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ mod receiver;
33
mod sender;
44

55
pub use limited_queue::{
6-
ChannelMetricMetadata, LimitedReceiver, LimitedSender, SendError, limited,
6+
ChannelMetricMetadata, DEFAULT_EWMA_HALF_LIFE_SECONDS, LimitedReceiver, LimitedSender,
7+
SendError, limited,
78
};
89
pub use receiver::*;
910
pub use sender::*;
10-
pub use vector_common::stats::DEFAULT_EWMA_ALPHA;
1111

1212
#[cfg(test)]
1313
mod tests;

lib/vector-common/src/stats/ewma_gauge.rs

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
use std::sync::Arc;
1+
use std::sync::{Arc, Mutex};
2+
use std::time::Instant;
23

34
use metrics::Gauge;
45

5-
use super::AtomicEwma;
6-
7-
/// The default alpha parameter used when constructing EWMA-backed gauges.
8-
pub const DEFAULT_EWMA_ALPHA: f64 = 0.9;
6+
use super::{AtomicEwma, TimeEwma};
97

108
/// Couples a [`Gauge`] with an [`AtomicEwma`] so gauge readings reflect the EWMA.
119
#[derive(Clone, Debug)]
@@ -19,7 +17,7 @@ pub struct EwmaGauge {
1917
impl EwmaGauge {
2018
#[must_use]
2119
pub fn new(gauge: Gauge, alpha: Option<f64>) -> Self {
22-
let alpha = alpha.unwrap_or(DEFAULT_EWMA_ALPHA);
20+
let alpha = alpha.unwrap_or(super::DEFAULT_EWMA_ALPHA);
2321
let ewma = Arc::new(AtomicEwma::new(alpha));
2422
Self { gauge, ewma }
2523
}
@@ -30,3 +28,31 @@ impl EwmaGauge {
3028
self.gauge.set(average);
3129
}
3230
}
31+
32+
/// Couples a [`Gauge`] with a [`TimeEwma`] so gauge readings reflect the EWMA. Since `TimeEwma` has
33+
/// an internal state consisting of multiple values, this gauge requires a mutex to protect the
34+
/// state update.
35+
#[derive(Clone, Debug)]
36+
pub struct TimeEwmaGauge {
37+
gauge: Gauge,
38+
ewma: Arc<Mutex<TimeEwma>>,
39+
}
40+
41+
impl TimeEwmaGauge {
42+
#[must_use]
43+
pub fn new(gauge: Gauge, half_life_seconds: f64) -> Self {
44+
let ewma = Arc::new(Mutex::new(TimeEwma::new(half_life_seconds)));
45+
Self { gauge, ewma }
46+
}
47+
48+
/// Records a new value, updates the EWMA, and sets the gauge accordingly.
49+
///
50+
/// # Panics
51+
///
52+
/// Panics if the EWMA mutex is poisoned.
53+
pub fn record(&self, value: f64, reference: Instant) {
54+
let mut ewma = self.ewma.lock().expect("time ewma gauge mutex poisoned");
55+
let average = ewma.update(value, reference);
56+
self.gauge.set(average);
57+
}
58+
}

lib/vector-common/src/stats/mod.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
#![allow(missing_docs)]
22

3-
pub mod ewma_gauge;
3+
mod ewma_gauge;
4+
mod time_ewma;
45

5-
pub use ewma_gauge::{DEFAULT_EWMA_ALPHA, EwmaGauge};
6+
pub use ewma_gauge::{EwmaGauge, TimeEwmaGauge};
7+
pub use time_ewma::TimeEwma;
68

79
use std::sync::atomic::Ordering;
810

911
use crate::atomic::AtomicF64;
1012

13+
/// The default alpha parameter used when constructing EWMA-backed gauges.
14+
pub const DEFAULT_EWMA_ALPHA: f64 = 0.9;
15+
1116
/// Exponentially Weighted Moving Average
1217
#[derive(Clone, Copy, Debug)]
1318
pub struct Ewma {
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
use std::time::Instant;
2+
3+
#[derive(Clone, Copy, Debug)]
4+
struct State {
5+
average: f64,
6+
point: f64,
7+
reference: Instant,
8+
}
9+
10+
/// Continuous-Time Exponentially Weighted Moving Average.
11+
///
12+
/// This is used to average values that are observed at irregular intervals but have a fixed value
13+
/// between the observations, AKA a piecewise-constant signal sampled at change points. Instead of
14+
/// an "alpha" parameter, this uses a "half-life" parameter which is the time it takes for the
15+
/// average to decay to half of its value, measured in seconds.
16+
#[derive(Clone, Copy, Debug)]
17+
pub struct TimeEwma {
18+
state: Option<State>,
19+
half_life_seconds: f64,
20+
}
21+
22+
impl TimeEwma {
23+
#[must_use]
24+
pub const fn new(half_life_seconds: f64) -> Self {
25+
Self {
26+
state: None,
27+
half_life_seconds,
28+
}
29+
}
30+
31+
#[must_use]
32+
pub fn average(&self) -> Option<f64> {
33+
self.state.map(|state| state.average)
34+
}
35+
36+
/// Update the current average and return it for convenience. Note that this average will "lag"
37+
/// the current observation because the new average is based on the previous point and the
38+
/// duration during which it was held constant. If the reference time is before the previous
39+
/// update, the update is ignored and the previous average is returned.
40+
pub fn update(&mut self, point: f64, reference: Instant) -> f64 {
41+
let average = match self.state {
42+
None => point,
43+
Some(state) => {
44+
if let Some(duration) = reference.checked_duration_since(state.reference) {
45+
let k = (-duration.as_secs_f64() / self.half_life_seconds).exp2();
46+
// The elapsed duration applies to the previously observed point, since that value
47+
// was held constant between observations.
48+
k * state.average + (1.0 - k) * state.point
49+
} else {
50+
state.average
51+
}
52+
}
53+
};
54+
self.state = Some(State {
55+
average,
56+
point,
57+
reference,
58+
});
59+
average
60+
}
61+
}
62+
63+
#[cfg(test)]
64+
mod tests {
65+
use super::TimeEwma;
66+
use std::time::{Duration, Instant};
67+
68+
#[test]
69+
#[expect(clippy::float_cmp, reason = "exact values for this test")]
70+
fn time_ewma_uses_previous_point_duration() {
71+
let mut ewma = TimeEwma::new(1.0);
72+
let t0 = Instant::now();
73+
let t1 = t0 + Duration::from_secs(1);
74+
let t2 = t1 + Duration::from_secs(1);
75+
76+
assert_eq!(ewma.average(), None);
77+
assert_eq!(ewma.update(0.0, t0), 0.0);
78+
assert_eq!(ewma.average(), Some(0.0));
79+
assert_eq!(ewma.update(10.0, t1), 0.0);
80+
assert_eq!(ewma.average(), Some(0.0));
81+
assert_eq!(ewma.update(10.0, t2), 5.0);
82+
assert_eq!(ewma.average(), Some(5.0));
83+
}
84+
}

lib/vector-core/src/config/global_options.rs

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ use super::{
1010
};
1111
use crate::serde::bool_or_struct;
1212

13+
#[expect(
14+
clippy::ref_option,
15+
reason = "we have to follow the serde calling convention"
16+
)]
17+
fn is_default_buffer_utilization_ewma_half_life_seconds(value: &Option<f64>) -> bool {
18+
value.is_none_or(|seconds| {
19+
seconds == vector_buffers::topology::channel::DEFAULT_EWMA_HALF_LIFE_SECONDS
20+
})
21+
}
22+
1323
#[derive(Debug, Snafu)]
1424
pub(crate) enum DataDirError {
1525
#[snafu(display("data_dir option required, but not given here or globally"))]
@@ -140,18 +150,25 @@ pub struct GlobalOptions {
140150
#[serde(skip_serializing_if = "crate::serde::is_default")]
141151
pub expire_metrics_per_metric_set: Option<Vec<PerMetricSetExpiration>>,
142152

143-
/// The alpha value for the exponential weighted moving average (EWMA) of source and transform
144-
/// buffer utilization metrics.
153+
/// The half-life, in seconds, for the exponential weighted moving average (EWMA) of source
154+
/// and transform buffer utilization metrics.
145155
///
146156
/// This controls how quickly the `*_buffer_utilization_mean` gauges respond to new
147-
/// observations. Values closer to 1.0 retain more of the previous value, leading to slower
148-
/// adjustments. The default value of 0.9 is equivalent to a "half life" of 6-7 measurements.
157+
/// observations. Longer half-lives retain more of the previous value, leading to slower
158+
/// adjustments.
149159
///
150-
/// Must be between 0 and 1 exclusively (0 < alpha < 1).
151-
#[serde(default, skip_serializing_if = "crate::serde::is_default")]
152-
#[configurable(validation(range(min = 0.0, max = 1.0)))]
160+
/// - Lower values (< 1): Metrics update quickly but may be volatile
161+
/// - Default (5): Balanced between responsiveness and stability
162+
/// - Higher values (> 5): Smooth, stable metrics that update slowly
163+
///
164+
/// Adjust based on whether you need fast detection of buffer issues (lower)
165+
/// or want to see sustained trends without noise (higher).
166+
///
167+
/// Must be greater than 0.
168+
#[serde(skip_serializing_if = "is_default_buffer_utilization_ewma_half_life_seconds")]
169+
#[configurable(validation(range(min = 0.0)))]
153170
#[configurable(metadata(docs::advanced))]
154-
pub buffer_utilization_ewma_alpha: Option<f64>,
171+
pub buffer_utilization_ewma_half_life_seconds: Option<f64>,
155172

156173
/// The alpha value for the exponential weighted moving average (EWMA) of transform latency
157174
/// metrics.
@@ -321,9 +338,9 @@ impl GlobalOptions {
321338
expire_metrics: self.expire_metrics.or(with.expire_metrics),
322339
expire_metrics_secs: self.expire_metrics_secs.or(with.expire_metrics_secs),
323340
expire_metrics_per_metric_set: merged_expire_metrics_per_metric_set,
324-
buffer_utilization_ewma_alpha: self
325-
.buffer_utilization_ewma_alpha
326-
.or(with.buffer_utilization_ewma_alpha),
341+
buffer_utilization_ewma_half_life_seconds: self
342+
.buffer_utilization_ewma_half_life_seconds
343+
.or(with.buffer_utilization_ewma_half_life_seconds),
327344
latency_ewma_alpha: self.latency_ewma_alpha.or(with.latency_ewma_alpha),
328345
metrics_storage_refresh_period: self
329346
.metrics_storage_refresh_period

0 commit comments

Comments
 (0)