micromegas_analytics/lakehouse/otel/
logs_block_processor.rs1use 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 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 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 let scope_name = scope.map(|s| s.name.clone()).unwrap_or_default();
106
107 for record in &scope_logs.log_records {
108 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 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 let msg = record
133 .body
134 .as_ref()
135 .map(any_value_to_string)
136 .unwrap_or_default();
137
138 let mut extras = scope_extras(scope, &scope_logs.schema_url);
143 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}