" prefix and maps it to the corresponding * log priority, so log entries appear in the journal with the correct severity. * * Format: LEVEL [channel] interpolated-message {"context":"key",...} */ class SystemdLogger implements LoggerInterface { /** Maps PSR-3 levels to RFC 5424 / syslog priority numbers */ private const PRIORITY = [ LogLevel::EMERGENCY => 0, LogLevel::ALERT => 1, LogLevel::CRITICAL => 2, LogLevel::ERROR => 3, LogLevel::WARNING => 4, LogLevel::NOTICE => 5, LogLevel::INFO => 6, LogLevel::DEBUG => 7, ]; /** @var resource */ private $stderr; public function __construct(private readonly string $channel = 'app') { $this->stderr = fopen('php://stderr', 'w'); } public function __destruct() { if (is_resource($this->stderr)) { fclose($this->stderr); } } 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 { // Extract tenant id injected by TenantAwareLogger; default to 'system'. $tenantId = isset($context['__tenant']) && is_string($context['__tenant']) ? $context['__tenant'] : 'system'; unset($context['__tenant']); $level = strtolower((string) $level); $priority = self::PRIORITY[$level] ?? 7; $interpolated = $this->interpolate((string) $message, $context); $contextStr = empty($context) ? '' : ' ' . $this->encodeContext($context); $line = sprintf( "<%d>%s [%s] [%s] %s%s\n", $priority, strtoupper($level), $this->channel, $tenantId, $interpolated, $contextStr, ); fwrite($this->stderr, $line); } 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)) { $replace['{' . $key . '}'] = (string) $val; } } return strtr($message, $replace); } private function encodeContext(array $context): string { $clean = []; foreach ($context as $k => $v) { if ($v instanceof \Throwable) { $clean[$k] = ['type' => get_class($v), 'message' => $v->getMessage()]; } elseif (is_resource($v)) { $clean[$k] = 'resource(' . get_resource_type($v) . ')'; } elseif (is_object($v)) { $clean[$k] = method_exists($v, '__toString') ? (string) $v : ['object' => get_class($v)]; } else { $clean[$k] = $v; } } return json_encode($clean, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{}'; } }