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    audiences: StringDictionaryBuilder<Int32Type>,
169    min_time: i64,
170    max_time: i64,
171    nb_appended: usize,
172    process_id_str: String,
173    stream_id_str: String,
174    block_id_str: String,
175    insert_time_nanos: i64,
176    process: Arc<ProcessMetadata>,
177}
178
179impl MeasuresRowBuilder {
180    fn new(
181        process_id_str: String,
182        stream_id_str: String,
183        block_id_str: String,
184        insert_time_nanos: i64,
185        process: Arc<ProcessMetadata>,
186    ) -> Self {
187        Self {
188            process_ids: StringDictionaryBuilder::new(),
189            stream_ids: StringDictionaryBuilder::new(),
190            block_ids: StringDictionaryBuilder::new(),
191            insert_times: PrimitiveBuilder::new(),
192            exes: StringDictionaryBuilder::new(),
193            usernames: StringDictionaryBuilder::new(),
194            computers: StringDictionaryBuilder::new(),
195            times: PrimitiveBuilder::new(),
196            targets: StringDictionaryBuilder::new(),
197            names: StringDictionaryBuilder::new(),
198            units: StringDictionaryBuilder::new(),
199            values: PrimitiveBuilder::new(),
200            properties: BinaryDictionaryBuilder::new(),
201            process_properties: BinaryDictionaryBuilder::new(),
202            audiences: StringDictionaryBuilder::new(),
203            min_time: i64::MAX,
204            max_time: i64::MIN,
205            nb_appended: 0,
206            process_id_str,
207            stream_id_str,
208            block_id_str,
209            insert_time_nanos,
210            process,
211        }
212    }
213
214    /// Appends one `measures` row for an already-extracted point. Shared tail of
215    /// `append` (Sum/Gauge) and `append_summary` (Summary). `props_jsonb` is the
216    /// already-serialized `properties` payload — attributes and derived extras
217    /// merged by `attrs_to_jsonb` — so a caller fanning one data point out into
218    /// several rows serializes it once.
219    fn append_row(
220        &mut self,
221        scope_name: &str,
222        metric_name: &str,
223        unit: &str,
224        time_nanos: i64,
225        value: f64,
226        props_jsonb: &[u8],
227    ) -> Result<()> {
228        self.min_time = self.min_time.min(time_nanos);
229        self.max_time = self.max_time.max(time_nanos);
230
231        self.process_ids.append(&self.process_id_str)?;
232        self.stream_ids.append(&self.stream_id_str)?;
233        self.block_ids.append(&self.block_id_str)?;
234        self.insert_times.append_value(self.insert_time_nanos);
235        self.exes.append(&self.process.exe)?;
236        self.usernames.append(&self.process.username)?;
237        self.computers.append(&self.process.computer)?;
238        self.times.append_value(time_nanos);
239        self.targets.append(scope_name)?;
240        self.names.append(metric_name)?;
241        self.units.append(unit)?;
242        self.values.append_value(value);
243        self.properties.append(props_jsonb)?;
244        self.process_properties.append(&**self.process.properties)?;
245        self.audiences.append(&*self.process.audience)?;
246
247        self.nb_appended += 1;
248        Ok(())
249    }
250
251    fn append(
252        &mut self,
253        scope_name: &str,
254        metric_name: &str,
255        unit: &str,
256        dp: &NumberDataPoint,
257        extras: &[(String, JsonbValue<'static>)],
258    ) -> Result<()> {
259        let Some(time_nanos) = nonzero_time_nanos("metric", metric_name, dp.time_unix_nano) else {
260            return Ok(());
261        };
262
263        let value = match dp.value.as_ref() {
264            Some(number_data_point::Value::AsDouble(d)) => *d,
265            Some(number_data_point::Value::AsInt(i)) => *i as f64,
266            None => {
267                debug!("OTel data point for {metric_name} has no value, skipping");
268                return Ok(());
269            }
270        };
271
272        self.append_row(
273            scope_name,
274            metric_name,
275            unit,
276            time_nanos,
277            value,
278            &attrs_to_jsonb(&dp.attributes, extras),
279        )
280    }
281
282    /// Fans a `SummaryDataPoint` out into rows for the four fixed statistics
283    /// (count, sum, min, max), each under its own suffixed metric name. Any
284    /// `quantile_values` entry other than `q=0.0`/`q=1.0` is logged and dropped —
285    /// configured percentiles are out of scope. No derived `otel.metric.*` extras
286    /// (aggregation_temporality/is_monotonic/kind) are added for Summary rows; the
287    /// per-point `dp.attributes` still populate `properties` the same as Sum/Gauge.
288    ///
289    /// A point flagged `NO_RECORDED_VALUE` is dropped entirely: `count`/`sum` are
290    /// non-optional proto scalars, so materializing it would inject real 0.0 samples
291    /// where the series actually has a gap.
292    fn append_summary(
293        &mut self,
294        scope_name: &str,
295        metric_name: &str,
296        unit: &str,
297        dp: &SummaryDataPoint,
298    ) -> Result<()> {
299        if dp.flags & DataPointFlags::NoRecordedValueMask as u32 != 0 {
300            debug!("OTel summary data point dropped (NO_RECORDED_VALUE): name={metric_name}");
301            return Ok(());
302        }
303
304        let Some(time_nanos) = nonzero_time_nanos("summary", metric_name, dp.time_unix_nano) else {
305            return Ok(());
306        };
307
308        // All four statistics share the same attribute map, so serialize it once.
309        let props_jsonb = attrs_to_jsonb(&dp.attributes, &[]);
310
311        self.append_row(
312            scope_name,
313            &format!("{metric_name}_count"),
314            "",
315            time_nanos,
316            dp.count as f64,
317            &props_jsonb,
318        )?;
319        self.append_row(
320            scope_name,
321            &format!("{metric_name}_sum"),
322            unit,
323            time_nanos,
324            dp.sum,
325            &props_jsonb,
326        )?;
327
328        let mut min_emitted = false;
329        let mut max_emitted = false;
330        for q in &dp.quantile_values {
331            if q.quantile == 0.0 {
332                if min_emitted {
333                    debug!(
334                        "OTel summary quantile dropped (duplicate q=0.0, _min already emitted): \
335                         name={metric_name}"
336                    );
337                    continue;
338                }
339                min_emitted = true;
340                self.append_row(
341                    scope_name,
342                    &format!("{metric_name}_min"),
343                    unit,
344                    time_nanos,
345                    q.value,
346                    &props_jsonb,
347                )?;
348            } else if q.quantile == 1.0 {
349                if max_emitted {
350                    debug!(
351                        "OTel summary quantile dropped (duplicate q=1.0, _max already emitted): \
352                         name={metric_name}"
353                    );
354                    continue;
355                }
356                max_emitted = true;
357                self.append_row(
358                    scope_name,
359                    &format!("{metric_name}_max"),
360                    unit,
361                    time_nanos,
362                    q.value,
363                    &props_jsonb,
364                )?;
365            } else {
366                debug!(
367                    "OTel summary quantile dropped (only count/sum/min/max are materialized): \
368                     name={metric_name} quantile={}",
369                    q.quantile
370                );
371            }
372        }
373        Ok(())
374    }
375
376    fn finish(mut self) -> Result<Option<PartitionRowSet>> {
377        if self.nb_appended == 0 {
378            return Ok(None);
379        }
380        let schema = Arc::new(crate::metrics_table::metrics_table_schema());
381        let batch = RecordBatch::try_new(
382            schema,
383            vec![
384                Arc::new(self.process_ids.finish()),
385                Arc::new(self.stream_ids.finish()),
386                Arc::new(self.block_ids.finish()),
387                Arc::new(self.insert_times.finish().with_timezone_utc()),
388                Arc::new(self.exes.finish()),
389                Arc::new(self.usernames.finish()),
390                Arc::new(self.computers.finish()),
391                Arc::new(self.times.finish().with_timezone_utc()),
392                Arc::new(self.targets.finish()),
393                Arc::new(self.names.finish()),
394                Arc::new(self.units.finish()),
395                Arc::new(self.values.finish()),
396                Arc::new(self.properties.finish()),
397                Arc::new(self.process_properties.finish()),
398                Arc::new(self.audiences.finish()),
399            ],
400        )
401        .with_context(|| "building OTel measures batch")?;
402
403        Ok(Some(PartitionRowSet::new(
404            TimeRange::new(
405                DateTime::from_timestamp_nanos(self.min_time),
406                DateTime::from_timestamp_nanos(self.max_time),
407            ),
408            batch,
409            None,
410        )))
411    }
412}