$input * @param array{ * since?: ?string, * until?: ?string, * min_requests?: int, * top?: int, * status?: ?string, * uri?: ?string, * host?: ?string, * slow_ms?: ?float, * now?: ?\DateTimeImmutable * } $options * @return array */ public function analyze(string|array $input, array $options = []): array { $files = $this->normalizeInputList($input); $now = $options['now'] ?? new \DateTimeImmutable('now'); $requestedStart = $this->sinceCutoff($options['since'] ?? null, $now); $requestedEnd = $this->untilInstant($options['until'] ?? null, $now); $minRequests = max(1, (int) ($options['min_requests'] ?? 1)); $top = max(1, (int) ($options['top'] ?? 20)); $statusFilter = $this->parseStatusFilter($options['status'] ?? null); $uriFilter = $options['uri'] ?? null; $hostFilter = isset($options['host']) ? (string) $options['host'] : ''; $slowMs = isset($options['slow_ms']) && $options['slow_ms'] !== null ? (float) $options['slow_ms'] / 1000 : null; $skipped = 0; $linesScanned = 0; $parseable = 0; $families = []; $slowest = []; $statusCounts = ['4xx' => 0, '5xx' => 0, '499' => 0, 'total' => 0]; $sources = []; $earliestParseable = null; $latestParseable = null; $earliestMatching = null; $latestMatching = null; foreach ($files as $file) { $source = [ 'path' => $file, 'gzip' => str_ends_with(strtolower($file), '.gz'), 'lines_scanned' => 0, 'parseable' => 0, 'in_window' => 0, 'error' => null, ]; $handle = $this->openStream($file); if ($handle === false) { $source['error'] = 'unreadable'; $sources[] = $source; continue; } try { while (($line = fgets($handle)) !== false) { $linesScanned++; $source['lines_scanned']++; $line = trim($line); if ($line === '') { continue; } $row = json_decode($line, true); if (! is_array($row)) { $skipped++; continue; } $time = $this->parseTime((string) ($row['time'] ?? '')); $requestTime = $this->parseFloat($row['request_time'] ?? null); if ($requestTime === null) { $skipped++; continue; } $parseable++; $source['parseable']++; if ($time instanceof \DateTimeImmutable) { $earliestParseable = $this->minTime($earliestParseable, $time); $latestParseable = $this->maxTime($latestParseable, $time); } if ($requestedStart !== null && ($time === null || $time < $requestedStart)) { continue; } if ($requestedEnd !== null && ($time === null || $time > $requestedEnd)) { continue; } $status = (int) ($row['status'] ?? 0); if (! $this->statusMatches($status, $statusFilter)) { 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; } $source['in_window']++; if ($time instanceof \DateTimeImmutable) { $earliestMatching = $this->minTime($earliestMatching, $time); $latestMatching = $this->maxTime($latestMatching, $time); } $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, ]; $slowest[array_key_last($slowest)]['upstream_response_time'] = $upstreamResponse; } } finally { fclose($handle); } $sources[] = $source; } 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']); $coverage = $this->coverageReport( $requestedStart, $requestedEnd, $earliestParseable, $latestParseable, $earliestMatching, $latestMatching, ); return [ 'skipped' => $skipped, 'lines_scanned' => $linesScanned, 'parseable' => $parseable, 'source_files' => count($files), 'sources' => $sources, 'coverage' => $coverage, 'warnings' => $coverage['warnings'], 'sample_empty' => $statusCounts['total'] === 0, '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', ]; } /** * @param list $paths * @return list */ public function deduplicatePaths(array $paths): array { $seen = []; $out = []; foreach ($paths as $path) { $path = str_replace('\\', '/', (string) $path); if ($path === '') { continue; } $resolved = realpath($path) ?: $path; $key = strtolower($resolved); if (isset($seen[$key])) { continue; } $seen[$key] = true; $out[] = $path; } return array_values($out); } /** * @return list */ public function expandGlob(string $pattern): array { $matches = glob($pattern, GLOB_NOSORT) ?: []; $files = []; foreach ($matches as $match) { if (is_file($match)) { $files[] = $match; } } natsort($files); return array_values($files); } /** * 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; } /** * @param string|list $input * @return list */ private function normalizeInputList(string|array $input): array { $files = is_array($input) ? $input : [$input]; $files = $this->deduplicatePaths(array_map('strval', $files)); if ($files === []) { throw new \InvalidArgumentException('No performance log files were provided.'); } return $files; } /** * @return resource|false */ private function openStream(string $file) { if (str_ends_with(strtolower($file), '.gz')) { return @gzopen($file, 'rb'); } return @fopen($file, 'r'); } /** * @return array{type: string, code?: int, class?: string} */ public function parseStatusFilter(?string $raw): array { if ($raw === null || $raw === '') { return ['type' => 'none']; } $value = strtolower(trim($raw)); if ($value === '4xx') { return ['type' => 'class', 'class' => '4xx']; } if ($value === '5xx') { return ['type' => 'class', 'class' => '5xx']; } if (preg_match('/^[1-5][0-9]{2}$/', $value) === 1) { return ['type' => 'exact', 'code' => (int) $value]; } throw new \InvalidArgumentException('Invalid --status value: '.$raw); } /** * @param array{type: string, code?: int, class?: string} $filter */ private function statusMatches(int $status, array $filter): bool { return match ($filter['type']) { 'none' => true, 'exact' => $status === (int) ($filter['code'] ?? 0), 'class' => ($filter['class'] ?? '') === '5xx' ? ($status >= 500 && $status <= 599) : ($status >= 400 && $status <= 499), default => true, }; } /** * @return array */ private function coverageReport( ?\DateTimeImmutable $requestedStart, ?\DateTimeImmutable $requestedEnd, ?\DateTimeImmutable $earliestParseable, ?\DateTimeImmutable $latestParseable, ?\DateTimeImmutable $earliestMatching, ?\DateTimeImmutable $latestMatching, ): array { $warnings = []; $complete = true; if ($requestedStart instanceof \DateTimeImmutable && $requestedEnd instanceof \DateTimeImmutable) { if (! $earliestParseable instanceof \DateTimeImmutable || ! $latestParseable instanceof \DateTimeImmutable) { $complete = false; $warnings[] = 'Observed records do not demonstrate full requested coverage.'; } else { $startGap = $earliestParseable->getTimestamp() - $requestedStart->getTimestamp(); $endGap = $requestedEnd->getTimestamp() - $latestParseable->getTimestamp(); if ($startGap > self::COVERAGE_TOLERANCE_SECONDS || $endGap > self::COVERAGE_TOLERANCE_SECONDS) { $complete = false; $warnings[] = sprintf( 'WARNING: Requested window is not fully covered by supplied logs. Requested: %s → %s Observed: %s → %s', $requestedStart->format(\DateTimeInterface::ATOM), $requestedEnd->format(\DateTimeInterface::ATOM), $earliestParseable->format(\DateTimeInterface::ATOM), $latestParseable->format(\DateTimeInterface::ATOM), ); } } } return [ 'requested_start' => $requestedStart?->format(\DateTimeInterface::ATOM), 'requested_end' => $requestedEnd?->format(\DateTimeInterface::ATOM), 'observed_earliest_parseable' => $earliestParseable?->format(\DateTimeInterface::ATOM), 'observed_latest_parseable' => $latestParseable?->format(\DateTimeInterface::ATOM), 'observed_earliest_matching' => $earliestMatching?->format(\DateTimeInterface::ATOM), 'observed_latest_matching' => $latestMatching?->format(\DateTimeInterface::ATOM), 'tolerance_seconds' => self::COVERAGE_TOLERANCE_SECONDS, 'complete' => $complete, 'warnings' => $warnings, ]; } private function minTime(?\DateTimeImmutable $current, \DateTimeImmutable $candidate): \DateTimeImmutable { if ($current === null || $candidate < $current) { return $candidate; } return $current; } private function maxTime(?\DateTimeImmutable $current, \DateTimeImmutable $candidate): \DateTimeImmutable { if ($current === null || $candidate > $current) { return $candidate; } return $current; } 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 $this->parseTime($since); } $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); } private function untilInstant(?string $until, \DateTimeImmutable $now): \DateTimeImmutable { if ($until === null || $until === '') { return $now; } return $this->parseTime($until) ?? $now; } }