Skip to content

Instantly share code, notes, and snippets.

@chuckadams
Created November 30, 2024 21:54
Show Gist options
  • Save chuckadams/5bf5616bb989e92016a2791b518e4962 to your computer and use it in GitHub Desktop.
Save chuckadams/5bf5616bb989e92016a2791b518e4962 to your computer and use it in GitHub Desktop.
Simple PSR-3 JSON Logger
<?php
declare(strict_types=1);
namespace App\Services;
use DateTime;
use Psr\Log\AbstractLogger;
use Psr\Log\LogLevel;
use RuntimeException;
use Stringable;
/**
* An "opinionated" PSR-3 logger class that logs in json format to a file, and that's it.
* It's expected that something like Fluentd/Grafana/OpenTelemetry will process the log further.
*
* Derived from https://raw.githubusercontent.com/katzgrau/KLogger by Kenny Katzgrau.
*/
class JsonLogger extends AbstractLogger
{
public string $dateFormat = 'Y-m-d\TH:i:s.up'; // ISO8601 with microseconds
protected string $logFile;
/** @var resource|null */
protected $fileHandle;
public const LEVELS = [
LogLevel::EMERGENCY => 0,
LogLevel::ALERT => 1,
LogLevel::CRITICAL => 2,
LogLevel::ERROR => 3,
LogLevel::WARNING => 4,
LogLevel::NOTICE => 5,
LogLevel::INFO => 6,
LogLevel::DEBUG => 7,
];
public function log(mixed $level, string|Stringable $message, array $context = []): void
{
$level = static::normalizeLevel($level);
if (static::LEVELS[$this->threshold] < static::LEVELS[$level]) {
return;
}
$timestamp = (new DateTime())->format($this->dateFormat);
$record = compact('timestamp', 'level', 'message');
$context and $record['context'] = $context;
$json = json_encode($record, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
$this->writeToLogFile($json . PHP_EOL);
}
public function __construct(string $path = 'php://stdout', public string $threshold = LogLevel::DEBUG)
{
$this->setLogFile($path);
}
public function setLogFile(string $path): void
{
$this->closeLogFile();
$this->logFile = $path;
$this->openLogFile();
}
public static function normalizeLevel(string|int $level): string
{
static $keys;
$keys ??= array_keys(static::LEVELS);
if (is_int($level)) {
// clamp level range so -1 = emergency, 999 = debug
$level = min($level, static::LEVELS['debug']);
$level = max($level, static::LEVELS['emergency']);
$level = $keys[$level]; // convert to string
}
$level = strtolower($level);
// an unrecognized level string does turn into 'debug'. we can't safely guess otherwise.
return $keys[static::LEVELS[$level] ?? 'debug'] ?? 'debug';
}
public function __destruct()
{
$this->closeLogFile();
}
protected function openLogFile(): void
{
$file = $this->logFile;
$mode = str_starts_with($file, 'php://') ? 'w+' : 'a';
$fh = fopen($file, $mode);
if (! $fh) {
$errorMessage = error_get_last()['message'] ?? '(no error information available)';
throw new RuntimeException("Unable to open $file: $errorMessage");
}
$this->fileHandle = $fh;
}
protected function closeLogFile(): void
{
if (! $this->fileHandle || str_starts_with($this->logFile, 'php://')) {
return;
}
fclose($this->fileHandle);
}
protected function writeToLogFile(string|Stringable $message): void
{
$this->fileHandle or throw new RuntimeException("Cannot write to $this->logFile: file is closed.");
if (! fwrite($this->fileHandle, $message)) {
$errorMessage = error_get_last()['message'] ?? '(no error information available)';
throw new RuntimeException("Cannot write to $this->logFile: $errorMessage");
}
fflush($this->fileHandle);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment