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