presseportale/app/Services/Admin/AdminSlowRequestReporter.php
Kevin Adametz 5b8bdf4182
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
12-05-2026 Frontend dev
2026-05-12 18:32:33 +02:00

323 lines
12 KiB
PHP

<?php
namespace App\Services\Admin;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Throwable;
class AdminSlowRequestReporter
{
/**
* @param array{
* from?: ?string,
* to?: ?string,
* route?: ?string,
* path?: ?string,
* status?: ?int,
* min_duration_ms?: ?int
* } $filters
* @param list<string>|null $paths
* @return array{
* summary: array<string, int|float|string|null>,
* top_routes: list<array<string, mixed>>,
* top_paths: list<array<string, mixed>>,
* status_codes: list<array<string, mixed>>,
* slowest_requests: list<array<string, mixed>>,
* query_heavy_requests: list<array<string, mixed>>,
* slow_queries: list<array<string, mixed>>,
* explain_plans: list<array{sql: string, explainable: bool, plan: list<array<string, mixed>>, error: ?string}>,
* entries: list<array<string, mixed>>,
* files: list<string>
* }
*/
public function report(array $filters = [], int $top = 10, int $limit = 50, ?array $paths = null): array
{
$top = max(1, $top);
$limit = max(1, $limit);
$files = $this->files($paths);
$entries = $this->entries($files)
->filter(fn (array $entry): bool => $this->matchesFilters($entry, $filters))
->sortByDesc('timestamp')
->values();
$slowQueries = $this->slowQueries($entries, $top);
return [
'summary' => $this->summary($entries, $files),
'top_routes' => $this->topBy($entries, 'route_name', $top),
'top_paths' => $this->topBy($entries, 'path', $top),
'status_codes' => $this->topBy($entries, 'status_code', $top),
'slowest_requests' => $this->slowestRequests($entries, $limit),
'query_heavy_requests' => $this->queryHeavyRequests($entries, $limit),
'slow_queries' => $slowQueries,
'explain_plans' => $this->explainPlans($slowQueries),
'entries' => $entries->take($limit)->values()->all(),
'files' => $files,
];
}
/**
* @param list<string>|null $paths
* @return list<string>
*/
private function files(?array $paths): array
{
if ($paths !== null && $paths !== []) {
return collect($paths)
->flatMap(fn (string $path): array => glob($path) ?: [$path])
->filter(fn (string $path): bool => File::isFile($path) && File::isReadable($path))
->unique()
->sort()
->values()
->all();
}
$configuredPath = (string) config('logging.channels.admin_slow.path', storage_path('logs/admin-slow.log'));
$directory = dirname($configuredPath);
$basename = pathinfo($configuredPath, PATHINFO_FILENAME);
return collect(glob($directory.'/'.$basename.'*.log') ?: [])
->filter(fn (string $path): bool => File::isFile($path) && File::isReadable($path))
->sortDesc()
->values()
->all();
}
/**
* @param list<string> $files
* @return Collection<int, array<string, mixed>>
*/
private function entries(array $files): Collection
{
return collect($files)
->flatMap(function (string $file): array {
return collect(File::lines($file))
->map(fn (string $line): ?array => $this->parseLine($line, $file))
->filter()
->values()
->all();
})
->values();
}
/**
* @return array<string, mixed>|null
*/
private function parseLine(string $line, string $file): ?array
{
if (! str_contains($line, 'Slow admin request detected.')) {
return null;
}
if (! preg_match('/^\[(?<timestamp>[^\]]+)\]\s+(?<environment>[^.]+)\.(?<level>[^:]+):\s+Slow admin request detected\.\s+(?<context>\{.*\})\s*$/', $line, $matches)) {
return null;
}
$context = json_decode($matches['context'], true);
if (! is_array($context)) {
return null;
}
$timestamp = CarbonImmutable::parse($matches['timestamp']);
return [
'timestamp' => $timestamp->toDateTimeString(),
'environment' => $matches['environment'],
'level' => strtolower($matches['level']),
'file' => $file,
'method' => (string) ($context['method'] ?? ''),
'path' => (string) ($context['path'] ?? ''),
'route_name' => (string) ($context['route_name'] ?? 'unknown'),
'status_code' => (int) ($context['status_code'] ?? 0),
'user_id' => isset($context['user_id']) ? (int) $context['user_id'] : null,
'duration_ms' => (int) ($context['duration_ms'] ?? 0),
'database_time_ms' => (float) ($context['database_time_ms'] ?? 0),
'query_count' => (int) ($context['query_count'] ?? 0),
'slow_queries' => is_array($context['slow_queries'] ?? null) ? $context['slow_queries'] : [],
];
}
/**
* @param array<string, mixed> $entry
* @param array<string, mixed> $filters
*/
private function matchesFilters(array $entry, array $filters): bool
{
$timestamp = CarbonImmutable::parse((string) $entry['timestamp']);
if (filled($filters['from'] ?? null) && $timestamp->lt(CarbonImmutable::parse((string) $filters['from']))) {
return false;
}
if (filled($filters['to'] ?? null) && $timestamp->gt(CarbonImmutable::parse((string) $filters['to']))) {
return false;
}
if (filled($filters['route'] ?? null) && ! Str::contains((string) $entry['route_name'], (string) $filters['route'], true)) {
return false;
}
if (filled($filters['path'] ?? null) && ! Str::contains((string) $entry['path'], (string) $filters['path'], true)) {
return false;
}
if (($filters['status'] ?? null) !== null && (int) $entry['status_code'] !== (int) $filters['status']) {
return false;
}
if (($filters['min_duration_ms'] ?? null) !== null && (int) $entry['duration_ms'] < (int) $filters['min_duration_ms']) {
return false;
}
return true;
}
/**
* @param Collection<int, array<string, mixed>> $entries
* @param list<string> $files
* @return array<string, int|float|string|null>
*/
private function summary(Collection $entries, array $files): array
{
return [
'files' => count($files),
'total_requests' => $entries->count(),
'unique_routes' => $entries->pluck('route_name')->filter()->unique()->count(),
'average_duration_ms' => round((float) $entries->avg('duration_ms'), 2),
'max_duration_ms' => (int) $entries->max('duration_ms'),
'average_database_time_ms' => round((float) $entries->avg('database_time_ms'), 2),
'max_database_time_ms' => round((float) $entries->max('database_time_ms'), 2),
'max_query_count' => (int) $entries->max('query_count'),
'period_from' => $entries->min('timestamp'),
'period_to' => $entries->max('timestamp'),
];
}
/**
* @param Collection<int, array<string, mixed>> $entries
* @return list<array<string, mixed>>
*/
private function topBy(Collection $entries, string $key, int $top): array
{
return $entries
->groupBy(fn (array $entry): string => (string) ($entry[$key] ?: 'unknown'))
->map(fn (Collection $group, string $value): array => [
'value' => $value,
'requests' => $group->count(),
'average_duration_ms' => round((float) $group->avg('duration_ms'), 2),
'max_duration_ms' => (int) $group->max('duration_ms'),
'average_database_time_ms' => round((float) $group->avg('database_time_ms'), 2),
'total_queries' => (int) $group->sum('query_count'),
])
->sortByDesc('requests')
->take($top)
->values()
->all();
}
/**
* @param Collection<int, array<string, mixed>> $entries
* @return list<array<string, mixed>>
*/
private function slowestRequests(Collection $entries, int $limit): array
{
return $entries
->sortByDesc('duration_ms')
->take($limit)
->values()
->all();
}
/**
* @param Collection<int, array<string, mixed>> $entries
* @return list<array<string, mixed>>
*/
private function queryHeavyRequests(Collection $entries, int $limit): array
{
return $entries
->sortByDesc('query_count')
->take($limit)
->values()
->all();
}
/**
* @param Collection<int, array<string, mixed>> $entries
* @return list<array<string, mixed>>
*/
private function slowQueries(Collection $entries, int $top): array
{
return $entries
->flatMap(fn (array $entry): array => $entry['slow_queries'])
->filter(fn (mixed $query): bool => is_array($query) && isset($query['sql']))
->groupBy(fn (array $query): string => (string) $query['sql'])
->map(fn (Collection $group, string $sql): array => [
'sql' => $sql,
'occurrences' => $group->count(),
'max_time_ms' => round((float) $group->max('time_ms'), 2),
'average_time_ms' => round((float) $group->avg('time_ms'), 2),
'connection' => $group->pluck('connection')->filter()->first(),
])
->sortByDesc('occurrences')
->take($top)
->values()
->all();
}
/**
* @param list<array<string, mixed>> $slowQueries
* @return list<array{sql: string, explainable: bool, plan: list<array<string, mixed>>, error: ?string}>
*/
private function explainPlans(array $slowQueries): array
{
return collect($slowQueries)
->map(function (array $query): array {
$sql = trim((string) $query['sql']);
if (! $this->isExplainable($sql)) {
return [
'sql' => $sql,
'explainable' => false,
'plan' => [],
'error' => 'Nur SELECT/CTE-Queries werden automatisch erklärt.',
];
}
try {
$bindings = array_fill(0, substr_count($sql, '?'), null);
$prefix = DB::connection()->getDriverName() === 'sqlite'
? 'EXPLAIN QUERY PLAN '
: 'EXPLAIN ';
return [
'sql' => $sql,
'explainable' => true,
'plan' => array_map(
static fn (object $row): array => (array) $row,
DB::select($prefix.$sql, $bindings),
),
'error' => null,
];
} catch (Throwable $exception) {
return [
'sql' => $sql,
'explainable' => true,
'plan' => [],
'error' => $exception->getMessage(),
];
}
})
->values()
->all();
}
private function isExplainable(string $sql): bool
{
return preg_match('/^\s*(select|with)\b/i', $sql) === 1;
}
}