|null $paths * @return array{ * summary: array, * top_routes: list>, * top_paths: list>, * status_codes: list>, * slowest_requests: list>, * query_heavy_requests: list>, * slow_queries: list>, * explain_plans: list>, error: ?string}>, * entries: list>, * files: list * } */ 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|null $paths * @return list */ 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 $files * @return Collection> */ 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|null */ private function parseLine(string $line, string $file): ?array { if (! str_contains($line, 'Slow admin request detected.')) { return null; } if (! preg_match('/^\[(?[^\]]+)\]\s+(?[^.]+)\.(?[^:]+):\s+Slow admin request detected\.\s+(?\{.*\})\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 $entry * @param array $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> $entries * @param list $files * @return array */ 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> $entries * @return list> */ 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> $entries * @return list> */ private function slowestRequests(Collection $entries, int $limit): array { return $entries ->sortByDesc('duration_ms') ->take($limit) ->values() ->all(); } /** * @param Collection> $entries * @return list> */ private function queryHeavyRequests(Collection $entries, int $limit): array { return $entries ->sortByDesc('query_count') ->take($limit) ->values() ->all(); } /** * @param Collection> $entries * @return list> */ 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> $slowQueries * @return list>, 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; } }