useMicroseconds = $useMicroseconds; $this->channel = $channel; if (!is_dir($logDir)) { @mkdir($logDir, 0775, true); } $this->logFile = rtrim($logDir, '/').'/'.$channel.'.log'; } 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 { $timestamp = $this->formatTimestamp(); $interpolated = $this->interpolate((string)$message, $context); $payload = [ 'time' => $timestamp, 'level' => strtolower((string)$level), 'channel' => $this->channel, 'message' => $interpolated, 'context' => $this->sanitizeContext($context), ]; $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if ($json === false) { // Fallback stringify if encoding fails (should be rare) $json = json_encode([ 'time' => $timestamp, 'level' => strtolower((string)$level), 'channel' => $this->channel, 'message' => $interpolated, 'context_error' => 'failed to encode context: '.json_last_error_msg(), ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{"error":"logging failure"}'; } $this->write($json); } private function formatTimestamp(): string { if ($this->useMicroseconds) { $dt = \DateTimeImmutable::createFromFormat('U.u', sprintf('%.6F', microtime(true))); return $dt?->format('Y-m-d H:i:s.u') ?? date('Y-m-d H:i:s'); } return date('Y-m-d H:i:s'); } 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)) { continue; // don't inline complex values } $replace['{'.$key.'}'] = (string)$val; } return strtr($message, $replace); } private function sanitizeContext(array $context): array { if (empty($context)) { return []; } $clean = []; foreach ($context as $k => $v) { if ($v instanceof \Throwable) { $clean[$k] = [ 'type' => get_class($v), 'message' => $v->getMessage(), 'code' => $v->getCode(), 'file' => $v->getFile(), 'line' => $v->getLine(), 'trace' => explode("\n", $v->getTraceAsString()), ]; } elseif (is_resource($v)) { $clean[$k] = 'resource('.get_resource_type($v).')'; } elseif (is_object($v)) { // Try to extract serializable data if (method_exists($v, '__toString')) { $clean[$k] = (string)$v; } else { $clean[$k] = ['object' => get_class($v)]; } } else { $clean[$k] = $v; } } return $clean; } private function write(string $line): void { $line = rtrim($line)."\n"; // newline-delimited JSON (JSONL) @file_put_contents($this->logFile, $line, FILE_APPEND | LOCK_EX); } }