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}