Files
Varaverk/Plugin/unraid/Tools/ai_log_check.php
T
Gmer4Lfe 426ca2e5c7 Read the system log and the containers, not only Varaverk's own logs
Triage could see what Varaverk wrote about itself and what the arrs said about themselves, and
nothing else — so a disk throwing I/O errors, a filesystem going read-only or a PCIe link
retraining every two minutes was invisible to the thing whose job is noticing. Container state
was already watched; no line any container actually wrote ever was.

Container patterns match the environment rather than the application. Fifty containers are fifty
programs with no shared vocabulary for malfunctioning, but an exact shared one for a full disk or
a corrupt SQLite file, because those strings come from libc and SQLite rather than from the app.

Both halves are checked by Tools/ai_log_check.sh, which is two tests because the failure modes
are opposite: fixtures for recall on faults this host has never had, and a replay of its real
logs for precision — 74,519 syslog lines and 79,193 container lines, matching only the PCIe
errors it genuinely has.

Severity was being graded from a hand-picked three fields, so every one of these would have been
filed as a warning however bad it was, and notified as one.
2026-08-09 22:16:04 -04:00

132 lines
6.5 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Recall and precision for the syslog triage patterns. See ai_log_check.sh for why both
// halves exist and why neither alone is enough.
//
// OPERATIONAL MODEL
// Reads ai_log_fixtures.txt and this host's /var/log/syslog*. Files nothing, writes nothing,
// and calls no part of the sweep beyond vv_ai_syslog_findings() on lines it supplies itself.
//
// EXIT
// 0 when every fixture is recognised as written. Precision findings are reported but never
// fail the run: what a real syslog contains is a fact about the machine, not about the
// patterns, and a genuinely failing disk should not turn this into a red test.
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once dirname(__DIR__) . '/include/ai_repair.php';
$args = array_slice($argv ?? [], 1);
$only = in_array('--precision', $args, true) ? 'precision'
: (in_array('--recall', $args, true) ? 'recall' : 'both');
$fixtures = __DIR__ . '/ai_log_fixtures.txt';
$pass = 0; $fail = 0;
function ok(string $what, bool $cond, string $got = ''): void {
global $pass, $fail;
if ($cond) { $pass++; printf(" ok %s\n", $what); }
else { $fail++; printf(" FAIL %s%s\n", $what, $got !== '' ? "\n$got" : ''); }
}
// One line at a time, so a fixture is asserted on its own rather than on whatever aggregated
// with it. The finders are given the line directly, bypassing the file, the timestamp filter and
// docker — those are tested separately, and a fixture dated last August would otherwise be
// silently dropped for being older than the marker.
function classify(string $line, string $source = 'sys'): ?array {
if ($source === 'ctr') {
$f = vv_ai_container_findings(0, ['fixture-container' => [$line]]);
return $f[0] ?? null;
}
$f = vv_ai_syslog_findings(0, [$line]);
return $f[0] ?? null;
}
if ($only !== 'precision') {
echo "── recall: fixtures ──────────────────────────────────────────────\n";
if (!is_readable($fixtures)) {
echo " FAIL cannot read $fixtures\n";
exit(1);
}
foreach (file($fixtures, FILE_IGNORE_NEW_LINES) as $n => $raw) {
$line = rtrim($raw);
if ($line === '' || $line[0] === '#') continue;
// A ! line must match nothing at all. ! source | line
if ($line[0] === '!') {
$rest = trim(substr($line, 1));
$bits = explode('|', $rest, 2);
if (count($bits) !== 2) { ok(sprintf('L%d is malformed', $n + 1), false, $line); continue; }
$src = trim($bits[0]);
$sample = trim($bits[1]);
$got = classify($sample, $src);
ok(sprintf('L%-3d %s no match: %s', $n + 1, $src, mb_substr($sample, 0, 58)),
$got === null, $got ? "matched as {$got['ref']} / {$got['subject']}" : '');
continue;
}
$parts = explode('|', $line, 4);
if (count($parts) !== 4) { ok(sprintf('L%d is malformed', $n + 1), false, $line); continue; }
[$src, $wantSubject, $wantLevel, $sample] = array_map('trim', $parts);
$got = classify($sample, $src);
if ($got === null) {
ok(sprintf('L%-3d %s', $n + 1, mb_substr($sample, 0, 64)), false, 'no pattern matched');
continue;
}
ok(sprintf('L%-3d %-12s %-5s %s', $n + 1, $got['subject'], $got['sys_level'],
mb_substr($got['ref'], 0, 30)),
$got['subject'] === $wantSubject && $got['sys_level'] === $wantLevel,
sprintf('wanted %s/%s, got %s/%s', $wantSubject, $wantLevel,
$got['subject'], $got['sys_level']));
}
}
if ($only !== 'recall') {
echo "\n── precision: this host's real syslog history ────────────────────\n";
$total = 0; $hits = [];
foreach (glob('/var/log/syslog*') ?: [] as $file) {
if (!is_readable($file)) continue;
$fh = @fopen($file, 'r');
if (!$fh) continue;
while (($l = fgets($fh)) !== false) {
$total++;
$got = classify(rtrim($l));
if ($got === null) continue;
$k = $got['ref'] . ' | ' . $got['subject'] . ' | ' . $got['sys_level'];
$hits[$k] = ($hits[$k] ?? 0) + 1;
}
fclose($fh);
}
printf(" scanned %s lines\n", number_format($total));
if ($hits) { arsort($hits); foreach ($hits as $k => $n) printf(" %-7s %s\n", number_format($n), $k); }
else { echo " nothing matched\n"; }
// Every running container, read far deeper than a sweep ever does. A pattern that is quiet
// over this much real output is a pattern that will be quiet in service.
echo "\n── precision: every running container's log ──────────────────────\n";
$cTotal = 0; $hits = [];
$names = vv_ai_running_containers();
foreach ($names as $name) {
$lines = vv_ai_container_log($name, 1, 2000);
$cTotal += count($lines);
$found = vv_ai_container_findings(0, [$name => $lines]);
foreach ($found as $f) {
$k = $f['ref'] . ' | ' . $f['subject'] . ' | ' . $f['sys_level'];
$hits[$k] = ($hits[$k] ?? 0) + (int)filter_var($f['observed'], FILTER_SANITIZE_NUMBER_INT);
}
}
printf(" scanned %s lines across %d container%s\n", number_format($cTotal), count($names),
count($names) === 1 ? '' : 's');
if (!$hits) {
echo " nothing matched — this host is not reporting any of these faults\n";
} else {
arsort($hits);
foreach ($hits as $k => $n) printf(" %-7s %s\n", number_format($n), $k);
echo "\n Each line above is a fault the sweep would file. If any of them is normal\n"
. " operation on this machine, the pattern is wrong — add it to the fixtures as a\n"
. " ! line and tighten the pattern until it stops matching.\n";
}
}
printf("\n%d passed, %d failed\n", $pass, $fail);
exit($fail ? 1 : 0);