323 lines
12 KiB
PHP
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;
|
|
}
|
|
}
|