Skip to main content

supercode_harness/
slow_log.rs

1//! The slow log: a command or door call that takes more than a second is one JSON line in
2//! `<supercode home>/logs/slow.jsonl`, so slowness reads as bug monitoring (the audit reads it
3//! every round). The Node packages write the same lines (`@volter/supercode-harness-sdk/slow-log`).
4//!
5//! `{ v: 1, at, kind: "command"|"door"|"call", name, args, ms, outcome, machine, pid, caller,
6//! slowest: { step, ms } }`. `slowest` is the longest step the command noted. Writing never
7//! fails the command it measures.
8
9use std::io::Write;
10use std::path::PathBuf;
11use std::sync::Mutex;
12use std::time::Instant;
13
14/// Anything slower than this many milliseconds is written.
15pub const SLOW_MS: u128 = 1000;
16
17/// The log's size before it rotates: it and the one rotation before it are all that is kept.
18pub const ROTATE_BYTES: u64 = 4 * 1024 * 1024;
19
20/// How many rotated logs are kept beside the current one.
21pub const KEEP_ROTATED: usize = 3;
22
23static SLOWEST: Mutex<Option<(String, u128)>> = Mutex::new(None);
24/// Every step the running command noted: its name, total milliseconds and count.
25static STEPS: Mutex<Vec<(String, u128, u32)>> = Mutex::new(Vec::new());
26
27/// A step of the running command took `ms`; the longest is the command's `slowest`, and every
28/// step's total is in its line's `steps`.
29pub fn note_step(step: &str, ms: u128) {
30    if let Ok(mut slowest) = SLOWEST.lock() {
31        if slowest.as_ref().is_none_or(|(_, longest)| ms > *longest) {
32            *slowest = Some((step.to_string(), ms));
33        }
34    }
35    if let Ok(mut steps) = STEPS.lock() {
36        match steps.iter_mut().find(|(name, _, _)| name == step) {
37            Some((_, total, count)) => {
38                *total += ms;
39                *count += 1;
40            }
41            None => steps.push((step.to_string(), ms, 1)),
42        }
43    }
44}
45
46/// The steps noted so far, longest total first: where a slow command spent its time.
47fn step_totals() -> serde_json::Value {
48    let mut steps = STEPS.lock().map(|steps| steps.clone()).unwrap_or_default();
49    steps.sort_by(|a, b| b.1.cmp(&a.1));
50    serde_json::Value::Array(
51        steps
52            .into_iter()
53            .take(12)
54            .map(|(step, ms, count)| serde_json::json!({ "step": step, "ms": ms as u64, "count": count }))
55            .collect(),
56    )
57}
58
59/// Run `f` as the step `step` of the running command.
60pub fn timed<T>(step: &str, f: impl FnOnce() -> T) -> T {
61    let started = Instant::now();
62    let value = f();
63    note_step(step, started.elapsed().as_millis());
64    value
65}
66
67/// The longest step noted so far.
68pub fn slowest_step() -> Option<(String, u128)> {
69    SLOWEST.lock().ok().and_then(|slowest| slowest.clone())
70}
71
72/// Where the lines go.
73pub fn path() -> PathBuf {
74    crate::agent::global_instructions_dir()
75        .join("logs")
76        .join("slow.jsonl")
77}
78
79fn secret_flag(flag: &str) -> bool {
80    let name = flag.trim_start_matches('-').to_ascii_lowercase();
81    matches!(
82        name.as_str(),
83        "token"
84            | "key"
85            | "api-key"
86            | "apikey"
87            | "secret"
88            | "password"
89            | "passwd"
90            | "credential"
91            | "credentials"
92            | "auth"
93            | "authorization"
94            | "bearer"
95            | "cookie"
96            | "session-token"
97    )
98}
99
100fn secret_name(name: &str) -> bool {
101    let name = name.to_ascii_lowercase();
102    [
103        "token",
104        "secret",
105        "password",
106        "passwd",
107        "credential",
108        "api_key",
109        "api-key",
110        "apikey",
111        "authorization",
112        "cookie",
113    ]
114    .iter()
115    .any(|word| name.contains(word))
116}
117
118fn secret_value(value: &str) -> bool {
119    let token_like = |prefix: &str, min: usize| value.starts_with(prefix) && value.len() >= min;
120    token_like("sk-", 19)
121        || token_like("ghp_", 24)
122        || token_like("gho_", 24)
123        || token_like("ghs_", 24)
124        || token_like("ghu_", 24)
125        || token_like("ghr_", 24)
126        || token_like("xoxb-", 15)
127        || token_like("xoxp-", 15)
128        || (value.starts_with("eyJ") && value.matches('.').count() == 2 && value.len() > 40)
129        || (value.len() >= 40
130            && value.bytes().all(|b| {
131                b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'_' | b'-' | b'=')
132            }))
133}
134
135/// The arguments as the log keeps them: secrets replaced, each at most 200 characters.
136pub fn redact_args(args: &[String]) -> Vec<String> {
137    let mut out = Vec::with_capacity(args.len());
138    let mut hide_next = false;
139    for arg in args {
140        if hide_next {
141            out.push("[redacted]".to_string());
142            hide_next = false;
143            continue;
144        }
145        if let Some((flag, _)) = arg.split_once('=') {
146            if flag.starts_with('-') && secret_flag(flag) {
147                out.push(format!("{flag}=[redacted]"));
148                continue;
149            }
150            if !flag.starts_with('-')
151                && !flag.is_empty()
152                && flag.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
153                && secret_name(flag)
154            {
155                out.push(format!("{flag}=[redacted]"));
156                continue;
157            }
158        }
159        if arg.starts_with('-') && secret_flag(arg) {
160            out.push(arg.clone());
161            hide_next = true;
162            continue;
163        }
164        if secret_value(arg) {
165            out.push("[redacted]".to_string());
166            continue;
167        }
168        if arg.chars().count() > 200 {
169            out.push(format!("{}…", arg.chars().take(200).collect::<String>()));
170        } else {
171            out.push(arg.clone());
172        }
173    }
174    out
175}
176
177/// Bounded: past [`ROTATE_BYTES`] the log is renamed to a name of its own (`slow.<ms>-<pid>.jsonl`) and starts
178/// again, and only the newest [`KEEP_ROTATED`] are kept. A name per rotation: two processes rotating at once each
179/// keep what they renamed (one fixed name let the second rename replace the first's file, losing its lines). The Node
180/// writer rotates the same way.
181fn rotate(path: &std::path::Path) {
182    if !std::fs::metadata(path).is_ok_and(|meta| meta.len() > ROTATE_BYTES) {
183        return;
184    }
185    let Some(dir) = path.parent() else { return };
186    let now = std::time::SystemTime::now()
187        .duration_since(std::time::UNIX_EPOCH)
188        .map(|elapsed| elapsed.as_millis())
189        .unwrap_or_default();
190    if std::fs::rename(
191        path,
192        dir.join(format!("slow.{now:013}-{}.jsonl", std::process::id())),
193    )
194    .is_err()
195    {
196        return;
197    }
198    let mut rotated: Vec<std::path::PathBuf> = std::fs::read_dir(dir)
199        .map(|entries| {
200            entries
201                .flatten()
202                .map(|entry| entry.path())
203                .filter(|path| {
204                    path.file_name()
205                        .and_then(|name| name.to_str())
206                        .is_some_and(|name| {
207                            name.starts_with("slow.")
208                                && name.ends_with(".jsonl")
209                                && name != "slow.jsonl"
210                        })
211                })
212                .collect()
213        })
214        .unwrap_or_default();
215    rotated.sort();
216    let excess = rotated.len().saturating_sub(KEEP_ROTATED);
217    for old in &rotated[..excess] {
218        let _ = std::fs::remove_file(old);
219    }
220}
221
222/// One line, when `ms` is above the bound. `caller` is asked only then (resolving a caller reads
223/// the process table). Answers whether a line was written.
224#[allow(clippy::too_many_arguments)]
225pub fn record(
226    kind: &str,
227    name: &str,
228    args: &[String],
229    ms: u128,
230    outcome: &str,
231    slowest: Option<(String, u128)>,
232    caller: impl FnOnce() -> Option<String>,
233) -> bool {
234    if ms <= SLOW_MS {
235        return false;
236    }
237    let line = serde_json::json!({
238        "v": 1,
239        "at": supercode_interchange::sidecar::ms_to_rfc3339(
240            std::time::SystemTime::now()
241                .duration_since(std::time::UNIX_EPOCH)
242                .map(|elapsed| elapsed.as_millis() as i64)
243                .unwrap_or_default(),
244        ),
245        "kind": kind,
246        "name": name,
247        "args": redact_args(args),
248        "ms": ms as u64,
249        "outcome": outcome,
250        "machine": crate::mailbox::local_machine_name(),
251        "pid": std::process::id(),
252        "caller": caller().map(|address| serde_json::json!({ "address": address })),
253        "slowest": slowest.map(|(step, ms)| serde_json::json!({ "step": step, "ms": ms as u64 })),
254        "steps": if kind == "command" { step_totals() } else { serde_json::Value::Null },
255    });
256    let path = path();
257    if let Some(dir) = path.parent() {
258        let _ = std::fs::create_dir_all(dir);
259    }
260    rotate(&path);
261    std::fs::OpenOptions::new()
262        .create(true)
263        .append(true)
264        .open(&path)
265        .and_then(|mut file| writeln!(file, "{line}"))
266        .is_ok()
267}