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']); });