Keep similar-ai from tripping the global circuit on a lone URL 502, clamp Qdrant search to 100, and add Server-Timing plus slow-request logging. Studio shared props, Academy S3 exists caching, heat chunking, and Redis/scheduler hygiene stay in this rollout.
111 lines
5.3 KiB
PHP
111 lines
5.3 KiB
PHP
<?php
|
|
|
|
use App\Support\Http\HttpPerformanceLogAnalyzer;
|
|
use App\Support\Http\HttpUriNormalizer;
|
|
|
|
function perfFixture(string $name): string
|
|
{
|
|
return dirname(__DIR__, 2).'/fixtures/http-performance/'.$name;
|
|
}
|
|
|
|
it('normalizes numeric, profile, and download URIs', function () {
|
|
$n = new HttpUriNormalizer();
|
|
|
|
expect($n->normalize('/download/artwork/17776'))->toBe('/download/artwork/{id}')
|
|
->and($n->normalize('/download/artwork/42026'))->toBe('/download/artwork/{id}')
|
|
->and($n->normalize('/@killua'))->toBe('/@{user}')
|
|
->and($n->normalize('/@killua/artworks'))->toBe('/@{user}/artworks')
|
|
->and($n->normalize('/academy/prompts/123'))->toBe('/academy/prompts/{id}')
|
|
->and($n->normalize('/forum/some-thread/456'))->toBe('/forum/some-thread/{id}')
|
|
->and($n->normalize('/art/53867/light-mark-docks'))->toBe('/art/{id}/{slug}')
|
|
->and($n->normalize('/art/3858/similar'))->toBe('/art/{id}/similar')
|
|
->and($n->normalize('/api/art/69876/view'))->toBe('/api/art/{id}/view')
|
|
->and($n->normalize('/api/rank/category/583'))->toBe('/api/rank/category/{id}')
|
|
->and($n->normalize('/search?q=skin&page=2'))->toBe('/search');
|
|
});
|
|
|
|
it('parses mixed nginx performance lines and skips malformed JSON', function () {
|
|
$analyzer = new HttpPerformanceLogAnalyzer(new HttpUriNormalizer());
|
|
$now = new DateTimeImmutable('2026-08-24T16:00:00+02:00');
|
|
|
|
$result = $analyzer->analyze(perfFixture('sample.log'), [
|
|
'since' => '24h',
|
|
'now' => $now,
|
|
'top' => 10,
|
|
'min_requests' => 1,
|
|
]);
|
|
|
|
expect($result['skipped'])->toBe(1)
|
|
->and($result['totals']['total'])->toBe(9)
|
|
->and($result['totals']['5xx'])->toBe(1)
|
|
->and($result['totals']['499'])->toBe(1)
|
|
->and($result['percentile_method'])->toContain('nearest-rank');
|
|
|
|
$academy = collect($result['by_count'])->firstWhere('uri', '/academy');
|
|
expect($academy['count'])->toBe(2)
|
|
->and($academy['p95'])->toBeGreaterThan(0);
|
|
|
|
$static = collect($result['by_count'])->firstWhere('uri', '/favicon.ico');
|
|
expect($static['avg_upstream_response'])->toBeNull();
|
|
|
|
$download = collect($result['slowest'])->firstWhere('uri', '/download/artwork/{id}');
|
|
expect($download['upstream_response_time'])->toBe(0.32);
|
|
|
|
$profile = collect($result['by_count'])->firstWhere('uri', '/@{user}');
|
|
expect($profile)->not->toBeNull();
|
|
});
|
|
|
|
it('uses nearest-rank percentiles rather than the average', function () {
|
|
$analyzer = new HttpPerformanceLogAnalyzer(new HttpUriNormalizer());
|
|
$sorted = [0.1, 0.2, 0.3, 0.4, 1.0];
|
|
|
|
expect($analyzer->percentile($sorted, 0.50))->toBe(0.3)
|
|
->and($analyzer->percentile($sorted, 0.95))->toBe(1.0)
|
|
->and($analyzer->percentile($sorted, 0.95))->not->toBe(0.4);
|
|
});
|
|
|
|
it('sums comma-separated upstream timings and treats dash as missing', function () {
|
|
$analyzer = new HttpPerformanceLogAnalyzer(new HttpUriNormalizer());
|
|
|
|
expect($analyzer->parseUpstream('-'))->toBeNull()
|
|
->and($analyzer->parseUpstream('0.451'))->toBe(0.451)
|
|
->and($analyzer->parseUpstream('0.100, 0.220'))->toBe(0.32);
|
|
});
|
|
|
|
it('filters hosts exactly and does not merge wildcard hosts', function () {
|
|
$analyzer = new HttpPerformanceLogAnalyzer(new HttpUriNormalizer());
|
|
$now = new DateTimeImmutable('2026-08-24T16:00:00+02:00');
|
|
$opts = ['since' => '24h', 'now' => $now, 'top' => 20, 'min_requests' => 1];
|
|
|
|
$filtered = $analyzer->analyze(perfFixture('hosts.log'), $opts + ['host' => 'skinbase.org']);
|
|
$hosts = array_unique(array_column($filtered['by_count'], 'host'));
|
|
|
|
expect($filtered['skipped'])->toBe(1)
|
|
->and($filtered['totals']['total'])->toBe(8)
|
|
->and($hosts)->toBe(['skinbase.org'])
|
|
->and(collect($filtered['by_count'])->pluck('host')->all())->not->toContain('www.skinbase.org')
|
|
->and(collect($filtered['by_count'])->pluck('host')->all())->not->toContain('thumb.skinbase.org')
|
|
->and(collect($filtered['by_count'])->pluck('host')->all())->not->toContain('hub.skinbase.org');
|
|
|
|
$unfiltered = $analyzer->analyze(perfFixture('hosts.log'), $opts);
|
|
$artRows = collect($unfiltered['by_count'])->where('uri', '/art/{id}/{slug}')->values();
|
|
|
|
expect($artRows)->toHaveCount(4)
|
|
->and($artRows->pluck('host')->sort()->values()->all())->toBe([
|
|
'hub.skinbase.org',
|
|
'skinbase.org',
|
|
'thumb.skinbase.org',
|
|
'www.skinbase.org',
|
|
])
|
|
->and($artRows->firstWhere('host', 'skinbase.org')['count'])->toBe(1)
|
|
->and($artRows->firstWhere('host', 'www.skinbase.org')['avg'])->toBe(0.9);
|
|
|
|
expect(collect($filtered['by_count'])->firstWhere('uri', '/art/{id}/{slug}')['host'])->toBe('skinbase.org')
|
|
->and(collect($filtered['by_count'])->firstWhere('uri', '/art/{id}/similar'))->not->toBeNull()
|
|
->and(collect($filtered['by_count'])->firstWhere('uri', '/download/artwork/{id}'))->not->toBeNull()
|
|
->and(collect($filtered['by_count'])->firstWhere('uri', '/api/art/{id}/view'))->not->toBeNull()
|
|
->and(collect($filtered['by_count'])->firstWhere('uri', '/api/rank/category/{id}'))->not->toBeNull()
|
|
->and(collect($filtered['by_count'])->firstWhere('uri', '/academy/prompts/{id}'))->not->toBeNull()
|
|
->and(collect($filtered['by_count'])->firstWhere('uri', '/@{user}'))->not->toBeNull();
|
|
});
|