Skip to main content

runtime/
diagnostics.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! The diagnostic log: write-only, five levels, three required fields.
7//! The design this implements is `docs/logging.md`.
8//!
9//! **Decision and recovery logic reads no log output.** That rule is held
10//! by shape rather than by discipline: this surface has write methods and
11//! no read method, so reading a line back inside the code cannot be
12//! spelled. The consequence is the one the design cares about — deleting
13//! every log leaves behaviour, replay and totals byte-identical, because
14//! nothing downstream could have depended on them.
15//!
16//! A line carries `seq` and never a timestamp. The Ledger position is
17//! what anchors a log line to the only history, and it is an integer
18//! two timelines can be lined up on; a wall clock sampled here would
19//! also be a second sampling point in a library that is not allowed one.
20//! A sink that wants a clock is free to add one — the assembly layer is
21//! where sampling is sanctioned.
22//!
23//! A sink receives an [`Entry`] rather than a rendered line, because a
24//! sink that had to read the fields back out of the text would be a
25//! second authority on what a line is made of. [`render`] is the one
26//! way text is produced, and a sink that wants text calls it.
27
28use crate::redact::Marker;
29use kernel::{RunId, Seq};
30
31/// Who reads a line, and when. Ordered from the widest audience to the
32/// narrowest: a floor admits every level at or before it.
33///
34/// The names answer "who reads this" rather than "how bad is this",
35/// because that is the question with a checkable answer. A severity
36/// ladder would invite the other one.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
38pub enum Level {
39    /// The person, live: what a door refused, in three parts.
40    Refuse,
41    /// The person, afterwards: which file was written, which provider
42    /// was called.
43    Effect,
44    /// The builder, when behaviour is wrong: why a verdict took the
45    /// value it did.
46    Decide,
47    /// The builder, when reproducing a defect: phase changes, locks,
48    /// retries.
49    Trace,
50    /// The builder, when a protocol does not connect: the bytes.
51    Wire,
52}
53
54impl Level {
55    /// The default floor. `refuse` and `effect` together answer nine of
56    /// ten questions a person has, and are small enough to leave on.
57    pub const DEFAULT: Level = Level::Effect;
58
59    /// Every level, in declaration order, for a command line to offer.
60    pub const ALL: [Level; 5] = [
61        Level::Refuse,
62        Level::Effect,
63        Level::Decide,
64        Level::Trace,
65        Level::Wire,
66    ];
67
68    #[must_use]
69    pub fn as_str(&self) -> &'static str {
70        match self {
71            Level::Refuse => "refuse",
72            Level::Effect => "effect",
73            Level::Decide => "decide",
74            Level::Trace => "trace",
75            Level::Wire => "wire",
76        }
77    }
78
79    /// Reads a level by name, for `--log <level>`. `off` is not a level
80    /// here: turning logging off is [`Diagnostics::off`], and letting
81    /// one word mean both a floor and the absence of one is how a
82    /// setting becomes ambiguous.
83    #[must_use]
84    pub fn parse(raw: &str) -> Option<Level> {
85        Level::ALL.into_iter().find(|level| level.as_str() == raw)
86    }
87}
88
89impl std::fmt::Display for Level {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        f.write_str(self.as_str())
92    }
93}
94
95/// The three fields every line carries. Required rather than optional:
96/// a line that cannot be placed in the history is a line nobody can act
97/// on later.
98#[derive(Debug, Clone, Copy)]
99pub struct Site<'a> {
100    pub run: RunId,
101    /// The Ledger position at the time. The load-bearing field.
102    pub seq: Seq,
103    pub module: &'a str,
104}
105
106/// One line, before anything has decided what it looks like.
107///
108/// `message` has already passed the secret scan, so a sink may write it
109/// anywhere it writes the rest of the entry.
110#[derive(Debug, Clone, Copy)]
111pub struct Entry<'a> {
112    pub level: Level,
113    pub site: Site<'a>,
114    pub message: &'a str,
115}
116
117/// Where lines go. A closure rather than a trait: this crate opens no
118/// files, and one implementation is not a seam.
119pub type Sink = Box<dyn FnMut(Entry<'_>) + Send>;
120
121/// The write-only logging surface.
122///
123/// Holding one is the only way to write a log line in this crate, and it
124/// offers no way to read one back.
125pub struct Diagnostics {
126    floor: Option<Level>,
127    sink: Sink,
128}
129
130impl std::fmt::Debug for Diagnostics {
131    /// The sink is not printable, and the floor is the only state worth
132    /// seeing. Manual rather than derived so a future field does not
133    /// quietly acquire a `Debug` that prints what it logged.
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        f.debug_struct("Diagnostics")
136            .field("floor", &self.floor)
137            .finish_non_exhaustive()
138    }
139}
140
141impl Diagnostics {
142    /// Writes every level at or before `floor` to `sink`.
143    #[must_use]
144    pub fn new(floor: Level, sink: Sink) -> Diagnostics {
145        Diagnostics {
146            floor: Some(floor),
147            sink,
148        }
149    }
150
151    /// Writes nothing at all. The state the deletion-invariance test
152    /// runs in, and a legitimate way to run the city.
153    #[must_use]
154    pub fn off() -> Diagnostics {
155        Diagnostics {
156            floor: None,
157            sink: Box::new(|_entry: Entry<'_>| {}),
158        }
159    }
160
161    /// The current floor, for `status` to report. `None` is off.
162    #[must_use]
163    pub fn floor(&self) -> Option<Level> {
164        self.floor
165    }
166
167    /// Whether a level would be written. Offered so a caller can skip
168    /// building an expensive message, not so it can branch on it.
169    #[must_use]
170    pub fn admits(&self, level: Level) -> bool {
171        self.floor.is_some_and(|floor| level <= floor)
172    }
173
174    /// Writes one line, if the floor admits it.
175    ///
176    /// The message passes the same secret scan the Ledger uses — the
177    /// same function, not a second one, because the scanner nobody
178    /// watches is the one that misses something. A hit is replaced in
179    /// place: dropping the line would lose the diagnostic entirely, and
180    /// the surrounding words are usually what the reader needed. A log
181    /// line marks plainly: see [`crate::redact::Marker`] for why history
182    /// and a log differ in that one argument and in nothing else.
183    pub fn write(&mut self, level: Level, site: Site<'_>, message: &str) {
184        if !self.admits(level) {
185            return;
186        }
187        let (scanned, _hits) = crate::redact::redact_text(message, Marker::Plain);
188        (self.sink)(Entry {
189            level,
190            site,
191            message: &scanned,
192        });
193    }
194}
195
196/// One JSON object per line, for the same reason the wire format is:
197/// the receiver may be a browser, and a person can still read it.
198///
199/// The one place a log line becomes text. A sink that writes to a
200/// terminal calls it; a sink that carries the fields onward does not,
201/// and so cannot disagree with this about what a line holds.
202#[must_use]
203pub fn render(entry: Entry<'_>) -> String {
204    let Entry {
205        level,
206        site,
207        message,
208    } = entry;
209    let mut map = serde_json::Map::new();
210    map.insert(
211        "level".to_owned(),
212        serde_json::Value::String(level.as_str().to_owned()),
213    );
214    map.insert(
215        "run".to_owned(),
216        serde_json::Value::String(site.run.to_string()),
217    );
218    map.insert(
219        "seq".to_owned(),
220        serde_json::Value::Number(site.seq.value().into()),
221    );
222    map.insert(
223        "module".to_owned(),
224        serde_json::Value::String(site.module.to_owned()),
225    );
226    map.insert(
227        "message".to_owned(),
228        serde_json::Value::String(message.to_owned()),
229    );
230    serde_json::Value::Object(map).to_string()
231}
232
233#[cfg(test)]
234#[allow(
235    clippy::unwrap_used,
236    clippy::expect_used,
237    clippy::panic,
238    clippy::indexing_slicing,
239    reason = "test code"
240)]
241mod tests {
242    use super::*;
243    use std::sync::{Arc, Mutex};
244
245    fn recorder() -> (Diagnostics, Arc<Mutex<Vec<String>>>) {
246        at_floor(Level::DEFAULT)
247    }
248
249    fn at_floor(floor: Level) -> (Diagnostics, Arc<Mutex<Vec<String>>>) {
250        let held = Arc::new(Mutex::new(Vec::new()));
251        let sink = Arc::clone(&held);
252        let log = Diagnostics::new(
253            floor,
254            Box::new(move |entry: Entry<'_>| {
255                sink.lock().unwrap().push(render(entry));
256            }),
257        );
258        (log, held)
259    }
260
261    fn site() -> Site<'static> {
262        Site {
263            run: RunId::CITY,
264            seq: Seq::FIRST,
265            module: "runtime::turn",
266        }
267    }
268
269    #[test]
270    fn the_default_floor_carries_the_two_levels_a_person_reads() {
271        let (mut log, held) = recorder();
272        for level in Level::ALL {
273            log.write(level, site(), "something happened");
274        }
275        let lines = held.lock().unwrap();
276        assert_eq!(lines.len(), 2);
277        assert!(lines[0].contains("\"level\":\"refuse\""));
278        assert!(lines[1].contains("\"level\":\"effect\""));
279    }
280
281    #[test]
282    fn every_line_can_be_placed_in_the_history() {
283        let (mut log, held) = recorder();
284        log.write(Level::Effect, site(), "wrote notes.md");
285        let lines = held.lock().unwrap();
286        let parsed: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
287        for field in ["run", "seq", "module"] {
288            assert!(!parsed[field].is_null(), "{field} is required");
289        }
290        assert_eq!(parsed["seq"], Seq::FIRST.value());
291        assert_eq!(parsed["module"], "runtime::turn");
292        // No timestamp: the position in the ledger is the anchor, and a
293        // clock sampled here would be a second time source.
294        assert!(parsed.get("t").is_none());
295        assert!(parsed.get("timestamp").is_none());
296    }
297
298    #[test]
299    fn a_credential_that_reaches_a_line_does_not_leave_in_it() {
300        // Assembled at runtime, for the same reason the gate wants: no
301        // credential-shaped literal at rest in the repository.
302        let token = ["sk-live-", "Zk29fQ4t", "Rr7mVx1L", "pA6c"].concat();
303        let (mut log, held) = recorder();
304        log.write(
305            Level::Effect,
306            site(),
307            &format!("calling the provider with {token}"),
308        );
309        let lines = held.lock().unwrap();
310        assert!(!lines[0].contains(&token), "line: {}", lines[0]);
311        assert!(lines[0].contains(&Marker::Plain.spell(token.as_bytes())));
312        // The words around it survive: a redaction that ate the sentence
313        // would cost the reader the thing they came for.
314        assert!(lines[0].contains("calling the provider with"));
315    }
316
317    #[test]
318    fn a_message_with_nothing_to_hide_is_unchanged() {
319        let (mut log, held) = recorder();
320        log.write(Level::Effect, site(), "wrote notes.md");
321        log.write(Level::Effect, site(), "");
322        let lines = held.lock().unwrap();
323        assert!(lines[0].contains("wrote notes.md"));
324        assert!(lines[1].contains("\"message\":\"\""));
325    }
326
327    #[test]
328    fn logging_off_writes_nothing_at_any_level() {
329        let mut log = Diagnostics::off();
330        assert_eq!(log.floor(), None);
331        for level in Level::ALL {
332            assert!(!log.admits(level));
333            // Reaches the sink or not is the whole question; `off` holds
334            // a sink that could not report either way, which is the
335            // point — nothing downstream can depend on a log.
336            log.write(level, site(), "ignored");
337        }
338    }
339
340    #[test]
341    fn a_floor_admits_everything_at_or_before_it() {
342        let (mut log, held) = at_floor(Level::Wire);
343        for level in Level::ALL {
344            log.write(level, site(), "x");
345        }
346        assert_eq!(held.lock().unwrap().len(), Level::ALL.len());
347        assert!(Level::Refuse < Level::Effect);
348        assert!(Level::Effect < Level::Wire);
349    }
350
351    #[test]
352    fn a_level_is_named_the_same_way_on_the_command_line_and_in_a_line() {
353        for level in Level::ALL {
354            assert_eq!(Level::parse(level.as_str()), Some(level));
355        }
356        assert_eq!(Level::parse("verbose"), None);
357        assert_eq!(Level::parse("off"), None);
358    }
359}