agentd/obs/log.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2//! Hand-rolled JSON-lines logger. ~150 lines reusing the `serde_json`
3//! serializer — deliberately not `tracing` (its implicit async span context
4//! is moot for a processes-plus-threads design, and the process tree gives
5//! us correlation for free).
6//!
7//! One NDJSON event per line to **stderr** (stdout is reserved for the
8//! agent's result). The canonical line schema is:
9//! `ts level event run_id agent_id agent_path comp pid [span_id parent_span_id
10//! trace_id] [dur_ms] [err] <event-specific>`.
11
12use serde_json::{Map, Value};
13use std::collections::VecDeque;
14use std::io::Write;
15use std::sync::Mutex;
16use std::sync::atomic::{AtomicU64, Ordering};
17use std::time::{SystemTime, UNIX_EPOCH};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
20pub enum Level {
21 Trace,
22 Debug,
23 Info,
24 Warn,
25 Error,
26}
27
28impl Level {
29 pub fn as_str(self) -> &'static str {
30 match self {
31 Level::Trace => "trace",
32 Level::Debug => "debug",
33 Level::Info => "info",
34 Level::Warn => "warn",
35 Level::Error => "error",
36 }
37 }
38
39 /// Parse a `--log-level` value; unknown → None (caller decides default).
40 pub fn parse(s: &str) -> Option<Level> {
41 match s.to_ascii_lowercase().as_str() {
42 "trace" => Some(Level::Trace),
43 "debug" => Some(Level::Debug),
44 "info" => Some(Level::Info),
45 "warn" | "warning" => Some(Level::Warn),
46 "error" => Some(Level::Error),
47 _ => None,
48 }
49 }
50}
51
52/// Which component is emitting. Part of the correlation tuple.
53#[derive(Debug, Clone, Copy)]
54pub enum Comp {
55 Supervisor,
56 Agent,
57 Mcp,
58 Intel,
59}
60
61impl Comp {
62 fn as_str(self) -> &'static str {
63 match self {
64 Comp::Supervisor => "supervisor",
65 Comp::Agent => "agent",
66 Comp::Mcp => "mcp",
67 Comp::Intel => "intel",
68 }
69 }
70}
71
72/// The correlation context stamped on every line. Children inherit `run_id`
73/// and `trace_id` and extend `agent_path` (the cheap subtree-query superpower:
74/// an `agent_path` prefix selects a subtree with no backend join).
75#[derive(Debug, Clone)]
76pub struct LogCtx {
77 pub run_id: String,
78 pub agent_id: String,
79 pub agent_path: String,
80 pub comp: Comp,
81 pub pid: u32,
82 pub trace_id: Option<String>,
83}
84
85/// A logger bound to one [`LogCtx`]. Cheap to clone (clones the ctx); writes
86/// serialize behind a process-global stderr mutex so lines never interleave.
87#[derive(Clone)]
88pub struct Logger {
89 ctx: LogCtx,
90 min: Level,
91 log_content: bool,
92}
93
94// One lock so concurrent threads in a process don't interleave partial lines.
95static STDERR_LOCK: Mutex<()> = Mutex::new(());
96
97// ---------------------------------------------------------------------------
98// The bounded in-memory event ring. A projection of the same stderr stream —
99// identical lines, identical closed event vocabulary — captured into a
100// fixed-size ring the `agentd://events` resource drains with an `?after=<seq>`
101// cursor (the self-MCP server reads it; this module only owns the store). NOT a
102// second telemetry path: stderr stays the source of truth; the ring is the
103// live-tail convenience.
104//
105// It is installed only when serving wants it (the supervisor calls
106// [`install_event_ring`] once at startup); without that, capture is a single
107// relaxed atomic load that short-circuits, so the default build pays nothing.
108// The ring is lossy and bounded by design: an overrun drops the oldest and bumps
109// `dropped`, never blocking — a slow or dead subscriber can never back-pressure
110// the supervisor.
111
112/// Envelope version for the `agentd://events` read body. Bumped only on a
113/// breaking change to the `{oldest_seq,newest_seq,dropped,events}` envelope —
114/// never for the per-line schema, which is versioned independently of this.
115pub const EVENTS_SCHEMA: &str = "1.0";
116
117/// Default ring capacity, overridable with `AGENTD_EVENTS_RING`: the last N
118/// emitted lines held in memory. Bounds memory on a slow subscriber.
119pub const EVENTS_RING_DEFAULT: usize = 1024;
120
121/// One captured line plus its monotonic ring `seq` — the only field added over
122/// the emitted line, and the cursor key the subscriber advances.
123struct RingEntry {
124 seq: u64,
125 /// The captured `level` (cheap prefix-filterable without re-parsing).
126 level: &'static str,
127 /// The captured `event` name (cheap prefix-filterable without re-parsing).
128 event: String,
129 /// The full emitted line object (the `seq` is added on read).
130 line: Value,
131}
132
133/// A fixed-capacity ring of the last `cap` emitted lines. Lossy oldest-evicted;
134/// `dropped` counts lines evicted to date (a subscriber whose `after` predates
135/// `oldest_seq` learns it fell behind and re-baselines).
136struct EventRing {
137 buf: VecDeque<RingEntry>,
138 cap: usize,
139 /// Total lines evicted since start (monotonic; surfaced as `dropped`).
140 dropped: u64,
141}
142
143impl EventRing {
144 fn new(cap: usize) -> EventRing {
145 // A zero cap would make every push an immediate eviction; clamp to 1 so
146 // the ring always holds at least the newest line.
147 let cap = cap.max(1);
148 EventRing {
149 buf: VecDeque::with_capacity(cap),
150 cap,
151 dropped: 0,
152 }
153 }
154
155 /// Append a line, evicting the oldest (and bumping `dropped`) on overrun.
156 fn push(&mut self, entry: RingEntry) {
157 if self.buf.len() == self.cap {
158 self.buf.pop_front();
159 self.dropped = self.dropped.saturating_add(1);
160 }
161 self.buf.push_back(entry);
162 }
163}
164
165/// The process-global ring. `None` until [`install_event_ring`] is called; a
166/// relaxed load gates the capture hot path so the default build is free.
167static EVENT_RING: Mutex<Option<EventRing>> = Mutex::new(None);
168/// Cheap presence flag so the logging hot path avoids the mutex when no ring is
169/// installed (the overwhelmingly common case). Set once at install.
170static RING_INSTALLED: AtomicU64 = AtomicU64::new(0);
171/// Monotonic ring sequence — the cursor key. Shared across all loggers in the
172/// process so every captured line gets a globally-ordered `seq`.
173static RING_SEQ: AtomicU64 = AtomicU64::new(0);
174/// Set on every ring push; the served `agentd://events` resource coalesces this
175/// into one `notifications/resources/updated` per tick rather than notifying per
176/// captured line. A flag, not a callback, keeps this `obs` layer free of any
177/// self-MCP server type — the server polls and clears it. Non-blocking, so a
178/// push never waits on a subscriber.
179static EVENTS_DIRTY: AtomicU64 = AtomicU64::new(0);
180
181/// Take-and-clear the "new events since last check" flag — the served
182/// `agentd://events` resource calls this on its coalescing tick to decide whether
183/// to fire a `notifications/resources/updated`. Returns `true` if any line was
184/// captured since the last call.
185pub fn take_events_dirty() -> bool {
186 EVENTS_DIRTY.swap(0, Ordering::Relaxed) != 0
187}
188
189/// Install the bounded event ring with capacity `cap`. Called once by the
190/// supervisor when the served `agentd://events` resource is wanted (gated by
191/// `--serve-mcp` plus the `events` feature at the call site). Idempotent — a
192/// second call resizes and clears. Never fatal: telemetry must not crash the
193/// run, so a poisoned lock is recovered rather than propagated.
194pub fn install_event_ring(cap: usize) {
195 let mut g = EVENT_RING.lock().unwrap_or_else(|e| e.into_inner());
196 *g = Some(EventRing::new(cap));
197 RING_INSTALLED.store(1, Ordering::Relaxed);
198}
199
200// ---------------------------------------------------------------------------
201// The runtime-events tap. The daemon emits a large dotted event vocabulary and
202// exposes almost none of it to ITSELF: "the breaker tripped" is a log line, not
203// something a workflow can react to. This tap turns a selected subset into
204// appends on a declared stream, so remediation, paging and SLO accounting
205// become ordinary `{kind: stream}` start nodes with the retention, subject
206// globbing, filters and dedup those already have.
207//
208// It is NOT the event ring. The ring is an explicitly lossy oldest-evicted
209// buffer installed only under `--serve-mcp` plus the `events` feature; teeing
210// it into a durable stream would produce silent gaps in exactly the consumer
211// being sold, and in the default deployment would do nothing at all. This taps
212// the emission itself — every `log.info`/`warn`/`error` call site — and is
213// opt-in per family, carrying metadata rather than payloads.
214//
215// Volume is the real hazard, because the refusals that matter most arrive in
216// storms precisely when the constrained resource is the thing being written
217// to. Three things bound it: the queue is FIXED and counts what it drops
218// (keeping the oldest, so the start of a storm survives and the count says how
219// big it got), a family may be marked `sampled` to contribute at 1/N, and the
220// drain goes through `append_event`, which sheds under pressure like every
221// other admission.
222
223/// The families a runtime event can belong to — the segment before the first
224/// dot in an event name. A closed vocabulary so `include: [pressur]` is a
225/// startup error rather than a filter that silently matches nothing.
226/// `families_cover_the_emitted_vocabulary` keeps this honest against the tree.
227pub const EVENT_FAMILIES: &[&str] = &[
228 "a2a",
229 "agent",
230 "audit",
231 "breaker",
232 "budget",
233 "cgroup",
234 "child",
235 "config",
236 "context",
237 "drain",
238 "goal",
239 "human",
240 "inbox",
241 "instance",
242 "instruction",
243 "intel",
244 "interface",
245 "knowledge",
246 "lifecycle",
247 "limit",
248 "loop",
249 "mcp",
250 "message",
251 "otel",
252 "plan",
253 "preflight",
254 "pressure",
255 "proc",
256 "prompt",
257 "registry",
258 "restore",
259 "run",
260 "skill",
261 "skills",
262 "start",
263 "step",
264 "store",
265 "stream",
266 "subagent",
267 "test",
268 "timer",
269 "tool",
270 "turn",
271 "wait",
272 "webhook",
273 "workflow",
274];
275
276/// One event awaiting its append: which stream, the subject (the event name),
277/// and the fields.
278pub struct TappedEvent {
279 pub stream: String,
280 pub subject: String,
281 pub data: Value,
282}
283
284/// How often a `sampled` family actually contributes.
285const SAMPLE_EVERY: u64 = 16;
286
287struct RuntimeTap {
288 /// Where selected runtime events land.
289 stream: String,
290 /// Families taken in full.
291 all: Vec<String>,
292 /// Families taken at 1-in-[`SAMPLE_EVERY`] — the high-rate ones, which
293 /// cannot share a list with a once-a-week `config.reloaded`.
294 sampled: Vec<String>,
295 queue: VecDeque<TappedEvent>,
296 cap: usize,
297 /// Dropped because the queue was full. Surfaced on the next drain, so a
298 /// storm is visible as a fact rather than as silence.
299 dropped: u64,
300 /// Per-family counters driving the sampling decision.
301 seen: u64,
302}
303
304static RUNTIME_TAP: Mutex<Option<RuntimeTap>> = Mutex::new(None);
305/// Cheap presence flag — the logging hot path avoids the mutex entirely when
306/// no tap is armed, which is the default.
307static TAP_ARMED: AtomicU64 = AtomicU64::new(0);
308
309/// Arm the tap. `all` and `sampled` are family names; `cap` bounds the queue.
310/// Idempotent — a second call replaces the selection and clears the queue.
311pub fn install_runtime_tap(stream: &str, all: Vec<String>, sampled: Vec<String>, cap: usize) {
312 let mut g = RUNTIME_TAP.lock().unwrap_or_else(|e| e.into_inner());
313 *g = Some(RuntimeTap {
314 stream: stream.to_string(),
315 all,
316 sampled,
317 queue: VecDeque::new(),
318 cap: cap.max(1),
319 dropped: 0,
320 seen: 0,
321 });
322 TAP_ARMED.store(1, Ordering::Relaxed);
323}
324
325/// Whether any tap is armed (the cheap check the audit sink also uses).
326pub fn runtime_tap_armed() -> bool {
327 TAP_ARMED.load(Ordering::Relaxed) != 0
328}
329
330/// Queue one event for a stream directly, bypassing family selection. The
331/// audit sink uses this: audit records are chosen by `audit.sink`, not by the
332/// runtime-event family list, and they go to their own stream.
333pub fn tap_direct(stream: &str, subject: &str, data: Value) {
334 if !runtime_tap_armed() {
335 return;
336 }
337 let mut g = RUNTIME_TAP.lock().unwrap_or_else(|e| e.into_inner());
338 if let Some(t) = g.as_mut() {
339 push_bounded(t, stream.to_string(), subject.to_string(), data);
340 }
341}
342
343fn push_bounded(t: &mut RuntimeTap, stream: String, subject: String, data: Value) {
344 if t.queue.len() >= t.cap {
345 // Drop the NEW one and keep the oldest: under a storm the first
346 // refusals say what started it, and the count says how bad it got.
347 t.dropped = t.dropped.saturating_add(1);
348 return;
349 }
350 t.queue.push_back(TappedEvent {
351 stream,
352 subject,
353 data,
354 });
355}
356
357/// Set while the drain is appending. The appends themselves log — a stream at
358/// its retention ceiling emits `stream.trimmed` on EVERY append — so without
359/// this the tap would feed itself: one appended event produces a log line that
360/// becomes the next tick's appended event, forever, and fastest exactly when
361/// the stream is already full. Telemetry must not observe its own plumbing.
362static TAP_DRAINING: AtomicU64 = AtomicU64::new(0);
363
364/// Suspend capture for the duration of the returned guard.
365pub fn tap_drain_guard() -> TapDrainGuard {
366 TAP_DRAINING.store(1, Ordering::Relaxed);
367 TapDrainGuard
368}
369
370pub struct TapDrainGuard;
371impl Drop for TapDrainGuard {
372 fn drop(&mut self) {
373 TAP_DRAINING.store(0, Ordering::Relaxed);
374 }
375}
376
377/// The capture hot path, called from [`Logger::event`] for every emission.
378fn capture_to_tap(event: &str, line: &Value) {
379 if !runtime_tap_armed() || TAP_DRAINING.load(Ordering::Relaxed) != 0 {
380 return;
381 }
382 let family = event.split('.').next().unwrap_or(event);
383 let mut g = RUNTIME_TAP.lock().unwrap_or_else(|e| e.into_inner());
384 let Some(t) = g.as_mut() else { return };
385 let full = t.all.iter().any(|f| f == family);
386 let sampled = !full && t.sampled.iter().any(|f| f == family);
387 if !full && !sampled {
388 return;
389 }
390 if sampled {
391 t.seen = t.seen.wrapping_add(1);
392 if t.seen % SAMPLE_EVERY != 0 {
393 return;
394 }
395 }
396 let stream = t.stream.clone();
397 // Metadata, not payloads: the emitted line already excludes conversation
398 // content unless `log_content` is on, and this carries it as-is so a
399 // consumer reads the same fields an operator would see in the log.
400 push_bounded(t, stream, event.to_string(), line.clone());
401}
402
403/// Take everything queued since the last drain, plus how many were dropped.
404/// The reactor calls this once per tick and appends each to its stream.
405pub fn drain_runtime_tap() -> (Vec<TappedEvent>, u64) {
406 if !runtime_tap_armed() {
407 return (Vec::new(), 0);
408 }
409 let mut g = RUNTIME_TAP.lock().unwrap_or_else(|e| e.into_inner());
410 let Some(t) = g.as_mut() else {
411 return (Vec::new(), 0);
412 };
413 let dropped = std::mem::take(&mut t.dropped);
414 (t.queue.drain(..).collect(), dropped)
415}
416
417/// A snapshot of the ring window an `agentd://events?after=<seq>` read returns:
418/// the entries with `seq > after` (after optional level/event-prefix filtering),
419/// plus the ring's current window bounds and cumulative `dropped`.
420pub struct EventWindow {
421 pub events: Vec<Value>,
422 pub oldest_seq: u64,
423 pub newest_seq: u64,
424 pub dropped: u64,
425}
426
427/// Drain the ring into an [`EventWindow`] for a cursor read. Returns the entries
428/// with `seq > after`, capped at `limit` (oldest-first), each with its `seq`
429/// folded into the emitted line object. `level`/`event_prefixes` are optional
430/// server-side filters — a cheap prefix match over the held lines, not a query
431/// engine. `None` when no ring is installed (the resource 404s at the server).
432pub fn read_event_window(
433 after: u64,
434 limit: usize,
435 level: Option<&str>,
436 event_prefixes: &[&str],
437) -> Option<EventWindow> {
438 if RING_INSTALLED.load(Ordering::Relaxed) == 0 {
439 return None;
440 }
441 let g = EVENT_RING.lock().unwrap_or_else(|e| e.into_inner());
442 let ring = g.as_ref()?;
443 let oldest_seq = ring.buf.front().map(|e| e.seq).unwrap_or(0);
444 let newest_seq = ring.buf.back().map(|e| e.seq).unwrap_or(0);
445 let mut events = Vec::new();
446 for entry in ring.buf.iter() {
447 if entry.seq <= after {
448 continue;
449 }
450 if let Some(want) = level
451 && entry.level != want
452 {
453 continue;
454 }
455 if !event_prefixes.is_empty() && !event_prefixes.iter().any(|p| entry.event.starts_with(p))
456 {
457 continue;
458 }
459 // Fold the ring `seq` into the line object (the only added field).
460 let mut line = match &entry.line {
461 Value::Object(m) => m.clone(),
462 _ => Map::new(),
463 };
464 line.insert("seq".into(), Value::Number(entry.seq.into()));
465 events.push(Value::Object(line));
466 if events.len() >= limit {
467 break;
468 }
469 }
470 Some(EventWindow {
471 events,
472 oldest_seq,
473 newest_seq,
474 dropped: ring.dropped,
475 })
476}
477
478/// Capture one already-assembled line into the ring (a no-op when none is
479/// installed). Pulls `level`/`event` off the object for cheap filterable
480/// metadata, mints a `seq`, and pushes — lossy oldest-evicted, never blocking.
481/// Best-effort: a poisoned lock is recovered, never fatal — telemetry must not
482/// take down the run it is describing.
483fn capture_to_ring(level: &'static str, event: &str, line: &Value) {
484 if RING_INSTALLED.load(Ordering::Relaxed) == 0 {
485 return;
486 }
487 let seq = RING_SEQ.fetch_add(1, Ordering::Relaxed) + 1;
488 let mut g = EVENT_RING.lock().unwrap_or_else(|e| e.into_inner());
489 if let Some(ring) = g.as_mut() {
490 ring.push(RingEntry {
491 seq,
492 level,
493 event: event.to_string(),
494 line: line.clone(),
495 });
496 // Mark the ring dirty so the served resource coalesces a notify.
497 EVENTS_DIRTY.store(1, Ordering::Relaxed);
498 }
499}
500
501impl Logger {
502 pub fn new(ctx: LogCtx, min: Level) -> Self {
503 Logger {
504 ctx,
505 min,
506 log_content: false,
507 }
508 }
509
510 /// Opt into content capture: callers that log tool args/results consult
511 /// [`Logger::content_capture`] first. Off by default, so args and results
512 /// are recorded as lengths only unless an operator asks for the bodies.
513 pub fn with_content(mut self, on: bool) -> Self {
514 self.log_content = on;
515 self
516 }
517
518 /// Whether this logger may record tool args/results (not just lengths).
519 pub fn content_capture(&self) -> bool {
520 self.log_content
521 }
522
523 pub fn ctx(&self) -> &LogCtx {
524 &self.ctx
525 }
526
527 /// Emit one event. `fields` should be a JSON object; its keys are merged
528 /// after the canonical fields (event-specific data). Non-object `fields`
529 /// is ignored. Below `min` level: dropped cheaply.
530 pub fn event(&self, level: Level, event: &str, fields: Value) {
531 if level < self.min {
532 return;
533 }
534 let mut m = Map::new();
535 m.insert(
536 "ts".into(),
537 Value::String(rfc3339_millis(SystemTime::now())),
538 );
539 m.insert("level".into(), Value::String(level.as_str().into()));
540 m.insert("event".into(), Value::String(event.into()));
541 m.insert("run_id".into(), Value::String(self.ctx.run_id.clone()));
542 m.insert("agent_id".into(), Value::String(self.ctx.agent_id.clone()));
543 m.insert(
544 "agent_path".into(),
545 Value::String(self.ctx.agent_path.clone()),
546 );
547 m.insert("comp".into(), Value::String(self.ctx.comp.as_str().into()));
548 m.insert("pid".into(), Value::Number(self.ctx.pid.into()));
549 if let Some(tid) = &self.ctx.trace_id {
550 m.insert("trace_id".into(), Value::String(tid.clone()));
551 }
552 if let Value::Object(extra) = fields {
553 for (k, v) in extra {
554 m.insert(k, v);
555 }
556 }
557 let value = Value::Object(m);
558 // Project the line into the bounded `agentd://events` ring — the same
559 // line, captured for the live-tail resource. A no-op (one relaxed atomic
560 // load) unless a ring is installed. Best-effort: capture never blocks and
561 // never fails the log write.
562 capture_to_ring(level.as_str(), event, &value);
563 // Project the line onto the runtime-events stream when one is armed and
564 // this family was selected. A single relaxed atomic load otherwise.
565 capture_to_tap(event, &value);
566 // Mirror to the OTLP logs exporter — a no-op (one atomic load) unless
567 // `otel.logs` armed it. Best-effort, never blocks the write.
568 crate::obs::otel::capture_log(
569 crate::obs::otel::now_unix_nanos(),
570 level.as_str(),
571 event,
572 &value,
573 );
574 // Build the whole line, then one locked write.
575 let mut line = serde_json::to_vec(&value).unwrap_or_else(|_| b"{}".to_vec());
576 line.push(b'\n');
577 let _guard = STDERR_LOCK.lock().unwrap_or_else(|e| e.into_inner());
578 let _ = std::io::stderr().write_all(&line);
579 }
580
581 pub fn info(&self, event: &str, fields: Value) {
582 self.event(Level::Info, event, fields);
583 }
584 pub fn warn(&self, event: &str, fields: Value) {
585 self.event(Level::Warn, event, fields);
586 }
587 pub fn error(&self, event: &str, fields: Value) {
588 self.event(Level::Error, event, fields);
589 }
590 pub fn debug(&self, event: &str, fields: Value) {
591 self.event(Level::Debug, event, fields);
592 }
593}
594
595/// Format a `SystemTime` as RFC 3339 UTC with millisecond precision, with no
596/// date-library dependency. Uses Howard Hinnant's `civil_from_days`
597/// algorithm. Pre-epoch times clamp to the epoch (we never log them).
598pub fn rfc3339_millis(t: SystemTime) -> String {
599 let dur = t.duration_since(UNIX_EPOCH).unwrap_or_default();
600 let secs = dur.as_secs() as i64;
601 let millis = dur.subsec_millis();
602
603 let days = secs.div_euclid(86_400);
604 let secs_of_day = secs.rem_euclid(86_400);
605 let (y, m, d) = civil_from_days(days);
606 let hh = secs_of_day / 3600;
607 let mm = (secs_of_day % 3600) / 60;
608 let ss = secs_of_day % 60;
609 format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}.{millis:03}Z")
610}
611
612/// Days since 1970-01-01 → (year, month, day). Hinnant's algorithm. Shared with
613/// the cron `timer` (UTC field decomposition).
614pub(crate) fn civil_from_days(z: i64) -> (i64, i64, i64) {
615 let z = z + 719_468;
616 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
617 let doe = z - era * 146_097; // [0, 146096]
618 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
619 let y = yoe + era * 400;
620 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
621 let mp = (5 * doy + 2) / 153; // [0, 11]
622 let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
623 let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
624 (if m <= 2 { y + 1 } else { y }, m, d)
625}
626
627#[cfg(test)]
628mod tests {
629 use super::*;
630 use std::time::Duration;
631
632 #[test]
633 fn rfc3339_known_timestamps() {
634 // 0 -> the epoch.
635 assert_eq!(rfc3339_millis(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
636 // 1_700_000_000 = 2023-11-14T22:13:20Z (a well-known round value).
637 let t = UNIX_EPOCH + Duration::from_secs(1_700_000_000);
638 assert_eq!(rfc3339_millis(t), "2023-11-14T22:13:20.000Z");
639 // millis are rendered.
640 let t = UNIX_EPOCH + Duration::from_millis(1_700_000_000_123);
641 assert_eq!(rfc3339_millis(t), "2023-11-14T22:13:20.123Z");
642 }
643
644 #[test]
645 fn leap_year_day() {
646 // 2024-02-29 is day 19782 since epoch.
647 let t = UNIX_EPOCH + Duration::from_secs(19_782 * 86_400);
648 assert_eq!(&rfc3339_millis(t)[..10], "2024-02-29");
649 }
650
651 #[test]
652 fn level_ordering_filters() {
653 assert!(Level::Debug < Level::Info);
654 assert!(Level::Error > Level::Warn);
655 }
656
657 fn ring_entry(seq: u64, level: &'static str, event: &str) -> RingEntry {
658 RingEntry {
659 seq,
660 level,
661 event: event.to_string(),
662 line: serde_json::json!({"event": event, "level": level}),
663 }
664 }
665
666 #[test]
667 fn ring_evicts_oldest_and_counts_dropped() {
668 // A 2-slot ring: pushing 3 lines drops exactly the oldest and bumps
669 // `dropped` once — the ring is lossy by design.
670 let mut r = EventRing::new(2);
671 r.push(ring_entry(1, "info", "loop.step"));
672 r.push(ring_entry(2, "info", "loop.step"));
673 assert_eq!(r.dropped, 0);
674 r.push(ring_entry(3, "warn", "limit.exceeded"));
675 assert_eq!(r.dropped, 1);
676 // Oldest (seq 1) is gone; 2 and 3 remain.
677 let seqs: Vec<u64> = r.buf.iter().map(|e| e.seq).collect();
678 assert_eq!(seqs, vec![2, 3]);
679 }
680
681 #[test]
682 fn ring_zero_cap_clamps_to_one() {
683 // A 0 cap would make every push an immediate eviction; it clamps to 1 so
684 // the ring always holds the newest line.
685 let mut r = EventRing::new(0);
686 r.push(ring_entry(1, "info", "a"));
687 r.push(ring_entry(2, "info", "b"));
688 assert_eq!(r.buf.len(), 1);
689 assert_eq!(r.buf.back().unwrap().seq, 2);
690 assert_eq!(r.dropped, 1);
691 }
692
693 #[test]
694 fn install_then_read_window_with_cursor_and_filters() {
695 // The ring is process-global, so this test owns it for its duration. It
696 // installs a fresh ring, emits a few lines through a real Logger (the
697 // capture path), then drains the window with the `?after` cursor and the
698 // level/event-prefix filters.
699 install_event_ring(64);
700 let base = RING_SEQ.load(Ordering::Relaxed); // cursor is global+monotonic
701 let log = Logger::new(
702 LogCtx {
703 run_id: "r".into(),
704 agent_id: "0".into(),
705 agent_path: "0".into(),
706 comp: Comp::Supervisor,
707 pid: 1,
708 trace_id: None,
709 },
710 Level::Trace,
711 );
712 log.info("loop.step", serde_json::json!({"step": 1}));
713 log.warn("limit.exceeded", serde_json::json!({"limit": "steps"}));
714 log.info("subagent.spawn", serde_json::json!({"node": 1}));
715
716 // No filter: everything after `base` is returned, each carrying a `seq`.
717 let w = read_event_window(base, 100, None, &[]).expect("ring installed");
718 assert!(w.events.len() >= 3);
719 assert!(w.events.iter().all(|e| e.get("seq").is_some()));
720 assert!(w.newest_seq >= w.oldest_seq);
721
722 // Level filter: only the warn line.
723 let w = read_event_window(base, 100, Some("warn"), &[]).expect("ring");
724 assert!(w.events.iter().all(|e| e["level"] == "warn"));
725 assert!(w.events.iter().any(|e| e["event"] == "limit.exceeded"));
726
727 // Event-prefix filter: only `subagent.*`.
728 let w = read_event_window(base, 100, None, &["subagent."]).expect("ring");
729 assert!(
730 w.events
731 .iter()
732 .all(|e| e["event"].as_str().unwrap().starts_with("subagent."))
733 );
734
735 // `limit` caps the slice oldest-first.
736 let w = read_event_window(base, 1, None, &[]).expect("ring");
737 assert_eq!(w.events.len(), 1);
738 }
739
740 /// The family list is a CLOSED vocabulary an operator's config is
741 /// validated against, so a family the tree emits but the list omits would
742 /// make `include: [that]` a startup error for an event that really exists.
743 /// Scanning the source keeps the list honest without anyone remembering
744 /// to: log an event under a family nobody has listed yet and this fails
745 /// until the family is added.
746 #[test]
747 fn families_cover_the_emitted_vocabulary() {
748 fn walk(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
749 for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
750 let p = e.path();
751 if p.is_dir() {
752 walk(&p, out);
753 } else if p.extension().is_some_and(|x| x == "rs") {
754 out.push(p);
755 }
756 }
757 }
758 let mut files = Vec::new();
759 walk(
760 &std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"),
761 &mut files,
762 );
763 let mut missing: Vec<String> = Vec::new();
764 for f in files {
765 let src = std::fs::read_to_string(&f).unwrap_or_default();
766 for m in ["\n.info(\"", ".warn(\"", ".error(\"", ".debug(\""] {
767 let needle = m.trim_start_matches('\n');
768 let mut rest = src.as_str();
769 while let Some(i) = rest.find(needle) {
770 rest = &rest[i + needle.len()..];
771 let Some(end) = rest.find('"') else { break };
772 let name = &rest[..end];
773 // Event names are dotted lowercase identifiers; anything
774 // else is a different call that happens to look similar.
775 if name.is_empty()
776 || !name.chars().all(|c| {
777 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '_'
778 })
779 {
780 continue;
781 }
782 let family = name.split('.').next().unwrap_or(name);
783 if !EVENT_FAMILIES.contains(&family) && !missing.contains(&family.to_string()) {
784 missing.push(family.to_string());
785 }
786 }
787 }
788 }
789 assert!(
790 missing.is_empty(),
791 "these event families are emitted but missing from EVENT_FAMILIES: {missing:?}"
792 );
793 }
794
795 /// Sorted and deduped, so the error message listing the known families
796 /// reads as a reference rather than as whatever order they were added in.
797 #[test]
798 fn the_family_list_is_sorted_and_unique() {
799 let mut sorted = EVENT_FAMILIES.to_vec();
800 sorted.sort_unstable();
801 sorted.dedup();
802 assert_eq!(sorted.as_slice(), EVENT_FAMILIES);
803 }
804}