1
0
Files
breadcrumb-the-shire/lib/Http/ErrorLogger.php

133 lines
4.3 KiB
PHP
Raw Normal View History

<?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;
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'] ?? [];
$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' => $exceptionData['message'] ?? $exception->getMessage(),
'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,
'url' => $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 shortenPath(string $path): string
{
$root = getcwd();
if ($root !== false && str_starts_with($path, $root . '/')) {
return substr($path, strlen($root) + 1);
}
return $path;
}
}