Created
December 9, 2025 04:07
-
-
Save jason-napolitano/6e8e7ca7613e568f16970997a3f3dd0c to your computer and use it in GitHub Desktop.
A PHP 8.4 powered, PSR-3 compliant logger
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| use Psr\Log\LoggerInterface; | |
| use Psr\Log\LogLevel; | |
| /** | |
| * The Logger Library Class is a utility class that assists in the | |
| * logging application of activity. | |
| * | |
| * @requires PHP 8.4 | |
| */ | |
| class Logger implements LoggerInterface | |
| { | |
| /** | |
| * File name and path of log file. | |
| * | |
| * @var string | |
| */ | |
| private string $file; | |
| /** | |
| * Log channel--namespace for log lines. | |
| * Used to identify and correlate groups of similar log lines. | |
| * | |
| * @var string | |
| */ | |
| private string $channel { | |
| set { | |
| $this->channel = $value; | |
| } | |
| } | |
| /** | |
| * Lowest log level to log. | |
| * | |
| * @var int | |
| */ | |
| private int $level; | |
| /** | |
| * Whether to log to standard out. | |
| * | |
| * @var bool | |
| */ | |
| private bool $stdout; | |
| /** | |
| * Log fields separated by tabs to form a TSV (CSV with tabs). | |
| */ | |
| protected const string TAB = "\t"; | |
| /** | |
| * Special minimum log level which will not log any log levels. | |
| */ | |
| protected const string LOG_LEVEL_NONE = 'none'; | |
| /** | |
| * Log level hierarchy | |
| */ | |
| protected const array LEVELS = [ | |
| self::LOG_LEVEL_NONE => -1, | |
| LogLevel::DEBUG => 0, | |
| LogLevel::INFO => 1, | |
| LogLevel::NOTICE => 2, | |
| LogLevel::WARNING => 3, | |
| LogLevel::ERROR => 4, | |
| LogLevel::CRITICAL => 5, | |
| LogLevel::ALERT => 6, | |
| LogLevel::EMERGENCY => 7, | |
| ]; | |
| /** | |
| * Logger constructor | |
| * | |
| * @param string $file File name and path of log file. | |
| * @param string $channel Logger channel associated with this logger. | |
| * @param string $level (optional) Lowest log level to log. | |
| */ | |
| public function __construct(string $file, string $channel = 'event', string $level = LogLevel::DEBUG) | |
| { | |
| $this->file = $file; | |
| $this->channel ??= $channel; | |
| $this->stdout = false; | |
| $this->setLevel($level); | |
| } | |
| /** | |
| * Set the lowest log level to log. | |
| * | |
| * @param string $level | |
| */ | |
| public function setLevel(string $level): void | |
| { | |
| if ( !array_key_exists($level, self::LEVELS) ) { | |
| throw new DomainException("Log level $level is not a valid log level. Must be one of (" . implode(', ', array_keys(self::LEVELS)) . ')'); | |
| } | |
| $this->level = self::LEVELS[$level]; | |
| } | |
| /** | |
| * Set the standard out option on or off. | |
| * If set to true, log lines will also be printed to standard out. | |
| * | |
| * @param bool $stdout | |
| */ | |
| public function setOutput(bool $stdout): void | |
| { | |
| $this->stdout = $stdout; | |
| } | |
| /** | |
| * @inheritdoc | |
| */ | |
| public function debug($message = '', ?array $context = null): void | |
| { | |
| if ( $this->logAtThisLevel(LogLevel::DEBUG) ) { | |
| $this->log(LogLevel::DEBUG, $message, $context); | |
| } | |
| } | |
| /** | |
| * @inheritdoc | |
| */ | |
| public function info($message = '', array $context = null): void | |
| { | |
| if ( $this->logAtThisLevel(LogLevel::INFO) ) { | |
| $this->log(LogLevel::INFO, $message, $context); | |
| } | |
| } | |
| /** | |
| * @inheritdoc | |
| */ | |
| public function notice($message = '', array $context = null): void | |
| { | |
| if ( $this->logAtThisLevel(LogLevel::NOTICE) ) { | |
| $this->log(LogLevel::NOTICE, $message, $context); | |
| } | |
| } | |
| /** | |
| * @inheritdoc | |
| */ | |
| public function warning($message = '', array $context = null): void | |
| { | |
| if ( $this->logAtThisLevel(LogLevel::WARNING) ) { | |
| $this->log(LogLevel::WARNING, $message, $context); | |
| } | |
| } | |
| /** | |
| * @inheritdoc | |
| */ | |
| public function error($message = '', array $context = null): void | |
| { | |
| if ( $this->logAtThisLevel(LogLevel::ERROR) ) { | |
| $this->log(LogLevel::ERROR, $message, $context); | |
| } | |
| } | |
| /** | |
| * @inheritdoc | |
| */ | |
| public function critical($message = '', array $context = null): void | |
| { | |
| if ( $this->logAtThisLevel(LogLevel::CRITICAL) ) { | |
| $this->log(LogLevel::CRITICAL, $message, $context); | |
| } | |
| } | |
| /** | |
| * @inheritdoc | |
| */ | |
| public function alert($message = '', array $context = null): void | |
| { | |
| if ( $this->logAtThisLevel(LogLevel::ALERT) ) { | |
| $this->log(LogLevel::ALERT, $message, $context); | |
| } | |
| } | |
| /** | |
| * @inheritdoc | |
| */ | |
| public function emergency($message = '', array $context = null): void | |
| { | |
| if ( $this->logAtThisLevel(LogLevel::EMERGENCY) ) { | |
| $this->log(LogLevel::EMERGENCY, $message, $context); | |
| } | |
| } | |
| /** | |
| * @inheritdoc | |
| */ | |
| public function log($level, $message = '', array $context = null): void | |
| { | |
| // Build log line | |
| $pid = getmypid(); | |
| [$exception, $context] = $this->handleException($context); | |
| $context = $context ? json_encode($context, JSON_UNESCAPED_SLASHES) : '{}'; | |
| $context = $context ?: '{}'; // Fail-safe in case json_encode fails. | |
| $log_line = $this->formatLogLine($level, $pid, $message, $context, $exception); | |
| // Log to file | |
| try { | |
| $fh = fopen($this->file, 'a+b'); | |
| fwrite($fh, $log_line); | |
| fclose($fh); | |
| } catch ( Throwable $e ) { | |
| throw new RuntimeException("Could not open log file $this->file for writing to \\Logger channel $this->channel!", 0, $e); | |
| } | |
| // Log to stdout if option set to do so. | |
| if ( $this->stdout ) { | |
| print($log_line); | |
| } | |
| } | |
| /** | |
| * Determine if the logger should log at a certain log level. | |
| * | |
| * @param string $level | |
| * | |
| * @return bool True if we log at this level; false otherwise. | |
| */ | |
| private function logAtThisLevel(string $level): bool | |
| { | |
| return self::LEVELS[$level] >= $this->level; | |
| } | |
| /** | |
| * Handle an exception in the data context array. | |
| * If an exception is included in the data context array, extract it. | |
| * | |
| * @param ?array $context | |
| * | |
| * @return array [exception, data (without exception)] | |
| * | |
| * @throws \JsonException | |
| */ | |
| private function handleException(?array $context = null): array | |
| { | |
| if ( isset($data['exception']) && $data['exception'] instanceof Throwable ) { | |
| $exception = $data['exception']; | |
| $exception_data = $this->buildExceptionData($exception); | |
| unset($data['exception']); | |
| } else { | |
| $exception_data = '{}'; | |
| } | |
| return [$exception_data, $context]; | |
| } | |
| /** | |
| * Build the exception log data. | |
| * | |
| * @param Throwable $e | |
| * | |
| * @return string JSON {message, code, file, line, trace} | |
| * | |
| * @throws \JsonException | |
| */ | |
| private function buildExceptionData(Throwable $e): string | |
| { | |
| $exceptionData = json_encode([ | |
| 'message' => $e->getMessage(), | |
| 'code' => $e->getCode(), | |
| 'file' => $e->getFile(), | |
| 'line' => $e->getLine(), | |
| 'trace' => $e->getTrace(), | |
| ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); | |
| // Fail-safe in case json_encode failed | |
| return $exceptionData ?: '{"message":"' . $e->getMessage() . '"}'; | |
| } | |
| /** | |
| * Format the log line. | |
| * YYYY-mm-dd HH:ii:ss.uuuuuu [loglevel] [channel] [pid:##] Log message content {"Optional":"JSON | |
| * Contextual Support Data"} {"Optional":"Exception Data"} | |
| * | |
| * | |
| * @param string $level | |
| * @param int $pid | |
| * @param string $message | |
| * @param string $data | |
| * @param string $exception_data | |
| * | |
| * @return string | |
| */ | |
| private function formatLogLine(string $level, int $pid, string $message, string $data, string $exception_data): string | |
| { | |
| return | |
| $this->getTime() . self::TAB . | |
| '[' . strtoupper($level) . ']' . self::TAB . | |
| "[$this->channel]" . self::TAB . | |
| "[pid:$pid]" . self::TAB . | |
| str_replace(PHP_EOL, ' ', trim($message)) . self::TAB . | |
| str_replace(PHP_EOL, ' ', $data) . self::TAB . | |
| str_replace(PHP_EOL, ' ', $exception_data) . PHP_EOL; | |
| } | |
| /** | |
| * Get current date time. | |
| * Format: YYYY-mm-dd HH:ii:ss.uuuuuu | |
| * Microsecond precision for PHP 7.1 and greater | |
| * | |
| * @return string Date time | |
| */ | |
| private function getTime(): string | |
| { | |
| return new \DateTimeImmutable('now')->format('Y-m-d H:i:s.u'); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment