2026-03-24 20:49:22 +01:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace MintyPHP\Http;
|
|
|
|
|
|
|
|
|
|
final class ErrorLogger
|
|
|
|
|
{
|
|
|
|
|
private const LOG_DIR = 'storage/logs/errors';
|
|
|
|
|
private const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
|
|
|
private const MAX_ROTATED_FILES = 5;
|
2026-03-24 22:00:14 +01:00
|
|
|
private const REDACTION_MARKER = '[REDACTED]';
|
2026-03-24 20:49:22 +01:00
|
|
|
|
|
|
|
|
public static function log(\Throwable $exception, array $collectedData): void
|
|
|
|
|
{
|
|
|
|
|
try {
|
|
|
|
|
$logDir = self::ensureLogDirectory();
|
|
|
|
|
if ($logDir === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$logFile = $logDir . '/error-' . date('Y-m-d') . '.log';
|
|
|
|
|
self::rotateIfNeeded($logFile);
|
|
|
|
|
|
|
|
|
|
$entry = self::buildLogEntry($exception, $collectedData);
|
|
|
|
|
$json = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
|
|
|
if ($json === false) {
|
|
|
|
|
$json = json_encode(['error' => '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<string, mixed>
|
|
|
|
|
*/
|
|
|
|
|
private static function buildLogEntry(\Throwable $exception, array $collectedData): array
|
|
|
|
|
{
|
|
|
|
|
$exceptionData = $collectedData['exception'] ?? [];
|
|
|
|
|
$requestData = $collectedData['request'] ?? [];
|
|
|
|
|
$envData = $collectedData['environment'] ?? [];
|
2026-03-24 22:00:14 +01:00
|
|
|
$exceptionMessage = (string) ($exceptionData['message'] ?? $exception->getMessage());
|
2026-03-24 20:49:22 +01:00
|
|
|
|
|
|
|
|
$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),
|
2026-03-24 22:00:14 +01:00
|
|
|
'message' => self::REDACTION_MARKER,
|
|
|
|
|
'message_hash' => self::hashMessage($exceptionMessage),
|
2026-03-24 20:49:22 +01:00
|
|
|
'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,
|
2026-03-24 22:00:14 +01:00
|
|
|
'path' => self::normalizeRequestPath($requestData['url'] ?? null),
|
2026-03-24 20:49:22 +01:00
|
|
|
'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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 22:00:14 +01:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 20:49:22 +01:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|