, request: array, environment: array, queries: array} */ public static function collect(\Throwable $exception): array { return [ 'exception' => self::collectException($exception), 'request' => self::collectRequest(), 'environment' => self::collectEnvironment(), 'queries' => self::collectQueries(), ]; } /** * @return array */ private static function collectException(\Throwable $exception): array { $data = [ 'class' => get_class($exception), 'message' => $exception->getMessage(), 'code' => $exception->getCode(), 'file' => $exception->getFile(), 'line' => $exception->getLine(), 'trace' => self::collectStackFrames($exception), ]; $previous = $exception->getPrevious(); if ($previous !== null) { $data['previous'] = self::collectException($previous); } return $data; } /** * @return list> */ private static function collectStackFrames(\Throwable $exception): array { $frames = []; // Frame 0 is the exception origin itself. $frames[] = [ 'file' => $exception->getFile(), 'line' => $exception->getLine(), 'function' => null, 'class' => null, 'code_snippet' => self::extractCodeSnippet($exception->getFile(), $exception->getLine()), ]; foreach (array_slice($exception->getTrace(), 0, self::MAX_TRACE_FRAMES) as $frame) { $file = $frame['file'] ?? null; $line = $frame['line'] ?? null; $function = $frame['function']; $class = $frame['class'] ?? null; $frames[] = [ 'file' => $file, 'line' => $line, 'function' => $function, 'class' => $class, 'args_summary' => self::summarizeArgs($frame['args'] ?? []), 'code_snippet' => ($file !== null && $line !== null) ? self::extractCodeSnippet($file, $line) : [], ]; } return $frames; } /** * @return array Line number => code line */ private static function extractCodeSnippet(string $file, int $line): array { try { if (!is_file($file) || !is_readable($file)) { return []; } $lines = @file($file, FILE_IGNORE_NEW_LINES); if ($lines === false) { return []; } $start = max(0, $line - self::CODE_CONTEXT_LINES - 1); $end = min(count($lines), $line + self::CODE_CONTEXT_LINES); $snippet = []; for ($i = $start; $i < $end; $i++) { $snippet[$i + 1] = $lines[$i]; } return $snippet; } catch (\Throwable) { return []; } } /** * @param list $args * @return list */ private static function summarizeArgs(array $args): array { $summary = []; foreach ($args as $arg) { $summary[] = match (true) { is_null($arg) => 'null', is_bool($arg) => $arg ? 'true' : 'false', is_int($arg) => 'int', is_float($arg) => 'float', is_string($arg) => 'string(' . strlen($arg) . ')', is_array($arg) => 'array(' . count($arg) . ')', is_object($arg) => get_class($arg), is_resource($arg) => 'resource', default => 'unknown', }; } return $summary; } /** * @return array */ private static function collectRequest(): array { $data = [ 'request_id' => null, 'channel' => 'web', 'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET', 'url' => ($_SERVER['REQUEST_URI'] ?? ''), 'ip' => $_SERVER['REMOTE_ADDR'] ?? null, 'user_agent' => substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), 'headers' => self::collectHeaders(), 'get' => $_GET, 'post' => self::redactParams($_POST), 'session_keys' => [], 'user_id' => null, 'tenant_id' => null, ]; // Safe request context access. try { $data['request_id'] = RequestContext::currentId() ?? RequestContext::id(); } catch (\Throwable) { // RequestContext may not be initialized. } try { $ctx = RequestContext::context(); $data['channel'] = $ctx['channel'] ?? 'web'; } catch (\Throwable) { } // Session data — keys and types only, never values. if (session_status() === PHP_SESSION_ACTIVE) { $data['session_keys'] = self::describeSessionKeys($_SESSION ?? []); $data['user_id'] = $_SESSION['user']['id'] ?? $_SESSION['user_id'] ?? null; $data['tenant_id'] = $_SESSION['current_tenant']['id'] ?? $_SESSION['tenant_id'] ?? null; } return $data; } /** * @return array */ private static function collectHeaders(): array { $headers = []; foreach ($_SERVER as $key => $value) { if (!str_starts_with($key, 'HTTP_')) { continue; } $name = strtolower(str_replace('_', '-', substr($key, 5))); $headers[$name] = self::shouldRedactHeader($name) ? '[REDACTED]' : (string) $value; } return $headers; } private static function shouldRedactHeader(string $name): bool { $lower = strtolower($name); foreach (self::REDACTED_HEADER_PATTERNS as $pattern) { if (str_contains($lower, $pattern)) { return true; } } return false; } /** * @param array $params * @return array */ private static function redactParams(array $params): array { $result = []; foreach ($params as $key => $value) { $result[$key] = self::shouldRedactParam((string) $key) ? '[REDACTED]' : $value; } return $result; } private static function shouldRedactParam(string $key): bool { $lower = strtolower($key); foreach (self::REDACTED_KEY_PATTERNS as $pattern) { if (str_contains($lower, $pattern)) { return true; } } return false; } /** * @return array */ private static function describeSessionKeys(array $session): array { $result = []; foreach ($session as $key => $value) { $result[(string) $key] = get_debug_type($value); } return $result; } /** * @return array */ private static function collectEnvironment(): array { $data = [ 'php_version' => PHP_VERSION, 'php_sapi' => PHP_SAPI, 'extensions' => get_loaded_extensions(), 'memory_current' => memory_get_usage(true), 'memory_peak' => memory_get_peak_usage(true), 'env' => self::collectSafeEnv(), 'modules' => [], ]; // Active modules — may fail if container is unavailable. try { if (function_exists('app')) { $registry = app(\MintyPHP\App\Module\ModuleRegistry::class); $modules = $registry->getModules(); $data['modules'] = array_map( fn (object $m): string => $m->id ?? (string) $m, $modules, ); } } catch (\Throwable) { } return $data; } /** * @return array */ private static function collectSafeEnv(): array { $result = []; foreach (self::SAFE_ENV_KEYS as $key) { $value = $_ENV[$key] ?? $_SERVER[$key] ?? null; if ($value !== null) { $result[$key] = (string) $value; } } return $result; } /** * @return array */ private static function collectQueries(): array { try { if (!class_exists(Debugger::class, false) || !Debugger::$enabled) { return ['available' => false, 'queries' => [], 'start' => null]; } $queries = Debugger::get('queries'); $start = Debugger::get('start'); if (!is_array($queries)) { $queries = []; } return [ 'available' => true, 'queries' => $queries, 'start' => is_float($start) ? $start : null, 'count' => count($queries), ]; } catch (\Throwable) { return ['available' => false, 'queries' => [], 'start' => null]; } } }