1use std::collections::HashMap;
45
46pub const MIN_GROUP: usize = 8;
51
52pub const SEVERITY_VERBATIM_BYTES: usize = 4096;
55
56pub const MAX_INPUT_BYTES: usize = 8 * 1024 * 1024;
58
59const MAX_TEMPLATE_LINE_BYTES: usize = 4096;
61
62const MAX_CONTINUATION: usize = 64;
64
65pub const MAX_NAME_SUFFIX_DIGITS: usize = 2;
68
69pub const MIN_HEX_ID: usize = 6;
71
72const HOLE: u8 = 0x01;
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct TemplateCollapsed {
78 pub body: String,
79 pub lines_before: usize,
80 pub lines_after: usize,
81 pub groups: usize,
83 pub grouped_lines: usize,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88enum Class {
89 Plain,
90 Severity,
91 Protected,
92}
93
94fn severity_re() -> &'static regex::Regex {
95 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
96 RE.get_or_init(|| {
97 regex::Regex::new(concat!(
98 r"(?i:\b(errors?|critical|fatal|panic(ked|king|s)?|exceptions?|fail(ed|ure|ures|ing|s)?",
99 r"|warn(ing|ings)?|traceback|segfault|abort(ed)?|denied|assert(ion|ions|ed)?)\b)",
100 r"|[a-z](Error|Exception)\b",
101 ))
102 .expect("BUG: invalid template_collapse severity regex")
103 })
104}
105
106fn test_runner_re() -> &'static regex::Regex {
107 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
108 RE.get_or_init(|| {
109 regex::Regex::new(concat!(
110 r"(?i:\btest result:|\brunning \d+ tests?\b|\b\d+ (passed|skipped|tests?)\b)",
111 r"|^(PASS|FAIL|ok|not ok|OK|FAILED)\b",
112 ))
113 .expect("BUG: invalid template_collapse test-runner regex")
114 })
115}
116
117fn is_diff_line(line: &str) -> bool {
118 line.starts_with("diff --git ") || (line.starts_with("@@ -") && line[4..].contains(" @@"))
119}
120
121fn is_indented(line: &str) -> bool {
122 line.starts_with([' ', '\t']) && !line.trim().is_empty()
123}
124
125fn is_data_record(line: &str) -> bool {
130 let t = line.trim();
131 t.starts_with('{')
132 || (t.starts_with('[')
133 && t.ends_with(']')
134 && serde_json::from_str::<serde::de::IgnoredAny>(t).is_ok())
135}
136
137fn classify(lines: &[&str]) -> Vec<Class> {
140 let mut class = vec![Class::Plain; lines.len()];
141 let mut i = 0;
142 while i < lines.len() {
143 let line = lines[i];
144 if line.contains("Traceback (most recent call last)") {
145 class[i] = Class::Protected;
146 let mut j = i + 1;
147 while j < lines.len() && (is_indented(lines[j]) || lines[j].trim().is_empty()) {
148 class[j] = Class::Protected;
149 j += 1;
150 }
151 if j < lines.len() {
152 class[j] = Class::Protected;
154 j += 1;
155 }
156 i = j;
157 continue;
158 }
159 if test_runner_re().is_match(line) {
160 class[i] = Class::Protected;
161 } else if severity_re().is_match(line) {
162 class[i] = Class::Severity;
163 let mut j = i + 1;
164 while j < lines.len() && j - i <= MAX_CONTINUATION && is_indented(lines[j]) {
165 class[j] = Class::Protected;
166 j += 1;
167 }
168 i = j;
169 continue;
170 }
171 i += 1;
172 }
173 let severity_bytes: usize = lines
174 .iter()
175 .zip(&class)
176 .filter(|(_, c)| **c == Class::Severity)
177 .map(|(l, _)| l.len() + 1)
178 .sum();
179 if severity_bytes <= SEVERITY_VERBATIM_BYTES {
180 for c in &mut class {
181 if *c == Class::Severity {
182 *c = Class::Protected;
183 }
184 }
185 }
186 class
187}
188
189fn is_uuid_at(b: &[u8]) -> bool {
190 const SHAPE: [usize; 5] = [8, 4, 4, 4, 12];
191 let mut pos = 0;
192 for (k, len) in SHAPE.iter().enumerate() {
193 if k > 0 {
194 if b.get(pos) != Some(&b'-') {
195 return false;
196 }
197 pos += 1;
198 }
199 let Some(part) = b.get(pos..pos + len) else {
200 return false;
201 };
202 if !part.iter().all(u8::is_ascii_hexdigit) {
203 return false;
204 }
205 pos += len;
206 }
207 !b.get(pos).is_some_and(u8::is_ascii_alphanumeric)
208}
209
210fn is_variable(line: &[u8], start: usize, word: &[u8]) -> bool {
213 let digits = word.iter().filter(|b| b.is_ascii_digit()).count();
214 if digits == 0 {
215 return false;
216 }
217 if digits == word.len() {
218 let name_suffix = start >= 2
219 && matches!(line[start - 1], b'-' | b'_')
220 && line[start - 2].is_ascii_alphabetic();
221 return !(name_suffix && word.len() <= MAX_NAME_SUFFIX_DIGITS);
222 }
223 if word[0].is_ascii_digit() && word.len() - digits <= 3 {
224 return true;
225 }
226 word.len() >= MIN_HEX_ID && word.iter().all(u8::is_ascii_hexdigit)
227}
228
229fn template(line: &str) -> (Vec<u8>, Vec<(usize, usize)>) {
231 let b = line.as_bytes();
232 let mut key = Vec::with_capacity(b.len());
233 let mut spans = Vec::new();
234 let mut i = 0;
235 while i < b.len() {
236 if !b[i].is_ascii_alphanumeric() {
237 key.push(b[i]);
238 i += 1;
239 continue;
240 }
241 let start = i;
242 let word_start_ok = start == 0 || !b[start - 1].is_ascii_alphanumeric();
243 if word_start_ok && is_uuid_at(&b[start..]) {
244 i = start + 36;
245 key.push(HOLE);
246 spans.push((start, i));
247 continue;
248 }
249 while i < b.len() && b[i].is_ascii_alphanumeric() {
250 i += 1;
251 }
252 if is_variable(b, start, &b[start..i]) {
253 key.push(HOLE);
254 spans.push((start, i));
255 } else {
256 key.extend_from_slice(&b[start..i]);
257 }
258 }
259 (key, spans)
260}
261
262struct Group {
263 first: usize,
264 last: usize,
265 count: usize,
266}
267
268#[must_use]
271pub fn collapse(input: &str) -> Option<TemplateCollapsed> {
272 if input.len() > MAX_INPUT_BYTES {
273 return None;
274 }
275 let lines: Vec<&str> = input.split('\n').collect();
276 if lines.iter().any(|l| is_diff_line(l)) {
277 return None;
278 }
279 let class = classify(&lines);
280 let mut index: HashMap<(bool, Vec<u8>), usize> = HashMap::new();
281 let mut groups: Vec<Group> = Vec::new();
282 let mut spans: Vec<Vec<(usize, usize)>> = vec![Vec::new(); lines.len()];
283 let mut member: Vec<Option<usize>> = vec![None; lines.len()];
284 for (i, line) in lines.iter().enumerate() {
285 if class[i] == Class::Protected
286 || line.trim().is_empty()
287 || line.len() > MAX_TEMPLATE_LINE_BYTES
288 || is_data_record(line)
289 {
290 continue;
291 }
292 let (key, line_spans) = template(line);
293 if line_spans.is_empty() {
294 continue;
297 }
298 spans[i] = line_spans;
299 let next = groups.len();
300 let g = *index
301 .entry((class[i] == Class::Severity, key))
302 .or_insert(next);
303 if g == next {
304 groups.push(Group {
305 first: i,
306 last: i,
307 count: 0,
308 });
309 }
310 groups[g].last = i;
311 groups[g].count += 1;
312 member[i] = Some(g);
313 }
314 let mut out: Vec<String> = Vec::with_capacity(lines.len());
315 let (mut group_lines, mut grouped) = (0usize, 0usize);
316 for (i, line) in lines.iter().enumerate() {
317 match member[i].map(|g| &groups[g]) {
318 Some(g) if g.count >= MIN_GROUP => {
319 if g.first == i {
320 out.push(group_line(&lines, &spans, g));
321 group_lines += 1;
322 grouped += g.count;
323 }
324 }
325 _ => out.push((*line).to_string()),
326 }
327 }
328 if group_lines == 0 {
329 return None;
330 }
331 Some(TemplateCollapsed {
332 body: out.join("\n"),
333 lines_before: lines.len(),
334 lines_after: out.len(),
335 groups: group_lines,
336 grouped_lines: grouped,
337 })
338}
339
340fn group_line(lines: &[&str], spans: &[Vec<(usize, usize)>], g: &Group) -> String {
342 fn field(line: &str, s: (usize, usize)) -> &str {
343 &line[s.0..s.1]
344 }
345 let (first, last) = (lines[g.first], lines[g.last]);
346 let (fs, ls) = (&spans[g.first], &spans[g.last]);
347 let changed: Vec<usize> = (0..fs.len().min(ls.len()))
348 .filter(|&k| field(first, fs[k]) != field(last, ls[k]))
349 .collect();
350 match (changed.first(), changed.last()) {
351 (Some(&a), Some(&z)) => format!(
352 "[{}× like] {first} … last: {}",
353 g.count,
354 &last[ls[a].0..ls[z].1]
355 ),
356 _ => format!("[{}× identical] {first}", g.count),
357 }
358}
359
360#[must_use]
363pub fn render(c: &TemplateCollapsed, recovery: &str) -> String {
364 format!(
365 "[lean-ctx: {} lines shown as {}; {} lines that differ only in numbers, ids or timestamps \
366 are grouped as \"[N× like] first … last: …\"; every distinct error message is kept; {recovery}]\n{}",
367 c.lines_before, c.lines_after, c.grouped_lines, c.body
368 )
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 fn heartbeat_log(n: usize) -> String {
376 (0..n).fold(String::new(), |mut s, i| {
377 let ms = i * 997;
378 s.push_str(&format!(
379 "2026-03-14T10:{:02}:{:02}.{:03}Z INFO heartbeat ok worker-2 seq={i}\n",
380 (ms / 60_000) % 60,
381 (ms / 1000) % 60,
382 ms % 1000
383 ));
384 s
385 })
386 }
387
388 #[test]
389 fn variable_field_table() {
390 let cases = [
392 (
393 "2026-03-14T10:00:00.001Z INFO heartbeat ok worker-2 seq=0",
394 "#-#-#:#:#.# INFO heartbeat ok worker-2 seq=#",
395 ),
396 ("delivery dlv-fc56b260 took 750ms", "delivery dlv-# took #"),
397 (
398 "batch b-006992 delivered 487/500",
399 "batch b-# delivered #/#",
400 ),
401 ("from 10.0.12.7:8443", "from #.#.#.#:#"),
402 ("id 550e8400-e29b-41d4-a716-446655440000 ok", "id # ok"),
403 (
404 "relayd/schema.py:47:101: W501 line too long (128 > 100)",
405 "relayd/schema.py:#:#: W501 line too long (# > #)",
406 ),
407 ("shard_7 v2 E201 cafe", "shard_7 v2 E201 cafe"),
408 ];
409 for (line, want) in cases {
410 let (key, _) = template(line);
411 let shown: String = key
412 .iter()
413 .map(|&b| if b == HOLE { '#' } else { b as char })
414 .collect();
415 assert_eq!(shown, want, "{line}");
416 }
417 }
418
419 #[test]
420 fn heartbeat_log_collapses_by_at_least_eighty_percent() {
421 let log = heartbeat_log(1500);
422 let c = collapse(&log).expect("collapses");
423 assert!(
424 c.body.len() * 5 <= log.len(),
425 "{} vs {}",
426 c.body.len(),
427 log.len()
428 );
429 assert!(c.body.starts_with(
430 "[1500× like] 2026-03-14T10:00:00.000Z INFO heartbeat ok worker-2 seq=0 … last: "
431 ));
432 assert!(
433 c.body.contains("seq=1499"),
434 "last instance named: {}",
435 c.body
436 );
437 assert_eq!((c.groups, c.grouped_lines), (1, 1500));
438 }
439
440 #[test]
441 fn rare_error_among_thousands_of_retries_stays_verbatim() {
442 let mut log = String::new();
443 let rare = "2026-03-14T03:12:09.114Z ERROR worker-2 batch b-006992 stopped: ValueError: invalid duration '5x' in parse_duration";
444 for i in 0..3000 {
445 if i == 1777 {
446 log.push_str(rare);
447 log.push('\n');
448 }
449 log.push_str(&format!(
450 "2026-03-14T03:{:02}:{:02}.{:03}Z WARN worker-1 delivery dlv-{:08x} to https://a.example/x failed: 503 Service Unavailable (attempt {}/5, retrying in 500ms)\n",
451 (i / 60) % 60, i % 60, i % 1000, i * 7919 + 0x1000_0000, i % 5 + 1
452 ));
453 }
454 let c = collapse(&log).expect("collapses");
455 assert!(c.body.lines().any(|l| l == rare), "rare error verbatim");
456 assert!(c.body.len() * 10 < log.len());
457 assert!(c.body.contains("[3000× like] "));
459 }
460
461 #[test]
462 fn small_severity_sets_stay_verbatim_and_never_mix_with_plain_lines() {
463 let mut log = heartbeat_log(200);
464 for i in 0..20 {
465 log.push_str(&format!(
466 "2026-03-14T11:00:{i:02}.000Z ERROR worker-3 timeout after {i}s\n"
467 ));
468 }
469 let c = collapse(&log).unwrap();
470 for i in 0..20 {
471 let line = format!("2026-03-14T11:00:{i:02}.000Z ERROR worker-3 timeout after {i}s");
472 assert!(c.body.lines().any(|l| l == line), "{line}");
473 }
474 }
475
476 #[test]
477 fn tracebacks_test_output_and_diffs_stay_untouched() {
478 let mut log = heartbeat_log(100);
479 let tb = "Traceback (most recent call last):\n File \"relayd/worker.py\", line 88, in run\n d = parse_duration(v)\n File \"relayd/config.py\", line 12, in parse_duration\n raise ValueError(v)\nValueError: 5x";
480 let tb_lines: Vec<String> = tb.lines().map(str::to_owned).collect();
481 for _ in 0..10 {
482 log.push_str(tb);
483 log.push('\n');
484 }
485 for i in 0..12 {
486 log.push_str(&format!("ok {i} - case_{i}\n"));
487 }
488 log.push_str("Ran 12 tests in 0.004s\n");
489 let c = collapse(&log).unwrap();
490 for l in &tb_lines {
491 assert_eq!(c.body.lines().filter(|b| b == l).count(), 10, "{l}");
492 }
493 assert_eq!(c.body.lines().filter(|l| l.starts_with("ok ")).count(), 12);
494 assert!(c.body.contains("Ran 12 tests in 0.004s"));
495
496 let diff = format!(
497 "{}diff --git a/x b/x\n@@ -1,2 +1,2 @@\n",
498 heartbeat_log(100)
499 );
500 assert!(collapse(&diff).is_none(), "diffs are refused whole");
501 }
502
503 #[test]
504 fn output_is_deterministic_and_order_preserving() {
505 let mut log = String::from("start\n");
506 log.push_str(&heartbeat_log(40));
507 log.push_str("middle marker\n");
508 log.push_str(&heartbeat_log(40));
509 log.push_str("end\n");
510 let a = collapse(&log).unwrap();
511 assert_eq!(a, collapse(&log).unwrap());
512 let body: Vec<&str> = a.body.lines().collect();
513 assert_eq!(body[0], "start");
514 assert!(body[1].starts_with("[80× like] "));
515 assert_eq!(&body[2..], &["middle marker", "end"]);
516 assert_eq!(render(&a, "full: X"), render(&a, "full: X"));
517 }
518
519 #[test]
520 fn nothing_to_group_returns_none() {
521 assert!(collapse("a\nb\nc").is_none());
522 assert!(collapse(&heartbeat_log(MIN_GROUP - 1)).is_none());
523 assert!(
524 collapse(&"plain text line\n".repeat(50)).is_none(),
525 "identical text is R2's"
526 );
527 let records = |open: &str, close: &str| {
529 (0..50).fold(String::new(), |mut s, i| {
530 s.push_str(&format!(
531 "{open}\"id\":{i},\"status\":{}{close}\n",
532 200 + i % 3
533 ));
534 s
535 })
536 };
537 assert!(collapse(&records("{", "}")).is_none());
538 assert!(collapse(&records("[{", "}]")).is_none());
539 let bracketed = (0..50).fold(String::new(), |mut s, i| {
540 s.push_str(&format!(
541 "[2026-03-14 10:00:{:02}] INFO heartbeat seq={i}\n",
542 i % 60
543 ));
544 s
545 });
546 assert!(collapse(&bracketed).is_some());
547 }
548}