feat: add ErrorContextAnalyser for sliding-window error/warning surfacing
Walks Entry[] once and emits one ErrorContextProblem per ERROR or WARNING entry, attaching up to 20 entries before and 20 after as context. Overlapping windows clip the second hit's before- and after-ranges so no Entry appears in two context arrays. Caps emission at 500 hits and adds an ErrorContextTruncatedInformation when reached. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
131
src/Analyser/ProjectZomboid/ErrorContextAnalyser.php
Normal file
131
src/Analyser/ProjectZomboid/ErrorContextAnalyser.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace IndifferentKetchup\Codex\Analyser\ProjectZomboid;
|
||||
|
||||
use IndifferentKetchup\Codex\Analyser\Analyser;
|
||||
use IndifferentKetchup\Codex\Analysis\Analysis;
|
||||
use IndifferentKetchup\Codex\Analysis\AnalysisInterface;
|
||||
use IndifferentKetchup\Codex\Analysis\ProjectZomboid\ErrorContextProblem;
|
||||
use IndifferentKetchup\Codex\Analysis\ProjectZomboid\ErrorContextTruncatedInformation;
|
||||
use IndifferentKetchup\Codex\Log\EntryInterface;
|
||||
use IndifferentKetchup\Codex\Log\Level;
|
||||
|
||||
/**
|
||||
* Surfaces ERROR or WARNING entries with a sliding context window of
|
||||
* surrounding entries, so a viewer can see the lead-up and aftermath of
|
||||
* each event without scanning the full log. PatternAnalyser cannot
|
||||
* express this because windows span multiple entries; this walks once,
|
||||
* classifies by Level (already resolved by the parser), and emits one
|
||||
* ErrorContextProblem per hit.
|
||||
*
|
||||
* Stack-trace continuation lines are absorbed into the same Entry as the
|
||||
* level header that preceded them by PatternParser, so noise filtering
|
||||
* happens at parse time — windows here count Entries, not raw lines, and
|
||||
* a stack-trace ERROR contributes exactly one window.
|
||||
*
|
||||
* Overlapping windows are merged: when two error/warning entries fall
|
||||
* within CONTEXT_BEFORE + CONTEXT_AFTER of each other, the later
|
||||
* window's before- and after-ranges are clipped to start past the
|
||||
* previously emitted range so no Entry appears in two context arrays.
|
||||
* The hit cap is enforced after emission; reaching it adds an
|
||||
* ErrorContextTruncatedInformation to the analysis instead of further
|
||||
* problems.
|
||||
*/
|
||||
class ErrorContextAnalyser extends Analyser
|
||||
{
|
||||
/**
|
||||
* Number of entries preceding a hit captured as leading context.
|
||||
* Twenty entries is wide enough to surface the immediate precursor
|
||||
* events (mod load, player join, prior warning) for a server-log
|
||||
* error without dragging in unrelated activity from minutes earlier.
|
||||
*/
|
||||
public const int CONTEXT_BEFORE = 20;
|
||||
|
||||
/**
|
||||
* Number of entries following a hit captured as trailing context.
|
||||
* Mirrors CONTEXT_BEFORE so windows are symmetric and the maximum
|
||||
* window size is CONTEXT_BEFORE + 1 (hit) + CONTEXT_AFTER = 41
|
||||
* entries.
|
||||
*/
|
||||
public const int CONTEXT_AFTER = 20;
|
||||
|
||||
/**
|
||||
* Maximum number of hits emitted before truncation. Caps memory and
|
||||
* output size on logs with cascading errors (e.g. a save-system
|
||||
* failure that produces an error every tick). Reaching the cap adds
|
||||
* an ErrorContextTruncatedInformation to the analysis so consumers
|
||||
* can flag truncation rather than silently dropping later hits.
|
||||
*/
|
||||
public const int HIT_CAP = 500;
|
||||
|
||||
public function analyse(): AnalysisInterface
|
||||
{
|
||||
$analysis = new Analysis();
|
||||
$analysis->setLog($this->log);
|
||||
|
||||
$entries = [];
|
||||
foreach ($this->log as $entry) {
|
||||
$entries[] = $entry;
|
||||
}
|
||||
$count = count($entries);
|
||||
|
||||
$hits = 0;
|
||||
$truncated = false;
|
||||
$lastEmittedIndex = -1;
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$type = $this->classify($entries[$i]);
|
||||
if ($type === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($hits >= self::HIT_CAP) {
|
||||
$truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
$beforeStart = max($lastEmittedIndex + 1, $i - self::CONTEXT_BEFORE);
|
||||
if ($beforeStart > $i) {
|
||||
$beforeStart = $i;
|
||||
}
|
||||
$afterStart = max($lastEmittedIndex + 1, $i + 1);
|
||||
$afterEnd = min($count - 1, $i + self::CONTEXT_AFTER);
|
||||
$afterLength = max(0, $afterEnd - $afterStart + 1);
|
||||
|
||||
$analysis->addInsight((new ErrorContextProblem())
|
||||
->setEntry($entries[$i])
|
||||
->setType($type)
|
||||
->setEntryIndex($i + 1)
|
||||
->setBefore(array_slice($entries, $beforeStart, $i - $beforeStart))
|
||||
->setAfter(array_slice($entries, $afterStart, $afterLength)));
|
||||
|
||||
$hits++;
|
||||
$lastEmittedIndex = max($lastEmittedIndex, $afterEnd);
|
||||
}
|
||||
|
||||
if ($truncated) {
|
||||
$analysis->addInsight((new ErrorContextTruncatedInformation())
|
||||
->setHitCap(self::HIT_CAP));
|
||||
}
|
||||
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify an entry as 'error', 'warning', or null based on its Level.
|
||||
* Levels at or below ERROR (EMERGENCY/ALERT/CRITICAL/ERROR) collapse
|
||||
* into 'error'; WARNING alone collapses into 'warning'. Returns null
|
||||
* for anything less severe so the analyser skips it.
|
||||
*/
|
||||
protected function classify(EntryInterface $entry): ?string
|
||||
{
|
||||
$level = $entry->getLevel()->asInt();
|
||||
if ($level <= Level::ERROR->asInt()) {
|
||||
return 'error';
|
||||
}
|
||||
if ($level === Level::WARNING->asInt()) {
|
||||
return 'warning';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
130
src/Analysis/ProjectZomboid/ErrorContextProblem.php
Normal file
130
src/Analysis/ProjectZomboid/ErrorContextProblem.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace IndifferentKetchup\Codex\Analysis\ProjectZomboid;
|
||||
|
||||
use IndifferentKetchup\Codex\Analysis\InsightInterface;
|
||||
use IndifferentKetchup\Codex\Analysis\Problem;
|
||||
use IndifferentKetchup\Codex\Log\EntryInterface;
|
||||
|
||||
/**
|
||||
* Problem emitted by ErrorContextAnalyser for each ERROR or WARNING entry,
|
||||
* carrying a sliding window of surrounding entries as before/after
|
||||
* context. Coalesced by 1-based entryIndex so re-adding the same hit
|
||||
* never produces duplicate problems.
|
||||
*/
|
||||
class ErrorContextProblem extends Problem
|
||||
{
|
||||
private string $type = 'error';
|
||||
private int $entryIndex = 0;
|
||||
|
||||
/**
|
||||
* @var EntryInterface[]
|
||||
*/
|
||||
private array $before = [];
|
||||
|
||||
/**
|
||||
* @var EntryInterface[]
|
||||
*/
|
||||
private array $after = [];
|
||||
|
||||
/**
|
||||
* @param string $type 'error' or 'warning'
|
||||
* @return $this
|
||||
*/
|
||||
public function setType(string $type): static
|
||||
{
|
||||
$this->type = $type;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getType(): string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $entryIndex 1-based index of the hit entry within the log
|
||||
* @return $this
|
||||
*/
|
||||
public function setEntryIndex(int $entryIndex): static
|
||||
{
|
||||
$this->entryIndex = $entryIndex;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int 1-based index of the hit entry within the log
|
||||
*/
|
||||
public function getEntryIndex(): int
|
||||
{
|
||||
return $this->entryIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param EntryInterface[] $entries
|
||||
* @return $this
|
||||
*/
|
||||
public function setBefore(array $entries): static
|
||||
{
|
||||
$this->before = $entries;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return EntryInterface[]
|
||||
*/
|
||||
public function getBefore(): array
|
||||
{
|
||||
return $this->before;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param EntryInterface[] $entries
|
||||
* @return $this
|
||||
*/
|
||||
public function setAfter(array $entries): static
|
||||
{
|
||||
$this->after = $entries;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return EntryInterface[]
|
||||
*/
|
||||
public function getAfter(): array
|
||||
{
|
||||
return $this->after;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience accessor returning before-context, hit entry, and
|
||||
* after-context as a single ordered array of at most
|
||||
* ErrorContextAnalyser::CONTEXT_BEFORE + 1 + CONTEXT_AFTER = 41
|
||||
* entries.
|
||||
*
|
||||
* @return EntryInterface[]
|
||||
*/
|
||||
public function getContext(): array
|
||||
{
|
||||
return [...$this->before, $this->getEntry(), ...$this->after];
|
||||
}
|
||||
|
||||
public function getMessage(): string
|
||||
{
|
||||
return sprintf(
|
||||
'%s at entry %d (%d before, %d after)',
|
||||
strtoupper($this->type),
|
||||
$this->entryIndex,
|
||||
count($this->before),
|
||||
count($this->after)
|
||||
);
|
||||
}
|
||||
|
||||
public function isEqual(InsightInterface $insight): bool
|
||||
{
|
||||
return $insight instanceof self && $insight->getEntryIndex() === $this->entryIndex;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace IndifferentKetchup\Codex\Analysis\ProjectZomboid;
|
||||
|
||||
use IndifferentKetchup\Codex\Analysis\Information;
|
||||
use IndifferentKetchup\Codex\Analysis\InsightInterface;
|
||||
|
||||
/**
|
||||
* Emitted by ErrorContextAnalyser exactly once when its hit cap is
|
||||
* reached, so downstream consumers can surface a "results truncated"
|
||||
* notice instead of silently dropping subsequent error/warning hits.
|
||||
*/
|
||||
class ErrorContextTruncatedInformation extends Information
|
||||
{
|
||||
private int $hitCap = 0;
|
||||
|
||||
/**
|
||||
* @param int $hitCap the cap that was hit (mirrors
|
||||
* ErrorContextAnalyser::HIT_CAP at emission time)
|
||||
* @return $this
|
||||
*/
|
||||
public function setHitCap(int $hitCap): static
|
||||
{
|
||||
$this->hitCap = $hitCap;
|
||||
$this->setLabel('Error context');
|
||||
$this->setValue(sprintf('truncated after %d hits', $hitCap));
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function getHitCap(): int
|
||||
{
|
||||
return $this->hitCap;
|
||||
}
|
||||
|
||||
public function isEqual(InsightInterface $insight): bool
|
||||
{
|
||||
return $insight instanceof self;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace IndifferentKetchup\Codex\Test\Tests\Games\ProjectZomboid\Analyser;
|
||||
|
||||
use IndifferentKetchup\Codex\Analyser\AnalyserInterface;
|
||||
use IndifferentKetchup\Codex\Analyser\ProjectZomboid\ErrorContextAnalyser;
|
||||
use IndifferentKetchup\Codex\Analysis\ProjectZomboid\ErrorContextProblem;
|
||||
use IndifferentKetchup\Codex\Analysis\ProjectZomboid\ErrorContextTruncatedInformation;
|
||||
use IndifferentKetchup\Codex\Log\AnalysableLog;
|
||||
use IndifferentKetchup\Codex\Log\Entry;
|
||||
use IndifferentKetchup\Codex\Log\Level;
|
||||
use IndifferentKetchup\Codex\Log\Line;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ErrorContextAnalyserTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* Build an in-memory AnalysableLog with $count entries; entries whose
|
||||
* 1-based index is in $errorIndices are tagged Level::ERROR, the rest
|
||||
* Level::INFO. Anonymous AnalysableLog subclass keeps the fixture
|
||||
* inline since we exercise the analyser directly via setLog().
|
||||
*
|
||||
* @param int[] $errorIndices 1-based entry indices to mark as ERROR
|
||||
*/
|
||||
private function makeLog(array $errorIndices, int $count): AnalysableLog
|
||||
{
|
||||
$errorSet = array_flip($errorIndices);
|
||||
$log = new class extends AnalysableLog {
|
||||
public static function getDefaultAnalyser(): AnalyserInterface
|
||||
{
|
||||
return new ErrorContextAnalyser();
|
||||
}
|
||||
};
|
||||
for ($n = 1; $n <= $count; $n++) {
|
||||
$level = isset($errorSet[$n]) ? Level::ERROR : Level::INFO;
|
||||
$entry = (new Entry())
|
||||
->setLevel($level)
|
||||
->addLine(new Line($n, sprintf('line %d', $n)));
|
||||
$log->addEntry($entry);
|
||||
}
|
||||
return $log;
|
||||
}
|
||||
|
||||
public function testEmitsThreeNonOverlappingWindows(): void
|
||||
{
|
||||
$log = $this->makeLog([10, 50, 95], 100);
|
||||
$analysis = (new ErrorContextAnalyser())->setLog($log)->analyse();
|
||||
|
||||
$problems = $analysis->getFilteredInsights(ErrorContextProblem::class);
|
||||
$this->assertCount(3, $problems);
|
||||
|
||||
$this->assertSame(10, $problems[0]->getEntryIndex());
|
||||
$this->assertSame(50, $problems[1]->getEntryIndex());
|
||||
$this->assertSame(95, $problems[2]->getEntryIndex());
|
||||
|
||||
// First hit (entry 10): 9 entries before (1..9), 20 after (11..30).
|
||||
$this->assertCount(9, $problems[0]->getBefore());
|
||||
$this->assertCount(20, $problems[0]->getAfter());
|
||||
|
||||
// Second hit (entry 50): clipped to 19 before (31..49), 20 after (51..70).
|
||||
$this->assertCount(19, $problems[1]->getBefore());
|
||||
$this->assertCount(20, $problems[1]->getAfter());
|
||||
|
||||
// Third hit (entry 95): clipped to 20 before (75..94), 5 after (96..100).
|
||||
$this->assertCount(20, $problems[2]->getBefore());
|
||||
$this->assertCount(5, $problems[2]->getAfter());
|
||||
|
||||
// Total window per hit never exceeds 1 + CONTEXT_BEFORE + CONTEXT_AFTER = 41.
|
||||
foreach ($problems as $problem) {
|
||||
$this->assertLessThanOrEqual(ErrorContextAnalyser::CONTEXT_BEFORE, count($problem->getBefore()));
|
||||
$this->assertLessThanOrEqual(ErrorContextAnalyser::CONTEXT_AFTER, count($problem->getAfter()));
|
||||
$this->assertLessThanOrEqual(41, count($problem->getContext()));
|
||||
}
|
||||
|
||||
// No entry appears in two problems' context arrays.
|
||||
$seen = [];
|
||||
foreach ($problems as $problem) {
|
||||
foreach ([...$problem->getBefore(), ...$problem->getAfter()] as $entry) {
|
||||
$id = spl_object_id($entry);
|
||||
$this->assertArrayNotHasKey($id, $seen, 'Entry duplicated across problem context arrays');
|
||||
$seen[$id] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testMergesAdjacentWindowsWhenWithinContextRange(): void
|
||||
{
|
||||
// Errors 5 entries apart; without merge their windows would
|
||||
// overlap heavily.
|
||||
$log = $this->makeLog([10, 15], 50);
|
||||
$analysis = (new ErrorContextAnalyser())->setLog($log)->analyse();
|
||||
|
||||
$problems = $analysis->getFilteredInsights(ErrorContextProblem::class);
|
||||
$this->assertCount(2, $problems);
|
||||
|
||||
// First hit: 9 before (1..9), 20 after (11..30). lastEmittedIndex=29 (0-based).
|
||||
$this->assertCount(9, $problems[0]->getBefore());
|
||||
$this->assertCount(20, $problems[0]->getAfter());
|
||||
|
||||
// Second hit at entry 15 (i=14). beforeStart clamped past i so before is empty.
|
||||
// afterStart=max(30, 15)=30, afterEnd=min(49, 34)=34, so after=entries 31..35
|
||||
// (5 entries, all unseen).
|
||||
$this->assertCount(0, $problems[1]->getBefore());
|
||||
$this->assertCount(5, $problems[1]->getAfter());
|
||||
|
||||
// Confirm no entry appears in both problems' context arrays.
|
||||
$first = [...$problems[0]->getBefore(), ...$problems[0]->getAfter()];
|
||||
$second = [...$problems[1]->getBefore(), ...$problems[1]->getAfter()];
|
||||
foreach ($second as $entry) {
|
||||
$this->assertNotContains($entry, $first, 'Entry duplicated across merged windows');
|
||||
}
|
||||
}
|
||||
|
||||
public function testTruncatesAtHitCap(): void
|
||||
{
|
||||
// 600 consecutive ERROR entries — analyser should cap emission at
|
||||
// HIT_CAP and add exactly one truncation Information.
|
||||
$log = $this->makeLog(range(1, 600), 600);
|
||||
$analysis = (new ErrorContextAnalyser())->setLog($log)->analyse();
|
||||
|
||||
$problems = $analysis->getFilteredInsights(ErrorContextProblem::class);
|
||||
$this->assertCount(ErrorContextAnalyser::HIT_CAP, $problems);
|
||||
|
||||
$information = $analysis->getFilteredInsights(ErrorContextTruncatedInformation::class);
|
||||
$this->assertCount(1, $information);
|
||||
$this->assertSame(ErrorContextAnalyser::HIT_CAP, $information[0]->getHitCap());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user