Skip to main content

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}