Skip to main content

keelrun_core/
events.rs

1//! Tier 1 live event sink: an append-only NDJSON feed of what the engine is
2//! doing *right now* — attempt starts/failures, backoff waits, breaker
3//! open/half-open/close, rate-limit queueing, cache hits/misses — written
4//! per run to `.keel/events/<run>.ndjson` for `keel tail` / `keel trace` to
5//! follow (dx-spec §6, invariant 4). **Non-contract**: the line format is
6//! versioned (`"v": 1` on every line) but lives outside `contracts/`; only
7//! Keel's own tooling reads it, and no daemon is involved — readers tail the
8//! file (dx invariant 3).
9//!
10//! # Activation (the decision, documented)
11//!
12//! Resolved once per [`Engine::new`](crate::Engine::new) from the process
13//! environment ([`EventSink::from_env`], testable as [`resolve_events_dir`]):
14//!
15//! - `KEEL_EVENTS` set to `0` / `false` / `off` / empty — force **off**.
16//! - `KEEL_EVENTS` set to anything else (`1`, `true`, …) — force **on**
17//!   (creates `./.keel/events/` on demand).
18//! - unset — **on** exactly when `./.keel` already exists (a Keel-initialized
19//!   project directory), **off** otherwise.
20//!
21//! Off is a zero-cost no-op: the engine holds no sink and every emit site is
22//! one `Option` discriminant check (the overhead bench's `a_empty` /
23//! `b_cache_miss` / `c_cache_hit` cases run this path; `d_events` measures
24//! the on path). A sink that cannot open (unwritable directory) degrades to a
25//! `warn!` and off — observability never fails the wrapped call.
26//!
27//! # Hot-path budget (dx invariant 8: ≤10µs)
28//!
29//! [`EventSink::emit`] allocates the event, stamps `seq`, and hands it to a
30//! background writer thread over a channel; JSON serialization and file I/O
31//! never run on the wrapped call's path. The writer buffers and flushes
32//! whenever its queue drains, so a live `keel tail` sees events promptly
33//! without a flush syscall per line.
34//!
35//! # Ordering and time
36//!
37//! `seq` is per-run monotonic and equals physical line order (allocated under
38//! the same lock that submits to the writer, so no interleaving can reorder
39//! the file). `ms` is engine-elapsed milliseconds from the engine's tokio
40//! clock — virtual under `start_paused`, so tests are wall-clock free. Wall
41//! time appears exactly once, in the `run_start` header line of a production
42//! sink (never under [`EventSink::to_writer`], the deterministic test/bench
43//! constructor).
44//!
45//! # Trace refs
46//!
47//! Every call's first event is `call_start`; the run id plus that event's
48//! `seq` form the [`TraceRef`] (`<run>#<seq>`) the engine appends to Tier 1
49//! terminal failure messages (`… trace: keel trace <ref>`, dx invariant 4) —
50//! only while a sink is active, so every implementation stays
51//! message-identical under conformance conditions (no sink attached). To
52//! resolve a ref: parse it ([`TraceRef::from_str`]), open
53//! `.keel/events/<run>.ndjson` ([`TraceRef::file_name`]), find the line with
54//! `seq` (the `call_start`), and select the call's other events by that
55//! line's `call` id.
56
57use core::fmt;
58use std::io::{self, Write};
59use std::path::{Path, PathBuf};
60use std::str::FromStr;
61use std::sync::Mutex;
62use std::sync::mpsc::{Receiver, Sender, SyncSender, TryRecvError, channel, sync_channel};
63use std::thread::JoinHandle;
64use std::time::{Duration, SystemTime, UNIX_EPOCH};
65
66use keel_core_api::{ErrorClass, ErrorCode};
67use serde::{Deserialize, Serialize};
68use tracing::warn;
69
70/// Sink line-format version — the `v` stamped on every line.
71pub const EVENTS_VERSION: u32 = 1;
72
73/// The subdirectory of `.keel/` holding per-run event files.
74pub const EVENTS_SUBDIR: &str = "events";
75
76/// File extension of a run's event file (newline-delimited JSON).
77pub const EVENTS_EXT: &str = "ndjson";
78
79/// How long [`EventSink::flush`] waits for the writer thread before giving
80/// up — a wedged filesystem must never hang the caller.
81const FLUSH_TIMEOUT: Duration = Duration::from_secs(5);
82
83/// One NDJSON line: the envelope every event shares, with the event-specific
84/// payload flattened beside it. Field order is fixed by this struct, so a
85/// given event serializes byte-identically everywhere.
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87pub struct Event {
88    /// Line-format version ([`EVENTS_VERSION`]).
89    pub v: u32,
90    /// Per-run monotonic sequence number; equals physical line order.
91    pub seq: u64,
92    /// Engine-elapsed milliseconds (virtual-clock-safe; never wall time).
93    pub ms: u64,
94    /// What happened, tagged as `"event"` in the JSON.
95    #[serde(flatten)]
96    pub kind: EventKind,
97}
98
99/// Which cache backend served (or missed) a call — mirrors the engine's
100/// cache-plan split, not the policy's `scope` field.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "snake_case")]
103pub enum CacheStore {
104    /// The in-process map.
105    Memory,
106    /// The journal's `cache` table.
107    Persistent,
108}
109
110/// The event vocabulary, tagged `"event"` with `snake_case` names. `call` is
111/// the call's `trace_id` (the `Outcome` field), so one call's events can be
112/// selected out of an interleaved feed; `target` repeats on every event so a
113/// tail can filter without joining back to `call_start`.
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
115#[serde(tag = "event", rename_all = "snake_case")]
116pub enum EventKind {
117    /// Per-run header, always `seq` 0: names the run and (production sinks
118    /// only) anchors it to wall time and a pid.
119    RunStart {
120        /// The run id — also the event file's stem.
121        run: String,
122        /// Milliseconds since the Unix epoch at sink open; absent under the
123        /// deterministic test/bench constructor.
124        #[serde(default, skip_serializing_if = "Option::is_none")]
125        wall_ms: Option<u64>,
126        /// The emitting process id; absent under the test/bench constructor.
127        #[serde(default, skip_serializing_if = "Option::is_none")]
128        pid: Option<u32>,
129    },
130    /// A call entered the layer chain (every call's first event; its `seq`
131    /// is the [`TraceRef`] anchor).
132    CallStart {
133        call: String,
134        target: String,
135        op: String,
136    },
137    /// The cache served the call (attempts stays 0).
138    CacheHit {
139        call: String,
140        target: String,
141        scope: CacheStore,
142    },
143    /// A cache plan existed but held no fresh entry; the call runs live.
144    CacheMiss {
145        call: String,
146        target: String,
147        scope: CacheStore,
148    },
149    /// The rate limiter queued the call for `wait_ms` (emitted before the
150    /// wait begins, so a live tail shows the queueing as it happens).
151    Throttle {
152        call: String,
153        target: String,
154        wait_ms: u64,
155    },
156    /// An open breaker failed the call fast (KEEL-E012; the effect never ran).
157    BreakerReject { call: String, target: String },
158    /// Cooldown elapsed: this call is the breaker's half-open probe.
159    BreakerHalfOpen { call: String, target: String },
160    /// The breaker tripped open (threshold reached, or the probe failed).
161    BreakerOpen {
162        call: String,
163        target: String,
164        cooldown_ms: u64,
165    },
166    /// A successful probe closed a previously-open breaker.
167    BreakerClose { call: String, target: String },
168    /// Attempt `attempt` (1-based) is about to invoke the effect.
169    AttemptStart {
170        call: String,
171        target: String,
172        attempt: u32,
173    },
174    /// Attempt `attempt` failed with the given class (pre-retry-decision).
175    AttemptError {
176        call: String,
177        target: String,
178        attempt: u32,
179        class: ErrorClass,
180        #[serde(default, skip_serializing_if = "Option::is_none")]
181        http_status: Option<u16>,
182    },
183    /// The retry layer is waiting `wait_ms` before the next attempt (emitted
184    /// before the wait begins).
185    Backoff {
186        call: String,
187        target: String,
188        attempt: u32,
189        wait_ms: u64,
190    },
191    /// The call settled (every call's last event). `result` mirrors the
192    /// Outcome's `"ok"` / `"error"`; `code` is the terminal error code.
193    CallEnd {
194        call: String,
195        target: String,
196        result: String,
197        #[serde(default, skip_serializing_if = "Option::is_none")]
198        code: Option<ErrorCode>,
199        attempts: u32,
200    },
201}
202
203/// A stable reference to one call in one run's event feed: the run id plus
204/// the `seq` of the call's `call_start` line. Rendered `<run>#<seq>` — the
205/// token Tier 1 failure messages carry after `trace: keel trace`.
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct TraceRef {
208    /// The run id (the event file's stem).
209    pub run: String,
210    /// The `seq` of the call's `call_start` event.
211    pub seq: u64,
212}
213
214impl TraceRef {
215    /// The event file this ref resolves against, relative to `.keel/events/`.
216    #[must_use]
217    pub fn file_name(&self) -> String {
218        format!("{}.{EVENTS_EXT}", self.run)
219    }
220}
221
222impl fmt::Display for TraceRef {
223    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224        write!(f, "{}#{}", self.run, self.seq)
225    }
226}
227
228/// A string that failed to parse as a [`TraceRef`].
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct ParseTraceRefError;
231
232impl fmt::Display for ParseTraceRefError {
233    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234        f.write_str("trace ref must look like <run>#<seq>, e.g. 019a2b3c4d5-7f2e#12")
235    }
236}
237
238impl std::error::Error for ParseTraceRefError {}
239
240impl FromStr for TraceRef {
241    type Err = ParseTraceRefError;
242
243    fn from_str(s: &str) -> Result<Self, Self::Err> {
244        let (run, seq) = s.rsplit_once('#').ok_or(ParseTraceRefError)?;
245        if run.is_empty() {
246            return Err(ParseTraceRefError);
247        }
248        let seq = seq.parse().map_err(|_| ParseTraceRefError)?;
249        Ok(Self {
250            run: run.to_owned(),
251            seq,
252        })
253    }
254}
255
256/// The environment inputs the activation decision depends on, captured as
257/// plain data so [`resolve_events_dir`] is unit-testable without touching
258/// process globals.
259#[derive(Debug, Clone)]
260pub struct EventsEnv {
261    /// The value of `KEEL_EVENTS`, if set.
262    pub keel_events: Option<String>,
263    /// The directory whose `.keel/` marks a Keel-initialized project (the
264    /// process working directory in production).
265    pub base_dir: PathBuf,
266}
267
268impl EventsEnv {
269    /// Snapshot the real process environment.
270    #[must_use]
271    pub fn capture() -> Self {
272        Self {
273            keel_events: std::env::var("KEEL_EVENTS").ok(),
274            base_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
275        }
276    }
277}
278
279/// Where events should be written, or `None` when the sink is off. See the
280/// module docs for the decision table this implements.
281#[must_use]
282pub fn resolve_events_dir(env: &EventsEnv) -> Option<PathBuf> {
283    let keel_dir = env.base_dir.join(".keel");
284    match env.keel_events.as_deref().map(str::trim) {
285        Some(v)
286            if v.is_empty()
287                || v.eq_ignore_ascii_case("0")
288                || v.eq_ignore_ascii_case("false")
289                || v.eq_ignore_ascii_case("off") =>
290        {
291            None
292        }
293        Some(_) => Some(keel_dir.join(EVENTS_SUBDIR)),
294        None => keel_dir.is_dir().then(|| keel_dir.join(EVENTS_SUBDIR)),
295    }
296}
297
298/// What crosses to the writer thread. Events stay typed until the writer
299/// serializes them — serialization is never on the wrapped call's path.
300enum Msg {
301    Event(Event),
302    Flush(SyncSender<()>),
303    Shutdown,
304}
305
306/// Sequence allocation and submission, under one lock so `seq` order and
307/// physical line order can never diverge.
308#[derive(Debug)]
309struct Emitter {
310    seq: u64,
311    tx: Sender<Msg>,
312}
313
314/// The live event sink: run identity + a buffered background NDJSON writer.
315/// One per engine; `&self`-concurrent (emit locks only to stamp `seq` and
316/// enqueue). Dropping the sink drains and flushes the feed (the writer thread
317/// is joined), so an engine dropped at process end loses nothing.
318#[derive(Debug)]
319pub struct EventSink {
320    emitter: Mutex<Emitter>,
321    run_id: String,
322    path: Option<PathBuf>,
323    writer: Option<JoinHandle<()>>,
324}
325
326impl EventSink {
327    /// Resolve activation from the process environment (see module docs) and
328    /// open the per-run file. `None` when off — or when the sink cannot open,
329    /// which degrades to a `warn!` (observability never fails the call).
330    #[must_use]
331    pub fn from_env() -> Option<Self> {
332        let dir = resolve_events_dir(&EventsEnv::capture())?;
333        match Self::open(&dir) {
334            Ok(sink) => Some(sink),
335            Err(error) => {
336                warn!(dir = %dir.display(), error = %error, "event sink unavailable; live events disabled");
337                None
338            }
339        }
340    }
341
342    /// Open a production sink in `dir` (created on demand): a fresh run id, a
343    /// `<run>.ndjson` file, and a `run_start` header anchored to wall time.
344    pub fn open(dir: &Path) -> io::Result<Self> {
345        std::fs::create_dir_all(dir)?;
346        // create_new: a run-id collision must never clobber another process's
347        // feed. Retry under a fresh id — the random suffix makes repeated
348        // same-millisecond collisions vanishingly unlikely.
349        let mut collision: io::Error = io::ErrorKind::AlreadyExists.into();
350        for _ in 0..3 {
351            let run_id = new_run_id();
352            let path = dir.join(format!("{run_id}.{EVENTS_EXT}"));
353            match std::fs::File::create_new(&path) {
354                Ok(file) => {
355                    return Self::start(
356                        Box::new(io::BufWriter::new(file)),
357                        run_id,
358                        Some(path),
359                        Some(epoch_ms()),
360                        Some(std::process::id()),
361                    );
362                }
363                Err(e) if e.kind() == io::ErrorKind::AlreadyExists => collision = e,
364                Err(e) => return Err(e),
365            }
366        }
367        Err(collision)
368    }
369
370    /// Deterministic test/bench sink: write to any `Write` under a caller-fixed
371    /// run id, with no wall-clock or pid fields anywhere in the feed — the
372    /// same events always produce byte-identical output.
373    pub fn to_writer(writer: Box<dyn Write + Send>, run_id: &str) -> io::Result<Self> {
374        Self::start(writer, run_id.to_owned(), None, None, None)
375    }
376
377    fn start(
378        writer: Box<dyn Write + Send>,
379        run_id: String,
380        path: Option<PathBuf>,
381        wall_ms: Option<u64>,
382        pid: Option<u32>,
383    ) -> io::Result<Self> {
384        let (tx, rx) = channel();
385        let handle = std::thread::Builder::new()
386            .name("keel-events".to_owned())
387            .spawn(move || write_events(&rx, writer))?;
388        let sink = Self {
389            emitter: Mutex::new(Emitter { seq: 0, tx }),
390            run_id: run_id.clone(),
391            path,
392            writer: Some(handle),
393        };
394        sink.emit(
395            0,
396            EventKind::RunStart {
397                run: run_id,
398                wall_ms,
399                pid,
400            },
401        );
402        Ok(sink)
403    }
404
405    /// This run's id — the token trace refs and the event file name carry.
406    #[must_use]
407    pub fn run_id(&self) -> &str {
408        &self.run_id
409    }
410
411    /// The event file being written, if this is a file-backed sink.
412    #[must_use]
413    pub fn path(&self) -> Option<&Path> {
414        self.path.as_deref()
415    }
416
417    /// Stamp `kind` with the next `seq` and the caller's clock reading, and
418    /// hand it to the writer. Returns the assigned `seq` (the [`TraceRef`]
419    /// anchor for `call_start`). A dead writer degrades to a dropped event.
420    pub fn emit(&self, ms: u64, kind: EventKind) -> u64 {
421        let mut emitter = self.emitter.lock().expect("event sink lock poisoned");
422        let seq = emitter.seq;
423        emitter.seq += 1;
424        let _ = emitter.tx.send(Msg::Event(Event {
425            v: EVENTS_VERSION,
426            seq,
427            ms,
428            kind,
429        }));
430        seq
431    }
432
433    /// Block until every event emitted so far is written and flushed (bounded
434    /// by [`FLUSH_TIMEOUT`]). For readers — tests, a same-process `keel
435    /// trace` — that need the file current *now*; the writer also flushes on
436    /// its own whenever its queue drains.
437    pub fn flush(&self) {
438        let (ack_tx, ack_rx) = sync_channel(1);
439        {
440            let emitter = self.emitter.lock().expect("event sink lock poisoned");
441            if emitter.tx.send(Msg::Flush(ack_tx)).is_err() {
442                return; // writer already gone; nothing left to flush
443            }
444        }
445        let _ = ack_rx.recv_timeout(FLUSH_TIMEOUT);
446    }
447}
448
449impl Drop for EventSink {
450    fn drop(&mut self) {
451        // `get_mut`: exclusive access, so a poisoned lock cannot block the
452        // drain. The writer flushes everything queued before honoring the
453        // shutdown, then the join guarantees the file is complete.
454        if let Ok(emitter) = self.emitter.get_mut() {
455            let _ = emitter.tx.send(Msg::Shutdown);
456        }
457        if let Some(handle) = self.writer.take() {
458            let _ = handle.join();
459        }
460    }
461}
462
463/// The writer thread: serialize + buffer each event, flush whenever the queue
464/// drains (so a tail sees events promptly, without a flush syscall per line).
465/// Write failures drop lines, never the call. `Shutdown` still drains what is
466/// already queued — messages ahead of it in the channel are processed first.
467fn write_events(rx: &Receiver<Msg>, mut out: Box<dyn Write + Send>) {
468    let mut dirty = false;
469    loop {
470        let msg = if dirty {
471            match rx.try_recv() {
472                Ok(msg) => msg,
473                Err(TryRecvError::Empty) => {
474                    let _ = out.flush();
475                    dirty = false;
476                    continue;
477                }
478                Err(TryRecvError::Disconnected) => break,
479            }
480        } else {
481            match rx.recv() {
482                Ok(msg) => msg,
483                Err(_) => break,
484            }
485        };
486        match msg {
487            Msg::Event(event) => {
488                if serde_json::to_writer(&mut out, &event).is_ok() && out.write_all(b"\n").is_ok() {
489                    dirty = true;
490                }
491            }
492            Msg::Flush(ack) => {
493                let _ = out.flush();
494                dirty = false;
495                let _ = ack.send(());
496            }
497            Msg::Shutdown => break,
498        }
499    }
500    let _ = out.flush();
501}
502
503/// A fresh run id: zero-padded hex epoch-milliseconds (lexically sortable, so
504/// "latest run" is a name sort) plus a random suffix against same-ms
505/// collisions. Wall clock is fine here — production names only; deterministic
506/// tests fix the run id via [`EventSink::to_writer`].
507fn new_run_id() -> String {
508    format!("{:011x}-{:04x}", epoch_ms(), fastrand::u16(..))
509}
510
511fn epoch_ms() -> u64 {
512    SystemTime::now()
513        .duration_since(UNIX_EPOCH)
514        .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
515}
516
517#[cfg(test)]
518mod tests {
519    use super::{
520        EVENTS_SUBDIR, Event, EventKind, EventSink, EventsEnv, ParseTraceRefError, TraceRef,
521        resolve_events_dir,
522    };
523    use std::io::Write;
524    use std::path::PathBuf;
525    use std::sync::{Arc, Mutex};
526
527    /// A `Write` the test keeps a handle on after the sink takes the box.
528    #[derive(Debug, Clone, Default)]
529    struct SharedBuf(Arc<Mutex<Vec<u8>>>);
530
531    impl SharedBuf {
532        fn contents(&self) -> String {
533            String::from_utf8(self.0.lock().expect("buf lock").clone()).expect("utf-8 feed")
534        }
535    }
536
537    impl Write for SharedBuf {
538        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
539            self.0.lock().expect("buf lock").extend_from_slice(buf);
540            Ok(buf.len())
541        }
542
543        fn flush(&mut self) -> std::io::Result<()> {
544            Ok(())
545        }
546    }
547
548    fn env(keel_events: Option<&str>, base_dir: &std::path::Path) -> EventsEnv {
549        EventsEnv {
550            keel_events: keel_events.map(str::to_owned),
551            base_dir: base_dir.to_owned(),
552        }
553    }
554
555    #[test]
556    fn activation_decision_table() {
557        let tmp = tempfile::tempdir().expect("tempdir");
558        let bare = tmp.path(); // no .keel yet
559
560        // Unset + no .keel dir: off.
561        assert_eq!(resolve_events_dir(&env(None, bare)), None);
562        // Explicitly off, in every accepted spelling, beats everything.
563        for off in ["0", "false", "off", "FALSE", "Off", "", "  "] {
564            assert_eq!(resolve_events_dir(&env(Some(off), bare)), None, "{off:?}");
565        }
566        // Any other set value forces on, .keel dir or not.
567        let expected = bare.join(".keel").join(EVENTS_SUBDIR);
568        for on in ["1", "true", "on", "yes"] {
569            assert_eq!(
570                resolve_events_dir(&env(Some(on), bare)),
571                Some(expected.clone()),
572                "{on:?}"
573            );
574        }
575        // Unset + an existing .keel dir: on (the keel-initialized project case).
576        std::fs::create_dir(bare.join(".keel")).expect("mk .keel");
577        assert_eq!(resolve_events_dir(&env(None, bare)), Some(expected));
578        // A .keel FILE is not a project marker.
579        let tmp2 = tempfile::tempdir().expect("tempdir");
580        std::fs::write(tmp2.path().join(".keel"), b"not a dir").expect("write file");
581        assert_eq!(resolve_events_dir(&env(None, tmp2.path())), None);
582    }
583
584    #[test]
585    fn trace_ref_round_trips_and_rejects_junk() {
586        let r = TraceRef {
587            run: "019a2b3c4d5-7f2e".to_owned(),
588            seq: 12,
589        };
590        assert_eq!(r.to_string(), "019a2b3c4d5-7f2e#12");
591        assert_eq!(r.file_name(), "019a2b3c4d5-7f2e.ndjson");
592        assert_eq!("019a2b3c4d5-7f2e#12".parse::<TraceRef>(), Ok(r));
593        // A '#' inside the run id resolves to the LAST separator.
594        assert_eq!(
595            "a#b#3".parse::<TraceRef>(),
596            Ok(TraceRef {
597                run: "a#b".to_owned(),
598                seq: 3
599            })
600        );
601        for bad in ["", "norun", "#7", "run#", "run#x", "run#-1"] {
602            assert_eq!(bad.parse::<TraceRef>(), Err(ParseTraceRefError), "{bad:?}");
603        }
604    }
605
606    #[test]
607    fn sink_writes_header_then_events_in_seq_order_and_drop_flushes() {
608        let buf = SharedBuf::default();
609        let sink =
610            EventSink::to_writer(Box::new(buf.clone()), "run-test").expect("sink must start");
611        assert_eq!(sink.run_id(), "run-test");
612        assert_eq!(sink.path(), None);
613        let seq = sink.emit(
614            5,
615            EventKind::CallStart {
616                call: "t-000001".to_owned(),
617                target: "api.example.com".to_owned(),
618                op: "GET api.example.com".to_owned(),
619            },
620        );
621        assert_eq!(seq, 1, "run_start header owns seq 0");
622        drop(sink); // joins the writer: everything queued is on disk after this
623
624        let lines: Vec<Event> = buf
625            .contents()
626            .lines()
627            .map(|l| serde_json::from_str(l).expect("every line parses"))
628            .collect();
629        assert_eq!(lines.len(), 2);
630        assert_eq!(
631            lines[0],
632            Event {
633                v: 1,
634                seq: 0,
635                ms: 0,
636                kind: EventKind::RunStart {
637                    run: "run-test".to_owned(),
638                    wall_ms: None,
639                    pid: None,
640                },
641            }
642        );
643        assert_eq!(lines[1].seq, 1);
644        assert_eq!(lines[1].ms, 5);
645        // Pin the exact wire shape of the header: field order is part of the
646        // format a later `keel tail` golden-tests against.
647        assert_eq!(
648            buf.contents().lines().next().expect("header line"),
649            r#"{"v":1,"seq":0,"ms":0,"event":"run_start","run":"run-test"}"#
650        );
651    }
652
653    #[test]
654    fn flush_makes_the_feed_current_without_dropping_the_sink() {
655        let buf = SharedBuf::default();
656        let sink =
657            EventSink::to_writer(Box::new(buf.clone()), "run-flush").expect("sink must start");
658        sink.emit(
659            1,
660            EventKind::BreakerReject {
661                call: "t-000001".to_owned(),
662                target: "api.example.com".to_owned(),
663            },
664        );
665        sink.flush();
666        let contents = buf.contents();
667        assert!(
668            contents.contains(r#""event":"breaker_reject""#),
669            "flushed feed must contain the event: {contents}"
670        );
671    }
672
673    #[test]
674    fn file_backed_sink_writes_run_file_with_wall_header() {
675        let tmp = tempfile::tempdir().expect("tempdir");
676        let dir = tmp.path().join(".keel").join(EVENTS_SUBDIR);
677        let sink = EventSink::open(&dir).expect("open sink");
678        let run = sink.run_id().to_owned();
679        let path = sink.path().expect("file-backed").to_owned();
680        assert_eq!(path, dir.join(format!("{run}.ndjson")));
681        drop(sink);
682
683        let feed = std::fs::read_to_string(&path).expect("feed readable");
684        let header: Event =
685            serde_json::from_str(feed.lines().next().expect("header")).expect("header parses");
686        match header.kind {
687            EventKind::RunStart {
688                run: r,
689                wall_ms,
690                pid,
691            } => {
692                assert_eq!(r, run);
693                assert!(wall_ms.is_some(), "production header anchors wall time");
694                assert_eq!(pid, Some(std::process::id()));
695            }
696            other => panic!("first line must be run_start, got {other:?}"),
697        }
698    }
699
700    #[test]
701    fn events_env_capture_reads_process_state() {
702        let env = EventsEnv::capture();
703        assert_ne!(env.base_dir, PathBuf::new(), "cwd captured");
704    }
705}