Skip to content

arrow: implement Layer 1 metric family assembly for histograms, summaries, and OpenMetrics 1.0 - #19

Open
bwplotka wants to merge 18 commits into
mainfrom
bwplotka/phase2-family-assembly
Open

bwplotka wants to merge 18 commits into
mainfrom
bwplotka/phase2-family-assembly

Conversation

@bwplotka

@bwplotka bwplotka commented Nov 4, 2026

Copy link
Copy Markdown
Member

Fixes #8

Implements Phase 2 Layer 1 metric family assembly in ArrowDecoder so full Prometheus text and OpenMetrics 1.0 scrapes (including avalanche targets) decode directly into OTAP Arrow record batches with zero steady-state heap allocations after warmup.

Commits

  1. parse: expose reusable scratch and raw slice accessors on OpenMetricsParser - Adds OpenMetricsParser::with_scratch, take_scratch, raw metadata/label/exemplar slice accessors, and format_openmetrics_float_into so ArrowDecoder can reuse buffers across OpenMetrics 1.0 scrapes without per-line allocations.
  2. arrow: add OTAP schemas and record slots for histograms, summaries, and exemplars - Extends MetricPayloadType and OtapMetricRecords to all 12 metric payload tables (UnivariateMetrics, NumberDataPoints, SummaryDataPoints, HistogramDataPoints, MetricAttrs, NumberDpAttrs, SummaryDpAttrs, HistogramDpAttrs, NumberDpExemplars, HistogramDpExemplars, NumberDpExemplarAttrs, HistogramDpExemplarAttrs) and adds cached schemas for summaries, histograms, and exemplars.
  3. arrow: assemble histograms, summaries, exemplars, and OpenMetrics types - Groups _bucket, quantile, _sum, _count, and _created lines into HistogramDataPoints and SummaryDataPoints, dispatches FormatType::OpenMetrics through OpenMetricsParser, supports # UNIT, info, stateset, _created / start_time_unix_nano, exemplars (trace_id / span_id), and translation policies (CounterSuffixPolicy, UnitSuffixPolicy, InfoPolicy, StatesetPolicy, UntypedPolicy), verified against otap_to_otlp and dhat in tests/otap-compat.

…Parser

Switch OpenMetricsParser internal label buffers to byte-offset LabelSpans and expose OmScratch and raw slice accessors matching PromParser so Layer 1 ArrowDecoder can reuse parser scratch buffers across OpenMetrics scrapes without per-sample allocations.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
…nd exemplars

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Group classic histogram (_bucket, _sum, _count, _created) and summary (quantile, _sum, _count, _created) series into HistogramDataPoints and SummaryDataPoints batches using flat reusable scratch buffers on ArrowDecoder.

Add OpenMetrics 1.0 dispatch via OpenMetricsParser, # UNIT metadata, info and stateset projection, exemplars (NumberDpExemplars, HistogramDpExemplars, and their attribute tables with 16-byte trace_id and 8-byte span_id decoding), and configurable translation policies.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
…families

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
…ries

Validate duplicate and reserved labels on dropped series via validate_empty_label instead of extract_series_labels_into_temp so dropped family label keys and values are not interned into key_interner or val_interner.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Set current_family.has_samples in append_counter_created_series so late # HELP or # TYPE lines after _created are rejected, and defer post-sample chunk flushing on Prometheus text counters until the next entry is inspected so trailing _created lines stay attached to their counter row.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Check OpenMetrics start_timestamp once per PendingDpGroup in ArrowDecoder, cache negative start_timestamp lookups in OpenMetricsParser, and stop start_timestamp lookahead as soon as the series key changes.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Require summary quantile bounds to be finite and histogram bucket bounds to be finite or +Inf before pushing into pending_points.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Build MetricAttrs after data-point and exemplar attribute tables so InfoPolicy::MetricAttrs does not intern prometheus.type or type names into DpAttrs dictionaries, and reuse scratch_u16 and temp_attrs instead of allocating temporary vectors per batch.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
…plar nulls

Add OTAP round-trip and dhat bounded-memory coverage for OpenMetrics gaugehistograms (_bucket, _gsum, _gcount, _created), Prometheus text _created lines across counters, histograms, and summaries, unsorted le and quantile lines, non-finite bound rejection, nullable histogram sum, mixed exemplar span_id/trace_id nullability, uppercase hex IDs, attribute-free exemplars, and InfoPolicy::MetricAttrs.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
@bwplotka
bwplotka force-pushed the bwplotka/phase2-family-assembly branch from b387609 to 863187d Compare November 4, 2026 17:37
…modules

Split src/arrow.rs (4,244 -> 1,273 lines) into focused submodules:
- src/arrow/options.rs: DecodeOptions, translation policy enums, and DecodeError
- src/arrow/columns.rs: zero-allocation column/table builders (MetricTable, NumberDpTable, SummaryDpTable, HistogramDpTable, ExemplarTable, AttrTable, AttrEncoding, ScratchBuffers)
- src/arrow/family.rs: CurrentFamily, SeriesRole, PendingDpGroup, PendingExemplar, and FamilyAssembly
- src/arrow/tests.rs: unit tests

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
@bwplotka
bwplotka force-pushed the bwplotka/phase2-family-assembly branch from 863187d to 65e0227 Compare November 4, 2026 17:59
Comment thread src/parse/omparse.rs Outdated
break None;
}
let Some(peeked_family) = peek.metric_name().strip_suffix(CREATED_SUFFIX) else {
if !peek

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue: OM _created lookahead stops earlier than Go

The new break None at omparse.rs:576 ends the lookahead at the first non-_created line of another series, while Go keeps scanning (https://github.com/prometheus/prometheus/blob/v0.315.1/model/textparse/openmetricsparse.go#L350-L353), which means for foo_total{a="1"} 1, foo_total{a="2"} 2, foo_created{a="1"} 100 Go gives a="1" a start time of 100000 and Rust gives none. Before 3027b54 Rust matched Go here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

addressed in d38bd46

Comment thread src/arrow.rs Outdated

// Attach `_created` in Prometheus text to the preceding counter data point of the active
// family when its base labels match.
if let Some(batch_id) = self.current_family.batch_id

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue: Text _created lines that don't follow their counter are dropped

arrow.rs:743 attaches a text _created line only when its labels match the data point right before it and otherwise returns Ok without emitting anything, so with foo_total{a="1"}, foo_total{a="2"}, foo_created{a="1"} 100 the a="1" sample is lost with no error, while Go's text parser keeps it as its own series.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

addressed in 10a7054

Comment thread src/arrow.rs Outdated
if CHUNKED
&& self.current_family.batch_id.is_none()
&& self.um_metric_types.len() >= MAX_METRICS_PER_BATCH
&& self.assembly.is_empty()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue: decode_stream ignores max_data_points_per_batch inside a histogram

arrow.rs:499 starts a new chunk only when self.assembly.is_empty(), and the assembly flushes only when the family changes, so one histogram family with 100 series and with_max_data_points_per_batch(10) comes out as a single chunk of 100 data points, which breaks the bound the decode_stream doc at arrow.rs:274 promises.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

addressed in a7609d4

Comment thread src/arrow/family.rs Outdated
}

#[inline]
fn collect_sorted_group_points(&mut self, group_idx: u32) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue: Histogram decode time grows with the square of the series count

flush() calls collect_sorted_group_points (family.rs:411) once per group and each call scans every point in the family, so decode time grows 4x each time the series count doubles, and 16k series with 6 buckets take 476ms in release against 10ms for the same number of gauge lines.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

addressed in 978dc97

Comment thread src/arrow.rs Outdated
let parent_id = self
.current_family
.emit_into_batch(&mut self.metrics, opts)?;
for &(k, v) in &self.temp_attrs {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue: InfoPolicy::MetricAttrs merges every info series into one metric

arrow.rs:681 appends the labels of each info sample to the same metric row, so target_info{env="a"} and target_info{env="b"} give parent 0 two env keys in MetricAttrs, which OTLP does not allow (https://opentelemetry.io/docs/specs/otel/common/#attribute-collections), should each series become its own metric row, or should this policy reject families with more than one series?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

addressed in 4dbcc8f

Comment thread src/arrow.rs Outdated
if !value.is_finite() {
return Ok(());
}
let created_ms = (value * 1000.0) as i64;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue: A huge _created value fails the whole scrape

arrow.rs:738 casts value * 1000.0 to i64, which saturates to i64::MAX for foo_created 1e300, and ms_to_ns then returns TimestampOverflow, so decode fails and every other metric in the scrape is lost, while Go's StartTimestamp has no error path for this value (https://github.com/prometheus/prometheus/blob/v0.315.1/model/textparse/openmetricsparse.go#L366). The same cast is at arrow.rs:820 and omparse.rs:587.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

addressed in decad82

Comment thread src/arrow/options.rs Outdated

/// A metric family uses a `# TYPE` not yet supported by `ArrowDecoder`.
#[error("unsupported metric type {metric_type:?} for metric {metric:?}")]
UnsupportedMetricType {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nitpick: UnsupportedMetricType is never returned now

options.rs:195 keeps the public variant and its "not yet supported" doc, but nothing in src builds it now that histograms and summaries are accepted, so callers keep match arms for an error that can't happen.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

addressed in 54d4ff7

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Prometheus text exposition format (0.0.4) has no _created suffix
semantics; only OpenMetrics 1.0 attaches _created series via
OpenMetricsParser::start_timestamp. Remove _created classification from
CurrentFamily::classify_series so Prometheus text *_created series are
emitted as regular metrics rather than being attached only when
adjacent or dropped when non-adjacent.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
In decode_stream, count staged groups in FamilyAssembly toward the active
batch's data-point budget. When a new series in an active histogram or
summary family does not match any already-staged group once the batch
has reached max_data_points_per_batch, flush the pending groups and emit
the batch before starting the next group.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Index PendingDpGroup entries in FamilyAssembly with an open-addressed
hash table over their interned attribute slices and chain bucket/quantile
points and exemplars onto per-group singly linked lists. This keeps
interleaved group lookup O(1) and family flushing O(P + E) instead of
O(G * (P + E)).

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Remove InfoPolicy, StatesetPolicy, and the metadata-only Empty metric
type projection (InfoPolicy::MetricAttrs) so the Arrow decoder only
emits standard OpenTelemetry metric types.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
In OpenMetricsParser::start_timestamp, verify that peek.value * 1000.0
lies within i64::MIN..i64::MAX before casting to i64 so values such as
1e300 return None instead of saturating to i64::MAX. Also use
checked_mul(1_000_000) when converting _created millisecond timestamps
to nanoseconds in ArrowDecoder so out-of-range _created values omit
start_time_unix_nano rather than failing the scrape.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
All Prometheus and OpenMetrics 1.0 metric types are now handled by
ArrowDecoder, so DecodeError::UnsupportedMetricType is unreachable.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
@bwplotka

bwplotka commented Nov 5, 2026

Copy link
Copy Markdown
Member Author

All addressed.

One notable change is that I am killing info and stateset metric types. We all use gauges for it everywhere so let's remove this info from APis for now. We can add later

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Phase 2: Layer 1 metric family assembly

2 participants