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}