Add support for MMSC instruments by introducing Histogram bridge#2050
Add support for MMSC instruments by introducing Histogram bridge#2050jmacd merged 9 commits intoopen-telemetry:mainfrom
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #2050 +/- ##
==========================================
+ Coverage 86.97% 86.99% +0.02%
==========================================
Files 536 536
Lines 172726 173347 +621
==========================================
+ Hits 150225 150803 +578
- Misses 21967 22010 +43
Partials 534 534
🚀 New features to boost your workflow:
|
| pub struct Mmsc { | ||
| min: f64, | ||
| max: f64, | ||
| sum: f64, | ||
| count: u64, | ||
| } |
There was a problem hiding this comment.
Unless I'm mistaken, since Mmsc appears to be a subset of the Summary metric as defined in OTLP, shouldn't we call this metric Summary?
There was a problem hiding this comment.
It is a subset of Summary metric, but the official docs seem to discourage from using it: https://opentelemetry.io/docs/specs/otel/metrics/data-model/#summary-legacy
This point type is not recommended for new applications and exists for compatibility with other formats.
Moreover, it would be a very poor summary metric as it would only have two quantile values: 0.0 and 1.0 (min and max). We wouldn't have median or 99th percentile value etc. which would be doing injustice to the summary metric.
However, OpenTelemetry Histogram natively supports the "MMSC" scenario by disabling bucket distribution computation.
There was a problem hiding this comment.
Yes +1 to representing MMSC as Histogram w/o buckets. 💯
| n => { | ||
| histogram.record(s.min, attributes); | ||
| histogram.record(s.max, attributes); | ||
| let fill = (s.sum - s.min - s.max) / (n - 2) as f64; | ||
| for _ in 0..(n - 2) { | ||
| histogram.record(fill, attributes); | ||
| } | ||
| } |
There was a problem hiding this comment.
That potentially results in a lot of iterations for a relatively low-quality histogram :-)
There was a problem hiding this comment.
Agreed. However, this runs once per reporting interval on already-aggregated snapshots, not on the hot path, so the iteration cost should be acceptable.
| Instrument::Counter => "counter", | ||
| Instrument::UpDownCounter => "gauge", | ||
| Instrument::Gauge => "gauge", | ||
| Instrument::Histogram => "histogram", |
There was a problem hiding this comment.
Why was this mapping changed to "histogram"? Seems like it is emit as a single scalar which sounds like a gauge.
There was a problem hiding this comment.
There was an existing inconsistency between agg_prometheus_text and format_prometheus_text in terms of what Instrument::Histogram should map. I have updated both to use gauge.
cijothomas
left a comment
There was a problem hiding this comment.
This is an excellent way of getting MMSC without full Histogram support!
| } | ||
| if value > self.max { | ||
| self.max = value; | ||
| } |
There was a problem hiding this comment.
nit - good to also guard against non-finite inputs?
There was a problem hiding this comment.
Actually thinking more, there is another issue - The default uses f64::MAX/f64::MIN as sentinels, but these break with +-Inf observations: first record +Inf leaves min stuck at f64::MAX as +Inf < f64::MAX; first record -Inf leaves max stuck at f64::MIN as -Inf > f64::MIN.
Use f64::INFINITY/f64::NEG_INFINITY as sentinels instead - then any finite-or-infinite value updates them correctly. Combined with an is_finite() guard in record(), both problems are solved.
There was a problem hiding this comment.
Deferring this to a follow-up. Today this matches what the OpenTelemetry SDK Histogram does. It accepts whatever f64 you hand it with no is_finite() guard or rejection of NaN/Inf. The same gap exists in our other internal instruments of the telemetry crate too (e.g., Counters could receive negative or NaN/Inf values). This would be worth a broader discussion on whether we want runtime enforcement across all instruments or just a doc-level contract that callers pass valid values.
There was a problem hiding this comment.
See also #2100. We have to protect against Inf and NaN measurements, I would say.
| n => { | ||
| histogram.record(s.min, attributes); | ||
| histogram.record(s.max, attributes); | ||
| let fill = (s.sum - s.min - s.max) / (n - 2) as f64; |
There was a problem hiding this comment.
nit - fill can become negative if sum < min + max (eg bad snapshot). Should we guard against that - eg, clamp tiny negatives to 0.0, and log/drop when clearly negative
There was a problem hiding this comment.
Related comment: #2050 (comment)
If the recorded values are negative, then the correct fill value could be negative as well. Similar to how if you use OpenTelemetry SDK Counter to record negative numbers, you will end up with a negative sum.
| ); | ||
| } | ||
| MetricValue::Mmsc(s) => { | ||
| for (suffix, fval) in [("_min", s.min), ("_max", s.max), ("_sum", s.sum)] { |
There was a problem hiding this comment.
When s.count == 0, s.min/s.max are sentinel values (f64::MAX/f64::MIN). In mixed metric sets, this branch still emits _min/_max, which leaks invalid values into output.
Please guard count == 0 here (skip MMSC emission, or emit only _count=0 and _sum=0).
| for (suffix, prom_type, val) in [ | ||
| ("_min", "gauge", s.min), | ||
| ("_max", "gauge", s.max), | ||
| ("_sum", "counter", s.sum), |
There was a problem hiding this comment.
_sum is emitted as Prometheus counter, but Mmsc::record() allows negative observations. Counters must be non-negative and monotonic - Can we either (1) emit _sum as gauge, or (2) enforce non-negative MMSC inputs so _sum is truly counter-safe?
There was a problem hiding this comment.
Related comment: #2050 (comment)
This is following the same trust model as Counter today. Counter<f64>::add() also accepts negative values at the API level without enforcement, yet we emit it as a Prometheus counter. Both rely on callers passing valid inputs. Changing _sum to gauge only for MMSC while keeping Counter as counter would be inconsistent. Happy to revisit this holistically across all instruments if we decide to add runtime enforcement, but that's a broader conversation.
| *lhs += rhs as f64; | ||
| } | ||
| MetricValue::Mmsc(_) => { | ||
| debug_assert!(false, "add_in_place: cannot add U64 to Mmsc"); |
There was a problem hiding this comment.
nit - debug_assert! is stripped in release, so this type mismatch becomes a silent no-op in prod. Is this intended?
There was a problem hiding this comment.
Yes, it would be no-op. That's an incorrect usage of add_in_place method so ideally nobody should call it like that.
jmacd
left a comment
There was a problem hiding this comment.
This is great as both a short-term approach and also a "low cost histogram". I look forward to an OTel exponential histogram that we can switch between, and even so, MMSC will remain a good option.
| for (suffix, prom_type, val) in [ | ||
| ("_min", "gauge", s.min), | ||
| ("_max", "gauge", s.max), | ||
| ("_sum", "counter", s.sum), |
| pub struct Mmsc { | ||
| min: f64, | ||
| max: f64, | ||
| sum: f64, | ||
| count: u64, | ||
| } |
There was a problem hiding this comment.
Yes +1 to representing MMSC as Histogram w/o buckets. 💯
| } | ||
| if value > self.max { | ||
| self.max = value; | ||
| } |
There was a problem hiding this comment.
See also #2100. We have to protect against Inf and NaN measurements, I would say.
| *lhs += rhs as f64; | ||
| } | ||
| MetricValue::Mmsc(_) => { | ||
| debug_assert!(false, "add_in_place: cannot add U64 to Mmsc"); |
fecd2a8
…n-telemetry#2050) # Change Summary ### Motivation We need to record latency-style metrics (e.g., request duration) that capture min, max, sum, and count — capabilities provided by histograms. However, the OpenTelemetry specification does not support Async/Observable Histograms, and our internal telemetry subsystem collects metrics via periodic snapshots of pre-aggregated state rather than individual observations. This PR introduces an internal MMSC instrument (`Min/Max/Sum/Count`) and exports it as an **OpenTelemetry SDK Histogram with no bucket boundaries.** This preserves the exact MMSC semantics through the standard SDK pipeline without requiring spec-level changes. ### Why This Works? Correctness: An OpenTelemetry SDK histogram built with `.with_boundaries(vec![])` disables bucket counting and only exports four values: **min, max, sum, count**. To reconstruct these four values from a pre-aggregated `MmscSnapshot { min, max, sum, count }`, we issue synthetic `histogram.record()` calls: | Count of measurements | Strategy | |------:|----------| | 0 | No-op | | 1 | `record(sum)` — when count is 1, `min = max = sum` | | 2 | `record(min)`, `record(max)` | | ≥ 3 | `record(min)`, `record(max)`, then `record(fill)` × (`count − 2`), where `fill = (sum − min − max) / (count − 2)` | The SDK histogram tracks `min = min(observations)`, `max = max(observations`), `sum = Σ observations`, `count = len(observations)`. Since we always record the exact min and max, those are preserved. The remaining `count − 2` observations sum to `sum − min − max`, and dividing evenly preserves the total sum. Therefore, the exported `HistogramDataPoint` carries the exact original min, max, sum, and count. ### Code Changes **New instrument** — `instrument.rs` - `Mmsc` struct with `record(f64)`, `get() -> MmscSnapshot`, `reset()` methods - `MmscSnapshot`- immutable snapshot holding min/max/sum/count **New descriptor variant** — `descriptor.rs` - Added `Instrument::Mmsc` to the instrument enum **Snapshot pipeline** — `metrics.rs` - Introduced `SnapshotValue` enum (`Scalar(MetricValue)` | `Mmsc(MmscSnapshot)`) replacing bare `MetricValue` throughout the snapshot pipeline - MMSC-aware accumulation in `MetricSetRegistry` - merges via min-of-mins, max-of-maxes, sum-of-sums, count-of-counts - Updated `MetricSetHandler::snapshot_values()` return type, `MetricsEntry`, and `MetricsIterator` to use `SnapshotValue` **OTel export** — `dispatcher.rs` - `record_synthetic_histogram()` implements the formula above - `add_opentelemetry_metric()` routes `Instrument::Mmsc` to the synthetic histogram path - Full end-to-end test using `InMemoryMetricExporter` validates min/max/sum/count and empty bucket boundaries **Derive macro** — `lib.rs` - `#[derive(MetricSetHandler)]` now handles `Mmsc` fields (no generic type parameter, always F64/Delta) **Admin / observability endpoints** — `telemetry.rs` - MMSC values expand into four sub-metrics in both Prometheus (`_min`, `_max`, `_sum`, `_count` with appropriate types) and Line Protocol formats - Tests for both output formats **Downstream updates** — `registry.rs`, `collector.rs`, `parquet_exporter.rs`, `metrics_types.rs` - Migrated from `MetricValue` to `SnapshotValue` throughout ## What issue does this PR close? Partially addresses open-telemetry#2051 by adding support for MMSC instrument. ## How are these changes tested? Unit tests ## Are there any user-facing changes? Yes, component authors would now be able to use MMSC instrument. --------- Co-authored-by: Cijo Thomas <cijo.thomas@gmail.com>
Change Summary
Motivation
We need to record latency-style metrics (e.g., request duration) that capture min, max, sum, and count — capabilities provided by histograms. However, the OpenTelemetry specification does not support Async/Observable Histograms, and our internal telemetry subsystem collects metrics via periodic snapshots of pre-aggregated state rather than individual observations.
This PR introduces an internal MMSC instrument (
Min/Max/Sum/Count) and exports it as an OpenTelemetry SDK Histogram with no bucket boundaries. This preserves the exact MMSC semantics through the standard SDK pipeline without requiring spec-level changes.Why This Works? Correctness:
An OpenTelemetry SDK histogram built with
.with_boundaries(vec![])disables bucket counting and only exports four values: min, max, sum, count. To reconstruct these four values from a pre-aggregatedMmscSnapshot { min, max, sum, count }, we issue synthetichistogram.record()calls:record(sum)— when count is 1,min = max = sumrecord(min),record(max)record(min),record(max), thenrecord(fill)× (count − 2), wherefill = (sum − min − max) / (count − 2)The SDK histogram tracks
min = min(observations),max = max(observations),sum = Σ observations,count = len(observations). Since we always record the exact min and max, those are preserved. The remainingcount − 2observations sum tosum − min − max, and dividing evenly preserves the total sum. Therefore, the exportedHistogramDataPointcarries the exact original min, max, sum, and count.Code Changes
New instrument —
instrument.rsMmscstruct withrecord(f64),get() -> MmscSnapshot,reset()methodsMmscSnapshot- immutable snapshot holding min/max/sum/countNew descriptor variant —
descriptor.rsInstrument::Mmscto the instrument enumSnapshot pipeline —
metrics.rsSnapshotValueenum (Scalar(MetricValue)|Mmsc(MmscSnapshot)) replacing bareMetricValuethroughout the snapshot pipelineMetricSetRegistry- merges via min-of-mins, max-of-maxes, sum-of-sums, count-of-countsMetricSetHandler::snapshot_values()return type,MetricsEntry, andMetricsIteratorto useSnapshotValueOTel export —
dispatcher.rsrecord_synthetic_histogram()implements the formula aboveadd_opentelemetry_metric()routesInstrument::Mmscto the synthetic histogram pathInMemoryMetricExportervalidates min/max/sum/count and empty bucket boundariesDerive macro —
lib.rs#[derive(MetricSetHandler)]now handlesMmscfields (no generic type parameter, always F64/Delta)Admin / observability endpoints —
telemetry.rs_min,_max,_sum,_countwith appropriate types) and Line Protocol formatsDownstream updates —
registry.rs,collector.rs,parquet_exporter.rs,metrics_types.rsMetricValuetoSnapshotValuethroughoutWhat issue does this PR close?
Partially addresses #2051 by adding support for MMSC instrument.
How are these changes tested?
Unit tests
Are there any user-facing changes?
Yes, component authors would now be able to use MMSC instrument.