mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 08:09:27 +00:00
fix(metrics): store OTLP exponential histograms as cumulative classic buckets (#14724)
This commit is contained in:
@@ -3246,9 +3246,11 @@ pub struct Prometheus {
|
||||
pub ha_cluster_label: String,
|
||||
#[env_config(name = "ZO_PROMETHEUS_HA_REPLICA", default = "__replica__")]
|
||||
pub ha_replica_label: String,
|
||||
/// Max `le` labels (buckets + gap markers + inf) a native histogram sample may
|
||||
/// expand to; over-limit samples are downscaled (adjacent buckets merged).
|
||||
#[env_config(name = "ZO_PROMETHEUS_NATIVE_HISTOGRAM_MAX_BUCKETS", default = 16)]
|
||||
/// Exponential histograms are stored at `min(producer schema, this)`, never count-driven.
|
||||
#[env_config(name = "ZO_METRICS_EXP_HISTOGRAM_TARGET_SCHEMA", default = 1)]
|
||||
pub exp_histogram_target_schema: i32,
|
||||
/// Safety valve, not a layout knob: past this many `le` labels a sample is downscaled.
|
||||
#[env_config(name = "ZO_PROMETHEUS_NATIVE_HISTOGRAM_MAX_BUCKETS", default = 512)]
|
||||
pub native_histogram_max_buckets: usize,
|
||||
}
|
||||
|
||||
@@ -3797,6 +3799,12 @@ fn check_common_config(cfg: &mut Config) -> Result<(), anyhow::Error> {
|
||||
if cfg.limit.metrics_max_points_per_series == 0 {
|
||||
cfg.limit.metrics_max_points_per_series = 30_000;
|
||||
}
|
||||
if !(-4..=8).contains(&cfg.prom.exp_histogram_target_schema) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"ZO_METRICS_EXP_HISTOGRAM_TARGET_SCHEMA must be within -4..=8, got {}",
|
||||
cfg.prom.exp_histogram_target_schema
|
||||
));
|
||||
}
|
||||
|
||||
// check search job retention
|
||||
if cfg.limit.search_job_retention == 0 {
|
||||
@@ -5918,6 +5926,30 @@ mod tests {
|
||||
assert_eq!(cfg.common.feature_bloom_filter_extra_fields, "trace_id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_common_config_exp_histogram_target_schema_range() {
|
||||
let cfg = Config::init().unwrap();
|
||||
assert_eq!(cfg.prom.exp_histogram_target_schema, 1);
|
||||
assert_eq!(cfg.prom.native_histogram_max_buckets, 512);
|
||||
// check_common_config scales sizes in place, so each call gets a fresh config
|
||||
for schema in [-4, 8] {
|
||||
let mut cfg = Config::init().unwrap();
|
||||
cfg.prom.exp_histogram_target_schema = schema;
|
||||
check_common_config(&mut cfg).unwrap();
|
||||
}
|
||||
for schema in [-5, 9] {
|
||||
let mut cfg = Config::init().unwrap();
|
||||
cfg.prom.exp_histogram_target_schema = schema;
|
||||
let err = check_common_config(&mut cfg).unwrap_err().to_string();
|
||||
assert_eq!(
|
||||
err,
|
||||
format!(
|
||||
"ZO_METRICS_EXP_HISTOGRAM_TARGET_SCHEMA must be within -4..=8, got {schema}"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_s3_config_bucket_prefix_trailing_slash() {
|
||||
let mut cfg = Config::default();
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Degrades Prometheus native (sparse) histograms into their classic representation:
|
||||
//! `_count`, `_sum` and cumulative `le` `_bucket` records, so existing PromQL
|
||||
//! (`histogram_quantile` etc.) works on them unchanged.
|
||||
//! Degrades exponential-bucket histograms (Prometheus native, OTLP exponential) into
|
||||
//! their classic representation: `_count`, `_sum` and cumulative `le` `_bucket` records,
|
||||
//! so existing PromQL (`histogram_quantile` etc.) works on them unchanged.
|
||||
//!
|
||||
//! Known limitations, inherited from classic semantics:
|
||||
//! - `sum by (le)` is only sound across series sharing a bucket layout. Each native series carries
|
||||
@@ -36,6 +36,9 @@ pub const CLASSIC_HISTOGRAM_SUFFIXES: [&str; 3] = ["_bucket", "_count", "_sum"];
|
||||
/// schema -53 -- cannot be converted: our wire type does not decode `custom_values`.
|
||||
const SCHEMA_RANGE: std::ops::RangeInclusive<i32> = -4..=8;
|
||||
|
||||
/// Finest schema ever emitted: label rounding keeps adjacent bounds distinct only up to here.
|
||||
const MAX_SCHEMA: i32 = *SCHEMA_RANGE.end();
|
||||
|
||||
/// Downscaling may merge below the native schema floor -- the emitted `le` bounds are
|
||||
/// plain classic bounds, not required to form a valid native schema. At -10
|
||||
/// (base 2^1024) one bucket spans all of f64, so the loop always terminates. A sample
|
||||
@@ -49,13 +52,126 @@ pub type ClassicHistogramRecord = (&'static str, Option<String>, f64);
|
||||
/// Prometheus's stale-marker bit pattern in `sum`; an ordinary NaN is NOT stale.
|
||||
const STALE_NAN_BITS: u64 = 0x7ff0_0000_0000_0002;
|
||||
|
||||
/// Degrades one native histogram sample into classic records: cumulative `le` buckets
|
||||
/// closed by `le="inf"`. Empty for unsupported schemas and stale markers. A sample
|
||||
/// expanding to more than `max_buckets` `le` labels is downscaled (adjacent buckets
|
||||
/// merged) until it fits.
|
||||
/// Emitted schema is `min(producer schema, target_schema)`; `max_buckets` is only a safety valve.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ExpansionLimits {
|
||||
pub target_schema: i32,
|
||||
pub max_buckets: usize,
|
||||
}
|
||||
|
||||
impl ExpansionLimits {
|
||||
pub fn from_config(cfg: &config::Config) -> Self {
|
||||
Self {
|
||||
target_schema: cfg.prom.exp_histogram_target_schema,
|
||||
max_buckets: cfg.prom.native_histogram_max_buckets,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ExponentialHistogram {
|
||||
pub schema: i32,
|
||||
pub count: f64,
|
||||
/// `None` emits no `_sum` record (OTLP marks `sum` optional).
|
||||
pub sum: Option<f64>,
|
||||
pub zero_count: f64,
|
||||
pub zero_threshold: f64,
|
||||
pub positive: Vec<(i64, f64)>,
|
||||
pub negative: Vec<(i64, f64)>,
|
||||
}
|
||||
|
||||
impl ExponentialHistogram {
|
||||
pub fn expand(self, limits: ExpansionLimits) -> Vec<ClassicHistogramRecord> {
|
||||
let Self {
|
||||
mut schema,
|
||||
count,
|
||||
sum,
|
||||
mut zero_count,
|
||||
zero_threshold,
|
||||
mut positive,
|
||||
mut negative,
|
||||
} = self;
|
||||
// a NaN or negative threshold reads as no zero region
|
||||
let zero_threshold = zero_threshold.max(0.0);
|
||||
// a NaN or negative zero count cannot cumulate, so it reads as an empty zero bucket
|
||||
if zero_count.is_nan() || zero_count < 0.0 {
|
||||
zero_count = 0.0;
|
||||
}
|
||||
|
||||
// one deterministic step to the target, so the layout only follows the producer's schema
|
||||
let target = limits.target_schema.clamp(MIN_DOWNSCALE_SCHEMA, MAX_SCHEMA);
|
||||
if schema > target {
|
||||
let steps = (i64::from(schema) - i64::from(target)).min(i64::from(u32::MAX)) as u32;
|
||||
positive = downscale(positive, steps);
|
||||
negative = downscale(negative, steps);
|
||||
schema = target;
|
||||
}
|
||||
// merging past the target changes the series layout, so this valve must stay exceptional
|
||||
let max_labels = limits.max_buckets.max(3);
|
||||
if le_estimate(&positive, &negative, zero_threshold) > max_labels {
|
||||
log::warn!(
|
||||
"[METRICS] exponential histogram exceeds {max_labels} le labels at schema {schema}; downscaling"
|
||||
);
|
||||
}
|
||||
while le_estimate(&positive, &negative, zero_threshold) > max_labels
|
||||
&& schema > MIN_DOWNSCALE_SCHEMA
|
||||
{
|
||||
schema -= 1;
|
||||
positive = downscale(positive, 1);
|
||||
negative = downscale(negative, 1);
|
||||
}
|
||||
|
||||
// buckets inside [-zero_threshold, zero_threshold] fold into the zero bucket, overlaps clip
|
||||
let mut buckets: Vec<(f64, f64, f64)> = Vec::new(); // (lower, upper, count)
|
||||
for &(idx, c) in &positive {
|
||||
let (lower, upper) = (bucket_bound(schema, idx - 1), bucket_bound(schema, idx));
|
||||
if upper <= zero_threshold {
|
||||
zero_count += c;
|
||||
} else {
|
||||
buckets.push((lower.max(zero_threshold), upper, c));
|
||||
}
|
||||
}
|
||||
for &(idx, c) in &negative {
|
||||
let (lower, upper) = (-bucket_bound(schema, idx), -bucket_bound(schema, idx - 1));
|
||||
if lower >= -zero_threshold {
|
||||
zero_count += c;
|
||||
} else {
|
||||
buckets.push((lower, upper.min(-zero_threshold), c));
|
||||
}
|
||||
}
|
||||
// the zero bucket is always reported, so its `le` series must exist before it fills
|
||||
buckets.push((-zero_threshold, zero_threshold, zero_count));
|
||||
|
||||
// classic buckets are cumulative in `le` order
|
||||
buckets.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
|
||||
let mut recs = Vec::with_capacity(2 * buckets.len() + 3);
|
||||
recs.push(("_count", None, count));
|
||||
if let Some(sum) = sum {
|
||||
recs.push(("_sum", None, sum));
|
||||
}
|
||||
|
||||
let mut points: Vec<(String, f64)> = Vec::with_capacity(2 * buckets.len() + 1);
|
||||
let mut cumulative = 0.0;
|
||||
let mut prev_upper = f64::NEG_INFINITY;
|
||||
for (lower, upper, c) in buckets {
|
||||
if lower > prev_upper && lower < upper {
|
||||
push_le_point(&mut points, format_le(lower), cumulative);
|
||||
}
|
||||
cumulative += c;
|
||||
push_le_point(&mut points, format_le(upper), cumulative);
|
||||
prev_upper = upper;
|
||||
}
|
||||
// `le="inf"` must equal `_count`; `max` also repairs a short count field
|
||||
push_le_point(&mut points, format_le(f64::INFINITY), count.max(cumulative));
|
||||
|
||||
recs.extend(points.into_iter().map(|(le, v)| ("_bucket", Some(le), v)));
|
||||
recs
|
||||
}
|
||||
}
|
||||
|
||||
pub fn expand_native_histogram(
|
||||
hp: &prometheus_rpc::Histogram,
|
||||
max_buckets: usize,
|
||||
limits: ExpansionLimits,
|
||||
) -> Vec<ClassicHistogramRecord> {
|
||||
if !SCHEMA_RANGE.contains(&hp.schema) {
|
||||
log::warn!(
|
||||
@@ -82,67 +198,26 @@ pub fn expand_native_histogram(
|
||||
None => 0.0,
|
||||
};
|
||||
|
||||
let mut pos = span_buckets(&hp.positive_spans, &hp.positive_deltas, &hp.positive_counts);
|
||||
let mut neg = span_buckets(&hp.negative_spans, &hp.negative_deltas, &hp.negative_counts);
|
||||
|
||||
// every emitted `le` label becomes a series, so merge adjacent buckets (halving
|
||||
// resolution) until the sample's le count fits the cardinality budget
|
||||
let mut schema = hp.schema;
|
||||
while le_estimate(&pos, &neg, zero_count) > max_buckets.max(3) && schema > MIN_DOWNSCALE_SCHEMA
|
||||
{
|
||||
schema -= 1;
|
||||
pos = downscale(pos);
|
||||
neg = downscale(neg);
|
||||
ExponentialHistogram {
|
||||
schema: hp.schema,
|
||||
count,
|
||||
sum: Some(hp.sum),
|
||||
zero_count,
|
||||
zero_threshold: hp.zero_threshold,
|
||||
positive: span_buckets(&hp.positive_spans, &hp.positive_deltas, &hp.positive_counts),
|
||||
negative: span_buckets(&hp.negative_spans, &hp.negative_deltas, &hp.negative_counts),
|
||||
}
|
||||
|
||||
// bucket `idx` covers `(base^(idx-1), base^idx]`, mirrored on the negative side
|
||||
let mut buckets: Vec<(f64, f64, f64)> = Vec::new(); // (lower, upper, count)
|
||||
for &(idx, c) in &pos {
|
||||
buckets.push((bucket_bound(schema, idx - 1), bucket_bound(schema, idx), c));
|
||||
}
|
||||
for &(idx, c) in &neg {
|
||||
buckets.push((
|
||||
-bucket_bound(schema, idx),
|
||||
-bucket_bound(schema, idx - 1),
|
||||
c,
|
||||
));
|
||||
}
|
||||
if zero_count > 0.0 {
|
||||
buckets.push((-hp.zero_threshold, hp.zero_threshold, zero_count));
|
||||
}
|
||||
|
||||
// classic buckets are cumulative in `le` order
|
||||
buckets.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
|
||||
let mut recs = Vec::with_capacity(2 * buckets.len() + 3);
|
||||
recs.push(("_count", None, count));
|
||||
recs.push(("_sum", None, hp.sum));
|
||||
|
||||
let mut points: Vec<(String, f64)> = Vec::with_capacity(2 * buckets.len() + 1);
|
||||
let mut cumulative = 0.0;
|
||||
let mut prev_upper = f64::NEG_INFINITY;
|
||||
for (lower, upper, c) in buckets {
|
||||
// a zero-increment record at the lower bound of each bucket run pins sparse
|
||||
// gaps, so quantile interpolation cannot smear counts across them.
|
||||
// `lower < upper` skips the marker when clamping collapsed both bounds.
|
||||
if lower > prev_upper && lower < upper {
|
||||
push_le_point(&mut points, format_le(lower), cumulative);
|
||||
}
|
||||
cumulative += c;
|
||||
push_le_point(&mut points, format_le(upper), cumulative);
|
||||
prev_upper = upper;
|
||||
}
|
||||
// `le="inf"` must equal `_count`; `max` also repairs a short count field
|
||||
push_le_point(&mut points, format_le(f64::INFINITY), count.max(cumulative));
|
||||
|
||||
recs.extend(points.into_iter().map(|(le, v)| ("_bucket", Some(le), v)));
|
||||
recs
|
||||
.expand(limits)
|
||||
}
|
||||
|
||||
/// `le` label: 4 significant digits, shortest decimal ("0.5946", "8"). Cannot collide
|
||||
/// adjacent buckets -- the finest schema spaces bounds 0.271% apart vs the 0.1%
|
||||
/// worst-case label resolution.
|
||||
fn format_le(v: f64) -> String {
|
||||
// a negated underflowed bound is -0.0, which must share the zero bucket's label
|
||||
if v == 0.0 {
|
||||
return "0".to_string();
|
||||
}
|
||||
let rounded: f64 = format!("{v:.3e}").parse().unwrap();
|
||||
if rounded.is_finite() || !v.is_finite() {
|
||||
rounded.to_string()
|
||||
@@ -162,19 +237,16 @@ fn push_le_point(points: &mut Vec<(String, f64)>, le: String, cumulative: f64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Upper bound of bucket `idx`: `2^(idx * 2^-schema)` via `exp2`, so powers of two are
|
||||
/// exact and a boundary shared between schemas is the identical f64 (downscaling keeps
|
||||
/// surviving `le`s in the same series). Clamped finite so the last representable
|
||||
/// bucket cannot collide with `le="inf"`.
|
||||
fn bucket_bound(schema: i32, idx: i64) -> f64 {
|
||||
let bound = ((idx as f64) * 2f64.powi(-schema)).exp2();
|
||||
bound.clamp(f64::MIN_POSITIVE, f64::MAX)
|
||||
if idx == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
// widened so `-i32::MIN` cannot overflow; past ±1100 the factor is already 0 or inf
|
||||
let factor = 2f64.powi((-(schema as i64)).clamp(-1100, 1100) as i32);
|
||||
((idx as f64) * factor).exp2().min(f64::MAX)
|
||||
}
|
||||
|
||||
/// Upper bound on the `le` labels a sample will emit: each populated bucket gets an
|
||||
/// upper record, each contiguous run a lower-bound gap marker, plus the zero bucket's
|
||||
/// two bounds and `inf`.
|
||||
fn le_estimate(pos: &[(i64, f64)], neg: &[(i64, f64)], zero_count: f64) -> usize {
|
||||
fn le_estimate(pos: &[(i64, f64)], neg: &[(i64, f64)], zero_threshold: f64) -> usize {
|
||||
let side = |b: &[(i64, f64)]| {
|
||||
let runs = b
|
||||
.iter()
|
||||
@@ -184,15 +256,15 @@ fn le_estimate(pos: &[(i64, f64)], neg: &[(i64, f64)], zero_count: f64) -> usize
|
||||
+ usize::from(!b.is_empty());
|
||||
b.len() + runs
|
||||
};
|
||||
side(pos) + side(neg) + if zero_count > 0.0 { 2 } else { 0 } + 1
|
||||
side(pos) + side(neg) + if zero_threshold > 0.0 { 2 } else { 1 } + 1
|
||||
}
|
||||
|
||||
/// Merges adjacent bucket pairs: `idx` at schema `s` maps to `ceil(idx / 2)` at
|
||||
/// schema `s - 1`.
|
||||
fn downscale(buckets: Vec<(i64, f64)>) -> Vec<(i64, f64)> {
|
||||
fn downscale(buckets: Vec<(i64, f64)>, steps: u32) -> Vec<(i64, f64)> {
|
||||
// indices are i32 offsets plus a length, so 2^62 already maps every bucket to 0 or 1
|
||||
let span = 1i64 << steps.min(62);
|
||||
let mut out: Vec<(i64, f64)> = Vec::with_capacity(buckets.len() / 2 + 1);
|
||||
for (idx, c) in buckets {
|
||||
let merged_idx = (idx + 1).div_euclid(2);
|
||||
let merged_idx = (idx + span - 1).div_euclid(span);
|
||||
match out.last_mut() {
|
||||
Some((last_idx, last_c)) if *last_idx == merged_idx => *last_c += c,
|
||||
_ => out.push((merged_idx, c)),
|
||||
@@ -201,8 +273,6 @@ fn downscale(buckets: Vec<(i64, f64)>) -> Vec<(i64, f64)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Decodes the sparse layout (spans + integer deltas or absolute float counts) into
|
||||
/// `(bucket index, absolute count)` pairs for every populated bucket.
|
||||
fn span_buckets(
|
||||
spans: &[prometheus_rpc::BucketSpan],
|
||||
deltas: &[i64],
|
||||
@@ -229,8 +299,7 @@ fn span_buckets(
|
||||
cumulative_delta as f64
|
||||
};
|
||||
pos += 1;
|
||||
// skips zero-count buckets and NaN float counts
|
||||
if count > 0.0 {
|
||||
if count >= 0.0 {
|
||||
buckets.push((idx, count));
|
||||
}
|
||||
idx += 1;
|
||||
@@ -243,6 +312,14 @@ fn span_buckets(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// No target downscaling: the producer's schema is emitted as is, capped only by the valve.
|
||||
fn lim(max_buckets: usize) -> ExpansionLimits {
|
||||
ExpansionLimits {
|
||||
target_schema: MAX_SCHEMA,
|
||||
max_buckets,
|
||||
}
|
||||
}
|
||||
|
||||
fn native_histogram_base() -> prometheus_rpc::Histogram {
|
||||
prometheus_rpc::Histogram {
|
||||
schema: 0, // base = 2
|
||||
@@ -296,7 +373,7 @@ mod tests {
|
||||
..native_histogram_base()
|
||||
};
|
||||
|
||||
let recs = expand_native_histogram(&hp, 16);
|
||||
let recs = expand_native_histogram(&hp, lim(16));
|
||||
assert_eq!(scalar_record(&recs, "_count"), 12.0);
|
||||
assert_eq!(scalar_record(&recs, "_sum"), 100.0);
|
||||
// gap markers: le=4 pins the empty (2,4], le=-0.001/0.5 open each bucket run
|
||||
@@ -337,15 +414,16 @@ mod tests {
|
||||
..native_histogram_base()
|
||||
};
|
||||
|
||||
let recs = expand_native_histogram(&hp, 16);
|
||||
let recs = expand_native_histogram(&hp, lim(16));
|
||||
assert_eq!(
|
||||
bucket_records(&recs),
|
||||
vec![
|
||||
(-2.0, 0.0), // lower-bound marker of the negative run
|
||||
(-1.0, 3.0), // negative idx 1: [-2, -1)
|
||||
(-0.5, 5.0), // negative idx 0: [-1, -0.5)
|
||||
(1.0, 5.0), // lower-bound marker: (-0.5, 1] carries nothing
|
||||
(2.0, 9.0), // positive idx 1: (1, 2]
|
||||
(0.0, 5.0),
|
||||
(1.0, 5.0), // lower-bound marker: (-0.5, 1] carries nothing
|
||||
(2.0, 9.0), // positive idx 1: (1, 2]
|
||||
(f64::INFINITY, 9.0),
|
||||
]
|
||||
);
|
||||
@@ -359,7 +437,7 @@ mod tests {
|
||||
count: Some(prometheus_rpc::histogram::Count::CountInt(5)),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(expand_native_histogram(&hp, 16).is_empty());
|
||||
assert!(expand_native_histogram(&hp, lim(16)).is_empty());
|
||||
}
|
||||
|
||||
/// A stale marker drops the whole sample; an ordinary NaN sum keeps count/buckets
|
||||
@@ -370,7 +448,7 @@ mod tests {
|
||||
sum: f64::from_bits(0x7ff0_0000_0000_0002),
|
||||
..native_histogram_base()
|
||||
};
|
||||
assert!(expand_native_histogram(&stale, 16).is_empty());
|
||||
assert!(expand_native_histogram(&stale, lim(16)).is_empty());
|
||||
|
||||
let plain_nan = prometheus_rpc::Histogram {
|
||||
sum: f64::NAN,
|
||||
@@ -382,12 +460,12 @@ mod tests {
|
||||
positive_deltas: vec![7],
|
||||
..native_histogram_base()
|
||||
};
|
||||
let recs = expand_native_histogram(&plain_nan, 16);
|
||||
let recs = expand_native_histogram(&plain_nan, lim(16));
|
||||
assert_eq!(scalar_record(&recs, "_count"), 7.0);
|
||||
assert!(scalar_record(&recs, "_sum").is_nan());
|
||||
assert_eq!(
|
||||
bucket_records(&recs),
|
||||
vec![(0.5, 0.0), (1.0, 7.0), (f64::INFINITY, 7.0)]
|
||||
vec![(0.0, 0.0), (0.5, 0.0), (1.0, 7.0), (f64::INFINITY, 7.0)]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -395,9 +473,12 @@ mod tests {
|
||||
/// back to the bucket total so `le="inf"` stays monotonic.
|
||||
#[test]
|
||||
fn test_expand_native_histogram_empty_and_short_count() {
|
||||
let empty = expand_native_histogram(&native_histogram_base(), 16);
|
||||
let empty = expand_native_histogram(&native_histogram_base(), lim(16));
|
||||
assert_eq!(scalar_record(&empty, "_count"), 0.0);
|
||||
assert_eq!(bucket_records(&empty), vec![(f64::INFINITY, 0.0)]);
|
||||
assert_eq!(
|
||||
bucket_records(&empty),
|
||||
vec![(0.0, 0.0), (f64::INFINITY, 0.0)]
|
||||
);
|
||||
|
||||
let hp = prometheus_rpc::Histogram {
|
||||
positive_spans: vec![prometheus_rpc::BucketSpan {
|
||||
@@ -407,10 +488,10 @@ mod tests {
|
||||
positive_deltas: vec![7],
|
||||
..native_histogram_base()
|
||||
};
|
||||
let recs = expand_native_histogram(&hp, 16);
|
||||
let recs = expand_native_histogram(&hp, lim(16));
|
||||
assert_eq!(
|
||||
bucket_records(&recs),
|
||||
vec![(0.5, 0.0), (1.0, 7.0), (f64::INFINITY, 7.0)]
|
||||
vec![(0.0, 0.0), (0.5, 0.0), (1.0, 7.0), (f64::INFINITY, 7.0)]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -436,7 +517,7 @@ mod tests {
|
||||
positive_deltas: vec![2, -1, 2, -1, 1],
|
||||
..native_histogram_base()
|
||||
};
|
||||
let recs = expand_native_histogram(&hp, 16);
|
||||
let recs = expand_native_histogram(&hp, lim(16));
|
||||
assert_bucket_invariants(&recs);
|
||||
assert_eq!(scalar_record(&recs, "_count"), 13.0);
|
||||
assert_eq!(scalar_record(&recs, "_sum"), 175.5);
|
||||
@@ -471,17 +552,17 @@ mod tests {
|
||||
positive_deltas: vec![5, -2],
|
||||
..native_histogram_base()
|
||||
};
|
||||
let recs = expand_native_histogram(&hp, 16);
|
||||
let recs = expand_native_histogram(&hp, lim(16));
|
||||
assert_bucket_invariants(&recs);
|
||||
let buckets = bucket_records(&recs);
|
||||
// lower marker at idx 249's bound, uppers for idx 250 and 251, then inf
|
||||
assert_eq!(buckets.len(), 4);
|
||||
// zero bucket, lower marker at idx 249's bound, uppers for idx 250 and 251, then inf
|
||||
assert_eq!(buckets.len(), 5);
|
||||
assert_eq!(
|
||||
buckets.iter().map(|(_, c)| *c).collect::<Vec<_>>(),
|
||||
vec![0.0, 5.0, 8.0, 8.0]
|
||||
vec![0.0, 0.0, 5.0, 8.0, 8.0]
|
||||
);
|
||||
assert_eq!(buckets[1].0, 1.968);
|
||||
assert_eq!(buckets[2].0, 1.973);
|
||||
assert_eq!(buckets[2].0, 1.968);
|
||||
assert_eq!(buckets[3].0, 1.973);
|
||||
}
|
||||
|
||||
/// Power-of-two bounds are exact and a boundary shared between schemas is the
|
||||
@@ -519,7 +600,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_expand_native_histogram_merges_le_rounding_collisions() {
|
||||
let hp = prometheus_rpc::Histogram {
|
||||
zero_threshold: 1.0001, // rounds to le="1", same as bucket idx 0's bound
|
||||
zero_threshold: 0.99995, // rounds to le="1", same as bucket idx 0's bound
|
||||
count: Some(prometheus_rpc::histogram::Count::CountInt(5)),
|
||||
zero_count: Some(prometheus_rpc::histogram::ZeroCount::ZeroCountInt(2)),
|
||||
positive_spans: vec![prometheus_rpc::BucketSpan {
|
||||
@@ -529,12 +610,12 @@ mod tests {
|
||||
positive_deltas: vec![3],
|
||||
..native_histogram_base()
|
||||
};
|
||||
let recs = expand_native_histogram(&hp, 16);
|
||||
let recs = expand_native_histogram(&hp, lim(16));
|
||||
assert_bucket_invariants(&recs);
|
||||
// both uppers format to "1" and merge, keeping the full cumulative 5
|
||||
assert_eq!(
|
||||
bucket_records(&recs),
|
||||
vec![(0.5, 0.0), (1.0, 5.0), (f64::INFINITY, 5.0)]
|
||||
vec![(-1.0, 0.0), (1.0, 5.0), (f64::INFINITY, 5.0)]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -553,15 +634,16 @@ mod tests {
|
||||
};
|
||||
|
||||
// no limit pressure: 8 buckets stay at schema 2
|
||||
let full = expand_native_histogram(&hp, 64);
|
||||
assert_eq!(bucket_records(&full).len(), 10); // 8 uppers + 1 lower marker + inf
|
||||
let full = expand_native_histogram(&hp, lim(64));
|
||||
assert_eq!(bucket_records(&full).len(), 11); // zero bucket + 8 uppers + 1 lower marker + inf
|
||||
|
||||
// limit 4 le labels: two halvings land on schema 0
|
||||
let scaled = expand_native_histogram(&hp, 4);
|
||||
// limit 5 le labels: two halvings land on schema 0
|
||||
let scaled = expand_native_histogram(&hp, lim(5));
|
||||
assert_bucket_invariants(&scaled);
|
||||
assert_eq!(
|
||||
bucket_records(&scaled),
|
||||
vec![
|
||||
(0.0, 0.0),
|
||||
(1.0, 0.0), // lower marker
|
||||
(2.0, 10.0), // (1, 2]: 1+2+3+4
|
||||
(4.0, 36.0), // (2, 4]: 5+6+7+8
|
||||
@@ -589,11 +671,10 @@ mod tests {
|
||||
..native_histogram_base()
|
||||
};
|
||||
|
||||
// 4 buckets + 4 markers + inf = 9 labels > 8: one halving makes them
|
||||
// contiguous (idx 0..=3 at schema 2), 6 labels
|
||||
let recs = expand_native_histogram(&hp, 8);
|
||||
// 4 buckets + 4 markers + zero bucket + inf = 10 > 8: one halving makes 7 contiguous labels
|
||||
let recs = expand_native_histogram(&hp, lim(8));
|
||||
assert_bucket_invariants(&recs);
|
||||
assert_eq!(bucket_records(&recs).len(), 6);
|
||||
assert_eq!(bucket_records(&recs).len(), 7);
|
||||
assert_eq!(scalar_record(&recs, "_count"), 4.0);
|
||||
}
|
||||
|
||||
@@ -613,7 +694,7 @@ mod tests {
|
||||
positive_deltas: vec![1, 0, 0, 0, 0, 0], // idx 0,2,4,6,8,10
|
||||
..native_histogram_base()
|
||||
};
|
||||
let recs = expand_native_histogram(&hp, 5);
|
||||
let recs = expand_native_histogram(&hp, lim(5));
|
||||
assert_bucket_invariants(&recs);
|
||||
assert!(bucket_records(&recs).len() <= 5);
|
||||
assert_eq!(scalar_record(&recs, "_count"), 6.0);
|
||||
@@ -636,7 +717,7 @@ mod tests {
|
||||
positive_deltas: vec![3],
|
||||
..native_histogram_base()
|
||||
};
|
||||
let recs = expand_native_histogram(&hp, 16);
|
||||
let recs = expand_native_histogram(&hp, lim(16));
|
||||
assert_bucket_invariants(&recs);
|
||||
assert_eq!(
|
||||
bucket_records(&recs),
|
||||
@@ -644,6 +725,168 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_native_histogram_zero_threshold_folds_and_clips_both_sides() {
|
||||
let hp = prometheus_rpc::Histogram {
|
||||
zero_threshold: 1.5, // (0.5, 1] lies inside, (1, 2] overlaps
|
||||
count: Some(prometheus_rpc::histogram::Count::CountInt(9)),
|
||||
zero_count: Some(prometheus_rpc::histogram::ZeroCount::ZeroCountInt(1)),
|
||||
positive_spans: vec![prometheus_rpc::BucketSpan {
|
||||
offset: 0,
|
||||
length: 2,
|
||||
}],
|
||||
positive_deltas: vec![2, 1], // idx 0: 2, idx 1: 3
|
||||
negative_spans: vec![prometheus_rpc::BucketSpan {
|
||||
offset: 0,
|
||||
length: 2,
|
||||
}],
|
||||
negative_deltas: vec![1, 1], // idx 0: 1, idx 1: 2
|
||||
..native_histogram_base()
|
||||
};
|
||||
let recs = expand_native_histogram(&hp, lim(16));
|
||||
assert_bucket_invariants(&recs);
|
||||
assert_eq!(
|
||||
bucket_records(&recs),
|
||||
vec![
|
||||
(-2.0, 0.0),
|
||||
(-1.5, 2.0),
|
||||
(1.5, 6.0), // zero 1 + inside buckets 2 + 1
|
||||
(2.0, 9.0),
|
||||
(f64::INFINITY, 9.0),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_extreme_schema_is_bounded() {
|
||||
let recs = ExponentialHistogram {
|
||||
schema: i32::MAX,
|
||||
count: 3.0,
|
||||
sum: None,
|
||||
zero_count: 0.0,
|
||||
zero_threshold: 0.0,
|
||||
positive: vec![(1, 1.0), (5, 1.0)],
|
||||
negative: vec![(1, 1.0)],
|
||||
}
|
||||
.expand(lim(16));
|
||||
assert_bucket_invariants(&recs);
|
||||
// every index collapses onto schema-8 bucket 1, (1, 2^(1/256)], on both sides
|
||||
assert_eq!(
|
||||
bucket_records(&recs),
|
||||
vec![
|
||||
(-1.003, 0.0),
|
||||
(-1.0, 1.0),
|
||||
(0.0, 1.0),
|
||||
(1.0, 1.0),
|
||||
(1.003, 3.0),
|
||||
(f64::INFINITY, 3.0),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_layout_follows_target_schema_not_counts() {
|
||||
// schema 3, one contiguous run: 13 then 14 then 28 populated buckets
|
||||
let sample = |n: i64, per: f64| ExponentialHistogram {
|
||||
schema: 3,
|
||||
count: n as f64 * per,
|
||||
sum: None,
|
||||
zero_count: 0.0,
|
||||
zero_threshold: 0.0,
|
||||
positive: (1..=n).map(|i| (i, per)).collect(),
|
||||
negative: vec![],
|
||||
};
|
||||
let limits = ExpansionLimits {
|
||||
target_schema: 2,
|
||||
max_buckets: 512,
|
||||
};
|
||||
let les = |recs: &[ClassicHistogramRecord]| -> Vec<String> {
|
||||
recs.iter().filter_map(|(_, le, _)| le.clone()).collect()
|
||||
};
|
||||
let a = les(&sample(13, 1.0).expand(limits));
|
||||
let b = les(&sample(14, 2.0).expand(limits));
|
||||
let c = les(&sample(28, 3.0).expand(limits));
|
||||
// every emitted schema-2 bound survives into the next sample; the old valve switched here
|
||||
assert!(a.iter().all(|le| b.contains(le)), "{a:?} vs {b:?}");
|
||||
assert!(b.iter().all(|le| c.contains(le)), "{b:?} vs {c:?}");
|
||||
assert_eq!(a.len(), 7 + 1 + 1 + 1); // ceil(13/2)=7 uppers + marker + zero + inf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_layout_identical_across_producer_schemas() {
|
||||
// the same distribution at schema 5 and schema 3 lands on identical schema-2 records
|
||||
let coarse = ExponentialHistogram {
|
||||
schema: 3,
|
||||
count: 10.0,
|
||||
sum: Some(1.0),
|
||||
zero_count: 0.0,
|
||||
zero_threshold: 0.0,
|
||||
positive: vec![(1, 3.0), (2, 0.0), (3, 7.0)],
|
||||
negative: vec![],
|
||||
};
|
||||
// schema 5 splits each schema-3 bucket k into 4k-3..=4k
|
||||
let fine = ExponentialHistogram {
|
||||
schema: 5,
|
||||
count: 10.0,
|
||||
sum: Some(1.0),
|
||||
zero_count: 0.0,
|
||||
zero_threshold: 0.0,
|
||||
positive: vec![(2, 3.0), (5, 0.0), (11, 7.0)],
|
||||
negative: vec![],
|
||||
};
|
||||
let limits = ExpansionLimits {
|
||||
target_schema: 2,
|
||||
max_buckets: 512,
|
||||
};
|
||||
assert_eq!(coarse.expand(limits), fine.expand(limits));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valve_only_past_max_buckets() {
|
||||
let hp = prometheus_rpc::Histogram {
|
||||
count: Some(prometheus_rpc::histogram::Count::CountInt(40)),
|
||||
positive_spans: vec![prometheus_rpc::BucketSpan {
|
||||
offset: 0,
|
||||
length: 40,
|
||||
}],
|
||||
positive_deltas: std::iter::once(1)
|
||||
.chain(std::iter::repeat_n(0, 39))
|
||||
.collect(),
|
||||
..native_histogram_base()
|
||||
};
|
||||
let stable = expand_native_histogram(&hp, lim(512));
|
||||
assert_eq!(bucket_records(&stable).len(), 40 + 1 + 1 + 1);
|
||||
let valve = expand_native_histogram(&hp, lim(16));
|
||||
assert!(bucket_records(&valve).len() <= 16);
|
||||
assert_bucket_invariants(&valve);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bucket_bound_extreme_negative_schema() {
|
||||
assert_eq!(bucket_bound(i32::MIN, 0), 1.0);
|
||||
assert_eq!(bucket_bound(i32::MIN, 1), f64::MAX);
|
||||
assert_eq!(bucket_bound(i32::MIN, -1), 0.0);
|
||||
let recs = ExponentialHistogram {
|
||||
schema: i32::MIN,
|
||||
count: 2.0,
|
||||
sum: None,
|
||||
zero_count: 0.0,
|
||||
zero_threshold: 0.0,
|
||||
positive: vec![(1, 1.0)],
|
||||
negative: vec![(1, 1.0)],
|
||||
}
|
||||
.expand(lim(16));
|
||||
assert_bucket_invariants(&recs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bucket_bound_keeps_subnormals() {
|
||||
assert_eq!(bucket_bound(0, -1023), 2f64.powi(-1023));
|
||||
assert!(bucket_bound(0, -1023) < f64::MIN_POSITIVE);
|
||||
assert_eq!(bucket_bound(0, -1080), 0.0);
|
||||
assert_eq!(format_le(-0.0), "0");
|
||||
}
|
||||
|
||||
/// The gauge reset hint changes rate/reset semantics at query time, not the bucket
|
||||
/// layout: expansion output is identical with and without it.
|
||||
#[test]
|
||||
@@ -663,8 +906,8 @@ mod tests {
|
||||
..counter.clone()
|
||||
};
|
||||
assert_eq!(
|
||||
expand_native_histogram(&counter, 16),
|
||||
expand_native_histogram(&gauge, 16)
|
||||
expand_native_histogram(&counter, lim(16)),
|
||||
expand_native_histogram(&gauge, lim(16))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -678,7 +921,7 @@ mod tests {
|
||||
zero_count: Some(prometheus_rpc::histogram::ZeroCount::ZeroCountInt(5)),
|
||||
..native_histogram_base()
|
||||
};
|
||||
let recs = expand_native_histogram(&hp, 16);
|
||||
let recs = expand_native_histogram(&hp, lim(16));
|
||||
assert_bucket_invariants(&recs);
|
||||
assert_eq!(
|
||||
bucket_records(&recs),
|
||||
@@ -699,11 +942,11 @@ mod tests {
|
||||
positive_deltas: vec![9], // carries 1
|
||||
..native_histogram_base()
|
||||
};
|
||||
let recs = expand_native_histogram(&hp, 16);
|
||||
let recs = expand_native_histogram(&hp, lim(16));
|
||||
assert_bucket_invariants(&recs);
|
||||
assert_eq!(
|
||||
bucket_records(&recs),
|
||||
vec![(0.5, 0.0), (1.0, 9.0), (f64::INFINITY, 9.0)]
|
||||
vec![(0.0, 0.0), (0.5, 0.0), (1.0, 9.0), (f64::INFINITY, 9.0)]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -719,12 +962,12 @@ mod tests {
|
||||
positive_deltas: vec![7],
|
||||
..native_histogram_base()
|
||||
};
|
||||
let recs = expand_native_histogram(&hp, 16);
|
||||
let recs = expand_native_histogram(&hp, lim(16));
|
||||
assert_bucket_invariants(&recs);
|
||||
assert_eq!(scalar_record(&recs, "_count"), 10.0);
|
||||
assert_eq!(
|
||||
bucket_records(&recs),
|
||||
vec![(0.5, 0.0), (1.0, 7.0), (f64::INFINITY, 10.0)]
|
||||
vec![(0.0, 0.0), (0.5, 0.0), (1.0, 7.0), (f64::INFINITY, 10.0)]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -747,11 +990,12 @@ mod tests {
|
||||
positive_deltas: vec![3, -2],
|
||||
..native_histogram_base()
|
||||
};
|
||||
let recs = expand_native_histogram(&hp, 16);
|
||||
let recs = expand_native_histogram(&hp, lim(16));
|
||||
assert_bucket_invariants(&recs);
|
||||
assert_eq!(
|
||||
bucket_records(&recs),
|
||||
vec![
|
||||
(0.0, 0.0),
|
||||
(0.5, 0.0),
|
||||
(1.0, 3.0),
|
||||
// rounding f64::MAX to 4 digits would overflow to infinity and collide
|
||||
@@ -762,51 +1006,175 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// NaN float counts are skipped; the remaining buckets keep their positions.
|
||||
/// NaN and negative float counts are skipped while an explicit zero count is kept.
|
||||
#[test]
|
||||
fn test_expand_native_histogram_nan_float_count_skipped() {
|
||||
let hp = prometheus_rpc::Histogram {
|
||||
count: Some(prometheus_rpc::histogram::Count::CountFloat(5.0)),
|
||||
positive_spans: vec![prometheus_rpc::BucketSpan {
|
||||
offset: 1,
|
||||
length: 2,
|
||||
length: 4,
|
||||
}],
|
||||
positive_counts: vec![f64::NAN, 5.0],
|
||||
positive_counts: vec![f64::NAN, 0.0, -1.0, 5.0],
|
||||
..native_histogram_base()
|
||||
};
|
||||
let recs = expand_native_histogram(&hp, 16);
|
||||
let recs = expand_native_histogram(&hp, lim(16));
|
||||
assert_bucket_invariants(&recs);
|
||||
// idx 1 (NaN) skipped; idx 2 -> (2, 4] with its lower-bound marker
|
||||
// idx 1 (NaN) skipped, so le=2 is the gap marker; idx 2 empty but kept; idx 3 (-1) skipped
|
||||
assert_eq!(
|
||||
bucket_records(&recs),
|
||||
vec![(2.0, 0.0), (4.0, 5.0), (f64::INFINITY, 5.0)]
|
||||
vec![
|
||||
(0.0, 0.0),
|
||||
(2.0, 0.0),
|
||||
(4.0, 0.0),
|
||||
(8.0, 0.0),
|
||||
(16.0, 5.0),
|
||||
(f64::INFINITY, 5.0)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// An interior empty bucket is skipped; the gap marker pins the next bucket's
|
||||
/// lower bound.
|
||||
/// A negative integer delta run is skipped, so cumulative counts never decrease.
|
||||
#[test]
|
||||
fn test_expand_native_histogram_interior_zero_bucket_gap() {
|
||||
fn test_expand_native_histogram_negative_integer_count_skipped() {
|
||||
let hp = prometheus_rpc::Histogram {
|
||||
count: Some(prometheus_rpc::histogram::Count::CountInt(5)),
|
||||
count: Some(prometheus_rpc::histogram::Count::CountInt(7)),
|
||||
positive_spans: vec![prometheus_rpc::BucketSpan {
|
||||
offset: 0,
|
||||
length: 3,
|
||||
}],
|
||||
positive_deltas: vec![3, -3, 2], // buckets 3, 0, 2
|
||||
positive_deltas: vec![3, -4, 5], // buckets 3, -1, 4
|
||||
..native_histogram_base()
|
||||
};
|
||||
let recs = expand_native_histogram(&hp, 16);
|
||||
let recs = expand_native_histogram(&hp, lim(16));
|
||||
assert_bucket_invariants(&recs);
|
||||
assert_eq!(
|
||||
bucket_records(&recs),
|
||||
vec![
|
||||
(0.0, 0.0),
|
||||
(0.5, 0.0),
|
||||
(1.0, 3.0),
|
||||
(2.0, 3.0), // idx 1 empty: le=2 is the gap marker for (2, 4]
|
||||
(4.0, 5.0),
|
||||
(f64::INFINITY, 5.0),
|
||||
(2.0, 3.0), // idx 1 (-1) skipped: le=2 is the gap marker for (2, 4]
|
||||
(4.0, 7.0),
|
||||
(f64::INFINITY, 7.0),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_native_histogram_interior_zero_bucket_kept() {
|
||||
let hp = prometheus_rpc::Histogram {
|
||||
count: Some(prometheus_rpc::histogram::Count::CountInt(7)),
|
||||
positive_spans: vec![prometheus_rpc::BucketSpan {
|
||||
offset: 0,
|
||||
length: 3,
|
||||
}],
|
||||
positive_deltas: vec![3, -3, 4], // buckets 3, 0, 4
|
||||
..native_histogram_base()
|
||||
};
|
||||
let recs = expand_native_histogram(&hp, lim(16));
|
||||
assert_bucket_invariants(&recs);
|
||||
assert_eq!(
|
||||
bucket_records(&recs),
|
||||
vec![
|
||||
(0.0, 0.0),
|
||||
(0.5, 0.0),
|
||||
(1.0, 3.0),
|
||||
(2.0, 3.0), // idx 1 empty: its own upper record, not a gap marker
|
||||
(4.0, 7.0),
|
||||
(f64::INFINITY, 7.0),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// An explicitly empty bucket inside the zero region adds nothing and emits no record.
|
||||
#[test]
|
||||
fn test_expand_native_histogram_empty_bucket_in_zero_region() {
|
||||
let hp = prometheus_rpc::Histogram {
|
||||
count: Some(prometheus_rpc::histogram::Count::CountInt(2)),
|
||||
zero_threshold: 4.0,
|
||||
positive_spans: vec![prometheus_rpc::BucketSpan {
|
||||
offset: 1,
|
||||
length: 3,
|
||||
}],
|
||||
positive_deltas: vec![0, 0, 2], // buckets (1,2]=0, (2,4]=0, (4,8]=2
|
||||
..native_histogram_base()
|
||||
};
|
||||
let recs = expand_native_histogram(&hp, lim(16));
|
||||
assert_bucket_invariants(&recs);
|
||||
assert_eq!(
|
||||
bucket_records(&recs),
|
||||
vec![(-4.0, 0.0), (4.0, 0.0), (8.0, 2.0), (f64::INFINITY, 2.0)]
|
||||
);
|
||||
}
|
||||
|
||||
/// The zero bucket's `le` series exists before its first observation.
|
||||
#[test]
|
||||
fn test_expand_native_histogram_zero_bucket_series_stable_across_samples() {
|
||||
let sample = |zero_count: u64| {
|
||||
let hp = prometheus_rpc::Histogram {
|
||||
count: Some(prometheus_rpc::histogram::Count::CountInt(3 + zero_count)),
|
||||
zero_count: Some(prometheus_rpc::histogram::ZeroCount::ZeroCountInt(
|
||||
zero_count,
|
||||
)),
|
||||
positive_spans: vec![prometheus_rpc::BucketSpan {
|
||||
offset: 0,
|
||||
length: 1,
|
||||
}],
|
||||
positive_deltas: vec![3],
|
||||
..native_histogram_base()
|
||||
};
|
||||
let recs = expand_native_histogram(&hp, lim(16));
|
||||
assert_bucket_invariants(&recs);
|
||||
bucket_records(&recs)
|
||||
};
|
||||
assert_eq!(
|
||||
sample(0),
|
||||
vec![(0.0, 0.0), (0.5, 0.0), (1.0, 3.0), (f64::INFINITY, 3.0)]
|
||||
);
|
||||
assert_eq!(
|
||||
sample(1),
|
||||
vec![(0.0, 1.0), (0.5, 1.0), (1.0, 4.0), (f64::INFINITY, 4.0)]
|
||||
);
|
||||
}
|
||||
|
||||
/// A negative or NaN float zero count reads as an empty zero bucket; neighbours are unaffected.
|
||||
#[test]
|
||||
fn test_expand_native_histogram_invalid_zero_count_reads_as_empty() {
|
||||
for zero_count in [-1.0, f64::NAN] {
|
||||
let hp = prometheus_rpc::Histogram {
|
||||
count: Some(prometheus_rpc::histogram::Count::CountFloat(7.0)),
|
||||
zero_count: Some(prometheus_rpc::histogram::ZeroCount::ZeroCountFloat(
|
||||
zero_count,
|
||||
)),
|
||||
zero_threshold: 0.1,
|
||||
positive_spans: vec![prometheus_rpc::BucketSpan {
|
||||
offset: 0,
|
||||
length: 1,
|
||||
}],
|
||||
positive_counts: vec![4.0],
|
||||
negative_spans: vec![prometheus_rpc::BucketSpan {
|
||||
offset: 0,
|
||||
length: 1,
|
||||
}],
|
||||
negative_counts: vec![3.0],
|
||||
..native_histogram_base()
|
||||
};
|
||||
let recs = expand_native_histogram(&hp, lim(16));
|
||||
assert_bucket_invariants(&recs);
|
||||
assert_eq!(
|
||||
bucket_records(&recs),
|
||||
vec![
|
||||
(-1.0, 0.0),
|
||||
(-0.5, 3.0),
|
||||
(-0.1, 3.0),
|
||||
(0.1, 3.0),
|
||||
(0.5, 3.0),
|
||||
(1.0, 7.0),
|
||||
(f64::INFINITY, 7.0),
|
||||
],
|
||||
"zero_count {zero_count}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+422
-82
@@ -57,6 +57,7 @@ use schema::stream_schema_exists;
|
||||
use super::{
|
||||
columnar::{self, ColumnarStream},
|
||||
ingest::{self, PipelineFailure, PipelineInputs, RecordsByStream},
|
||||
native_histogram,
|
||||
};
|
||||
use crate::{
|
||||
common::meta::{http::HttpResponse as MetaHttpResponse, stream::SchemaRecords},
|
||||
@@ -873,9 +874,10 @@ fn process_exponential_histogram(
|
||||
);
|
||||
let mut records = vec![];
|
||||
process_aggregation_temporality(rec, hist.aggregation_temporality);
|
||||
let limits = native_histogram::ExpansionLimits::from_config(&config::get_config());
|
||||
for data_point in &hist.data_points {
|
||||
let mut dp_rec = rec.clone();
|
||||
for mut bucket_rec in process_exp_hist_data_point(&mut dp_rec, data_point) {
|
||||
for mut bucket_rec in process_exp_hist_data_point(&mut dp_rec, data_point, limits) {
|
||||
let val_map = bucket_rec.as_object_mut().unwrap();
|
||||
let hash = super::signature_without_labels(val_map, METRICS_HASH_EXCLUDED_LABELS);
|
||||
val_map.insert(HASH_LABEL.to_string(), json::Value::Number(hash.into()));
|
||||
@@ -995,6 +997,7 @@ fn process_hist_data_point(
|
||||
fn process_exp_hist_data_point(
|
||||
rec: &mut json::Value,
|
||||
data_point: &ExponentialHistogramDataPoint,
|
||||
limits: native_histogram::ExpansionLimits,
|
||||
) -> Vec<serde_json::Value> {
|
||||
if no_recorded_value(data_point.flags) {
|
||||
return vec![];
|
||||
@@ -1006,46 +1009,53 @@ fn process_exp_hist_data_point(
|
||||
rec["start_time"] = data_point.start_time_unix_nano.to_string().into();
|
||||
rec["flag"] = data_point_flag(data_point.flags).into();
|
||||
process_exemplars(rec, &data_point.exemplars);
|
||||
// add count record
|
||||
let mut count_rec = rec.clone();
|
||||
count_rec[VALUE_LABEL] = (data_point.count as f64).into();
|
||||
count_rec[NAME_LABEL] = format!("{}_count", count_rec[NAME_LABEL].as_str().unwrap()).into();
|
||||
bucket_recs.push(count_rec);
|
||||
|
||||
// add sum record -- OTLP marks `sum` optional, so an absent (or NaN) sum emits no record
|
||||
if let Some(sum) = data_point.sum.and_then(super::metric_value) {
|
||||
let mut sum_rec = rec.clone();
|
||||
sum_rec[VALUE_LABEL] = sum;
|
||||
sum_rec[NAME_LABEL] = format!("{}_sum", sum_rec[NAME_LABEL].as_str().unwrap()).into();
|
||||
bucket_recs.push(sum_rec);
|
||||
// OTLP bucket `i` covers `(base^i, base^(i+1)]`, the native layout's bucket `i+1`
|
||||
let otlp_buckets = |b: &Option<exponential_histogram_data_point::Buckets>| -> Vec<(i64, f64)> {
|
||||
let Some(b) = b else { return vec![] };
|
||||
// explicitly reported empty buckets are kept so their `le` series exists before they fill
|
||||
b.bucket_counts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| (b.offset as i64 + i as i64 + 1, *c as f64))
|
||||
.collect()
|
||||
};
|
||||
let expanded = native_histogram::ExponentialHistogram {
|
||||
schema: data_point.scale,
|
||||
count: data_point.count as f64,
|
||||
// OTLP marks `sum` optional, so an absent sum emits no record
|
||||
sum: data_point.sum,
|
||||
zero_count: data_point.zero_count as f64,
|
||||
zero_threshold: data_point.zero_threshold,
|
||||
positive: otlp_buckets(&data_point.positive),
|
||||
negative: otlp_buckets(&data_point.negative),
|
||||
}
|
||||
.expand(limits);
|
||||
for (suffix, le, value) in expanded {
|
||||
let Some(value) = super::metric_value(value) else {
|
||||
continue;
|
||||
};
|
||||
let mut hist_rec = rec.clone();
|
||||
hist_rec[VALUE_LABEL] = value;
|
||||
hist_rec[NAME_LABEL] = format!("{}{suffix}", rec[NAME_LABEL].as_str().unwrap()).into();
|
||||
if let Some(le) = le {
|
||||
hist_rec["le"] = le.into();
|
||||
}
|
||||
bucket_recs.push(hist_rec);
|
||||
}
|
||||
|
||||
// OTLP exponential histogram bucket boundaries are powers of `base`, where
|
||||
// `base = 2^(2^-scale)`. Bucket index `idx` (offset + array position) covers the
|
||||
// range (base^idx, base^(idx+1)], so its upper bound is `base^(idx+1)`.
|
||||
// NOTE: `^` is bitwise XOR in Rust, not exponentiation -- use `powf`/`powi`.
|
||||
let base = 2f64.powf(2f64.powi(-data_point.scale));
|
||||
// add negative bucket records (negative values, so the boundary is negated)
|
||||
if let Some(buckets) = &data_point.negative {
|
||||
let offset = buckets.offset;
|
||||
for (i, val) in buckets.bucket_counts.iter().enumerate() {
|
||||
let mut bucket_rec = rec.clone();
|
||||
bucket_rec[NAME_LABEL] = format!("{}_bucket", rec[NAME_LABEL].as_str().unwrap()).into();
|
||||
bucket_rec[VALUE_LABEL] = (*val as f64).into();
|
||||
bucket_rec["le"] = (-base.powi(offset + (i as i32) + 1)).to_string().into();
|
||||
bucket_recs.push(bucket_rec);
|
||||
}
|
||||
if let Some(min) = data_point.min.and_then(super::metric_value) {
|
||||
let mut min_rec = rec.clone();
|
||||
min_rec[VALUE_LABEL] = min;
|
||||
min_rec[NAME_LABEL] = format!("{}_min", min_rec[NAME_LABEL].as_str().unwrap()).into();
|
||||
bucket_recs.push(min_rec);
|
||||
}
|
||||
// add positive bucket records
|
||||
if let Some(buckets) = &data_point.positive {
|
||||
let offset = buckets.offset;
|
||||
for (i, val) in buckets.bucket_counts.iter().enumerate() {
|
||||
let mut bucket_rec = rec.clone();
|
||||
bucket_rec[NAME_LABEL] = format!("{}_bucket", rec[NAME_LABEL].as_str().unwrap()).into();
|
||||
bucket_rec[VALUE_LABEL] = (*val as f64).into();
|
||||
bucket_rec["le"] = base.powi(offset + (i as i32) + 1).to_string().into();
|
||||
bucket_recs.push(bucket_rec);
|
||||
}
|
||||
|
||||
if let Some(max) = data_point.max.and_then(super::metric_value) {
|
||||
let mut max_rec = rec.clone();
|
||||
max_rec[VALUE_LABEL] = max;
|
||||
max_rec[NAME_LABEL] = format!("{}_max", max_rec[NAME_LABEL].as_str().unwrap()).into();
|
||||
bucket_recs.push(max_rec);
|
||||
}
|
||||
|
||||
bucket_recs
|
||||
@@ -1228,6 +1238,14 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
/// No target downscaling: the producer's scale is emitted as is, capped only by the valve.
|
||||
fn lim(max_buckets: usize) -> native_histogram::ExpansionLimits {
|
||||
native_histogram::ExpansionLimits {
|
||||
target_schema: 8,
|
||||
max_buckets,
|
||||
}
|
||||
}
|
||||
|
||||
fn process_gauge(
|
||||
rec: &json::Value,
|
||||
gauge: &Gauge,
|
||||
@@ -1677,17 +1695,31 @@ mod tests {
|
||||
}),
|
||||
};
|
||||
|
||||
let result = process_exp_hist_data_point(&mut rec, &data_point);
|
||||
let result = process_exp_hist_data_point(&mut rec, &data_point, lim(16));
|
||||
|
||||
// Verify the processed data
|
||||
assert!(!result.is_empty());
|
||||
// Should have count, sum, and bucket records
|
||||
assert!(result.len() >= 2);
|
||||
|
||||
// Check count record
|
||||
let count_rec = &result[0];
|
||||
assert!(count_rec["__name__"].as_str().unwrap().ends_with("_count"));
|
||||
assert_eq!(count_rec["value"], json!(100.0));
|
||||
|
||||
// the explicitly empty negative bucket (-2, -1] keeps its `le` series
|
||||
let buckets: Vec<(&str, f64)> = result
|
||||
.iter()
|
||||
.filter(|r| r["__name__"].as_str().unwrap().ends_with("_bucket"))
|
||||
.map(|r| (r["le"].as_str().unwrap(), r["value"].as_f64().unwrap()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
buckets,
|
||||
vec![
|
||||
("-2", 0.0),
|
||||
("-1", 0.0),
|
||||
("0", 10.0),
|
||||
("1", 10.0),
|
||||
("2", 60.0),
|
||||
("4", 90.0),
|
||||
("8", 110.0),
|
||||
("inf", 110.0),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2608,6 +2640,19 @@ mod tests {
|
||||
assert!(le_values.contains(&"10".to_string()));
|
||||
}
|
||||
|
||||
fn exp_hist_buckets(result: &[serde_json::Value]) -> Vec<(String, f64)> {
|
||||
result
|
||||
.iter()
|
||||
.filter(|r| r["__name__"].as_str().unwrap_or("").ends_with("_bucket"))
|
||||
.map(|r| {
|
||||
(
|
||||
r["le"].as_str().unwrap().to_string(),
|
||||
r["value"].as_f64().unwrap(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exponential_histogram_buckets() {
|
||||
let mut rec = json!({"__name__": "test_exp_histogram"});
|
||||
@@ -2634,50 +2679,344 @@ mod tests {
|
||||
}),
|
||||
};
|
||||
|
||||
let result = process_exp_hist_data_point(&mut rec, &data_point);
|
||||
let result = process_exp_hist_data_point(&mut rec, &data_point, lim(16));
|
||||
|
||||
// Should have count, sum, positive buckets, and negative buckets
|
||||
assert!(result.len() >= 2); // At least count and sum
|
||||
assert_eq!(result[0]["__name__"], "test_exp_histogram_count");
|
||||
assert_eq!(result[0]["value"], 200.0);
|
||||
assert_eq!(result[1]["__name__"], "test_exp_histogram_sum");
|
||||
assert_eq!(result[1]["value"], 500.0);
|
||||
|
||||
// Check count record
|
||||
let count_record = result
|
||||
let expected = [
|
||||
("-1", 0.0),
|
||||
("-0.7071", 10.0),
|
||||
("-0.5", 15.0),
|
||||
("-0.001", 15.0),
|
||||
("0.001", 20.0),
|
||||
("1", 20.0),
|
||||
("1.414", 30.0),
|
||||
("2", 50.0),
|
||||
("2.828", 80.0),
|
||||
// `count` wins over a short bucket total so `le="inf"` equals `_count`
|
||||
("inf", 200.0),
|
||||
];
|
||||
let expected: Vec<(String, f64)> = expected
|
||||
.iter()
|
||||
.find(|r| r["__name__"].as_str().unwrap_or("").ends_with("_count"));
|
||||
assert!(count_record.is_some());
|
||||
assert_eq!(count_record.unwrap()["value"], 200.0);
|
||||
|
||||
// Check sum record
|
||||
let sum_record = result
|
||||
.iter()
|
||||
.find(|r| r["__name__"].as_str().unwrap_or("").ends_with("_sum"));
|
||||
assert!(sum_record.is_some());
|
||||
assert_eq!(sum_record.unwrap()["value"], 500.0);
|
||||
|
||||
// Bucket boundaries: base = 2^(2^-scale). scale=1 => base = 2^0.5 = sqrt(2).
|
||||
let base = 2f64.powf(2f64.powi(-1));
|
||||
|
||||
// Positive buckets (offset 0): le = base^(idx+1) for idx = 0,1,2.
|
||||
let positive_les: Vec<f64> = result
|
||||
.iter()
|
||||
.filter(|r| r["__name__"].as_str().unwrap_or("").ends_with("_bucket"))
|
||||
.filter_map(|r| r["le"].as_str().and_then(|s| s.parse::<f64>().ok()))
|
||||
.filter(|le| *le > 0.0)
|
||||
.map(|(le, v)| (le.to_string(), *v))
|
||||
.collect();
|
||||
assert_eq!(positive_les.len(), 3);
|
||||
for (i, le) in positive_les.iter().enumerate() {
|
||||
assert!((le - base.powi(i as i32 + 1)).abs() < 1e-9);
|
||||
assert_eq!(exp_hist_buckets(&result), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exponential_histogram_buckets_issue_14634() {
|
||||
let mut rec = json!({"__name__": "h"});
|
||||
let data_point = opentelemetry_proto::tonic::metrics::v1::ExponentialHistogramDataPoint {
|
||||
attributes: vec![],
|
||||
start_time_unix_nano: 0,
|
||||
time_unix_nano: 1640995200000000000,
|
||||
exemplars: vec![],
|
||||
flags: 0,
|
||||
count: 7,
|
||||
sum: Some(21.0),
|
||||
min: None,
|
||||
max: None,
|
||||
scale: 2,
|
||||
zero_count: 1,
|
||||
zero_threshold: 0.0,
|
||||
positive: Some(opentelemetry_proto::tonic::metrics::v1::exponential_histogram_data_point::Buckets {
|
||||
offset: 0,
|
||||
bucket_counts: vec![2, 3],
|
||||
}),
|
||||
negative: Some(opentelemetry_proto::tonic::metrics::v1::exponential_histogram_data_point::Buckets {
|
||||
offset: 0,
|
||||
bucket_counts: vec![1],
|
||||
}),
|
||||
};
|
||||
|
||||
let result = process_exp_hist_data_point(&mut rec, &data_point, lim(16));
|
||||
|
||||
let expected = [
|
||||
("-1.189", 0.0),
|
||||
("-1", 1.0),
|
||||
("0", 2.0),
|
||||
("1", 2.0),
|
||||
("1.189", 4.0),
|
||||
("1.414", 7.0),
|
||||
("inf", 7.0),
|
||||
];
|
||||
let expected: Vec<(String, f64)> = expected
|
||||
.iter()
|
||||
.map(|(le, v)| (le.to_string(), *v))
|
||||
.collect();
|
||||
assert_eq!(exp_hist_buckets(&result), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exponential_histogram_extreme_scales_still_expand() {
|
||||
for scale in [i32::MAX, 21, -11, i32::MIN] {
|
||||
let mut rec = json!({"__name__": "h"});
|
||||
let data_point = opentelemetry_proto::tonic::metrics::v1::ExponentialHistogramDataPoint {
|
||||
attributes: vec![],
|
||||
start_time_unix_nano: 0,
|
||||
time_unix_nano: 1640995200000000000,
|
||||
exemplars: vec![],
|
||||
flags: 0,
|
||||
count: 1,
|
||||
sum: Some(1.0),
|
||||
min: None,
|
||||
max: None,
|
||||
scale,
|
||||
zero_count: 0,
|
||||
zero_threshold: 0.0,
|
||||
positive: Some(opentelemetry_proto::tonic::metrics::v1::exponential_histogram_data_point::Buckets {
|
||||
offset: 0,
|
||||
bucket_counts: vec![1],
|
||||
}),
|
||||
negative: None,
|
||||
};
|
||||
let result = process_exp_hist_data_point(&mut rec, &data_point, lim(16));
|
||||
assert_eq!(result[0]["__name__"], "h_count", "scale {scale}");
|
||||
assert_eq!(result[1]["__name__"], "h_sum", "scale {scale}");
|
||||
let buckets = exp_hist_buckets(&result);
|
||||
assert_eq!(
|
||||
buckets.last().unwrap(),
|
||||
&("inf".to_string(), 1.0),
|
||||
"scale {scale}"
|
||||
);
|
||||
for w in buckets.windows(2) {
|
||||
let (a, b) = (
|
||||
w[0].0.parse::<f64>().unwrap(),
|
||||
w[1].0.parse::<f64>().unwrap(),
|
||||
);
|
||||
assert!(a < b && w[0].1 <= w[1].1, "scale {scale}: {buckets:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Negative buckets (offset -2): le = -base^(idx+1) for idx = -2,-1.
|
||||
let negative_les: Vec<f64> = result
|
||||
#[test]
|
||||
fn test_exponential_histogram_clips_bucket_to_zero_threshold() {
|
||||
let mut rec = json!({"__name__": "h"});
|
||||
let data_point = opentelemetry_proto::tonic::metrics::v1::ExponentialHistogramDataPoint {
|
||||
attributes: vec![],
|
||||
start_time_unix_nano: 0,
|
||||
time_unix_nano: 1640995200000000000,
|
||||
exemplars: vec![],
|
||||
flags: 0,
|
||||
count: 10,
|
||||
sum: Some(17.5),
|
||||
min: None,
|
||||
max: None,
|
||||
scale: 0,
|
||||
zero_count: 0,
|
||||
zero_threshold: 1.5,
|
||||
positive: Some(opentelemetry_proto::tonic::metrics::v1::exponential_histogram_data_point::Buckets {
|
||||
offset: 0,
|
||||
bucket_counts: vec![10],
|
||||
}),
|
||||
negative: None,
|
||||
};
|
||||
|
||||
let result = process_exp_hist_data_point(&mut rec, &data_point, lim(16));
|
||||
|
||||
let expected = [("-1.5", 0.0), ("1.5", 0.0), ("2", 10.0), ("inf", 10.0)];
|
||||
let expected: Vec<(String, f64)> = expected
|
||||
.iter()
|
||||
.filter(|r| r["__name__"].as_str().unwrap_or("").ends_with("_bucket"))
|
||||
.filter_map(|r| r["le"].as_str().and_then(|s| s.parse::<f64>().ok()))
|
||||
.filter(|le| *le < 0.0)
|
||||
.map(|(le, v)| (le.to_string(), *v))
|
||||
.collect();
|
||||
assert_eq!(negative_les.len(), 2);
|
||||
assert!((negative_les[0] - (-base.powi(-1))).abs() < 1e-9);
|
||||
assert!((negative_les[1] - (-base.powi(0))).abs() < 1e-9);
|
||||
assert_eq!(exp_hist_buckets(&result), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exponential_histogram_layout_stable_as_buckets_grow() {
|
||||
// scale 0, 13 then 14 buckets: the old 16-label default downscaled the second sample
|
||||
let sample = |counts: Vec<u64>| {
|
||||
let mut rec = json!({"__name__": "h"});
|
||||
let dp = opentelemetry_proto::tonic::metrics::v1::ExponentialHistogramDataPoint {
|
||||
attributes: vec![],
|
||||
start_time_unix_nano: 0,
|
||||
time_unix_nano: 1640995200000000000,
|
||||
exemplars: vec![],
|
||||
flags: 0,
|
||||
count: counts.iter().sum(),
|
||||
sum: None,
|
||||
min: None,
|
||||
max: None,
|
||||
scale: 0,
|
||||
zero_count: 0,
|
||||
zero_threshold: 0.0,
|
||||
positive: Some(opentelemetry_proto::tonic::metrics::v1::exponential_histogram_data_point::Buckets {
|
||||
offset: 0,
|
||||
bucket_counts: counts,
|
||||
}),
|
||||
negative: None,
|
||||
};
|
||||
let limits = native_histogram::ExpansionLimits {
|
||||
target_schema: 2,
|
||||
max_buckets: 512,
|
||||
};
|
||||
exp_hist_buckets(&process_exp_hist_data_point(&mut rec, &dp, limits))
|
||||
.into_iter()
|
||||
.map(|(le, _)| le)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let a = sample(vec![1; 13]);
|
||||
let b = sample(vec![1; 14]);
|
||||
assert_eq!(a.len(), 13 + 1 + 1 + 1);
|
||||
assert_eq!(b.len(), 14 + 1 + 1 + 1);
|
||||
assert!(a.iter().all(|le| b.contains(le)), "{a:?} vs {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exponential_histogram_downscales_fine_scale() {
|
||||
let mut rec = json!({"__name__": "h"});
|
||||
let data_point = opentelemetry_proto::tonic::metrics::v1::ExponentialHistogramDataPoint {
|
||||
attributes: vec![],
|
||||
start_time_unix_nano: 0,
|
||||
time_unix_nano: 1640995200000000000,
|
||||
exemplars: vec![],
|
||||
flags: 0,
|
||||
count: 3,
|
||||
sum: Some(3.0),
|
||||
min: None,
|
||||
max: None,
|
||||
scale: 10,
|
||||
zero_count: 0,
|
||||
zero_threshold: 0.0,
|
||||
positive: Some(opentelemetry_proto::tonic::metrics::v1::exponential_histogram_data_point::Buckets {
|
||||
offset: 0,
|
||||
bucket_counts: vec![1, 1, 1],
|
||||
}),
|
||||
negative: None,
|
||||
};
|
||||
|
||||
let result = process_exp_hist_data_point(&mut rec, &data_point, lim(16));
|
||||
|
||||
// scale 10 buckets 1..=3 merge into schema-8 bucket 1: (1, 2^(1/256)]
|
||||
let expected = [("0", 0.0), ("1", 0.0), ("1.003", 3.0), ("inf", 3.0)];
|
||||
let expected: Vec<(String, f64)> = expected
|
||||
.iter()
|
||||
.map(|(le, v)| (le.to_string(), *v))
|
||||
.collect();
|
||||
assert_eq!(exp_hist_buckets(&result), expected);
|
||||
}
|
||||
|
||||
/// Explicitly reported empty buckets keep their `le` series before they fill.
|
||||
#[test]
|
||||
fn test_exponential_histogram_keeps_explicit_empty_buckets() {
|
||||
let sample = |counts: Vec<u64>| {
|
||||
let mut rec = json!({"__name__": "h"});
|
||||
let data_point = opentelemetry_proto::tonic::metrics::v1::ExponentialHistogramDataPoint {
|
||||
attributes: vec![],
|
||||
start_time_unix_nano: 0,
|
||||
time_unix_nano: 1640995200000000000,
|
||||
exemplars: vec![],
|
||||
flags: 0,
|
||||
count: counts.iter().sum(),
|
||||
sum: None,
|
||||
min: None,
|
||||
max: None,
|
||||
scale: 0,
|
||||
zero_count: 0,
|
||||
zero_threshold: 0.0,
|
||||
positive: Some(opentelemetry_proto::tonic::metrics::v1::exponential_histogram_data_point::Buckets {
|
||||
offset: 0,
|
||||
bucket_counts: counts,
|
||||
}),
|
||||
negative: None,
|
||||
};
|
||||
exp_hist_buckets(&process_exp_hist_data_point(&mut rec, &data_point, lim(16)))
|
||||
};
|
||||
let expect = |pairs: &[(&str, f64)]| -> Vec<(String, f64)> {
|
||||
pairs.iter().map(|(le, v)| (le.to_string(), *v)).collect()
|
||||
};
|
||||
|
||||
// (2,4] and (4,8] are empty but reported: no gap marker, cumulative unchanged
|
||||
assert_eq!(
|
||||
sample(vec![10, 0, 0, 10]),
|
||||
expect(&[
|
||||
("0", 0.0),
|
||||
("1", 0.0),
|
||||
("2", 10.0),
|
||||
("4", 10.0),
|
||||
("8", 10.0),
|
||||
("16", 20.0),
|
||||
("inf", 20.0),
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
sample(vec![10, 1, 0, 10]),
|
||||
expect(&[
|
||||
("0", 0.0),
|
||||
("1", 0.0),
|
||||
("2", 10.0),
|
||||
("4", 11.0),
|
||||
("8", 11.0),
|
||||
("16", 21.0),
|
||||
("inf", 21.0),
|
||||
])
|
||||
);
|
||||
// leading and trailing zeros are kept as well
|
||||
assert_eq!(
|
||||
sample(vec![0, 5, 0]),
|
||||
expect(&[
|
||||
("0", 0.0),
|
||||
("1", 0.0),
|
||||
("2", 0.0),
|
||||
("4", 5.0),
|
||||
("8", 5.0),
|
||||
("inf", 5.0),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/// The zero bucket's `le` series exists before its first observation.
|
||||
#[test]
|
||||
fn test_exponential_histogram_zero_bucket_series_stable_across_samples() {
|
||||
let sample = |zero_count: u64| {
|
||||
let mut rec = json!({"__name__": "h"});
|
||||
let data_point = opentelemetry_proto::tonic::metrics::v1::ExponentialHistogramDataPoint {
|
||||
attributes: vec![],
|
||||
start_time_unix_nano: 0,
|
||||
time_unix_nano: 1640995200000000000,
|
||||
exemplars: vec![],
|
||||
flags: 0,
|
||||
count: 10 + zero_count,
|
||||
sum: None,
|
||||
min: None,
|
||||
max: None,
|
||||
scale: 0,
|
||||
zero_count,
|
||||
zero_threshold: 0.001,
|
||||
positive: Some(opentelemetry_proto::tonic::metrics::v1::exponential_histogram_data_point::Buckets {
|
||||
offset: 0,
|
||||
bucket_counts: vec![10],
|
||||
}),
|
||||
negative: None,
|
||||
};
|
||||
exp_hist_buckets(&process_exp_hist_data_point(&mut rec, &data_point, lim(16)))
|
||||
};
|
||||
let expect = |pairs: &[(&str, f64)]| -> Vec<(String, f64)> {
|
||||
pairs.iter().map(|(le, v)| (le.to_string(), *v)).collect()
|
||||
};
|
||||
assert_eq!(
|
||||
sample(0),
|
||||
expect(&[
|
||||
("-0.001", 0.0),
|
||||
("0.001", 0.0),
|
||||
("1", 0.0),
|
||||
("2", 10.0),
|
||||
("inf", 10.0),
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
sample(1),
|
||||
expect(&[
|
||||
("-0.001", 0.0),
|
||||
("0.001", 1.0),
|
||||
("1", 1.0),
|
||||
("2", 11.0),
|
||||
("inf", 11.0),
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3022,7 +3361,7 @@ mod tests {
|
||||
negative: None,
|
||||
};
|
||||
|
||||
let result = process_exp_hist_data_point(&mut rec, &data_point);
|
||||
let result = process_exp_hist_data_point(&mut rec, &data_point, lim(16));
|
||||
|
||||
// Should have at least count and sum records
|
||||
let metric_names: Vec<&str> = result
|
||||
@@ -3181,6 +3520,7 @@ mod tests {
|
||||
}),
|
||||
negative: None,
|
||||
},
|
||||
lim(16),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3395,7 +3735,7 @@ mod tests {
|
||||
negative: None,
|
||||
};
|
||||
|
||||
assert!(process_exp_hist_data_point(&mut rec, &stale).is_empty());
|
||||
assert!(process_exp_hist_data_point(&mut rec, &stale, lim(16)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -53,7 +53,7 @@ use search_service;
|
||||
|
||||
use super::{
|
||||
columnar, ingest,
|
||||
native_histogram::{CLASSIC_HISTOGRAM_SUFFIXES, expand_native_histogram},
|
||||
native_histogram::{CLASSIC_HISTOGRAM_SUFFIXES, ExpansionLimits, expand_native_histogram},
|
||||
prom_decode,
|
||||
};
|
||||
use crate::{
|
||||
@@ -459,7 +459,7 @@ pub async fn remote_write(
|
||||
&event.histograms,
|
||||
&labels,
|
||||
&metric_name,
|
||||
cfg.prom.native_histogram_max_buckets,
|
||||
ExpansionLimits::from_config(&cfg),
|
||||
&mut gate,
|
||||
&mut sink,
|
||||
)
|
||||
@@ -1112,7 +1112,7 @@ async fn buffer_native_histograms(
|
||||
histograms: &[prometheus_rpc::Histogram],
|
||||
labels: &json::Map<String, json::Value>,
|
||||
metric_name: &str,
|
||||
max_buckets: usize,
|
||||
limits: ExpansionLimits,
|
||||
gate: &mut HaGate<'_>,
|
||||
sink: &mut RecordSink<'_>,
|
||||
) -> Option<usize> {
|
||||
@@ -1127,7 +1127,7 @@ async fn buffer_native_histograms(
|
||||
let mut counted = 0;
|
||||
for hp in histograms {
|
||||
counted += 1;
|
||||
let records = expand_native_histogram(hp, max_buckets);
|
||||
let records = expand_native_histogram(hp, limits);
|
||||
if records.is_empty() {
|
||||
// unsupported schema or stale marker: nothing will be written
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user