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

File diff suppressed because one or more lines are too long

View File

@@ -225,8 +225,12 @@
- [x] Step 4 — Update templates (data-queue-type on all inputs, data-existing-files in edit) - [x] Step 4 — Update templates (data-queue-type on all inputs, data-existing-files in edit)
- [x] Step 5 — Update upload-progress.js (new collectFileNames, pending-uploads guard) - [x] Step 5 — Update upload-progress.js (new collectFileNames, pending-uploads guard)
- [ ] Step 6 — QA / integration testing - [ ] Step 6 — QA / integration testing
- [x] Logs accessible via Paramètres: app, admin, error, audit tabs (JSON parsed to readable lines), nginx tabs kept
- [x] Remove nginx config tab and PHP-FPM error log tab from UI
- [ ] Step 7 — Cleanup: remove transition flags, remove INPUT_ID_TO_TYPE - [ ] Step 7 — Cleanup: remove transition flags, remove INPUT_ID_TO_TYPE
# CSP & Deploy Fixes (May 2026) # CSP & Deploy Fixes (May 2026)
- [x] Track vendor JS files in jj (they were moved to vendor/ but never `jj file track`ed) - [x] Track vendor JS files in jj (they were moved to vendor/ but never `jj file track`ed)

View File

@@ -48,11 +48,9 @@ $diskPct = $diskInfo['pct'];
$diskColor = SystemController::diskColor($diskPct); $diskColor = SystemController::diskColor($diskPct);
// ── Logs section ────────────────────────────────────────────────────────────── // ── Logs section ──────────────────────────────────────────────────────────────
$activeTab = $_GET['tab'] ?? 'nginx_access'; $activeTab = $_GET['tab'] ?? 'app';
if ($activeTab === 'status') { if ($activeTab === 'status' || !array_key_exists($activeTab, SystemController::LOG_FILES)) {
$activeTab = 'nginx_access'; $activeTab = 'app';
} elseif ($activeTab !== 'nginx_config' && !array_key_exists($activeTab, SystemController::LOG_FILES)) {
$activeTab = 'nginx_access';
} }
$selectedN = isset($_GET['n']) ? (int) $_GET['n'] : 100; $selectedN = isset($_GET['n']) ? (int) $_GET['n'] : 100;
@@ -60,27 +58,12 @@ if (!in_array($selectedN, SystemController::ALLOWED_LINES, true)) {
$selectedN = 100; $selectedN = 100;
} }
$logLines = null; $logData = $_controller->getLogData($activeTab, $selectedN);
$logError = null; $logLines = $logData['lines'];
$logFileMeta = null; $logError = $logData['error'];
$logFileMeta = $logData['meta'];
$nginxConfigLines = null; $logIsJson = $logData['isJson'] ?? false;
$nginxConfigSource = null; $notYet = $logData['notYet'] ?? false;
$nginxConfigError = null;
$nginxConfigMeta = null;
if ($activeTab === 'nginx_config') {
$nginxData = $_controller->getNginxConfigData();
$nginxConfigLines = $nginxData['lines'];
$nginxConfigSource = $nginxData['source'];
$nginxConfigMeta = $nginxData['meta'];
$nginxConfigError = $nginxData['error'];
} else {
$logData = $_controller->getLogData($activeTab, $selectedN);
$logLines = $logData['lines'];
$logError = $logData['error'];
$logFileMeta = $logData['meta'];
}
$collapsed = $_COOKIE['sys_collapsed'] ?? null; $collapsed = $_COOKIE['sys_collapsed'] ?? null;
$statusInitiallyCollapsed = $collapsed === '1'; $statusInitiallyCollapsed = $collapsed === '1';

View File

@@ -2,9 +2,9 @@
/** /**
* system-fragment.php — returns only the tab-panel HTML for the admin system page. * system-fragment.php — returns only the tab-panel HTML for the admin system page.
* *
* Called by fetch() from system.php JS when switching tabs or changing line count. * Called by fetch() from parametres.php JS when switching tabs or changing line count.
* With JS disabled the user never hits this URL directly; the tab <a> hrefs still * With JS disabled the user never hits this URL directly; the tab <a> hrefs still
* point at system.php?tab=… so navigation degrades gracefully. * point at parametres.php?tab=… so navigation degrades gracefully.
* *
* Response: text/html fragment (no <html>/<head>/<body> wrapper). * Response: text/html fragment (no <html>/<head>/<body> wrapper).
* On any auth failure or bad request: 403 / 400 with a plain-text body. * On any auth failure or bad request: 403 / 400 with a plain-text body.
@@ -23,9 +23,9 @@ if (!AdminAuth::isAuthenticated()) {
} }
// ── Validate inputs ──────────────────────────────────────────────────────── // ── Validate inputs ────────────────────────────────────────────────────────
$activeTab = $_GET['tab'] ?? 'nginx_access'; $activeTab = $_GET['tab'] ?? 'app';
if ($activeTab !== 'nginx_config' && !array_key_exists($activeTab, SystemController::LOG_FILES)) { if (!array_key_exists($activeTab, SystemController::LOG_FILES)) {
$activeTab = 'nginx_access'; $activeTab = 'app';
} }
$selectedN = isset($_GET['n']) ? (int) $_GET['n'] : 100; $selectedN = isset($_GET['n']) ? (int) $_GET['n'] : 100;
@@ -42,19 +42,11 @@ $_cache = new SystemCache($_db->getPDO());
$_controller = new SystemController($_db, $_cache); $_controller = new SystemController($_db, $_cache);
// ── Render ───────────────────────────────────────────────────────────────── // ── Render ─────────────────────────────────────────────────────────────────
if ($activeTab === 'nginx_config') { $logData = $_controller->getLogData($activeTab, $selectedN);
$nginxData = $_controller->getNginxConfigData(); $logLines = $logData['lines'];
$nginxConfigLines = $nginxData['lines']; $logError = $logData['error'];
$nginxConfigSource = $nginxData['source']; $logFileMeta = $logData['meta'];
$nginxConfigMeta = $nginxData['meta']; $logIsJson = $logData['isJson'] ?? false;
$nginxConfigError = $nginxData['error']; $notYet = $logData['notYet'] ?? false;
include APP_ROOT . '/templates/admin/partials/system-nginx-config-panel.php'; include APP_ROOT . '/templates/admin/partials/system-log-panel.php';
} else {
$logData = $_controller->getLogData($activeTab, $selectedN);
$logLines = $logData['lines'];
$logError = $logData['error'];
$logFileMeta = $logData['meta'];
include APP_ROOT . '/templates/admin/partials/system-log-panel.php';
}

View File

@@ -352,29 +352,4 @@
border: 1px solid var(--success-muted-border); border: 1px solid var(--success-muted-border);
} }
/* ── Nginx config viewer ───────────────────────────────────────────────── */
.nginx-source-badge {
display: inline-block;
font-size: var(--step--2);
font-family: ui-monospace, monospace;
padding: var(--space-3xs) var(--space-2xs);
border-radius: var(--radius);
margin-left: var(--space-2xs);
vertical-align: middle;
}
.nginx-source-badge--live {
background: var(--success-muted-bg);
color: var(--success);
border: 1px solid var(--success-muted-border);
}
.nginx-source-badge--local {
background: var(--warning-muted-bg);
color: var(--warning);
border: 1px solid var(--warning-muted-border);
}
/* Nginx syntax highlight layers inside .log-output */
.nginx-comment { color: var(--sys-syntax-comment); font-style: italic; }
.nginx-directive { color: var(--sys-syntax-directive); }
.nginx-block { color: var(--sys-syntax-block); font-weight: 600; }
.nginx-value { color: var(--sys-syntax-value); }
.nginx-location { color: var(--sys-syntax-location); }

View File

@@ -14,21 +14,10 @@
*/ */
class AdminLogger class AdminLogger
{ {
private string $logFile;
private ?Database $db; private ?Database $db;
public function __construct(?Database $db = null) 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; $this->db = $db;
} }
@@ -256,10 +245,10 @@ class AdminLogger
$entry['context'] = $context; $entry['context'] = $context;
} }
$line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; $line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if (is_writable($this->logFile) || (!file_exists($this->logFile) && is_writable(dirname($this->logFile)))) {
error_log($line, 3, $this->logFile); // File output — delegates to Monolog 'admin' channel
} Logger::get('admin')->info($line);
if ($this->db !== null) { if ($this->db !== null) {
$this->insertDb($resource, $action, $status, $context); $this->insertDb($resource, $action, $status, $context);

View File

@@ -3,9 +3,8 @@
/** /**
* Structured application logger for form submissions. * Structured application logger for form submissions.
* *
* Writes JSON-lines to a log file in storage/logs/. * Thin facade over Monolog channel 'app'.
* Each entry contains: timestamp, source (admin|partage), action, * Delegates all file I/O — keeps existing public API unchanged.
* status (success|error), context (IP, UA, thesis ID, error message, etc.).
*/ */
class AppLogger class AppLogger
{ {
@@ -16,10 +15,7 @@ class AppLogger
{ {
$this->logDir = $logDir ?? (defined('STORAGE_ROOT') ? STORAGE_ROOT . '/logs' : __DIR__ . '/../storage/logs'); $this->logDir = $logDir ?? (defined('STORAGE_ROOT') ? STORAGE_ROOT . '/logs' : __DIR__ . '/../storage/logs');
if (!is_dir($this->logDir)) { // Keep for backward compat — actual file I/O is now handled by Monolog via Logger::get('app')
mkdir($this->logDir, 0755, true);
}
$this->logFile = $this->logDir . '/form-submissions.log'; $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 private function write(array $entry): void
{ {
@@ -96,7 +92,8 @@ class AppLogger
$entry['ip'] = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; $entry['ip'] = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$entry['user_agent'] = $_SERVER['HTTP_USER_AGENT'] ?? ''; $entry['user_agent'] = $_SERVER['HTTP_USER_AGENT'] ?? '';
$line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; $line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
error_log($line, 3, $this->logFile);
Logger::get('app')->info($line);
} }
} }

View File

@@ -34,6 +34,7 @@ class Audit
?array $oldData = null, ?array $oldData = null,
?array $newData = null ?array $newData = null
): void { ): void {
// DB write is the primary path — best-effort, never crash.
try { try {
$stmt = $db->getConnection()->prepare( $stmt = $db->getConnection()->prepare(
'INSERT INTO audit_log (actor, action, table_name, record_id, old_data, new_data) 'INSERT INTO audit_log (actor, action, table_name, record_id, old_data, new_data)
@@ -49,7 +50,33 @@ class Audit
]); ]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
// Audit logging is best-effort — never crash the app over it. // 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());
} }
} }

View File

@@ -11,9 +11,8 @@
* maintenance mode) with SystemCache TTL caching * maintenance mode) with SystemCache TTL caching
* - PHP environment info (1-hour TTL) * - PHP environment info (1-hour TTL)
* - Disk usage info (5-minute TTL) * - Disk usage info (5-minute TTL)
* - Log file reading (tail, meta) * - Log file reading (tail, meta, JSON-line parsing)
* - Nginx config file reading * - Log line classifiers used by both system.php and system-fragment.php
* - Log/nginx line classifiers used by both system.php and system-fragment.php
* *
* Both system.php (full page) and system-fragment.php (AJAX panel) delegate * Both system.php (full page) and system-fragment.php (AJAX panel) delegate
* here so helpers are never duplicated. * here so helpers are never duplicated.
@@ -23,17 +22,39 @@ class SystemController
// ── Constants ───────────────────────────────────────────────────────────── // ── Constants ─────────────────────────────────────────────────────────────
public const LOG_FILES = [ public const LOG_FILES = [
'nginx_access' => ['label' => 'nginx — accès', 'path' => '/var/log/nginx/xamxam_access.log'], 'app' => ['label' => 'App — soumissions', 'path' => null, 'json' => true],
'nginx_error' => ['label' => 'nginxerreurs', 'path' => '/var/log/nginx/xamxam_error.log'], 'admin' => ['label' => 'Admin — actions', 'path' => null, 'json' => true],
'php_error' => ['label' => 'PHP-FPM — erreurs', 'path' => '/var/log/php8.4-fpm.log'], '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 ALLOWED_LINES = [50, 100, 200, 500];
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';
// ── TTLs ────────────────────────────────────────────────────────────────── // ── TTLs ──────────────────────────────────────────────────────────────────
private const TTL_STATUS = 120; // 2 minutes private const TTL_STATUS = 120; // 2 minutes
@@ -140,11 +161,37 @@ class SystemController
*/ */
public function getLogData(string $tab, int $n): array 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; $error = null;
$lines = $this->readLogTail($logPath, $n, $error); $rawLines = null;
$meta = 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)) { if (file_exists($logPath)) {
$sz = filesize($logPath); $sz = filesize($logPath);
$meta = [ $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; $formatted = [];
$localPath = self::NGINX_CONFIG_LOCAL; foreach ($jsonLines as $raw) {
$entry = json_decode($raw, true);
foreach ([[$livePath, 'live'], [$localPath, 'local']] as [$path, $src]) { if (!is_array($entry)) {
if (file_exists($path) && is_readable($path)) { $formatted[] = $raw; // not valid JSON — show raw
$raw = file($path, FILE_IGNORE_NEW_LINES); continue;
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];
}
} }
$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) return $formatted;
? '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];
} }
// ── Line classifiers (used by both system.php and system-fragment.php) ──── // ── Line classifiers (used by both system.php and system-fragment.php) ────
@@ -223,21 +329,6 @@ class SystemController
return ''; 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 ────────────────────────────────────────────────────────── // ── View helpers ──────────────────────────────────────────────────────────
/** /**
@@ -399,15 +490,14 @@ class SystemController
/** /**
* Read the tail of a log file, newest-first. Returns null on error. * 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 private function readLogTail(string $logPath, int $n, ?string &$errorMsg): ?array
{ {
$errorMsg = null; $errorMsg = null;
if (!function_exists('exec')) {
$errorMsg = 'exec() est désactivé sur ce serveur.';
return null;
}
if (!file_exists($logPath)) { if (!file_exists($logPath)) {
$errorMsg = 'Fichier introuvable : ' . htmlspecialchars($logPath); $errorMsg = 'Fichier introuvable : ' . htmlspecialchars($logPath);
return null; return null;
@@ -417,16 +507,25 @@ class SystemController
return null; return null;
} }
$output = []; // Try tail(1) first — fast on large log files
$rc = 0; if (function_exists('exec')) {
exec('tail -n ' . intval($n) . ' ' . escapeshellarg($logPath) . ' 2>/dev/null', $output, $rc); $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.'; $errorMsg = 'Erreur lors de la lecture du fichier journal.';
return null; return null;
} }
return array_reverse($output); // newest first $slice = array_slice($raw, -min($n, count($raw)));
return array_reverse($slice);
} }
/** /**

View File

@@ -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 string $context e.g. 'thesis_edit', 'partage_submit', 'csv_import'
* @param \Throwable $e * @param \Throwable $e
@@ -162,22 +162,22 @@ class ErrorHandler
*/ */
public static function log(string $context, \Throwable $e, array $extra = []): void public static function log(string $context, \Throwable $e, array $extra = []): void
{ {
$parts = [ $entry = [
'context=' . $context, 'timestamp' => date('c'),
'exception=' . get_class($e), 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
'message=' . $e->getMessage(), '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
View 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';
}
}

View File

@@ -472,23 +472,10 @@
<?= htmlspecialchars($def['label']) ?> <?= htmlspecialchars($def['label']) ?>
</a> </a>
<?php endforeach; ?> <?php endforeach; ?>
<a href="?tab=nginx_config"
class="sys-tab <?= $activeTab === 'nginx_config' ? 'active' : '' ?>"
hx-get="/admin/system-fragment.php?tab=nginx_config"
hx-target="#sys-tab-panel"
hx-push-url="?tab=nginx_config"
hx-swap="innerHTML"
hx-indicator="#sys-tab-panel"
data-tab="nginx_config"
<?= $activeTab === 'nginx_config' ? 'aria-current="page"' : '' ?>>nginx — config</a>
</nav> </nav>
<div id="sys-tab-panel"> <div id="sys-tab-panel">
<?php if ($activeTab === 'nginx_config'): ?> <?php include APP_ROOT . '/templates/admin/partials/system-log-panel.php'; ?>
<?php include APP_ROOT . '/templates/admin/partials/system-nginx-config-panel.php'; ?>
<?php else: ?>
<?php include APP_ROOT . '/templates/admin/partials/system-log-panel.php'; ?>
<?php endif; ?>
</div> </div>
</section> </section>
</article> </article>

View File

@@ -19,7 +19,7 @@
<?php if ($logFileMeta): ?> <?php if ($logFileMeta): ?>
<div class="log-meta"> <div class="log-meta">
<span data-label="Fichier"><?= htmlspecialchars(SystemController::LOG_FILES[$activeTab]['path']) ?></span> <span data-label="Fichier"><?= htmlspecialchars($logFileMeta['path']) ?></span>
<span data-label="Taille"><?= $logFileMeta['size'] ?></span> <span data-label="Taille"><?= $logFileMeta['size'] ?></span>
<span data-label="Modifié"><?= $logFileMeta['mtime'] ?></span> <span data-label="Modifié"><?= $logFileMeta['mtime'] ?></span>
</div> </div>
@@ -29,7 +29,7 @@
<div class="log-unavailable"> <div class="log-unavailable">
<strong>Journaux non disponibles</strong> <strong>Journaux non disponibles</strong>
<div class="log-unavail-path"><?= $logError ?></div> <div class="log-unavail-path"><?= $logError ?></div>
<?php if (php_sapi_name() === 'cli-server'): ?> <?php if (php_sapi_name() === 'cli-server' && str_starts_with($activeTab, 'nginx')): ?>
<div class="log-unavail-dev"> <div class="log-unavail-dev">
En environnement de développement, les logs nginx ne sont pas disponibles. En environnement de développement, les logs nginx ne sont pas disponibles.
Cette page est pleinement fonctionnelle sur le serveur de production. Cette page est pleinement fonctionnelle sur le serveur de production.
@@ -37,6 +37,12 @@
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php elseif (!empty($notYet)): ?>
<div class="log-empty">
Aucune entrée pour le moment.<br>
<span class="log-unavail-path">Le journal sera créé automatiquement au premier événement.</span>
</div>
<?php elseif (empty($logLines)): ?> <?php elseif (empty($logLines)): ?>
<div class="log-empty">Le fichier journal est vide.</div> <div class="log-empty">Le fichier journal est vide.</div>

View File

@@ -1,40 +0,0 @@
<?php if ($nginxConfigMeta): ?>
<div class="log-meta">
<span data-label="Fichier"><?= htmlspecialchars($nginxConfigMeta['path']) ?></span>
<span data-label="Taille"><?= $nginxConfigMeta['size'] ?></span>
<span data-label="Modifié"><?= $nginxConfigMeta['mtime'] ?></span>
<?php if ($nginxConfigSource === 'live'): ?>
<span class="nginx-source-badge nginx-source-badge--live">● Config déployée</span>
<?php else: ?>
<span class="nginx-source-badge nginx-source-badge--local">⚠ Référence locale (config live inaccessible)</span>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($nginxConfigError !== null): ?>
<div class="log-unavailable">
<strong>Configuration nginx non disponible</strong>
<div class="log-unavail-path"><?= htmlspecialchars($nginxConfigError) ?></div>
<?php if (php_sapi_name() === 'cli-server'): ?>
<div class="log-unavail-dev">
En développement, <code>/etc/nginx/sites-available/xamxam</code> n'existe pas.
La config de référence se trouve dans <code>nginx/xamxam.conf</code>.
</div>
<?php endif; ?>
</div>
<?php elseif (empty($nginxConfigLines)): ?>
<div class="log-empty">Le fichier de configuration est vide.</div>
<?php else: ?>
<div class="log-output" id="log-output" role="region" aria-label="Configuration nginx">
<button class="btn btn--secondary btn--sm log-copy-btn" id="log-copy-btn" type="button" title="Copier la configuration"
onclick="copyLogContent(this);return false">
Copier
</button>
<?php foreach ($nginxConfigLines as $i => $line): ?>
<span class="log-line <?= SystemController::nginxLineClass($line) ?>"
data-n="<?= $i + 1 ?>"><?= htmlspecialchars($line, ENT_QUOTES | ENT_SUBSTITUTE) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>

View File

@@ -1,114 +0,0 @@
<main id="main-content">
<h1>Système</h1>
<p class="sys-refresh-note">
Affiché le <?= date('d/m/Y à H:i:s') ?>
<a href="?tab=<?= htmlspecialchars($activeTab) ?>&amp;n=<?= $selectedN ?>">Rafraîchir</a> —
<a href="?tab=<?= htmlspecialchars($activeTab) ?>&amp;n=<?= $selectedN ?>&amp;refresh=1">Forcer actualisation</a>
</p>
<!-- ════════════════════════════════════════════════════════════════════
STATUS SECTION — always visible above tabs
════════════════════════════════════════════════════════════════════ -->
<section class="sys-status-section" aria-label="Statut du système">
<div class="sys-status-header">
<h2 class="srv-section-title srv-section-title--compact">Statut
<?php if ($statusCached && $statusCacheAge !== null): ?>
<span class="sys-cache-badge sys-cache-badge--hit" title="Données en cache">
⚡ Cache — il y a <?= $statusCacheAge ?>s
</span>
<?php else: ?>
<span class="sys-cache-badge sys-cache-badge--miss" title="Données fraîches">
⟳ Actualisé
</span>
<?php endif; ?>
</h2>
<button id="sys-status-toggle" class="sys-status-toggle"
aria-expanded="<?= $statusInitiallyCollapsed ? 'false' : 'true' ?>" aria-controls="sys-status-body"
type="button"
onclick="var b=document.getElementById('sys-status-body');var c=b.hidden;b.hidden=!c;this.setAttribute('aria-expanded',c);this.textContent=c?'▲ Réduire':'▼ Développer';document.cookie='sys_collapsed='+(!c)+';path=/;max-age=31536000';return false">
<?= $statusInitiallyCollapsed ? '▼ Développer' : '▲ Réduire' ?>
</button>
</div>
<div id="sys-status-body"<?= $statusInitiallyCollapsed ? ' hidden' : '' ?>>
<div class="srv-grid">
<?php foreach ($checks as $check): ?>
<?php $st = $check['status'] ?? 'unknown'; ?>
<div class="srv-card">
<div class="srv-card__header">
<span class="srv-card__name"><?= htmlspecialchars($check['label']) ?></span>
<span class="<?= SystemController::statusClass($st) ?>"><?= SystemController::statusLabel($st) ?></span>
</div>
<?php if (!empty($check['detail'])): ?>
<div class="srv-card__detail"><?= htmlspecialchars($check['detail']) ?></div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<div class="sys-status-meta">
<div>
<h3 class="srv-section-title srv-section-title--sub">Environnement PHP</h3>
<div class="php-grid php-grid--flush">
<?php foreach ($phpInfo as $key => $val): ?>
<div class="php-item">
<div class="php-item__key"><?= htmlspecialchars($key) ?></div>
<div class="php-item__val"><?= htmlspecialchars($val) ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
<div>
<h3 class="srv-section-title srv-section-title--sub">Espace disque</h3>
<div class="disk-bar-wrap">
<div class="disk-bar" style="--disk-pct:<?= $diskPct ?>%;--disk-color:<?= $diskColor ?>"></div>
</div>
<div class="disk-stats">
<span><?= SystemController::humanBytes($diskUsed) ?> utilisé (<?= $diskPct ?>%)</span>
<span><?= SystemController::humanBytes($diskFree) ?> libre / <?= SystemController::humanBytes($diskTotal) ?></span>
</div>
</div>
</div>
</div>
</section>
<!-- ── Tab bar ─────────────────────────────────────────────────────── -->
<nav class="sys-tabs" aria-label="Journaux et configuration">
<?php foreach (SystemController::LOG_FILES as $key => $def): ?>
<a href="?tab=<?= htmlspecialchars($key) ?>&amp;n=<?= $selectedN ?>"
class="sys-tab <?= $activeTab === $key ? 'active' : '' ?>"
hx-get="/admin/system-fragment.php?tab=<?= htmlspecialchars($key) ?>&amp;n=<?= $selectedN ?>"
hx-target="#sys-tab-panel"
hx-push-url="?tab=<?= htmlspecialchars($key) ?>&amp;n=<?= $selectedN ?>"
hx-swap="innerHTML"
hx-indicator="#sys-tab-panel"
data-tab="<?= htmlspecialchars($key) ?>"
<?= $activeTab === $key ? 'aria-current="page"' : '' ?>>
<?= htmlspecialchars($def['label']) ?>
</a>
<?php endforeach; ?>
<a href="?tab=nginx_config"
class="sys-tab <?= $activeTab === 'nginx_config' ? 'active' : '' ?>"
hx-get="/admin/system-fragment.php?tab=nginx_config"
hx-target="#sys-tab-panel"
hx-push-url="?tab=nginx_config"
hx-swap="innerHTML"
hx-indicator="#sys-tab-panel"
data-tab="nginx_config"
<?= $activeTab === 'nginx_config' ? 'aria-current="page"' : '' ?>>nginx — config</a>
</nav>
<!-- Tab panel — content swapped by HTMX -->
<div id="sys-tab-panel">
<?php if ($activeTab === 'nginx_config'): ?>
<?php include APP_ROOT . '/templates/admin/partials/system-nginx-config-panel.php'; ?>
<?php else: ?>
<?php include APP_ROOT . '/templates/admin/partials/system-log-panel.php'; ?>
<?php endif; ?>
</div><!-- #sys-tab-panel -->
</main>
<script src="/assets/js/app/admin-logs.js"></script>

View File

@@ -6,10 +6,11 @@
"require": { "require": {
"php": ">=8.4", "php": ">=8.4",
"ext-json": "*", "ext-json": "*",
"ext-pdo": "*",
"ext-openssl": "*", "ext-openssl": "*",
"league/commonmark": "^2.4", "ext-pdo": "*",
"guzzlehttp/guzzle": "^7.9", "guzzlehttp/guzzle": "^7.9",
"league/commonmark": "^2.4",
"monolog/monolog": "^3.10",
"phpmailer/phpmailer": "^6.9" "phpmailer/phpmailer": "^6.9"
}, },
"require-dev": { "require-dev": {

209
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "14e1f81f7d65a5f6cf56c604bb12847d", "content-hash": "b03509a0cf5bec5df187ca91f13c2f6a",
"packages": [ "packages": [
{ {
"name": "dflydev/dot-access-data", "name": "dflydev/dot-access-data",
@@ -597,6 +597,109 @@
], ],
"time": "2022-12-11T20:36:23+00:00" "time": "2022-12-11T20:36:23+00:00"
}, },
{
"name": "monolog/monolog",
"version": "3.10.0",
"source": {
"type": "git",
"url": "https://github.com/Seldaek/monolog.git",
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0",
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0",
"shasum": ""
},
"require": {
"php": ">=8.1",
"psr/log": "^2.0 || ^3.0"
},
"provide": {
"psr/log-implementation": "3.0.0"
},
"require-dev": {
"aws/aws-sdk-php": "^3.0",
"doctrine/couchdb": "~1.0@dev",
"elasticsearch/elasticsearch": "^7 || ^8",
"ext-json": "*",
"graylog2/gelf-php": "^1.4.2 || ^2.0",
"guzzlehttp/guzzle": "^7.4.5",
"guzzlehttp/psr7": "^2.2",
"mongodb/mongodb": "^1.8 || ^2.0",
"php-amqplib/php-amqplib": "~2.4 || ^3",
"php-console/php-console": "^3.1.8",
"phpstan/phpstan": "^2",
"phpstan/phpstan-deprecation-rules": "^2",
"phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "^10.5.17 || ^11.0.7",
"predis/predis": "^1.1 || ^2",
"rollbar/rollbar": "^4.0",
"ruflin/elastica": "^7 || ^8",
"symfony/mailer": "^5.4 || ^6",
"symfony/mime": "^5.4 || ^6"
},
"suggest": {
"aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB",
"doctrine/couchdb": "Allow sending log messages to a CouchDB server",
"elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client",
"ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
"ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler",
"ext-mbstring": "Allow to work properly with unicode symbols",
"ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)",
"ext-openssl": "Required to send log messages using SSL",
"ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)",
"graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
"mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
"php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
"rollbar/rollbar": "Allow sending log messages to Rollbar",
"ruflin/elastica": "Allow sending log messages to an Elastic Search server"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Monolog\\": "src/Monolog"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "https://seld.be"
}
],
"description": "Sends your logs to files, sockets, inboxes, databases and various web services",
"homepage": "https://github.com/Seldaek/monolog",
"keywords": [
"log",
"logging",
"psr-3"
],
"support": {
"issues": "https://github.com/Seldaek/monolog/issues",
"source": "https://github.com/Seldaek/monolog/tree/3.10.0"
},
"funding": [
{
"url": "https://github.com/Seldaek",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/monolog/monolog",
"type": "tidelift"
}
],
"time": "2026-01-02T08:56:05+00:00"
},
{ {
"name": "nette/schema", "name": "nette/schema",
"version": "v1.3.5", "version": "v1.3.5",
@@ -1046,6 +1149,56 @@
}, },
"time": "2023-04-04T09:54:51+00:00" "time": "2023-04-04T09:54:51+00:00"
}, },
{
"name": "psr/log",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/php-fig/log.git",
"reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
"reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Log\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for logging libraries",
"homepage": "https://github.com/php-fig/log",
"keywords": [
"log",
"psr",
"psr-3"
],
"support": {
"source": "https://github.com/php-fig/log/tree/3.0.2"
},
"time": "2024-09-11T13:17:53+00:00"
},
{ {
"name": "ralouphie/getallheaders", "name": "ralouphie/getallheaders",
"version": "3.0.3", "version": "3.0.3",
@@ -2614,56 +2767,6 @@
}, },
"time": "2021-11-05T16:47:00+00:00" "time": "2021-11-05T16:47:00+00:00"
}, },
{
"name": "psr/log",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/php-fig/log.git",
"reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
"reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Log\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for logging libraries",
"homepage": "https://github.com/php-fig/log",
"keywords": [
"log",
"psr",
"psr-3"
],
"support": {
"source": "https://github.com/php-fig/log/tree/3.0.2"
},
"time": "2024-09-11T13:17:53+00:00"
},
{ {
"name": "react/cache", "name": "react/cache",
"version": "v1.2.0", "version": "v1.2.0",
@@ -5638,8 +5741,8 @@
"platform": { "platform": {
"php": ">=8.4", "php": ">=8.4",
"ext-json": "*", "ext-json": "*",
"ext-pdo": "*", "ext-openssl": "*",
"ext-openssl": "*" "ext-pdo": "*"
}, },
"platform-dev": {}, "platform-dev": {},
"platform-overrides": { "platform-overrides": {

141
docs/monolog-plan.md Normal file
View File

@@ -0,0 +1,141 @@
# XAMXAM — Monolog Integration Plan
## Goal
Replace the three separate logging systems (`AppLogger`, `AdminLogger`, `ErrorHandler`, `Audit`) with a single
Monolog-based logger, PSR-3 compliant, without changing any call sites in the first pass.
---
## Prerequisites
```bash
composer require monolog/monolog
```
---
## Step 1 — Understand the current landscape
Four logging systems exist. Map them before touching anything:
| Class | What it logs | Output | Call sites |
|---|---|---|---|
| `AppLogger` | App-level errors, warnings | File (JSON lines) | Scattered across controllers |
| `AdminLogger` | Admin actions, audit trail | File + DB | Admin controllers |
| `ErrorHandler` | PHP errors, exceptions | File (JSON lines) | Registered globally in boot |
| `Audit` | Data mutations (create/edit/delete) | DB table | DB layer, controllers |
Before writing any code, grep the codebase for every call site of each class and note the method signatures.
The goal is to know exactly what the new unified interface must support before designing it.
---
## Step 2 — Create a central `Logger` factory
Create `app/Logger.php` — a single factory/registry that holds named Monolog channel instances.
Do not replace any existing class yet. Just build the foundation.
```php
// Channels to create:
// - 'app' → replaces AppLogger
// - 'admin' → replaces AdminLogger
// - 'error' → replaces ErrorHandler logging
// - 'audit' → replaces Audit (DB writes stay, but structured through Monolog)
```
Each channel gets:
- A `RotatingFileHandler` writing to `storage/logs/{channel}.log`, keeping 30 days
- A `JsonFormatter` so log lines stay JSON (preserving the existing format contract)
- Log level set from an environment variable (`LOG_LEVEL`, defaulting to `WARNING` in production, `DEBUG` in dev)
The factory must be a simple static registry (`Logger::get('app')`) so existing call sites can be migrated
one file at a time without passing instances around.
---
## Step 3 — Replace `AppLogger`
- Rewrite `AppLogger` as a thin wrapper that delegates to `Logger::get('app')`
- Keep the existing public method signatures identical — no call sites change in this step
- Run the app, verify log output appears in `storage/logs/app.log`
- Delete the old file-writing implementation inside `AppLogger`, keep the class as a facade for now
---
## Step 4 — Replace `ErrorHandler` logging
- In `ErrorHandler`, replace the internal `log()` method to delegate to `Logger::get('error')`
- Monolog's `ErrorHandler` integration can optionally replace the manual `set_error_handler` /
`set_exception_handler` registration — evaluate whether to adopt that or keep the custom handler
and just swap the write path
- Verify that fatal errors and uncaught exceptions still produce log entries
---
## Step 5 — Replace `AdminLogger`
This is the most complex because `AdminLogger` writes to both a file and the DB.
- File path → delegate to `Logger::get('admin')` with a `RotatingFileHandler`
- DB writes → keep as-is for now inside `AdminLogger`, or add a custom Monolog `Handler` that
writes to the DB table. A custom handler is cleaner but optional in this pass.
- Keep public method signatures identical
---
## Step 6 — Replace `Audit`
`Audit` is DB-only (no file output). Two options:
- **Option A (simple):** Keep `Audit` as-is, add a Monolog `Logger::get('audit')` that shadows
writes to a file for debuggability, call both from `Audit` methods
- **Option B (clean):** Write a custom Monolog `AuditHandler` that writes to the DB table,
replace `Audit` entirely
Option A is lower risk for this pass. Option B is the right long-term shape.
Recommend Option A now, Option B as a follow-up.
---
## Step 7 — Collapse the facades
Once all four classes delegate to Monolog internally, the facades (`AppLogger`, `AdminLogger`, etc.)
are just indirection. This step is optional in this pass but sets up the cleanup:
- Identify call sites that use `AppLogger::warning(...)` style static calls
- Decide whether to keep the facades permanently (low churn, acceptable) or migrate call sites
to `Logger::get('app')->warning(...)` directly (cleaner, more churn)
- A middle path: have the facades implement `Psr\Log\LoggerInterface` explicitly, which makes
them swappable in tests
---
## Step 8 — Add context standardisation
One of the main wins of Monolog over the current setup is structured context. Once the plumbing works,
add processors to inject consistent fields into every log entry:
- `WebProcessor` — adds URL, IP, HTTP method to every request log automatically
- A custom processor for `request_id` — generate a UUID per request in `App::boot()` and attach
it to all channels so log entries from one request can be correlated across channels
---
## What NOT to do in this pass
- Do not change any call site outside the four logger classes
- Do not change log file paths or formats yet (other tooling may depend on them)
- Do not add Slack/email handlers yet — get the foundation right first
- Do not touch `Audit`'s DB schema
---
## Definition of done
- `composer require monolog/monolog` is the only `composer.json` change
- All four logging systems write through Monolog internally
- Existing log file locations and JSON format are preserved
- No call site outside the four logger classes has changed
- `AppLogger`, `AdminLogger`, `ErrorHandler`, `Audit` still exist and work as before from the outside
- A single `LOG_LEVEL` environment variable controls verbosity across all channels

View File

@@ -4,7 +4,7 @@ use PHPUnit\Framework\TestCase;
/** /**
* SystemControllerHelpersTest — Pure logic tests for the static helper methods * SystemControllerHelpersTest — Pure logic tests for the static helper methods
* in SystemController (humanBytes, diskColor, logLineClass, nginxLineClass, * in SystemController (humanBytes, diskColor, logLineClass,
* statusLabel, statusClass). * statusLabel, statusClass).
* *
* These are stateless, no-IO functions. * These are stateless, no-IO functions.
@@ -116,32 +116,6 @@ class SystemControllerHelpersTest extends TestCase
$this->assertSame('', SystemController::logLineClass('GET / " 200 123 "')); $this->assertSame('', SystemController::logLineClass('GET / " 200 123 "'));
} }
// ── nginxLineClass() ──────────────────────────────────────────────────────
public function testNginxLineClassComment(): void
{
$this->assertSame('nginx-comment', SystemController::nginxLineClass('# comment'));
$this->assertSame('nginx-comment', SystemController::nginxLineClass(' # indented comment'));
$this->assertSame('nginx-comment', SystemController::nginxLineClass(''));
$this->assertSame('nginx-comment', SystemController::nginxLineClass(' '));
}
public function testNginxLineClassBlock(): void
{
$this->assertSame('nginx-block', SystemController::nginxLineClass('server {'));
$this->assertSame('nginx-block', SystemController::nginxLineClass('location / {'));
$this->assertSame('nginx-block', SystemController::nginxLineClass(' upstream backend {'));
$this->assertSame('nginx-block', SystemController::nginxLineClass('events {'));
$this->assertSame('nginx-block', SystemController::nginxLineClass('http {'));
}
public function testNginxLineClassDirective(): void
{
$this->assertSame('nginx-directive', SystemController::nginxLineClass('listen 80;'));
$this->assertSame('nginx-directive', SystemController::nginxLineClass('root /var/www;'));
$this->assertSame('nginx-directive', SystemController::nginxLineClass(' server_name example.com;'));
}
// ── statusLabel() ───────────────────────────────────────────────────────── // ── statusLabel() ─────────────────────────────────────────────────────────
public function testStatusLabelActive(): void public function testStatusLabelActive(): void