Files
server/core/lib/Logger/FileLogger.php
2025-12-21 10:09:54 -05:00

126 lines
4.8 KiB
PHP

<?php
namespace KTXC\Logger;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
/**
* Simple file-based PSR-3 logger.
*/
class FileLogger implements LoggerInterface
{
private string $logFile;
private bool $useMicroseconds;
private string $channel;
/**
* @param string $logDir Directory where log files are written
* @param string $channel Logical channel name (used in filename)
* @param bool $useMicroseconds Whether to include microseconds in timestamp
*/
public function __construct(string $logDir, string $channel = 'app', bool $useMicroseconds = true)
{
$this->useMicroseconds = $useMicroseconds;
$this->channel = $channel;
if (!is_dir($logDir)) {
@mkdir($logDir, 0775, true);
}
$this->logFile = rtrim($logDir, '/').'/'.$channel.'.log';
}
public function emergency($message, array $context = []): void { $this->log(LogLevel::EMERGENCY, $message, $context); }
public function alert($message, array $context = []): void { $this->log(LogLevel::ALERT, $message, $context); }
public function critical($message, array $context = []): void { $this->log(LogLevel::CRITICAL, $message, $context); }
public function error($message, array $context = []): void { $this->log(LogLevel::ERROR, $message, $context); }
public function warning($message, array $context = []): void { $this->log(LogLevel::WARNING, $message, $context); }
public function notice($message, array $context = []): void { $this->log(LogLevel::NOTICE, $message, $context); }
public function info($message, array $context = []): void { $this->log(LogLevel::INFO, $message, $context); }
public function debug($message, array $context = []): void { $this->log(LogLevel::DEBUG, $message, $context); }
public function log($level, $message, array $context = []): void
{
$timestamp = $this->formatTimestamp();
$interpolated = $this->interpolate((string)$message, $context);
$payload = [
'time' => $timestamp,
'level' => strtolower((string)$level),
'channel' => $this->channel,
'message' => $interpolated,
'context' => $this->sanitizeContext($context),
];
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
// Fallback stringify if encoding fails (should be rare)
$json = json_encode([
'time' => $timestamp,
'level' => strtolower((string)$level),
'channel' => $this->channel,
'message' => $interpolated,
'context_error' => 'failed to encode context: '.json_last_error_msg(),
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{"error":"logging failure"}';
}
$this->write($json);
}
private function formatTimestamp(): string
{
if ($this->useMicroseconds) {
$dt = \DateTimeImmutable::createFromFormat('U.u', sprintf('%.6F', microtime(true)));
return $dt?->format('Y-m-d H:i:s.u') ?? date('Y-m-d H:i:s');
}
return date('Y-m-d H:i:s');
}
private function interpolate(string $message, array $context): string
{
if (!str_contains($message, '{')) {
return $message;
}
$replace = [];
foreach ($context as $key => $val) {
if (is_array($val) || is_object($val)) {
continue; // don't inline complex values
}
$replace['{'.$key.'}'] = (string)$val;
}
return strtr($message, $replace);
}
private function sanitizeContext(array $context): array
{
if (empty($context)) { return []; }
$clean = [];
foreach ($context as $k => $v) {
if ($v instanceof \Throwable) {
$clean[$k] = [
'type' => get_class($v),
'message' => $v->getMessage(),
'code' => $v->getCode(),
'file' => $v->getFile(),
'line' => $v->getLine(),
'trace' => explode("\n", $v->getTraceAsString()),
];
} elseif (is_resource($v)) {
$clean[$k] = 'resource('.get_resource_type($v).')';
} elseif (is_object($v)) {
// Try to extract serializable data
if (method_exists($v, '__toString')) {
$clean[$k] = (string)$v;
} else {
$clean[$k] = ['object' => get_class($v)];
}
} else {
$clean[$k] = $v;
}
}
return $clean;
}
private function write(string $line): void
{
$line = rtrim($line)."\n"; // newline-delimited JSON (JSONL)
@file_put_contents($this->logFile, $line, FILE_APPEND | LOCK_EX);
}
}