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:
Pontoporeia
2026-05-20 02:16:17 +02:00
parent a6e0aa5887
commit ae66c2baad
19 changed files with 662 additions and 433 deletions

View File

@@ -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' => 'nginxerreurs', '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);
}
/**