Skip to main content

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}