Skip to main content

micromegas_analytics/lakehouse/otel/
block_decoders.rs

1//! `BlockObjectDecoder` implementations for OTLP `ResourceLogs`/`ResourceMetrics`/
2//! `ResourceSpans` block payloads, used by `parse_block` to give a debug-level view
3//! into OTLP blocks (see the design plan for the row-shape rationale).
4//!
5//! Each decoder walks `scope_* → leaf records` and emits one row per leaf (log
6//! record / span / metric data point), in payload order. The row's `value` is a
7//! faithful OTLP/JSON dump of the leaf (via `serde_json` + `jsonb::Value::from`),
8//! plus a synthesized `__`-prefixed envelope (`__type`, `__attributes`,
9//! `__resource`, `__scope`, and — metrics only — `__metric`).
10
11use super::attrs::{attrs_to_jsonb_value, scope_extras};
12use crate::lakehouse::block_object_decoder::{BlockObjectDecoder, ObjectVisitor};
13use crate::metadata::StreamMetadata;
14use anyhow::{Context, Result};
15use jsonb::Value as JsonbValue;
16use micromegas_telemetry::block_wire_format::BlockPayload;
17use micromegas_tracing::prelude::*;
18use opentelemetry_proto::tonic::common::v1::KeyValue;
19use opentelemetry_proto::tonic::logs::v1::ResourceLogs;
20use opentelemetry_proto::tonic::metrics::v1::{
21    ExponentialHistogramDataPoint, HistogramDataPoint, NumberDataPoint, ResourceMetrics,
22    SummaryDataPoint, metric::Data,
23};
24use opentelemetry_proto::tonic::trace::v1::ResourceSpans;
25use prost::Message;
26use serde::Serialize;
27use std::borrow::Cow;
28use std::collections::BTreeMap;
29
30/// Builds the JSONB `value` for one OTLP leaf record (a `LogRecord`, `Span`, or
31/// metric data point): a faithful OTLP/JSON dump of `leaf` (via `serde_json` +
32/// `jsonb::Value::from`), with a synthesized `__`-prefixed envelope layered on top
33/// (`__type`, `__attributes`, `__resource`, `__scope`, and — when `metric_extras`
34/// is non-empty — `__metric`). See the design plan §"JSONB value shape" for the
35/// full rationale, including the known lossy cases (non-finite `f64` → `null`,
36/// `asInt`/`Exemplar.as_int` as bare numbers rather than quoted strings).
37///
38/// `resource` and `scope` are the already-built `__resource`/`__scope` envelope
39/// values for the enclosing resource/scope block — callers compute these once
40/// per resource/scope (via `attrs_to_jsonb_value`/`scope_extras`) rather than
41/// once per leaf, since they're invariant across every leaf sharing that block.
42fn leaf_jsonb<T: Serialize>(
43    type_name: &str,
44    leaf: &T,
45    leaf_attrs: &[KeyValue],
46    resource: &JsonbValue<'static>,
47    scope: &JsonbValue<'static>,
48    metric_extras: &[(String, JsonbValue<'static>)],
49) -> Result<Vec<u8>> {
50    let json = serde_json::to_value(leaf).context("serializing OTLP leaf to JSON")?;
51    let mut map: BTreeMap<String, JsonbValue<'static>> = match JsonbValue::from(&json) {
52        JsonbValue::Object(map) => map,
53        // Every OTLP leaf message (LogRecord/Span/*DataPoint) is a struct, so
54        // `serde_json::to_value` always yields a JSON object. Defensive fallback
55        // rather than a panic if that ever stops being true upstream.
56        other => {
57            let mut map = BTreeMap::new();
58            map.insert("__value".to_string(), other);
59            map
60        }
61    };
62
63    map.insert(
64        "__type".to_string(),
65        JsonbValue::String(Cow::Owned(type_name.to_string())),
66    );
67    map.insert(
68        "__attributes".to_string(),
69        attrs_to_jsonb_value(leaf_attrs, &[]),
70    );
71    map.insert("__resource".to_string(), resource.clone());
72    map.insert("__scope".to_string(), scope.clone());
73    if !metric_extras.is_empty() {
74        map.insert(
75            "__metric".to_string(),
76            JsonbValue::Object(metric_extras.iter().cloned().collect()),
77        );
78    }
79
80    let mut buf = Vec::new();
81    JsonbValue::Object(map).write_to_vec(&mut buf);
82    Ok(buf)
83}
84
85/// Decodes OTLP `ResourceLogs` block payloads (`streams.format = "otlp/v1/logs"`)
86/// into one row per `LogRecord`.
87#[derive(Debug)]
88pub struct OtelLogsBlockDecoder;
89
90impl BlockObjectDecoder for OtelLogsBlockDecoder {
91    fn decode(
92        &self,
93        _stream: &StreamMetadata,
94        payload: &BlockPayload,
95        visitor: &mut dyn ObjectVisitor,
96    ) -> Result<()> {
97        // Block payload format: BlockPayload { dependencies: [], objects: <ResourceLogs proto> }.
98        // Not compressed, unlike the transit path — see `otel-ingestion/src/block.rs`.
99        let resource_logs = ResourceLogs::decode(payload.objects.as_slice())
100            .inspect_err(|e| error!("corrupt OTLP logs block payload: {e:?}"))
101            .context("decoding ResourceLogs proto")?;
102
103        let resource = resource_logs.resource.as_ref();
104        let resource_attrs: &[KeyValue] = resource.map_or(&[], |r| r.attributes.as_slice());
105        let resource_jsonb = attrs_to_jsonb_value(resource_attrs, &[]);
106        for scope_logs in &resource_logs.scope_logs {
107            let scope = scope_logs.scope.as_ref();
108            let scope_jsonb = JsonbValue::Object(
109                scope_extras(scope, &scope_logs.schema_url)
110                    .into_iter()
111                    .collect(),
112            );
113            for record in &scope_logs.log_records {
114                let bytes = leaf_jsonb(
115                    "otlp.LogRecord",
116                    record,
117                    &record.attributes,
118                    &resource_jsonb,
119                    &scope_jsonb,
120                    &[],
121                )?;
122                if !visitor.visit("otlp.LogRecord", &bytes)? {
123                    return Ok(());
124                }
125            }
126        }
127        Ok(())
128    }
129}
130
131/// Decodes OTLP `ResourceSpans` block payloads (`streams.format = "otlp/v1/traces"`)
132/// into one row per `Span` (with its `events`/`links` nested in the JSONB value).
133#[derive(Debug)]
134pub struct OtelTracesBlockDecoder;
135
136impl BlockObjectDecoder for OtelTracesBlockDecoder {
137    fn decode(
138        &self,
139        _stream: &StreamMetadata,
140        payload: &BlockPayload,
141        visitor: &mut dyn ObjectVisitor,
142    ) -> Result<()> {
143        let resource_spans = ResourceSpans::decode(payload.objects.as_slice())
144            .inspect_err(|e| error!("corrupt OTLP traces block payload: {e:?}"))
145            .context("decoding ResourceSpans proto")?;
146
147        let resource = resource_spans.resource.as_ref();
148        let resource_attrs: &[KeyValue] = resource.map_or(&[], |r| r.attributes.as_slice());
149        let resource_jsonb = attrs_to_jsonb_value(resource_attrs, &[]);
150        for scope_spans in &resource_spans.scope_spans {
151            let scope = scope_spans.scope.as_ref();
152            let scope_jsonb = JsonbValue::Object(
153                scope_extras(scope, &scope_spans.schema_url)
154                    .into_iter()
155                    .collect(),
156            );
157            for span in &scope_spans.spans {
158                let bytes = leaf_jsonb(
159                    "otlp.Span",
160                    span,
161                    &span.attributes,
162                    &resource_jsonb,
163                    &scope_jsonb,
164                    &[],
165                )?;
166                if !visitor.visit("otlp.Span", &bytes)? {
167                    return Ok(());
168                }
169            }
170        }
171        Ok(())
172    }
173}
174
175/// Gives generic access to the `attributes` field shared by all four OTLP metric
176/// data point message types, so `emit_data_points` can build `leaf_jsonb`'s
177/// `leaf_attrs` argument without matching on the concrete data point type.
178trait DataPointAttrs {
179    fn attributes(&self) -> &[KeyValue];
180}
181
182impl DataPointAttrs for NumberDataPoint {
183    fn attributes(&self) -> &[KeyValue] {
184        &self.attributes
185    }
186}
187
188impl DataPointAttrs for HistogramDataPoint {
189    fn attributes(&self) -> &[KeyValue] {
190        &self.attributes
191    }
192}
193
194impl DataPointAttrs for ExponentialHistogramDataPoint {
195    fn attributes(&self) -> &[KeyValue] {
196        &self.attributes
197    }
198}
199
200impl DataPointAttrs for SummaryDataPoint {
201    fn attributes(&self) -> &[KeyValue] {
202        &self.attributes
203    }
204}
205
206/// Shared emit loop for one `metric::Data` variant's data points: builds each
207/// leaf's JSONB `value` via `leaf_jsonb` and feeds it to `visitor`, stopping
208/// early if the visitor returns `false`. Factors out the logic that was
209/// previously duplicated across the Sum/Gauge/Histogram/ExponentialHistogram/
210/// Summary match arms in `OtelMetricsBlockDecoder::decode` — only the
211/// kind-specific `metric_extras` (aggregation_temporality, is_monotonic, ...)
212/// stay inline in each arm.
213///
214/// Returns `Ok(false)` if the visitor asked to stop; callers should return
215/// `Ok(())` from `decode` in that case.
216fn emit_data_points<T: Serialize + DataPointAttrs>(
217    type_name: &str,
218    data_points: &[T],
219    resource: &JsonbValue<'static>,
220    scope: &JsonbValue<'static>,
221    metric_extras: &[(String, JsonbValue<'static>)],
222    visitor: &mut dyn ObjectVisitor,
223) -> Result<bool> {
224    for dp in data_points {
225        let bytes = leaf_jsonb(
226            type_name,
227            dp,
228            dp.attributes(),
229            resource,
230            scope,
231            metric_extras,
232        )?;
233        if !visitor.visit(type_name, &bytes)? {
234            return Ok(false);
235        }
236    }
237    Ok(true)
238}
239
240/// Decodes OTLP `ResourceMetrics` block payloads (`streams.format = "otlp/v1/metrics"`)
241/// into one row per data point (Sum/Gauge → `otlp.NumberDataPoint`, Histogram →
242/// `otlp.HistogramDataPoint`, ExponentialHistogram → `otlp.ExponentialHistogramDataPoint`,
243/// Summary → `otlp.SummaryDataPoint`). Per-data-point granularity lines up with
244/// `measures` and with `metrics_bounds`'s `nb_objects` counting basis (see the
245/// design plan §"Per-data-point vs. per-`Metric` rows for metrics").
246///
247/// The parent `Metric`'s `name`/`unit`/`description`, plus kind-specific fields
248/// unreachable from the data point itself (`aggregation_temporality`,
249/// `is_monotonic`, `metadata`), ride along in every row's `__metric` object —
250/// see `leaf_jsonb`.
251#[derive(Debug)]
252pub struct OtelMetricsBlockDecoder;
253
254impl BlockObjectDecoder for OtelMetricsBlockDecoder {
255    fn decode(
256        &self,
257        _stream: &StreamMetadata,
258        payload: &BlockPayload,
259        visitor: &mut dyn ObjectVisitor,
260    ) -> Result<()> {
261        let resource_metrics = ResourceMetrics::decode(payload.objects.as_slice())
262            .inspect_err(|e| error!("corrupt OTLP metrics block payload: {e:?}"))
263            .context("decoding ResourceMetrics proto")?;
264
265        let resource = resource_metrics.resource.as_ref();
266        let resource_attrs: &[KeyValue] = resource.map_or(&[], |r| r.attributes.as_slice());
267        let resource_jsonb = attrs_to_jsonb_value(resource_attrs, &[]);
268        for scope_metrics in &resource_metrics.scope_metrics {
269            let scope = scope_metrics.scope.as_ref();
270            let scope_jsonb = JsonbValue::Object(
271                scope_extras(scope, &scope_metrics.schema_url)
272                    .into_iter()
273                    .collect(),
274            );
275            for metric in &scope_metrics.metrics {
276                let Some(data) = metric.data.as_ref() else {
277                    // A `Metric` with `data: None` emits nothing and consumes no
278                    // ordinal: `object_index` is purely positional over emitted
279                    // leaves for OTLP blocks, so calling `visitor.skip()` here
280                    // would invent a gap rather than preserve one.
281                    debug!(
282                        "OTel metric '{}' has no data, skipping (no row emitted, no ordinal consumed)",
283                        metric.name
284                    );
285                    continue;
286                };
287
288                // Fields the parent `Metric` carries that no data point can express
289                // on its own: name/unit/description (identity), plus `metadata`
290                // (tag 12) when non-empty. Common to every leaf kind below.
291                let mut extras: Vec<(String, JsonbValue<'static>)> = vec![
292                    (
293                        "name".to_string(),
294                        JsonbValue::String(Cow::Owned(metric.name.clone())),
295                    ),
296                    (
297                        "unit".to_string(),
298                        JsonbValue::String(Cow::Owned(metric.unit.clone())),
299                    ),
300                    (
301                        "description".to_string(),
302                        JsonbValue::String(Cow::Owned(metric.description.clone())),
303                    ),
304                ];
305                if !metric.metadata.is_empty() {
306                    extras.push((
307                        "otel.metric.metadata".to_string(),
308                        attrs_to_jsonb_value(&metric.metadata, &[]),
309                    ));
310                }
311
312                match data {
313                    Data::Sum(sum) => {
314                        extras.push((
315                            "otel.metric.kind".to_string(),
316                            JsonbValue::String(Cow::Borrowed("sum")),
317                        ));
318                        extras.push((
319                            "otel.metric.aggregation_temporality".to_string(),
320                            JsonbValue::Number(jsonb::Number::Int64(
321                                sum.aggregation_temporality as i64,
322                            )),
323                        ));
324                        extras.push((
325                            "otel.metric.is_monotonic".to_string(),
326                            JsonbValue::Bool(sum.is_monotonic),
327                        ));
328                        if !emit_data_points(
329                            "otlp.NumberDataPoint",
330                            &sum.data_points,
331                            &resource_jsonb,
332                            &scope_jsonb,
333                            &extras,
334                            visitor,
335                        )? {
336                            return Ok(());
337                        }
338                    }
339                    Data::Gauge(gauge) => {
340                        extras.push((
341                            "otel.metric.kind".to_string(),
342                            JsonbValue::String(Cow::Borrowed("gauge")),
343                        ));
344                        if !emit_data_points(
345                            "otlp.NumberDataPoint",
346                            &gauge.data_points,
347                            &resource_jsonb,
348                            &scope_jsonb,
349                            &extras,
350                            visitor,
351                        )? {
352                            return Ok(());
353                        }
354                    }
355                    Data::Histogram(h) => {
356                        extras.push((
357                            "otel.metric.kind".to_string(),
358                            JsonbValue::String(Cow::Borrowed("histogram")),
359                        ));
360                        extras.push((
361                            "otel.metric.aggregation_temporality".to_string(),
362                            JsonbValue::Number(jsonb::Number::Int64(
363                                h.aggregation_temporality as i64,
364                            )),
365                        ));
366                        if !emit_data_points(
367                            "otlp.HistogramDataPoint",
368                            &h.data_points,
369                            &resource_jsonb,
370                            &scope_jsonb,
371                            &extras,
372                            visitor,
373                        )? {
374                            return Ok(());
375                        }
376                    }
377                    Data::ExponentialHistogram(h) => {
378                        extras.push((
379                            "otel.metric.kind".to_string(),
380                            JsonbValue::String(Cow::Borrowed("exponential_histogram")),
381                        ));
382                        extras.push((
383                            "otel.metric.aggregation_temporality".to_string(),
384                            JsonbValue::Number(jsonb::Number::Int64(
385                                h.aggregation_temporality as i64,
386                            )),
387                        ));
388                        if !emit_data_points(
389                            "otlp.ExponentialHistogramDataPoint",
390                            &h.data_points,
391                            &resource_jsonb,
392                            &scope_jsonb,
393                            &extras,
394                            visitor,
395                        )? {
396                            return Ok(());
397                        }
398                    }
399                    Data::Summary(s) => {
400                        extras.push((
401                            "otel.metric.kind".to_string(),
402                            JsonbValue::String(Cow::Borrowed("summary")),
403                        ));
404                        if !emit_data_points(
405                            "otlp.SummaryDataPoint",
406                            &s.data_points,
407                            &resource_jsonb,
408                            &scope_jsonb,
409                            &extras,
410                            visitor,
411                        )? {
412                            return Ok(());
413                        }
414                    }
415                }
416            }
417        }
418        Ok(())
419    }
420}