*/ public function analyze(string $file, array $options = []): array { $since = $this->sinceCutoff($options['since'] ?? null, $options['now'] ?? new \DateTimeImmutable('now')); $minRequests = max(1, (int) ($options['min_requests'] ?? 1)); $top = max(1, (int) ($options['top'] ?? 20)); $statusFilter = isset($options['status']) ? strtolower((string) $options['status']) : null; $uriFilter = $options['uri'] ?? null; $hostFilter = isset($options['host']) ? (string) $options['host'] : ''; $slowMs = isset($options['slow_ms']) ? (float) $options['slow_ms'] / 1000 : null; $skipped = 0; $families = []; $slowest = []; $statusCounts = ['4xx' => 0, '5xx' => 0, '499' => 0, 'total' => 0]; $handle = fopen($file, 'r'); if ($handle === false) { throw new \RuntimeException('Unable to open performance log: '.$file); } try { while (($line = fgets($handle)) !== false) { $line = trim($line); if ($line === '') { continue; } $row = json_decode($line, true); if (! is_array($row)) { $skipped++; continue; } $time = $this->parseTime((string) ($row['time'] ?? '')); if ($since !== null && ($time === null || $time < $since)) { continue; } $requestTime = $this->parseFloat($row['request_time'] ?? null); if ($requestTime === null) { $skipped++; continue; } $status = (int) ($row['status'] ?? 0); if ($statusFilter === '5xx' && ($status < 500 || $status > 599)) { continue; } if ($statusFilter === '4xx' && ($status < 400 || $status > 499)) { continue; } if ($statusFilter === '499' && $status !== 499) { continue; } $host = (string) ($row['host'] ?? ''); if ($hostFilter !== '' && $host !== $hostFilter) { continue; } $uri = $this->uris->normalize((string) ($row['uri'] ?? '/')); if (is_string($uriFilter) && $uriFilter !== '' && ! str_starts_with($uri, $uriFilter)) { continue; } if ($slowMs !== null && $requestTime < $slowMs) { continue; } $upstreamResponse = $this->parseUpstream($row['upstream_response_time'] ?? null); $upstreamHeader = $this->parseUpstream($row['upstream_header_time'] ?? null); $bytes = (int) ($row['bytes'] ?? 0); $method = (string) ($row['method'] ?? ''); $statusCounts['total']++; if ($status === 499) { $statusCounts['499']++; } elseif ($status >= 500) { $statusCounts['5xx']++; } elseif ($status >= 400) { $statusCounts['4xx']++; } $familyKey = $host."\n".$uri; $families[$familyKey] ??= [ 'host' => $host, 'uri' => $uri, 'count' => 0, 'times' => [], 'upstream_sum' => 0.0, 'upstream_n' => 0, 'header_sum' => 0.0, 'header_n' => 0, 'bytes_sum' => 0, '4xx' => 0, '5xx' => 0, '499' => 0, '404' => 0, ]; $families[$familyKey]['count']++; $families[$familyKey]['times'][] = $requestTime; $families[$familyKey]['bytes_sum'] += $bytes; if ($upstreamResponse !== null) { $families[$familyKey]['upstream_sum'] += $upstreamResponse; $families[$familyKey]['upstream_n']++; } if ($upstreamHeader !== null) { $families[$familyKey]['header_sum'] += $upstreamHeader; $families[$familyKey]['header_n']++; } if ($status === 499) { $families[$familyKey]['499']++; } elseif ($status >= 500) { $families[$familyKey]['5xx']++; } elseif ($status >= 400) { $families[$familyKey]['4xx']++; if ($status === 404) { $families[$familyKey]['404']++; } } $slowest[] = [ 'time' => $row['time'] ?? null, 'host' => $host, 'uri' => $uri, 'method' => $method, 'status' => $status, 'request_time' => $requestTime, 'upstream_response_time' => $upstreamResponse, ]; } } finally { fclose($handle); } usort($slowest, static fn (array $a, array $b): int => $b['request_time'] <=> $a['request_time']); $slowest = array_slice($slowest, 0, $top); $summaries = []; foreach ($families as $family) { if ($family['count'] < $minRequests) { continue; } $times = $family['times']; sort($times, SORT_NUMERIC); $avg = array_sum($times) / $family['count']; $summaries[] = [ 'host' => $family['host'], 'uri' => $family['uri'], 'count' => $family['count'], 'p50' => $this->percentile($times, 0.50), 'p95' => $this->percentile($times, 0.95), 'p99' => $this->percentile($times, 0.99), 'max' => $times[array_key_last($times)], 'avg' => round($avg, 4), 'total' => round(array_sum($times), 4), 'avg_upstream_response' => $family['upstream_n'] > 0 ? round($family['upstream_sum'] / $family['upstream_n'], 4) : null, 'avg_upstream_header' => $family['header_n'] > 0 ? round($family['header_sum'] / $family['header_n'], 4) : null, 'avg_bytes' => (int) round($family['bytes_sum'] / $family['count']), '4xx' => $family['4xx'], '4xx_rate' => round($family['4xx'] / $family['count'], 4), '404' => $family['404'], '5xx' => $family['5xx'], '5xx_rate' => round($family['5xx'] / $family['count'], 4), '499' => $family['499'], ]; } $byP95 = $summaries; usort($byP95, static fn (array $a, array $b): int => ($b['p95'] ?? 0) <=> ($a['p95'] ?? 0)); $byP99 = $summaries; usort($byP99, static fn (array $a, array $b): int => ($b['p99'] ?? 0) <=> ($a['p99'] ?? 0)); $byTotal = $summaries; usort($byTotal, static fn (array $a, array $b): int => $b['total'] <=> $a['total']); $byCount = $summaries; usort($byCount, static fn (array $a, array $b): int => $b['count'] <=> $a['count']); $byCost = $summaries; usort($byCost, static fn (array $a, array $b): int => ($b['count'] * $b['avg']) <=> ($a['count'] * $a['avg'])); $by5xx = array_values(array_filter($summaries, static fn (array $row): bool => $row['5xx'] > 0)); usort($by5xx, static fn (array $a, array $b): int => $b['5xx'] <=> $a['5xx']); $by404 = array_values(array_filter($summaries, static fn (array $row): bool => ($row['404'] ?? 0) > 0)); usort($by404, static fn (array $a, array $b): int => $b['404'] <=> $a['404']); return [ 'skipped' => $skipped, 'totals' => $statusCounts, 'by_p95' => array_slice($byP95, 0, $top), 'by_p99' => array_slice($byP99, 0, $top), 'by_total_time' => array_slice($byTotal, 0, $top), 'by_count' => array_slice($byCount, 0, $top), 'by_cost' => array_slice($byCost, 0, $top), 'by_5xx' => array_slice($by5xx, 0, $top), 'by_4xx' => array_slice($by404, 0, $top), 'slowest' => $slowest, 'percentile_method' => 'nearest-rank (ceil(p * n), 1-indexed)', 'upstream_aggregation' => 'sum of numeric comma-separated upstream timings; "-" is null', ]; } /** * Nearest-rank: rank = ceil(p * n), 1-indexed. * * @param list $sorted */ public function percentile(array $sorted, float $p): ?float { $n = count($sorted); if ($n === 0) { return null; } $rank = (int) ceil($p * $n); $index = max(0, min($n - 1, $rank - 1)); return round($sorted[$index], 4); } public function parseUpstream(mixed $value): ?float { if ($value === null || $value === '' || $value === '-') { return null; } if (is_int($value) || is_float($value)) { return (float) $value; } $parts = preg_split('/\s*,\s*/', (string) $value) ?: []; $sum = 0.0; $found = false; foreach ($parts as $part) { if ($part === '' || $part === '-') { continue; } if (! is_numeric($part)) { continue; } $sum += (float) $part; $found = true; } return $found ? $sum : null; } private function parseFloat(mixed $value): ?float { if ($value === null || $value === '' || $value === '-') { return null; } if (! is_numeric($value)) { return null; } return (float) $value; } private function parseTime(string $value): ?\DateTimeImmutable { if ($value === '') { return null; } try { return new \DateTimeImmutable($value); } catch (\Exception) { return null; } } private function sinceCutoff(?string $since, \DateTimeImmutable $now): ?\DateTimeImmutable { if ($since === null || $since === '') { return null; } if (preg_match('/^(\d+)(h|d|m)$/', strtolower($since), $matches) !== 1) { return null; } $n = (int) $matches[1]; $unit = $matches[2]; $interval = match ($unit) { 'm' => new \DateInterval('PT'.$n.'M'), 'h' => new \DateInterval('PT'.$n.'H'), 'd' => new \DateInterval('P'.$n.'D'), }; return $now->sub($interval); } }