getConnection()->prepare( 'INSERT INTO audit_log (actor, action, table_name, record_id, old_data, new_data) VALUES (?, ?, ?, ?, ?, ?)' ); $stmt->execute([ $actor, $action, $tableName, $recordId, $oldData !== null ? self::safeJsonEncode($oldData) : null, $newData !== null ? self::safeJsonEncode($newData) : null, ]); } catch (\Throwable $e) { // Audit logging is best-effort — never crash the app over it. 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()); } } /** * Build the actor string from the current request context. */ public static function actor(): string { $ip = $_SERVER['REMOTE_ADDR'] ?? 'cli'; $user = $_SESSION['admin_user'] ?? null; return $user ? "$user@$ip" : $ip; } /** * JSON-encode data, redacting sensitive/password fields. */ private static function safeJsonEncode(array $data): string { $safe = $data; // Redact password-like fields foreach (['password', 'pass', 'secret', 'token', 'credential'] as $key) { if (array_key_exists($key, $safe)) { $safe[$key] = '[REDACTED]'; } } return json_encode($safe, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR); } }