Skip to main content

micromegas_analytics/lakehouse/otel/
metrics_block_processor.rs

1//! `BlockProcessor` for OTLP `ResourceMetrics` payloads → `measures` rows.
2//!
3//! Handles Sum and Gauge data points directly, and Summary data points by fanning each
4//! one out into count/sum/min/max rows under suffixed metric names (`<metric>_count`,
5//! `_sum`, `_min`, `_max`); any other `quantile_values` entry (configured percentiles)
6//! is logged and dropped. Histogram and ExponentialHistogram are still logged and
7//! skipped — a histogram-aware schema is future work. Aggregation temporality and
8//! `is_monotonic` ride along on per-row properties for Sum/Gauge.
9
10use super::attrs::attrs_to_jsonb;
11use crate::lakehouse::{
12    block_partition_spec::BlockProcessor, partition_source_data::PartitionSourceBlock,
13    write_partition::PartitionRowSet,
14};
15use crate::metadata::ProcessMetadata;
16use crate::payload::fetch_block_payload;
17use crate::time::TimeRange;
18use anyhow::{Context, Result};
19use async_trait::async_trait;
20use chrono::DateTime;
21use datafusion::arrow::array::{
22    BinaryDictionaryBuilder, PrimitiveBuilder, StringDictionaryBuilder,
23};
24use datafusion::arrow::datatypes::{Float64Type, Int32Type, TimestampNanosecondType};
25use datafusion::arrow::record_batch::RecordBatch;
26use jsonb::Value as JsonbValue;
27use micromegas_telemetry::blob_storage::BlobStorage;
28use micromegas_tracing::prelude::*;
29use opentelemetry_proto::tonic::metrics::v1::{
30    DataPointFlags, NumberDataPoint, ResourceMetrics, SummaryDataPoint, metric::Data,
31    number_data_point,
32};
33use prost::Message;
34use std::borrow::Cow;
35use std::sync::Arc;
36
37/// Returns `time_unix_nano` as `i64` unless it's zero, in which case the data point is
38/// logged and skipped. Shared by `append` (Sum/Gauge) and `append_summary`, which only
39/// differ in the `kind` word used in the log message.
40fn nonzero_time_nanos(kind: &str, metric_name: &str, time_unix_nano: u64) -> Option<i64> {
41    let time_nanos = time_unix_nano as i64;
42    if time_nanos == 0 {
43        debug!("OTel {kind} data point for {metric_name} dropped (time_unix_nano=0)");
44        return None;
45    }
46    Some(time_nanos)
47}
48
49#[derive(Debug)]
50pub struct OtelMetricsBlockProcessor {}
51
52#[async_trait]
53impl BlockProcessor for OtelMetricsBlockProcessor {
54    #[span_fn]
55    async fn process(
56        &self,
57        blob_storage: Arc<BlobStorage>,
58        src_block: Arc<PartitionSourceBlock>,
59    ) -> Result<Option<PartitionRowSet>> {
60        let payload = fetch_block_payload(
61            blob_storage,
62            sqlx::types::Uuid::from_bytes(*src_block.block.process_id.as_bytes()),
63            sqlx::types::Uuid::from_bytes(*src_block.block.stream_id.as_bytes()),
64            sqlx::types::Uuid::from_bytes(*src_block.block.block_id.as_bytes()),
65        )
66        .await
67        .with_context(|| "fetch_block_payload")?;
68
69        let resource_metrics = ResourceMetrics::decode(payload.objects.as_slice())
70            .with_context(|| "decoding ResourceMetrics proto")?;
71
72        let insert_time_nanos = src_block
73            .block
74            .insert_time
75            .timestamp_nanos_opt()
76            .with_context(|| "block.insert_time → nanos")?;
77        let mut builder = MeasuresRowBuilder::new(
78            src_block.process.process_id.to_string(),
79            src_block.block.stream_id.to_string(),
80            src_block.block.block_id.to_string(),
81            insert_time_nanos,
82            src_block.process.clone(),
83        );
84
85        for scope_metrics in &resource_metrics.scope_metrics {
86            let scope = scope_metrics.scope.as_ref();
87            let scope_name = scope.map(|s| s.name.clone()).unwrap_or_default();
88
89            for metric in &scope_metrics.metrics {
90                match metric.data.as_ref() {
91                    Some(Data::Sum(sum)) => {
92                        let extras = [
93                            (
94                                "otel.metric.aggregation_temporality".to_string(),
95                                JsonbValue::Number(jsonb::Number::Int64(
96                                    sum.aggregation_temporality as i64,
97                                )),
98                            ),
99                            (
100                                "otel.metric.is_monotonic".to_string(),
101                                JsonbValue::Bool(sum.is_monotonic),
102                            ),
103                            (
104                                "otel.metric.kind".to_string(),
105                                JsonbValue::String(Cow::Borrowed("sum")),
106                            ),
107                        ];
108                        for dp in &sum.data_points {
109                            builder.append(&scope_name, &metric.name, &metric.unit, dp, &extras)?;
110                        }
111                    }
112                    Some(Data::Gauge(gauge)) => {
113                        let extras = [(
114                            "otel.metric.kind".to_string(),
115                            JsonbValue::String(Cow::Borrowed("gauge")),
116                        )];
117                        for dp in &gauge.data_points {
118                            builder.append(&scope_name, &metric.name, &metric.unit, dp, &extras)?;
119                        }
120                    }
121                    Some(Data::Histogram(h)) => {
122                        debug!(
123                            "OTel histogram dropped (deferred to v2): name={} unit={} points={}",
124                            metric.name,
125                            metric.unit,
126                            h.data_points.len()
127                        );
128                    }
129                    Some(Data::ExponentialHistogram(h)) => {
130                        debug!(
131                            "OTel exponential_histogram dropped (deferred to v2): name={} unit={} points={}",
132                            metric.name,
133                            metric.unit,
134                            h.data_points.len()
135                        );
136                    }
137                    Some(Data::Summary(s)) => {
138                        for dp in &s.data_points {
139                            builder.append_summary(&scope_name, &metric.name, &metric.unit, dp)?;
140                        }
141                    }
142                    None => {}
143                }
144            }
145        }
146
147        builder.finish()
148    }
149}
150
151/// Per-block accumulator for `measures` rows: owns the column builders, time
152/// bounds, and per-block constants so `append` only takes per-data-point inputs.
153struct MeasuresRowBuilder {
154    process_ids: StringDictionaryBuilder<Int32Type>,
155    stream_ids: StringDictionaryBuilder<Int32Type>,
156    block_ids: StringDictionaryBuilder<Int32Type>,
157    insert_times: PrimitiveBuilder<TimestampNanosecondType>,
158    exes: StringDictionaryBuilder<Int32Type>,
159    usernames: StringDictionaryBuilder<Int32Type>,
160    computers: StringDictionaryBuilder<Int32Type>,
161    times: PrimitiveBuilder<TimestampNanosecondType>,
162    targets: StringDictionaryBuilder<Int32Type>,
163    names: StringDictionaryBuilder<Int32Type>,
164    units: StringDictionaryBuilder<Int32Type>,
165    values: PrimitiveBuilder<Float64Type>,
166    properties: BinaryDictionaryBuilder<Int32Type>,
167    process_properties: BinaryDictionaryBuilder<Int32Type>,
168    min_time: i64,
169    max_time: i64,
170    nb_appended: usize,
171    process_id_str: String,
172    stream_id_str: String,
173    block_id_str: String,
174    insert_time_nanos: i64,
175    process: Arc<ProcessMetadata>,
176}
177
178impl MeasuresRowBuilder {
179    fn new(
180        process_id_str: String,
181        stream_id_str: String,
182        block_id_str: String,
183        insert_time_nanos: i64,
184        process: Arc<ProcessMetadata>,
185    ) -> Self {
186        Self {
187            process_ids: StringDictionaryBuilder::new(),
188            stream_ids: StringDictionaryBuilder::new(),
189            block_ids: StringDictionaryBuilder::new(),
190            insert_times: PrimitiveBuilder::new(),
191            exes: StringDictionaryBuilder::new(),
192            usernames: StringDictionaryBuilder::new(),
193            computers: StringDictionaryBuilder::new(),
194            times: PrimitiveBuilder::new(),
195            targets: StringDictionaryBuilder::new(),
196            names: StringDictionaryBuilder::new(),
197            units: StringDictionaryBuilder::new(),
198            values: PrimitiveBuilder::new(),
199            properties: BinaryDictionaryBuilder::new(),
200            process_properties: BinaryDictionaryBuilder::new(),
201            min_time: i64::MAX,
202            max_time: i64::MIN,
203            nb_appended: 0,
204            process_id_str,
205            stream_id_str,
206            block_id_str,
207            insert_time_nanos,
208            process,
209        }
210    }
211
212    /// Appends one `measures` row for an already-extracted point. Shared tail of
213    /// `append` (Sum/Gauge) and `append_summary` (Summary). `props_jsonb` is the
214    /// already-serialized `properties` payload — attributes and derived extras
215    /// merged by `attrs_to_jsonb` — so a caller fanning one data point out into
216    /// several rows serializes it once.
217    fn append_row(
218        &mut self,
219        scope_name: &str,
220        metric_name: &str,
221        unit: &str,
222        time_nanos: i64,
223        value: f64,
224        props_jsonb: &[u8],
225    ) -> Result<()> {
226        self.min_time = self.min_time.min(time_nanos);
227        self.max_time = self.max_time.max(time_nanos);
228
229        self.process_ids.append(&self.process_id_str)?;
230        self.stream_ids.append(&self.stream_id_str)?;
231        self.block_ids.append(&self.block_id_str)?;
232        self.insert_times.append_value(self.insert_time_nanos);
233        self.exes.append(&self.process.exe)?;
234        self.usernames.append(&self.process.username)?;
235        self.computers.append(&self.process.computer)?;
236        self.times.append_value(time_nanos);
237        self.targets.append(scope_name)?;
238        self.names.append(metric_name)?;
239        self.units.append(unit)?;
240        self.values.append_value(value);
241        self.properties.append(props_jsonb)?;
242        self.process_properties.append(&**self.process.properties)?;
243
244        self.nb_appended += 1;
245        Ok(())
246    }
247
248    fn append(
249        &mut self,
250        scope_name: &str,
251        metric_name: &str,
252        unit: &str,
253        dp: &NumberDataPoint,
254        extras: &[(String, JsonbValue<'static>)],
255    ) -> Result<()> {
256        let Some(time_nanos) = nonzero_time_nanos("metric", metric_name, dp.time_unix_nano) else {
257            return Ok(());
258        };
259
260        let value = match dp.value.as_ref() {
261            Some(number_data_point::Value::AsDouble(d)) => *d,
262            Some(number_data_point::Value::AsInt(i)) => *i as f64,
263            None => {
264                debug!("OTel data point for {metric_name} has no value, skipping");
265                return Ok(());
266            }
267        };
268
269        self.append_row(
270            scope_name,
271            metric_name,
272            unit,
273            time_nanos,
274            value,
275            &attrs_to_jsonb(&dp.attributes, extras),
276        )
277    }
278
279    /// Fans a `SummaryDataPoint` out into rows for the four fixed statistics
280    /// (count, sum, min, max), each under its own suffixed metric name. Any
281    /// `quantile_values` entry other than `q=0.0`/`q=1.0` is logged and dropped —
282    /// configured percentiles are out of scope. No derived `otel.metric.*` extras
283    /// (aggregation_temporality/is_monotonic/kind) are added for Summary rows; the
284    /// per-point `dp.attributes` still populate `properties` the same as Sum/Gauge.
285    ///
286    /// A point flagged `NO_RECORDED_VALUE` is dropped entirely: `count`/`sum` are
287    /// non-optional proto scalars, so materializing it would inject real 0.0 samples
288    /// where the series actually has a gap.
289    fn append_summary(
290        &mut self,
291        scope_name: &str,
292        metric_name: &str,
293        unit: &str,
294        dp: &SummaryDataPoint,
295    ) -> Result<()> {
296        if dp.flags & DataPointFlags::NoRecordedValueMask as u32 != 0 {
297            debug!("OTel summary data point dropped (NO_RECORDED_VALUE): name={metric_name}");
298            return Ok(());
299        }
300
301        let Some(time_nanos) = nonzero_time_nanos("summary", metric_name, dp.time_unix_nano) else {
302            return Ok(());
303        };
304
305        // All four statistics share the same attribute map, so serialize it once.
306        let props_jsonb = attrs_to_jsonb(&dp.attributes, &[]);
307
308        self.append_row(
309            scope_name,
310            &format!("{metric_name}_count"),
311            "",
312            time_nanos,
313            dp.count as f64,
314            &props_jsonb,
315        )?;
316        self.append_row(
317            scope_name,
318            &format!("{metric_name}_sum"),
319            unit,
320            time_nanos,
321            dp.sum,
322            &props_jsonb,
323        )?;
324
325        let mut min_emitted = false;
326        let mut max_emitted = false;
327        for q in &dp.quantile_values {
328            if q.quantile == 0.0 {
329                if min_emitted {
330                    debug!(
331                        "OTel summary quantile dropped (duplicate q=0.0, _min already emitted): \
332                         name={metric_name}"
333                    );
334                    continue;
335                }
336                min_emitted = true;
337                self.append_row(
338                    scope_name,
339                    &format!("{metric_name}_min"),
340                    unit,
341                    time_nanos,
342                    q.value,
343                    &props_jsonb,
344                )?;
345            } else if q.quantile == 1.0 {
346                if max_emitted {
347                    debug!(
348                        "OTel summary quantile dropped (duplicate q=1.0, _max already emitted): \
349                         name={metric_name}"
350                    );
351                    continue;
352                }
353                max_emitted = true;
354                self.append_row(
355                    scope_name,
356                    &format!("{metric_name}_max"),
357                    unit,
358                    time_nanos,
359                    q.value,
360                    &props_jsonb,
361                )?;
362            } else {
363                debug!(
364                    "OTel summary quantile dropped (only count/sum/min/max are materialized): \
365                     name={metric_name} quantile={}",
366                    q.quantile
367                );
368            }
369        }
370        Ok(())
371    }
372
373    fn finish(mut self) -> Result<Option<PartitionRowSet>> {
374        if self.nb_appended == 0 {
375            return Ok(None);
376        }
377        let schema = Arc::new(crate::metrics_table::metrics_table_schema());
378        let batch = RecordBatch::try_new(
379            schema,
380            vec![
381                Arc::new(self.process_ids.finish()),
382                Arc::new(self.stream_ids.finish()),
383                Arc::new(self.block_ids.finish()),
384                Arc::new(self.insert_times.finish().with_timezone_utc()),
385                Arc::new(self.exes.finish()),
386                Arc::new(self.usernames.finish()),
387                Arc::new(self.computers.finish()),
388                Arc::new(self.times.finish().with_timezone_utc()),
389                Arc::new(self.targets.finish()),
390                Arc::new(self.names.finish()),
391                Arc::new(self.units.finish()),
392                Arc::new(self.values.finish()),
393                Arc::new(self.properties.finish()),
394                Arc::new(self.process_properties.finish()),
395            ],
396        )
397        .with_context(|| "building OTel measures batch")?;
398
399        Ok(Some(PartitionRowSet::new(
400            TimeRange::new(
401                DateTime::from_timestamp_nanos(self.min_time),
402                DateTime::from_timestamp_nanos(self.max_time),
403            ),
404            batch,
405        )))
406    }
407}