mira_core/logs.rs
1//! OTLP logs -> Arrow, one allocation-lean pass over the decoded protobuf.
2//!
3//! The builder accumulates across many export requests and is drained once, at
4//! flush. `RecordBatch` is immutable and has no append, so the accumulation
5//! lives in Arrow's typed builders (which own growable Vecs) rather than in a
6//! `Vec<RecordBatch>` that would need `concat_batches` at flush — that costs
7//! roughly 2x peak memory for the duration of the concat.
8//!
9//! Everything shared with traces and metrics — the attribute tables and the
10//! Resource-Scope preamble — lives in [`crate::attrs`]. What is left here is the
11//! `logs` root table and nothing else.
12
13use arrow_array::builder::{
14 BinaryBuilder, FixedSizeBinaryBuilder, Int32Builder, StringBuilder, TimestampNanosecondBuilder,
15 UInt16Builder, UInt32Builder,
16};
17use arrow_array::{ArrayRef, RecordBatch};
18use prost::Message;
19use std::sync::Arc;
20
21use mira_proto::collector::logs::v1::ExportLogsServiceRequest;
22use mira_proto::common::v1::any_value::Value;
23
24use crate::attrs::{AttrsBuilder, DictColumn, ResourceScope, resource_kv, scope_kv};
25use crate::error::Result;
26use crate::schema::LOGS;
27use crate::signal::{Sealed, Sidecars, SignalBuilder};
28
29pub struct LogsBuilder {
30 id: UInt32Builder,
31 time: TimestampNanosecondBuilder,
32 observed: TimestampNanosecondBuilder,
33 sev_num: Int32Builder,
34 /// Twenty-four distinct values in practice, unbounded from a hostile
35 /// client, so it is counted like any other dictionary.
36 sev_text: DictColumn,
37 /// Set only on OTel Events, and enumerable by definition — an event name
38 /// names a schema, so a producer minting a new one per record is already
39 /// wrong.
40 event_name: DictColumn,
41 body: StringBuilder,
42 body_ser: BinaryBuilder,
43 trace_id: FixedSizeBinaryBuilder,
44 span_id: FixedSizeBinaryBuilder,
45 flags: UInt32Builder,
46 dropped: UInt32Builder,
47 resource_id: UInt16Builder,
48 scope_id: UInt16Builder,
49
50 log_attrs: AttrsBuilder,
51 rs: ResourceScope,
52
53 next_id: u32,
54 min_ts: i64,
55 max_ts: i64,
56}
57
58impl Default for LogsBuilder {
59 fn default() -> Self {
60 Self::new()
61 }
62}
63
64impl LogsBuilder {
65 pub fn new() -> Self {
66 Self {
67 id: UInt32Builder::new(),
68 time: TimestampNanosecondBuilder::new(),
69 observed: TimestampNanosecondBuilder::new(),
70 sev_num: Int32Builder::new(),
71 sev_text: DictColumn::new("logs.severity_text"),
72 event_name: DictColumn::new("logs.event_name"),
73 body: StringBuilder::new(),
74 body_ser: BinaryBuilder::new(),
75 trace_id: FixedSizeBinaryBuilder::new(16),
76 span_id: FixedSizeBinaryBuilder::new(8),
77 flags: UInt32Builder::new(),
78 dropped: UInt32Builder::new(),
79 resource_id: UInt16Builder::new(),
80 scope_id: UInt16Builder::new(),
81 log_attrs: AttrsBuilder::new("log_attrs.key"),
82 rs: ResourceScope::new(),
83 next_id: 0,
84 min_ts: i64::MAX,
85 max_ts: i64::MIN,
86 }
87 }
88
89 pub fn num_rows(&self) -> usize {
90 self.next_id as usize
91 }
92
93 pub fn is_empty(&self) -> bool {
94 self.next_id == 0
95 }
96
97 /// Rough resident cost of the accumulated builders. Used by the flusher to
98 /// decide when to seal, so that block size is bounded by bytes rather than
99 /// by row count (rows vary by three orders of magnitude in width).
100 ///
101 /// The variable-width heaps are measured rather than estimated. Counting only
102 /// fixed-width columns makes a 32 KB log body weigh the same as a 20-byte one,
103 /// which is how a 32 MB target seals a multi-gigabyte block on a GenAI
104 /// workload — and resident footprint is one of the four axes.
105 pub fn approx_bytes(&self) -> usize {
106 self.next_id as usize * 64
107 + (self.log_attrs.len() + self.rs.len()) * 48
108 + self.body.values_slice().len()
109 + self.body_ser.values_slice().len()
110 + self.log_attrs.heap_bytes()
111 + self.rs.heap_bytes()
112 }
113
114 /// Whether `req` is guaranteed to fit without overflowing a `UInt16`
115 /// dictionary or id space.
116 ///
117 /// Deliberately conservative — it assumes every key in the request is new —
118 /// because the alternative is discovering the overflow halfway through an
119 /// append, and an Arrow builder cannot be rolled back. The flusher seals when
120 /// this returns false and puts the request in the next block, so overflow
121 /// costs a slightly small block and never costs the caller their data.
122 pub fn has_headroom_for(&self, req: &ExportLogsServiceRequest) -> bool {
123 let (mut resources, mut scopes, mut records) = (0usize, 0usize, 0usize);
124 let (mut res_kv, mut sc_kv, mut log_kv) = (0usize, 0usize, 0usize);
125 for rl in &req.resource_logs {
126 resources += 1;
127 res_kv += resource_kv(rl.resource.as_ref());
128 for sl in &rl.scope_logs {
129 scopes += 1;
130 sc_kv += scope_kv(sl.scope.as_ref());
131 records += sl.log_records.len();
132 log_kv += sl
133 .log_records
134 .iter()
135 .map(|r| r.attributes.len())
136 .sum::<usize>();
137 }
138 }
139 self.rs.has_headroom(resources, scopes, res_kv, sc_kv)
140 && self.log_attrs.has_headroom(log_kv)
141 && self.sev_text.has_headroom(records)
142 && self.event_name.has_headroom(records)
143 }
144
145 /// Absorb one OTLP export request. Returns the number of log records added.
146 pub fn append_request(&mut self, req: &ExportLogsServiceRequest) -> Result<usize> {
147 let mut added = 0;
148 for rl in &req.resource_logs {
149 let rid = self.rs.resource(rl.resource.as_ref())?;
150 for sl in &rl.scope_logs {
151 let sid = self.rs.scope(sl.scope.as_ref())?;
152 for rec in &sl.log_records {
153 // Same rule as `AttrsBuilder::append`: the fallible steps of
154 // the row run before anything else is written, so a
155 // dictionary overflow cannot leave a half-row behind and
156 // poison the block. They also run before `next_id` moves, so
157 // ids stay dense — the whole join story rests on that.
158 //
159 // Two dictionaries now, so an overflow of the second does
160 // leave the first one row long. `has_headroom_for` counts
161 // both, and the one caller that skips it — an oversized
162 // request against an empty block — discards the builder on
163 // any error, so neither path can publish an uneven block.
164 self.sev_text.append(&rec.severity_text)?;
165 self.event_name.append(&rec.event_name)?;
166
167 let id = self.next_id;
168 self.next_id += 1;
169
170 // Both timestamps are optional on the wire, and `nanos` folds
171 // an unrepresentable one into the same "unset", so this one
172 // chain covers a missing clock and a broken one.
173 // `observed_time_unix_nano` is defined by the spec as the
174 // receiver's own reading, which makes it the fallback OTLP
175 // already names; when even that is absent, the moment we took
176 // delivery is the only fact left. Storing the zero instead
177 // would put the record at the epoch and drag the block's
178 // `min_ts` down with it — and a block claiming to start in
179 // 1970 is opened by every query in retention, so one record
180 // would defeat the pruning the whole design rests on.
181 let observed = nanos(rec.observed_time_unix_nano);
182 let t = match nanos(rec.time_unix_nano) {
183 0 if observed != 0 => observed,
184 0 => now_nanos(),
185 t => t,
186 };
187 self.min_ts = self.min_ts.min(t);
188 self.max_ts = self.max_ts.max(t);
189
190 self.id.append_value(id);
191 self.time.append_value(t);
192 if observed != 0 {
193 self.observed.append_value(observed);
194 } else {
195 self.observed.append_null();
196 }
197 self.sev_num.append_value(rec.severity_number);
198
199 match rec.body.as_ref().and_then(|b| b.value.as_ref()) {
200 Some(Value::StringValue(s)) => {
201 self.body.append_value(s);
202 self.body_ser.append_null();
203 }
204 Some(_) => {
205 self.body.append_null();
206 self.body_ser
207 .append_value(rec.body.as_ref().unwrap().encode_to_vec());
208 }
209 None => {
210 self.body.append_null();
211 self.body_ser.append_null();
212 }
213 }
214
215 append_fixed(&mut self.trace_id, &rec.trace_id, 16)?;
216 append_fixed(&mut self.span_id, &rec.span_id, 8)?;
217
218 self.flags.append_value(rec.flags);
219 self.dropped.append_value(rec.dropped_attributes_count);
220 self.resource_id.append_value(rid);
221 self.scope_id.append_value(sid);
222
223 self.log_attrs.append_all(id, &rec.attributes)?;
224 added += 1;
225 }
226 }
227 }
228 Ok(added)
229 }
230
231 /// Seal the accumulated rows into a block and reset for the next one.
232 ///
233 /// The reset happens on the error path too. `seal` calls `finish` on each
234 /// column builder as it goes, so a failure part way through leaves this one
235 /// holding columns of unequal length; reusing it would make every subsequent
236 /// seal fail identically and the node would reject exports until restarted.
237 pub fn finish(&mut self) -> Result<Sealed> {
238 let out = self.seal(Sidecars::Build);
239 *self = Self::new();
240 out
241 }
242
243 fn seal(&self, sidecars: Sidecars) -> Result<Sealed> {
244 // The same trace filter a traces block carries. "The logs for this
245 // trace" is the second half of every trace investigation, and it is the
246 // half with no useful time bound — you look a trace up because you do
247 // not know when it happened. Without this the spans come out of one
248 // block and the logs cost a scan of all of retention.
249 let trace_ids = self.trace_id.finish_cloned();
250 let trace_idx = match sidecars {
251 Sidecars::Build => crate::bloom::build(&trace_ids),
252 Sidecars::Skip => None,
253 };
254 let cols: Vec<ArrayRef> = vec![
255 Arc::new(self.id.finish_cloned()),
256 Arc::new(self.time.finish_cloned()),
257 Arc::new(self.observed.finish_cloned()),
258 Arc::new(self.sev_num.finish_cloned()),
259 self.sev_text.finish(),
260 self.event_name.finish(),
261 Arc::new(self.body.finish_cloned()),
262 Arc::new(self.body_ser.finish_cloned()),
263 Arc::new(trace_ids),
264 Arc::new(self.span_id.finish_cloned()),
265 Arc::new(self.flags.finish_cloned()),
266 Arc::new(self.dropped.finish_cloned()),
267 Arc::new(self.resource_id.finish_cloned()),
268 Arc::new(self.scope_id.finish_cloned()),
269 ];
270 let mut tables = vec![
271 ("logs", RecordBatch::try_new(LOGS.clone(), cols)?),
272 ("log_attrs", self.log_attrs.finish()?),
273 ];
274 tables.extend(self.rs.finish()?);
275 Ok(Sealed::with(
276 sidecars,
277 self.next_id as usize,
278 tables,
279 self.min_ts,
280 self.max_ts,
281 )
282 .with_sidecar(crate::bloom::TRACE_IDX, trace_idx))
283 }
284}
285
286impl SignalBuilder for LogsBuilder {
287 type Request = ExportLogsServiceRequest;
288 const SIGNAL: &'static str = "logs";
289
290 fn has_headroom_for(&self, req: &Self::Request) -> bool {
291 LogsBuilder::has_headroom_for(self, req)
292 }
293 fn append_request(&mut self, req: &Self::Request) -> Result<usize> {
294 LogsBuilder::append_request(self, req)
295 }
296 fn approx_bytes(&self) -> usize {
297 LogsBuilder::approx_bytes(self)
298 }
299 fn is_empty(&self) -> bool {
300 LogsBuilder::is_empty(self)
301 }
302 fn finish(&mut self) -> Result<Sealed> {
303 LogsBuilder::finish(self)
304 }
305 fn snapshot(&self) -> Result<Sealed> {
306 self.seal(Sidecars::Skip)
307 }
308}
309
310/// One OTLP `fixed64` nanosecond timestamp, read into the `Int64` Arrow uses.
311///
312/// Everything at or above 2^63 is unrepresentable, and it arrives: a skewed
313/// clock, an SDK that scales seconds to nanoseconds twice, or one line of curl.
314/// Reading it as zero — the same value the wire uses for "unset" — is the only
315/// clamp honest at both ends. A raw `as i64` wraps negative, and a negative
316/// `min_ts` renders as a directory name starting with `-`, which
317/// `block::parse_dir_name` splits into an empty first field and rejects: the
318/// block is published, the export is acked as durable, and no query and no
319/// retention sweep can ever see it again. Saturating to `i64::MAX` avoids the
320/// wrap but pushes `max_ts` past every cutoff `block::expire` will ever
321/// compute, which is the same disk leak by a longer route.
322///
323/// So this follows [`append_fixed`]: a malformed value reads as absent, and the
324/// absent-value path each signal already has takes it from there.
325pub(crate) fn nanos(t: u64) -> i64 {
326 i64::try_from(t).unwrap_or(0)
327}
328
329/// The wall clock, read the way the rest of the tree reads it. Used for exactly
330/// one thing — a log record that carries no timestamp of any kind — because the
331/// receipt time is the only honest stamp left for one, and a stored zero is what
332/// makes a block match every query ever asked.
333fn now_nanos() -> i64 {
334 let since_epoch = std::time::SystemTime::now()
335 .duration_since(std::time::UNIX_EPOCH)
336 .unwrap_or_default();
337 i64::try_from(since_epoch.as_nanos()).unwrap_or(i64::MAX)
338}
339
340/// OTLP leaves trace_id/span_id empty when unset; anything that is neither
341/// empty nor the exact width is malformed and becomes null rather than an error
342/// — a bad id must not cost the caller the whole export.
343pub(crate) fn append_fixed(b: &mut FixedSizeBinaryBuilder, v: &[u8], width: usize) -> Result<()> {
344 if v.len() == width {
345 b.append_value(v)?;
346 } else {
347 b.append_null();
348 }
349 Ok(())
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use arrow_array::{Array, TimestampNanosecondArray};
356 use mira_proto::logs::v1::{LogRecord, ResourceLogs, ScopeLogs};
357
358 fn request(records: Vec<LogRecord>) -> ExportLogsServiceRequest {
359 ExportLogsServiceRequest {
360 resource_logs: vec![ResourceLogs {
361 scope_logs: vec![ScopeLogs {
362 log_records: records,
363 ..Default::default()
364 }],
365 ..Default::default()
366 }],
367 }
368 }
369
370 fn col(s: &Sealed, name: &str) -> TimestampNanosecondArray {
371 s.table("logs")
372 .unwrap()
373 .column_by_name(name)
374 .unwrap()
375 .as_any()
376 .downcast_ref::<TimestampNanosecondArray>()
377 .unwrap()
378 .clone()
379 }
380
381 /// `time_unix_nano` is a `fixed64` and one line of curl can set it past
382 /// 2^63. Cast straight to `i64` it wrapped negative, `min_ts` took it,
383 /// `block::dir_name` wrote a directory starting with `-`, and
384 /// `block::parse_dir_name` then refused to parse it back — so the block was
385 /// acked as durable and never seen again by a query or by retention. This
386 /// asserts the whole chain, not just the number.
387 #[test]
388 fn a_timestamp_past_i64_still_publishes_a_block_the_catalog_can_see() {
389 let mut b = LogsBuilder::new();
390 b.append_request(&request(vec![
391 LogRecord {
392 time_unix_nano: u64::MAX,
393 observed_time_unix_nano: u64::MAX,
394 ..Default::default()
395 },
396 LogRecord {
397 time_unix_nano: 5_000,
398 ..Default::default()
399 },
400 ]))
401 .unwrap();
402 let sealed = b.finish().unwrap();
403 assert_eq!(sealed.min_ts, 5_000);
404 assert!(sealed.min_ts >= 0 && sealed.max_ts >= sealed.min_ts);
405 // Unrepresentable is unset, so the row keeps the receipt time and the
406 // observed column stays null rather than recording a wrapped value.
407 assert!(col(&sealed, "time_unix_nano").value(0) > 5_000);
408 assert!(col(&sealed, "observed_time_unix_nano").is_null(0));
409
410 let root = std::env::temp_dir().join(format!("mira-ts-{}", std::process::id()));
411 let _ = std::fs::remove_dir_all(&root);
412 let published = crate::block::publish(&root, "logs", 7, 1, 0, &sealed).unwrap();
413 assert_eq!(crate::block::scan(&root, "logs").unwrap(), vec![published]);
414 // And retention can reclaim it, which the wrapped name also prevented.
415 assert_eq!(crate::block::expire(&root, "logs", i64::MAX).unwrap(), 1);
416 let _ = std::fs::remove_dir_all(&root);
417 }
418
419 /// `time_unix_nano` is optional and plenty of SDKs leave it unset once
420 /// `observed_time_unix_nano` is. Both of them zero used to store a zero,
421 /// which put the block's `min_ts` at the epoch and made every query in
422 /// retention overlap it — block pruning defeated by one record.
423 #[test]
424 fn a_record_with_no_clock_falls_back_to_observed_then_to_receipt_time() {
425 let before = now_nanos();
426 let mut b = LogsBuilder::new();
427 assert!(b.is_empty() && b.num_rows() == 0, "nothing appended yet");
428 b.append_request(&request(vec![
429 LogRecord {
430 time_unix_nano: 3_000,
431 observed_time_unix_nano: 4_000,
432 ..Default::default()
433 },
434 LogRecord {
435 observed_time_unix_nano: 4_000,
436 ..Default::default()
437 },
438 LogRecord::default(),
439 ]))
440 .unwrap();
441 // The flusher sizes and logs a block by this counter, and it counts
442 // records rather than requests: one export of three records is three.
443 assert_eq!(b.num_rows(), 3);
444 assert!(!b.is_empty());
445 let sealed = b.finish().unwrap();
446 assert_eq!(sealed.table("logs").unwrap().num_rows(), 3);
447
448 let time = col(&sealed, "time_unix_nano");
449 assert_eq!(time.value(0), 3_000, "a real clock wins");
450 assert_eq!(time.value(1), 4_000, "then the receiver's own reading");
451 assert!(time.value(2) >= before, "then the moment we took delivery");
452 // The one that matters: no record dragged the range to 1970.
453 assert_eq!(sealed.min_ts, 3_000);
454 assert_eq!(sealed.max_ts, time.value(2));
455
456 let observed = col(&sealed, "observed_time_unix_nano");
457 assert_eq!(observed.value(0), 4_000);
458 assert!(observed.is_null(2), "the fallback is not written back");
459 }
460}