Skip to main content

micromegas_analytics/lakehouse/otel/
logs_block_processor.rs

1//! `BlockProcessor` for OTLP `ResourceLogs` payloads → `log_entries` rows.
2//!
3//! The block payload carries a single `ResourceLogs` message (one resource per block —
4//! see Plan §"One block per Resource"). We prost-decode it, walk each scope and log
5//! record, emit one row per `LogRecord`.
6
7use super::attrs::{any_value_to_string, attrs_to_jsonb, scope_extras, severity_number_to_level};
8use crate::lakehouse::{
9    block_partition_spec::BlockProcessor, partition_source_data::PartitionSourceBlock,
10    write_partition::PartitionRowSet,
11};
12use crate::payload::fetch_block_payload;
13use crate::time::TimeRange;
14use anyhow::{Context, Result};
15use async_trait::async_trait;
16use chrono::DateTime;
17use datafusion::arrow::array::{
18    BinaryDictionaryBuilder, PrimitiveBuilder, StringBuilder, StringDictionaryBuilder,
19};
20use datafusion::arrow::datatypes::{Int32Type, TimestampNanosecondType};
21use datafusion::arrow::record_batch::RecordBatch;
22use jsonb::Value as JsonbValue;
23use micromegas_telemetry::blob_storage::BlobStorage;
24use micromegas_tracing::prelude::*;
25use opentelemetry_proto::tonic::logs::v1::ResourceLogs;
26use prost::Message;
27use std::borrow::Cow;
28use std::sync::Arc;
29
30#[derive(Debug)]
31pub struct OtelLogsBlockProcessor {}
32
33#[async_trait]
34impl BlockProcessor for OtelLogsBlockProcessor {
35    #[span_fn]
36    async fn process(
37        &self,
38        blob_storage: Arc<BlobStorage>,
39        src_block: Arc<PartitionSourceBlock>,
40    ) -> Result<Option<PartitionRowSet>> {
41        let payload = fetch_block_payload(
42            blob_storage,
43            sqlx::types::Uuid::from_bytes(*src_block.block.process_id.as_bytes()),
44            sqlx::types::Uuid::from_bytes(*src_block.block.stream_id.as_bytes()),
45            sqlx::types::Uuid::from_bytes(*src_block.block.block_id.as_bytes()),
46        )
47        .await
48        .with_context(|| "fetch_block_payload")?;
49
50        // Block payload format: BlockPayload { dependencies: [], objects: <ResourceLogs proto> }.
51        // (`fetch_block_payload` already CBOR-decodes the envelope; we get the proto bytes.)
52        let resource_logs = ResourceLogs::decode(payload.objects.as_slice())
53            .with_context(|| "decoding ResourceLogs proto")?;
54
55        let process = &src_block.process;
56        let stream_id_str = src_block.block.stream_id.to_string();
57        let block_id_str = src_block.block.block_id.to_string();
58        let process_id_str = process.process_id.to_string();
59        let insert_time_nanos = src_block
60            .block
61            .insert_time
62            .timestamp_nanos_opt()
63            .with_context(|| "block.insert_time → nanos")?;
64        let begin_time_nanos = src_block
65            .block
66            .begin_time
67            .timestamp_nanos_opt()
68            .with_context(|| "block.begin_time → nanos")?;
69
70        // Pre-count rows so dictionary builders can size their backing storage.
71        let row_count: usize = resource_logs
72            .scope_logs
73            .iter()
74            .map(|s| s.log_records.len())
75            .sum();
76        if row_count == 0 {
77            return Ok(None);
78        }
79
80        let mut process_ids = StringDictionaryBuilder::<Int32Type>::new();
81        let mut stream_ids = StringDictionaryBuilder::<Int32Type>::new();
82        let mut block_ids = StringDictionaryBuilder::<Int32Type>::new();
83        let mut insert_times =
84            PrimitiveBuilder::<TimestampNanosecondType>::with_capacity(row_count);
85        let mut exes = StringDictionaryBuilder::<Int32Type>::new();
86        let mut usernames = StringDictionaryBuilder::<Int32Type>::new();
87        let mut computers = StringDictionaryBuilder::<Int32Type>::new();
88        let mut times = PrimitiveBuilder::<TimestampNanosecondType>::with_capacity(row_count);
89        let mut targets = StringDictionaryBuilder::<Int32Type>::new();
90        let mut levels = PrimitiveBuilder::<Int32Type>::with_capacity(row_count);
91        let mut msgs = StringBuilder::new();
92        let mut properties = BinaryDictionaryBuilder::<Int32Type>::new();
93        let mut process_properties = BinaryDictionaryBuilder::<Int32Type>::new();
94        let mut audiences = StringDictionaryBuilder::<Int32Type>::new();
95
96        let mut min_time = i64::MAX;
97        let mut max_time = i64::MIN;
98        let mut nb_appended = 0usize;
99        let mut nb_substituted_block_time = 0usize;
100        let mut nb_severity_out_of_range = 0usize;
101
102        for scope_logs in &resource_logs.scope_logs {
103            let scope = scope_logs.scope.as_ref();
104            // `target` mirrors the existing native semantics (logger name).
105            let scope_name = scope.map(|s| s.name.clone()).unwrap_or_default();
106
107            for record in &scope_logs.log_records {
108                // time_unix_nano is optional — fall back to observed_time per OTLP spec.
109                let time_nanos = if record.time_unix_nano != 0 {
110                    record.time_unix_nano as i64
111                } else if record.observed_time_unix_nano != 0 {
112                    record.observed_time_unix_nano as i64
113                } else {
114                    // No timestamp in the payload: the block's begin_time carries the
115                    // arrival-time fallback that ingestion applied in build_prepared_block.
116                    // Using begin_time (not insert_time) keeps the row inside
117                    // [block.begin_time, block.end_time], which partition bounds and
118                    // min/max stats depend on. Aggregated below to one log line per block.
119                    nb_substituted_block_time += 1;
120                    begin_time_nanos
121                };
122                min_time = min_time.min(time_nanos);
123                max_time = max_time.max(time_nanos);
124
125                let level = severity_number_to_level(record.severity_number);
126                if !(0..=24).contains(&record.severity_number) {
127                    nb_severity_out_of_range += 1;
128                }
129
130                // Body → msg. String body lands directly; structured body gets stringified
131                // (deferred parsing — see the plan's "Logs → log_entries" mapping table).
132                let msg = record
133                    .body
134                    .as_ref()
135                    .map(any_value_to_string)
136                    .unwrap_or_default();
137
138                // Properties: log record attributes + scope info (otel.scope.*) +
139                // optional trace correlation + raw severity_text. Built per-row because
140                // OTel attributes vary record-to-record; dictionary dedup happens inside
141                // BinaryDictionaryBuilder by content hash.
142                let mut extras = scope_extras(scope, &scope_logs.schema_url);
143                // W3C Trace Context: trace_id is 16 bytes, span_id is 8 bytes.
144                // `otel_spans` enforces these lengths and skips bad rows; we mirror
145                // that here so a buggy SDK can't write half-size hex strings that
146                // silently fail correlation joins against the spans view.
147                if !record.trace_id.is_empty() {
148                    if record.trace_id.len() == 16 {
149                        let hex = hex::encode(&record.trace_id);
150                        extras.push((
151                            "otel.trace_id".to_string(),
152                            JsonbValue::String(Cow::Owned(hex)),
153                        ));
154                    } else {
155                        debug!(
156                            "OTel log record with bad trace_id ({}b), dropping property",
157                            record.trace_id.len()
158                        );
159                    }
160                }
161                if !record.span_id.is_empty() {
162                    if record.span_id.len() == 8 {
163                        let hex = hex::encode(&record.span_id);
164                        extras.push((
165                            "otel.span_id".to_string(),
166                            JsonbValue::String(Cow::Owned(hex)),
167                        ));
168                    } else {
169                        debug!(
170                            "OTel log record with bad span_id ({}b), dropping property",
171                            record.span_id.len()
172                        );
173                    }
174                }
175                if !record.severity_text.is_empty() {
176                    extras.push((
177                        "otel.severity_text".to_string(),
178                        JsonbValue::String(Cow::Owned(record.severity_text.clone())),
179                    ));
180                }
181                if !record.event_name.is_empty() {
182                    extras.push((
183                        "otel.event_name".to_string(),
184                        JsonbValue::String(Cow::Owned(record.event_name.clone())),
185                    ));
186                }
187
188                let props_jsonb = attrs_to_jsonb(&record.attributes, &extras);
189
190                process_ids.append(&process_id_str)?;
191                stream_ids.append(&stream_id_str)?;
192                block_ids.append(&block_id_str)?;
193                insert_times.append_value(insert_time_nanos);
194                exes.append(&process.exe)?;
195                usernames.append(&process.username)?;
196                computers.append(&process.computer)?;
197                times.append_value(time_nanos);
198                targets.append(&scope_name)?;
199                levels.append_value(level);
200                msgs.append_value(&msg);
201                properties.append(&props_jsonb)?;
202                process_properties.append(&**process.properties)?;
203                audiences.append(&*process.audience)?;
204
205                nb_appended += 1;
206            }
207        }
208
209        if nb_substituted_block_time > 0 {
210            debug!(
211                "OTel log records without timestamp substituted the block's begin_time (block_id={block_id_str}, count={nb_substituted_block_time})"
212            );
213        }
214        if nb_severity_out_of_range > 0 {
215            debug!(
216                "OTel log records with out-of-range severity_number treated as Info (block_id={block_id_str}, count={nb_severity_out_of_range})"
217            );
218        }
219
220        if nb_appended == 0 {
221            return Ok(None);
222        }
223
224        let schema = Arc::new(crate::log_entries_table::log_table_schema());
225        let batch = RecordBatch::try_new(
226            schema,
227            vec![
228                Arc::new(process_ids.finish()),
229                Arc::new(stream_ids.finish()),
230                Arc::new(block_ids.finish()),
231                Arc::new(insert_times.finish().with_timezone_utc()),
232                Arc::new(exes.finish()),
233                Arc::new(usernames.finish()),
234                Arc::new(computers.finish()),
235                Arc::new(times.finish().with_timezone_utc()),
236                Arc::new(targets.finish()),
237                Arc::new(levels.finish()),
238                Arc::new(msgs.finish()),
239                Arc::new(properties.finish()),
240                Arc::new(process_properties.finish()),
241                Arc::new(audiences.finish()),
242            ],
243        )
244        .with_context(|| "building OTel log_entries batch")?;
245
246        Ok(Some(PartitionRowSet::new(
247            TimeRange::new(
248                DateTime::from_timestamp_nanos(min_time),
249                DateTime::from_timestamp_nanos(max_time),
250            ),
251            batch,
252            None,
253        )))
254    }
255}