Add HTTP performance log analyzer (M12.6).

Parse rotated nginx JSON access logs into host/route timings so production hotspots can be measured without ad-hoc one-off scripts.
This commit is contained in:
2026-08-29 12:25:27 +02:00
parent 4ab315e9d7
commit fb560fb7dd
10 changed files with 737 additions and 139 deletions
@@ -0,0 +1,223 @@
<?php
use App\Support\Http\HttpPerformanceLogAnalyzer;
use App\Support\Http\HttpUriNormalizer;
function rotationDir(): string
{
return dirname(__DIR__, 2).'/fixtures/http-performance/rotation';
}
function analyzerNow(): DateTimeImmutable
{
return new DateTimeImmutable('2026-08-25T08:00:00+02:00');
}
function writeGzFixture(): string
{
$plain = rotationDir().'/oldest.log';
$gz = rotationDir().'/oldest.log.2.gz';
$lines = <<<'JSON'
{"time":"2026-08-24T07:50:00+02:00","host":"skinbase.org","method":"GET","uri":"/","status":200,"request_time":0.100,"upstream_response_time":"0.090","upstream_connect_time":"0.000","upstream_header_time":"0.080","bytes":1000,"request_length":80,"content_type":"text/html","cache":""}
{"time":"2026-08-24T08:10:00+02:00","host":"skinbase.org","method":"GET","uri":"/art/53867/light-mark-docks","status":200,"request_time":0.400,"upstream_response_time":"0.380","upstream_connect_time":"0.000","upstream_header_time":"0.200","bytes":10000,"request_length":100,"content_type":"text/html","cache":""}
{"time":"2026-08-24T08:11:00+02:00","host":"skinbase.org","method":"GET","uri":"/missing-old","status":404,"request_time":0.030,"upstream_response_time":"0.020","upstream_connect_time":"0.000","upstream_header_time":"0.010","bytes":700,"request_length":60,"content_type":"text/html","cache":""}
{"time":"2026-08-24T08:12:00+02:00","host":"skinbase.org","method":"GET","uri":"/broken","status":500,"request_time":0.500,"upstream_response_time":"0.480","upstream_connect_time":"0.000","upstream_header_time":"0.200","bytes":200,"request_length":70,"content_type":"text/html","cache":""}
JSON;
file_put_contents($plain, $lines."\n");
$gzHandle = gzopen($gz, 'wb');
fwrite($gzHandle, $lines."\n");
gzclose($gzHandle);
return $gz;
}
function rotationFiles(): array
{
return [
rotationDir().'/active.log',
rotationDir().'/rotated.1',
writeGzFixture(),
];
}
function analyzeRotation(array $extra = []): array
{
$analyzer = new HttpPerformanceLogAnalyzer(new HttpUriNormalizer());
return $analyzer->analyze(rotationFiles(), $extra + [
'since' => '24h',
'now' => analyzerNow(),
'top' => 50,
'min_requests' => 1,
]);
}
it('filters exact status 404 and does not leak other codes', function () {
$result = analyzeRotation(['status' => '404', 'host' => 'skinbase.org']);
$statuses = collect($result['slowest'])->pluck('status')->unique()->values()->all();
expect($result['totals']['total'])->toBe(2)
->and($statuses)->toBe([404])
->and($result['totals']['5xx'])->toBe(0)
->and($result['totals']['499'])->toBe(0);
});
it('filters exact 422 and 500 separately', function () {
$s422 = analyzeRotation(['status' => '422', 'host' => 'skinbase.org']);
$s500 = analyzeRotation(['status' => '500', 'host' => 'skinbase.org']);
expect($s422['totals']['total'])->toBe(1)
->and(collect($s422['slowest'])->pluck('status')->all())->toBe([422])
->and($s500['totals']['total'])->toBe(1)
->and(collect($s500['slowest'])->pluck('status')->all())->toBe([500]);
});
it('supports 4xx and 5xx classes without treating 404 as a class', function () {
$four = analyzeRotation(['status' => '4xx', 'host' => 'skinbase.org']);
$five = analyzeRotation(['status' => '5xx', 'host' => 'skinbase.org']);
$fourStatuses = collect($four['slowest'])->pluck('status')->sort()->values()->all();
$fiveStatuses = collect($five['slowest'])->pluck('status')->sort()->values()->all();
expect($fourStatuses)->toBe([404, 404, 422, 499])
->and($fiveStatuses)->toBe([500, 502]);
});
it('rejects malformed status filters', function () {
$analyzer = new HttpPerformanceLogAnalyzer(new HttpUriNormalizer());
expect(fn () => $analyzer->parseStatusFilter('foo'))->toThrow(InvalidArgumentException::class)
->and(fn () => $analyzer->parseStatusFilter('40'))->toThrow(InvalidArgumentException::class)
->and(fn () => $analyzer->parseStatusFilter('9999'))->toThrow(InvalidArgumentException::class);
});
it('includes active, rotated, and gzip files in one window', function () {
$result = analyzeRotation(['host' => 'skinbase.org']);
$paths = collect($result['sources'])->pluck('path')->implode(' ');
expect($result['source_files'])->toBe(3)
->and($paths)->toContain('active.log')
->and($paths)->toContain('rotated.1')
->and($paths)->toContain('.gz')
->and($result['totals']['total'])->toBeGreaterThan(5);
$gz = collect($result['sources'])->first(fn (array $row): bool => str_ends_with($row['path'], '.gz'));
expect($gz['in_window'])->toBeGreaterThan(0);
});
it('deduplicates the same file passed twice without dropping distinct events', function () {
$analyzer = new HttpPerformanceLogAnalyzer(new HttpUriNormalizer());
$active = rotationDir().'/active.log';
$once = $analyzer->analyze($active, [
'since' => '24h',
'now' => analyzerNow(),
'host' => 'skinbase.org',
'min_requests' => 1,
'top' => 20,
]);
$twice = $analyzer->analyze([$active, $active], [
'since' => '24h',
'now' => analyzerNow(),
'host' => 'skinbase.org',
'min_requests' => 1,
'top' => 20,
]);
expect($twice['source_files'])->toBe(1)
->and($twice['totals']['total'])->toBe($once['totals']['total']);
});
it('does not event-deduplicate identical-looking rows from different files', function () {
$dir = sys_get_temp_dir().'/m126-dup-'.uniqid();
mkdir($dir);
$line = '{"time":"2026-08-25T07:00:00+02:00","host":"skinbase.org","method":"GET","uri":"/","status":200,"request_time":0.1,"upstream_response_time":"0.1","upstream_connect_time":"0.0","upstream_header_time":"0.1","bytes":1,"request_length":1,"content_type":"text/html","cache":""}'."\n";
file_put_contents($dir.'/a.log', $line);
file_put_contents($dir.'/b.log', $line);
$result = (new HttpPerformanceLogAnalyzer(new HttpUriNormalizer()))->analyze(
[$dir.'/a.log', $dir.'/b.log'],
['since' => '24h', 'now' => analyzerNow(), 'host' => 'skinbase.org', 'min_requests' => 1, 'top' => 10],
);
expect($result['totals']['total'])->toBe(2);
});
it('applies the time window across rotation and gzip files', function () {
$result = analyzeRotation(['host' => 'skinbase.org']);
$times = collect($result['slowest'])->pluck('time');
expect($times->contains('2026-08-24T07:50:00+02:00'))->toBeFalse()
->and($times->contains('2026-08-25T08:10:00+02:00'))->toBeFalse()
->and($times->contains('2026-08-24T08:10:00+02:00'))->toBeTrue()
->and($times->contains('2026-08-24T23:50:00+02:00'))->toBeTrue()
->and($times->contains('2026-08-25T07:50:00+02:00'))->toBeTrue();
});
it('keeps p50/p95/p99 identical when the same events are split across files', function () {
$analyzer = new HttpPerformanceLogAnalyzer(new HttpUriNormalizer());
$opts = ['since' => '24h', 'now' => analyzerNow(), 'host' => 'skinbase.org', 'min_requests' => 1, 'top' => 20];
$files = rotationFiles();
$combined = sys_get_temp_dir().'/m126-combined-'.uniqid().'.log';
$blob = '';
foreach ($files as $file) {
if (str_ends_with($file, '.gz')) {
$blob .= gzdecode(file_get_contents($file));
} else {
$blob .= file_get_contents($file);
}
if (! str_ends_with($blob, "\n")) {
$blob .= "\n";
}
}
file_put_contents($combined, $blob);
$split = $analyzer->analyze($files, $opts);
$single = $analyzer->analyze($combined, $opts);
$splitHome = collect($split['by_count'])->firstWhere('uri', '/');
$singleHome = collect($single['by_count'])->firstWhere('uri', '/');
expect($split['totals']['total'])->toBe($single['totals']['total'])
->and($splitHome['count'])->toBe($singleHome['count'])
->and($splitHome['p50'])->toBe($singleHome['p50'])
->and($splitHome['p95'])->toBe($singleHome['p95'])
->and($splitHome['p99'])->toBe($singleHome['p99'])
->and($splitHome['avg'])->toBe($singleHome['avg'])
->and($splitHome['max'])->toBe($singleHome['max']);
});
it('warns when observed records start many hours after the requested window', function () {
$analyzer = new HttpPerformanceLogAnalyzer(new HttpUriNormalizer());
$result = $analyzer->analyze(rotationDir().'/active.log', [
'since' => '24h',
'now' => analyzerNow(),
'host' => 'skinbase.org',
'min_requests' => 1,
'top' => 10,
]);
expect($result['coverage']['complete'])->toBeFalse()
->and($result['warnings'])->not->toBeEmpty();
});
it('does not warn when parseable records span the requested 24h window', function () {
$result = analyzeRotation();
expect($result['coverage']['complete'])->toBeTrue()
->and($result['warnings'])->toBeEmpty()
->and($result['coverage']['requested_start'])->toBe('2026-08-24T08:00:00+02:00')
->and($result['coverage']['requested_end'])->toBe('2026-08-25T08:00:00+02:00');
});
it('reports an empty sample when the host matches nothing', function () {
$result = analyzeRotation(['host' => 'no-such.example']);
expect($result['sample_empty'])->toBeTrue()
->and($result['totals']['total'])->toBe(0)
->and($result['by_count'])->toBe([]);
});
it('keeps exact host filtering on mixed rotation files', function () {
$result = analyzeRotation(['host' => 'skinbase.org']);
$hosts = array_unique(array_column($result['by_count'], 'host'));
expect($hosts)->toBe(['skinbase.org']);
});