'Failed to encode log entry', 'request_id' => $entry['request_id'] ?? 'unknown']); } file_put_contents($logFile, $json . "\n", FILE_APPEND | LOCK_EX); } catch (\Throwable) { // Logging must never cause secondary failures. } } private static function ensureLogDirectory(): ?string { $dir = self::LOG_DIR; if (is_dir($dir)) { return $dir; } if (!@mkdir($dir, 0o775, true) && !is_dir($dir)) { return null; } return $dir; } private static function rotateIfNeeded(string $filePath): void { if (!is_file($filePath)) { return; } $size = @filesize($filePath); if ($size === false || $size < self::MAX_FILE_SIZE) { return; } // Find the next available rotation slot. for ($i = self::MAX_ROTATED_FILES; $i >= 2; $i--) { $older = $filePath . '.' . $i; $newer = $filePath . '.' . ($i - 1); if (is_file($newer)) { @rename($newer, $older); } } @rename($filePath, $filePath . '.1'); } /** * @param array{exception?: array, request?: array, environment?: array, queries?: array} $collectedData * @return array */ private static function buildLogEntry(\Throwable $exception, array $collectedData): array { $exceptionData = $collectedData['exception'] ?? []; $requestData = $collectedData['request'] ?? []; $envData = $collectedData['environment'] ?? []; $exceptionMessage = (string) ($exceptionData['message'] ?? $exception->getMessage()); $traceSummary = ''; $frames = $exceptionData['trace'] ?? []; foreach (array_slice($frames, 0, 10) as $i => $frame) { $file = $frame['file'] ?? '?'; $line = $frame['line'] ?? '?'; $traceSummary .= "#{$i} {$file}:{$line} "; } return [ 'timestamp' => date('c'), 'level' => 'error', 'request_id' => $requestData['request_id'] ?? null, 'channel' => $requestData['channel'] ?? 'web', 'exception' => [ 'class' => $exceptionData['class'] ?? get_class($exception), 'message' => self::REDACTION_MARKER, 'message_hash' => self::hashMessage($exceptionMessage), 'code' => $exceptionData['code'] ?? $exception->getCode(), 'file' => self::shortenPath($exceptionData['file'] ?? $exception->getFile()), 'line' => $exceptionData['line'] ?? $exception->getLine(), ], 'trace_summary' => trim($traceSummary), 'request' => [ 'method' => $requestData['method'] ?? null, 'path' => self::normalizeRequestPath($requestData['url'] ?? null), 'ip_hash' => self::hashIp($requestData['ip'] ?? null), 'user_id' => $requestData['user_id'] ?? null, 'tenant_id' => $requestData['tenant_id'] ?? null, ], 'php_version' => $envData['php_version'] ?? PHP_VERSION, 'memory_peak_mb' => round(($envData['memory_peak'] ?? memory_get_peak_usage(true)) / 1024 / 1024, 1), ]; } private static function hashIp(?string $ip): ?string { if ($ip === null || $ip === '') { return null; } return 'sha256:' . hash('sha256', $ip); } private static function hashMessage(string $message): ?string { $message = trim($message); if ($message === '') { return null; } return 'sha256:' . hash('sha256', $message); } private static function normalizeRequestPath(mixed $rawUrl): ?string { if (!is_string($rawUrl) || trim($rawUrl) === '') { return null; } $path = parse_url($rawUrl, PHP_URL_PATH); if (!is_string($path) || trim($path) === '') { return '/'; } $path = preg_replace('/[[:cntrl:]]+/', '', $path) ?? ''; $path = trim($path); if ($path === '') { return '/'; } if (!str_starts_with($path, '/')) { $path = '/' . $path; } return substr($path, 0, 500); } private static function shortenPath(string $path): string { $root = getcwd(); if ($root !== false && str_starts_with($path, $root . '/')) { return substr($path, strlen($root) + 1); } return $path; } }