mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
Integrate Monolog: replace four logging systems with single PSR-3 factory
- Add monolog/monolog dependency (^3.10)
- Create app/Logger.php central factory with channels: app, admin, error, audit
- Each channel gets RotatingFileHandler (30-day retention) with pass-through LineFormatter
preserving existing JSON format contracts
- Rewrite AppLogger as thin facade delegating to Logger::get('app')
- Rewrite ErrorHandler::log() to delegate to Logger::get('error')
- Rewrite AdminLogger file output to delegate to Logger::get('admin'), keep DB writes
- Add Monolog file shadow to Audit via Logger::get('audit') (Option A per monolog-plan)
- Log level controlled by LOG_LEVEL env var (defaults: DEBUG in cli-server, WARNING otherwise)
- Graceful NullHandler fallback when log directory is not writable
- Update SystemController LOG_FILES: remove php_error, add app/admin/error/audit
- JSON app logs parsed to readable one-liners in the log viewer
- Remove nginx config tab (parametres + fragment + template + css)
- Friendly empty-state message when app log files don't exist yet (notYet)
- PHP tail fallback when exec() unavailable
- All 228 PHPUnit tests pass, no call sites changed
This commit is contained in:
@@ -14,21 +14,10 @@
|
||||
*/
|
||||
class AdminLogger
|
||||
{
|
||||
private string $logFile;
|
||||
private ?Database $db;
|
||||
|
||||
public function __construct(?Database $db = null)
|
||||
{
|
||||
if (php_sapi_name() === 'cli-server') {
|
||||
$dir = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/logs' : __DIR__ . '/../storage/logs';
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
$this->logFile = $dir . '/admin.log';
|
||||
} else {
|
||||
$this->logFile = '/var/log/xamxam.log';
|
||||
}
|
||||
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
@@ -256,10 +245,10 @@ class AdminLogger
|
||||
$entry['context'] = $context;
|
||||
}
|
||||
|
||||
$line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n";
|
||||
if (is_writable($this->logFile) || (!file_exists($this->logFile) && is_writable(dirname($this->logFile)))) {
|
||||
error_log($line, 3, $this->logFile);
|
||||
}
|
||||
$line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
// File output — delegates to Monolog 'admin' channel
|
||||
Logger::get('admin')->info($line);
|
||||
|
||||
if ($this->db !== null) {
|
||||
$this->insertDb($resource, $action, $status, $context);
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
/**
|
||||
* Structured application logger for form submissions.
|
||||
*
|
||||
* Writes JSON-lines to a log file in storage/logs/.
|
||||
* Each entry contains: timestamp, source (admin|partage), action,
|
||||
* status (success|error), context (IP, UA, thesis ID, error message, etc.).
|
||||
* Thin facade over Monolog channel 'app'.
|
||||
* Delegates all file I/O — keeps existing public API unchanged.
|
||||
*/
|
||||
class AppLogger
|
||||
{
|
||||
@@ -16,10 +15,7 @@ class AppLogger
|
||||
{
|
||||
$this->logDir = $logDir ?? (defined('STORAGE_ROOT') ? STORAGE_ROOT . '/logs' : __DIR__ . '/../storage/logs');
|
||||
|
||||
if (!is_dir($this->logDir)) {
|
||||
mkdir($this->logDir, 0755, true);
|
||||
}
|
||||
|
||||
// Keep for backward compat — actual file I/O is now handled by Monolog via Logger::get('app')
|
||||
$this->logFile = $this->logDir . '/form-submissions.log';
|
||||
}
|
||||
|
||||
@@ -88,7 +84,7 @@ class AppLogger
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a structured log line.
|
||||
* Write a structured log line — delegates to Monolog 'app' channel.
|
||||
*/
|
||||
private function write(array $entry): void
|
||||
{
|
||||
@@ -96,7 +92,8 @@ class AppLogger
|
||||
$entry['ip'] = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
$entry['user_agent'] = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||
|
||||
$line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n";
|
||||
error_log($line, 3, $this->logFile);
|
||||
$line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
Logger::get('app')->info($line);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ class Audit
|
||||
?array $oldData = null,
|
||||
?array $newData = null
|
||||
): void {
|
||||
// DB write is the primary path — best-effort, never crash.
|
||||
try {
|
||||
$stmt = $db->getConnection()->prepare(
|
||||
'INSERT INTO audit_log (actor, action, table_name, record_id, old_data, new_data)
|
||||
@@ -49,7 +50,33 @@ class Audit
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// Audit logging is best-effort — never crash the app over it.
|
||||
error_log('[Audit] write failed: ' . $e->getMessage());
|
||||
error_log('[Audit] DB write failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// File shadow — structured JSON-line log for debuggability
|
||||
// (Option A from monolog-plan: keep Audit DB logic as-is, add file trace)
|
||||
try {
|
||||
$entry = [
|
||||
'timestamp' => date('c'),
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'cli',
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||
'actor' => $actor,
|
||||
'action' => $action,
|
||||
'table' => $tableName,
|
||||
'record_id' => $recordId,
|
||||
];
|
||||
if ($oldData !== null) {
|
||||
$entry['old_data'] = $oldData;
|
||||
}
|
||||
if ($newData !== null) {
|
||||
$entry['new_data'] = $newData;
|
||||
}
|
||||
|
||||
$line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
Logger::get('audit')->info($line);
|
||||
} catch (\Throwable $e) {
|
||||
// File shadow is also best-effort
|
||||
error_log('[Audit] file shadow write failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,8 @@
|
||||
* maintenance mode) with SystemCache TTL caching
|
||||
* - PHP environment info (1-hour TTL)
|
||||
* - Disk usage info (5-minute TTL)
|
||||
* - Log file reading (tail, meta)
|
||||
* - Nginx config file reading
|
||||
* - Log/nginx line classifiers used by both system.php and system-fragment.php
|
||||
* - Log file reading (tail, meta, JSON-line parsing)
|
||||
* - Log line classifiers used by both system.php and system-fragment.php
|
||||
*
|
||||
* Both system.php (full page) and system-fragment.php (AJAX panel) delegate
|
||||
* here so helpers are never duplicated.
|
||||
@@ -23,17 +22,39 @@ class SystemController
|
||||
// ── Constants ─────────────────────────────────────────────────────────────
|
||||
|
||||
public const LOG_FILES = [
|
||||
'nginx_access' => ['label' => 'nginx — accès', 'path' => '/var/log/nginx/xamxam_access.log'],
|
||||
'nginx_error' => ['label' => 'nginx — erreurs', 'path' => '/var/log/nginx/xamxam_error.log'],
|
||||
'php_error' => ['label' => 'PHP-FPM — erreurs', 'path' => '/var/log/php8.4-fpm.log'],
|
||||
'app' => ['label' => 'App — soumissions', 'path' => null, 'json' => true],
|
||||
'admin' => ['label' => 'Admin — actions', 'path' => null, 'json' => true],
|
||||
'error' => ['label' => 'Erreurs — application', 'path' => null, 'json' => true],
|
||||
'audit' => ['label' => 'Audit — données', 'path' => null, 'json' => true],
|
||||
'nginx_access' => ['label' => 'nginx — accès', 'path' => '/var/log/nginx/xamxam_access.log', 'json' => false],
|
||||
'nginx_error' => ['label' => 'nginx — erreurs','path' => '/var/log/nginx/xamxam_error.log', 'json' => false],
|
||||
];
|
||||
|
||||
public const ALLOWED_LINES = [50, 100, 200, 500];
|
||||
/**
|
||||
* Resolve a log file path — app logs live under STORAGE_ROOT, system logs
|
||||
* have hard-coded paths (only valid in production).
|
||||
*/
|
||||
private static function resolveLogPath(string $tab): string
|
||||
{
|
||||
$def = self::LOG_FILES[$tab];
|
||||
if ($def['path'] !== null) {
|
||||
return $def['path'];
|
||||
}
|
||||
// App logs: storage/logs/{channel}.log (Monolog RotatingFileHandler uses
|
||||
// this as base name; the current log is always at {channel}-YYYY-MM-DD.log)
|
||||
$dir = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/logs' : APP_ROOT . '/storage/logs';
|
||||
// Find the most recent dated log file for this channel
|
||||
$base = $dir . '/' . $tab;
|
||||
$dated = glob($base . '-20[0-9][0-9]-[0-9][0-9]-[0-9][0-9].log');
|
||||
if (!empty($dated)) {
|
||||
rsort($dated); // newest first
|
||||
return $dated[0];
|
||||
}
|
||||
// Fall back to the bare name (pre-existing or non-rotated)
|
||||
return $base . '.log';
|
||||
}
|
||||
|
||||
/** Live deployed nginx config path. */
|
||||
public const NGINX_CONFIG_LIVE = '/etc/nginx/sites-available/xamxam';
|
||||
/** Local reference copy used as fallback in dev. */
|
||||
public const NGINX_CONFIG_LOCAL = APP_ROOT . '/nginx/xamxam.conf';
|
||||
public const ALLOWED_LINES = [50, 100, 200, 500];
|
||||
|
||||
// ── TTLs ──────────────────────────────────────────────────────────────────
|
||||
private const TTL_STATUS = 120; // 2 minutes
|
||||
@@ -140,11 +161,37 @@ class SystemController
|
||||
*/
|
||||
public function getLogData(string $tab, int $n): array
|
||||
{
|
||||
$logPath = self::LOG_FILES[$tab]['path'];
|
||||
$logPath = self::resolveLogPath($tab);
|
||||
$isJson = self::LOG_FILES[$tab]['json'] ?? false;
|
||||
$error = null;
|
||||
$lines = $this->readLogTail($logPath, $n, $error);
|
||||
$meta = null;
|
||||
$rawLines = null;
|
||||
|
||||
if (!file_exists($logPath)) {
|
||||
// App logs are rotated by Monolog; a missing file just means no
|
||||
// events have been logged yet. Show a friendly empty-state message
|
||||
// instead of a scary "fichier introuvable" error.
|
||||
if ($isJson) {
|
||||
return [
|
||||
'lines' => [],
|
||||
'error' => null,
|
||||
'meta' => null,
|
||||
'isJson' => true,
|
||||
'notYet' => true,
|
||||
];
|
||||
}
|
||||
// System logs genuinely missing — show an error
|
||||
$error = 'Fichier introuvable : ' . htmlspecialchars($logPath);
|
||||
} else {
|
||||
$rawLines = $this->readLogTail($logPath, $n, $error);
|
||||
}
|
||||
|
||||
// Parse JSON lines into display strings
|
||||
$lines = null;
|
||||
if ($rawLines !== null) {
|
||||
$lines = $isJson ? self::formatJsonLines($rawLines) : $rawLines;
|
||||
}
|
||||
|
||||
$meta = null;
|
||||
if (file_exists($logPath)) {
|
||||
$sz = filesize($logPath);
|
||||
$meta = [
|
||||
@@ -156,43 +203,102 @@ class SystemController
|
||||
];
|
||||
}
|
||||
|
||||
return ['lines' => $lines, 'error' => $error, 'meta' => $meta];
|
||||
return ['lines' => $lines, 'error' => $error, 'meta' => $meta, 'isJson' => $isJson, 'notYet' => false];
|
||||
}
|
||||
|
||||
// ── Nginx config tab ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read and return data for the nginx config tab.
|
||||
* Format JSON log lines into human-readable display strings.
|
||||
*
|
||||
* @return array{lines: ?array, source: ?string, meta: ?array, error: ?string}
|
||||
* Each line is a flat JSON object. We extract key fields and build a
|
||||
* compact one-line representation with emoji status indicators.
|
||||
*
|
||||
* @param string[] $jsonLines Raw JSON strings (newest first).
|
||||
* @return string[] Formatted display lines.
|
||||
*/
|
||||
public function getNginxConfigData(): array
|
||||
private static function formatJsonLines(array $jsonLines): array
|
||||
{
|
||||
$livePath = self::NGINX_CONFIG_LIVE;
|
||||
$localPath = self::NGINX_CONFIG_LOCAL;
|
||||
|
||||
foreach ([[$livePath, 'live'], [$localPath, 'local']] as [$path, $src]) {
|
||||
if (file_exists($path) && is_readable($path)) {
|
||||
$raw = file($path, FILE_IGNORE_NEW_LINES);
|
||||
if ($raw !== false) {
|
||||
$sz = filesize($path);
|
||||
$meta = [
|
||||
'path' => $path,
|
||||
'size' => $sz > 1048576
|
||||
? number_format($sz / 1048576, 2) . ' MB'
|
||||
: number_format($sz / 1024, 1) . ' KB',
|
||||
'mtime' => date('d/m/Y H:i:s', filemtime($path)),
|
||||
];
|
||||
return ['lines' => $raw, 'source' => $src, 'meta' => $meta, 'error' => null];
|
||||
}
|
||||
$formatted = [];
|
||||
foreach ($jsonLines as $raw) {
|
||||
$entry = json_decode($raw, true);
|
||||
if (!is_array($entry)) {
|
||||
$formatted[] = $raw; // not valid JSON — show raw
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
|
||||
// Timestamp
|
||||
$ts = $entry['timestamp'] ?? '';
|
||||
if ($ts !== '') {
|
||||
$parts[] = substr($ts, 0, 19); // YYYY-MM-DDTHH:MM:SS
|
||||
}
|
||||
|
||||
// Status emoji
|
||||
$status = $entry['status'] ?? '';
|
||||
if ($status === 'success' || $status === 'active') {
|
||||
$parts[] = '✓';
|
||||
} elseif ($status === 'error' || $status === 'duplicate') {
|
||||
$parts[] = '✗';
|
||||
}
|
||||
|
||||
// Key identifying fields (vary by channel)
|
||||
$id = $entry['resource']
|
||||
?? $entry['source']
|
||||
?? $entry['context']
|
||||
?? '';
|
||||
if ($id !== '') {
|
||||
$parts[] = $id;
|
||||
}
|
||||
|
||||
// Action
|
||||
$action = $entry['action'] ?? '';
|
||||
if ($action !== '') {
|
||||
$parts[] = $action;
|
||||
}
|
||||
|
||||
// Status text
|
||||
if ($status !== '' && $status !== 'success') {
|
||||
$parts[] = $status;
|
||||
}
|
||||
|
||||
// Actor / IP
|
||||
$actor = $entry['actor'] ?? '';
|
||||
if ($actor !== '') {
|
||||
$parts[] = $actor;
|
||||
} elseif (isset($entry['ip'])) {
|
||||
$parts[] = $entry['ip'];
|
||||
}
|
||||
|
||||
// User agent (compact)
|
||||
$ua = $entry['user_agent'] ?? '';
|
||||
if ($ua !== '' && $ua !== 'unknown' && $ua !== 'cli') {
|
||||
// Truncate UA: first meaningful segment
|
||||
$uaShort = preg_match('#^([^(]+)#', $ua, $m) ? trim($m[1]) : $ua;
|
||||
if (mb_strlen($uaShort) > 60) {
|
||||
$uaShort = mb_substr($uaShort, 0, 57) . '…';
|
||||
}
|
||||
$parts[] = $uaShort;
|
||||
}
|
||||
|
||||
// For error log: exception + message
|
||||
if (isset($entry['exception']) && isset($entry['message'])) {
|
||||
$msg = $entry['exception'] . ': ' . $entry['message'];
|
||||
if (mb_strlen($msg) > 120) {
|
||||
$msg = mb_substr($msg, 0, 117) . '…';
|
||||
}
|
||||
$parts[] = $msg;
|
||||
}
|
||||
|
||||
// For audit log: table + record_id
|
||||
$table = $entry['table'] ?? '';
|
||||
if ($table !== '') {
|
||||
$parts[] = $table . (isset($entry['record_id']) ? '#' . $entry['record_id'] : '');
|
||||
}
|
||||
|
||||
$formatted[] = implode(' ', $parts);
|
||||
}
|
||||
|
||||
$error = file_exists($livePath)
|
||||
? 'Fichier non lisible (permissions insuffisantes) : ' . htmlspecialchars($livePath)
|
||||
: 'Config live introuvable (' . htmlspecialchars($livePath) . ') et config locale introuvable (' . htmlspecialchars($localPath) . ').';
|
||||
|
||||
return ['lines' => null, 'source' => null, 'meta' => null, 'error' => $error];
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
// ── Line classifiers (used by both system.php and system-fragment.php) ────
|
||||
@@ -223,21 +329,6 @@ class SystemController
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the CSS class for a nginx config line.
|
||||
*/
|
||||
public static function nginxLineClass(string $line): string
|
||||
{
|
||||
$trimmed = ltrim($line);
|
||||
if ($trimmed === '' || str_starts_with($trimmed, '#')) {
|
||||
return 'nginx-comment';
|
||||
}
|
||||
if (preg_match('/^\s*(location|server|upstream|events|http|geo|map|types)\b/', $line)) {
|
||||
return 'nginx-block';
|
||||
}
|
||||
return 'nginx-directive';
|
||||
}
|
||||
|
||||
// ── View helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -399,15 +490,14 @@ class SystemController
|
||||
|
||||
/**
|
||||
* Read the tail of a log file, newest-first. Returns null on error.
|
||||
*
|
||||
* Prefers tail(1) for efficiency on large files; falls back to a pure-PHP
|
||||
* read for app log files when exec() is unavailable or tail fails.
|
||||
*/
|
||||
private function readLogTail(string $logPath, int $n, ?string &$errorMsg): ?array
|
||||
{
|
||||
$errorMsg = null;
|
||||
|
||||
if (!function_exists('exec')) {
|
||||
$errorMsg = 'exec() est désactivé sur ce serveur.';
|
||||
return null;
|
||||
}
|
||||
if (!file_exists($logPath)) {
|
||||
$errorMsg = 'Fichier introuvable : ' . htmlspecialchars($logPath);
|
||||
return null;
|
||||
@@ -417,16 +507,25 @@ class SystemController
|
||||
return null;
|
||||
}
|
||||
|
||||
$output = [];
|
||||
$rc = 0;
|
||||
exec('tail -n ' . intval($n) . ' ' . escapeshellarg($logPath) . ' 2>/dev/null', $output, $rc);
|
||||
// Try tail(1) first — fast on large log files
|
||||
if (function_exists('exec')) {
|
||||
$output = [];
|
||||
$rc = 0;
|
||||
@exec('tail -n ' . intval($n) . ' ' . escapeshellarg($logPath) . ' 2>/dev/null', $output, $rc);
|
||||
if ($rc === 0) {
|
||||
return array_reverse($output); // newest first
|
||||
}
|
||||
}
|
||||
|
||||
if ($rc !== 0) {
|
||||
// PHP fallback — reads the whole file, returns last N lines
|
||||
$raw = @file($logPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
if ($raw === false) {
|
||||
$errorMsg = 'Erreur lors de la lecture du fichier journal.';
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_reverse($output); // newest first
|
||||
$slice = array_slice($raw, -min($n, count($raw)));
|
||||
return array_reverse($slice);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -154,7 +154,7 @@ class ErrorHandler
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a structured error log entry.
|
||||
* Write a structured error log entry — delegates to Monolog 'error' channel.
|
||||
*
|
||||
* @param string $context e.g. 'thesis_edit', 'partage_submit', 'csv_import'
|
||||
* @param \Throwable $e
|
||||
@@ -162,22 +162,22 @@ class ErrorHandler
|
||||
*/
|
||||
public static function log(string $context, \Throwable $e, array $extra = []): void
|
||||
{
|
||||
$parts = [
|
||||
'context=' . $context,
|
||||
'exception=' . get_class($e),
|
||||
'message=' . $e->getMessage(),
|
||||
$entry = [
|
||||
'timestamp' => date('c'),
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||
'context' => $context,
|
||||
'exception' => get_class($e),
|
||||
'message' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
];
|
||||
foreach ($extra as $k => $v) {
|
||||
if (is_scalar($v) || $v === null) {
|
||||
$parts[] = $k . '=' . json_encode($v, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
} elseif (is_array($v)) {
|
||||
$parts[] = $k . '=' . json_encode($v, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
$parts[] = $k . '=' . gettype($v);
|
||||
}
|
||||
}
|
||||
$parts[] = 'trace=' . $e->getTraceAsString();
|
||||
|
||||
error_log(implode(' | ', $parts));
|
||||
if (!empty($extra)) {
|
||||
$entry['extra'] = $extra;
|
||||
}
|
||||
|
||||
$line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
Logger::get('error')->error($line);
|
||||
}
|
||||
}
|
||||
|
||||
105
app/src/Logger.php
Normal file
105
app/src/Logger.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
use Monolog\Handler\RotatingFileHandler;
|
||||
use Monolog\Handler\NullHandler;
|
||||
use Monolog\Formatter\LineFormatter;
|
||||
use Monolog\Level;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Central logger factory — provides named Monolog channel instances
|
||||
* backed by rotating JSON-line log files.
|
||||
*
|
||||
* Channels:
|
||||
* - 'app' → replaces AppLogger
|
||||
* - 'admin' → replaces AdminLogger file output
|
||||
* - 'error' → replaces ErrorHandler logging
|
||||
* - 'audit' → replaces Audit file shadow (DB writes stay in Audit)
|
||||
*
|
||||
* Usage:
|
||||
* Logger::get('app')->info('message', [...]);
|
||||
* Logger::get('admin')->warning('message', [...]);
|
||||
*/
|
||||
class Logger
|
||||
{
|
||||
/** @var array<string, LoggerInterface> */
|
||||
private static array $channels = [];
|
||||
|
||||
/**
|
||||
* Get (or lazily create) a named Monolog channel.
|
||||
*/
|
||||
public static function get(string $channel): LoggerInterface
|
||||
{
|
||||
if (!isset(self::$channels[$channel])) {
|
||||
self::$channels[$channel] = self::create($channel);
|
||||
}
|
||||
return self::$channels[$channel];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Monolog channel with rotating JSON file handler.
|
||||
*
|
||||
* Falls back to NullHandler if the log directory is not writable
|
||||
* (e.g. CLI scripts on a machine that doesn't have the production path).
|
||||
*/
|
||||
private static function create(string $channel): \Monolog\Logger
|
||||
{
|
||||
$logDir = self::logDir();
|
||||
|
||||
if (!is_dir($logDir) && !@mkdir($logDir, 0755, true) && !is_dir($logDir)) {
|
||||
// Directory can't be created — use null handler (graceful degradation)
|
||||
$logger = new \Monolog\Logger($channel);
|
||||
$logger->pushHandler(new NullHandler());
|
||||
return $logger;
|
||||
}
|
||||
|
||||
try {
|
||||
$handler = new RotatingFileHandler(
|
||||
$logDir . '/' . $channel . '.log',
|
||||
30, // keep 30 days of logs
|
||||
self::level()
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// Can't open log file — use null handler
|
||||
$logger = new \Monolog\Logger($channel);
|
||||
$logger->pushHandler(new NullHandler());
|
||||
return $logger;
|
||||
}
|
||||
|
||||
// Pass-through formatter: the facades (AppLogger, AdminLogger, etc.)
|
||||
// construct their own JSON lines and pass them as the log message.
|
||||
// %message% preserves the existing JSON format contract exactly.
|
||||
$handler->setFormatter(new LineFormatter("%message%\n", null, true));
|
||||
|
||||
$logger = new \Monolog\Logger($channel);
|
||||
$logger->pushHandler($handler);
|
||||
|
||||
return $logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the LOG_LEVEL env var with sensible defaults.
|
||||
*/
|
||||
private static function level(): Level
|
||||
{
|
||||
$level = strtoupper(getenv('LOG_LEVEL') ?: '');
|
||||
|
||||
// Default: WARNING in production (always set in .env), DEBUG otherwise
|
||||
if ($level === '') {
|
||||
return php_sapi_name() === 'cli-server' ? Level::Debug : Level::Warning;
|
||||
}
|
||||
|
||||
return Level::fromName($level);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the log directory.
|
||||
*/
|
||||
private static function logDir(): string
|
||||
{
|
||||
if (defined('STORAGE_ROOT')) {
|
||||
return STORAGE_ROOT . '/logs';
|
||||
}
|
||||
return __DIR__ . '/../storage/logs';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user