diff --git a/_include/Container.php b/_include/Container.php index 5e1e09fe..7aaa3f32 100644 --- a/_include/Container.php +++ b/_include/Container.php @@ -2,16 +2,16 @@ /** * Simple DI container to be used in legacy code. * - * @copyright (C) 2023 Roman Parpalak + * @copyright (C) 2023-2024 Roman Parpalak * @license http://www.gnu.org/licenses/gpl.html GPL version 2 or higher * @package S2 */ -use johnykvsky\Utils\JKLogger; use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; use S2\Cms\Image\ThumbnailGenerator; use S2\Cms\Layout\LayoutMatcherFactory; +use S2\Cms\Logger\Logger; use S2\Cms\Pdo\DbLayer; use S2\Cms\Pdo\DbLayerPostgres; use S2\Cms\Pdo\DbLayerSqlite; @@ -95,10 +95,10 @@ private static function instantiate(string $className): object ); case LoggerInterface::class: - return new JKLogger(defined('S2_LOG_DIR') ? S2_LOG_DIR : S2_CACHE_DIR, LogLevel::INFO, ['prefix' => 'log_', 'extension' => 'log']); + return new Logger((defined('S2_LOG_DIR') ? S2_LOG_DIR : S2_CACHE_DIR) . 'app.log', 'app', LogLevel::INFO); case 'recommendations_logger': - return new JKLogger(defined('S2_LOG_DIR') ? S2_LOG_DIR : S2_CACHE_DIR, LogLevel::DEBUG, ['prefix' => 'recommendations_', 'extension' => 'log']); + return new Logger((defined('S2_LOG_DIR') ? S2_LOG_DIR : S2_CACHE_DIR) . 'recommendations.log', 'recommendations', LogLevel::INFO); case 'recommendations_cache': return new FilesystemAdapter('recommendations', 0, S2_CACHE_DIR); diff --git a/_include/src/Logger/Logger.php b/_include/src/Logger/Logger.php new file mode 100644 index 00000000..d5a5775b --- /dev/null +++ b/_include/src/Logger/Logger.php @@ -0,0 +1,269 @@ +info('Normal informational event happened.'); + * $logger->error('Something bad happened.', ['key1' => 'value that gives context', 'key2' => 'some more context', 'exception' => $e]); + * + * Optional constructor option: Set default lowest log level (Example error and above): + * $logger = new SimpleLog\Logger('logfile.log', 'channelname', \Psr\Log\LogLevel::ERROR); + * $logger->error('This will get logged'); + * $logger->info('This is below the minimum log level and will not get logged'); + * + * To log an exception, set as data context array key 'exception' + * $logger->error('Something exceptional happened.', ['exception' => $e]); + * + * To set output to standard out (STDOUT) as well as a log file: + * $logger->setOutput(true); + * + * To change the channel after construction: + * $logger->setChannel('newname') + */ +class Logger implements LoggerInterface +{ + use LoggerTrait; + + /** + * Lowest log level to log. + */ + private int $logLevel; + + /** + * Whether to log to standard out. + */ + private bool $stdout = false; + + /** + * Log fields separated by tabs to form a TSV (CSV with tabs). + */ + private const TAB = "\t"; + + /** + * Special minimum log level which will not log any log levels. + */ + public const LOG_LEVEL_NONE = 'none'; + + /** + * Log level hierarchy + */ + public const 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, + ]; + + /** + * @param string $log_file File name and path of log file. + * @param string $channel Logger channel ("namespace") associated with this logger. + * @param string $logLevel (optional) Lowest log level to log. + */ + public function __construct( + private readonly string $log_file, + private string $channel, + string $logLevel = LogLevel::DEBUG + ) { + $this->setLogLevel($logLevel); + } + + /** + * Set the lowest log level to log. + */ + public function setLogLevel(string $logLevel): void + { + if (!isset(self::LEVELS[$logLevel])) { + throw new \DomainException("Log level $logLevel is not a valid log level. Must be one of (" . implode(', ', array_keys(self::LEVELS)) . ')'); + } + + $this->logLevel = self::LEVELS[$logLevel]; + } + + /** + * Set the log channel which identifies the log line. + */ + public function setChannel(string $channel): void + { + $this->channel = $channel; + } + + /** + * Set the standard out option on or off. + * If set to true, log lines will also be printed to standard out. + */ + public function setOutput(bool $stdout): void + { + $this->stdout = $stdout; + } + + /** + * Log a message. + * Generic log routine that all severity levels use to log an event. + * + * @throws \RuntimeException when log file cannot be opened for writing. + */ + public function log($level, string|\Stringable $message, array $context = []): void + { + if (!$this->logAtThisLevel($level)) { + return; + } + + // Build log line + /** @var string $formattedException */ + [$formattedException, $contextData] = $this->handleException($context); + try { + $formattedContext = json_encode($contextData, JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); + } catch (\JsonException $e) { + $formattedContext = '{}'; + } + $logLine = $this->formatLogLine($level, $message, $formattedContext, $formattedException); + + // Log to file + try { + $fh = fopen($this->log_file, 'ab'); + if ($fh === false) { + throw new \RuntimeException('fopen failed'); + } + fwrite($fh, $logLine); + fclose($fh); + } catch (\Throwable $e) { + throw new \RuntimeException("Could not open log file {$this->log_file} for writing to SimpleLog channel {$this->channel}!", 0, $e); + } + + // Log to stdout if option set to do so. + if ($this->stdout) { + print($logLine); + } + } + + /** + * Determine if the logger should log at a certain log level. + * + * @return bool True if we log at this level; false otherwise. + */ + private function logAtThisLevel(string $level): bool + { + return self::LEVELS[$level] >= $this->logLevel; + } + + /** + * Handle an exception in the data context array. + * If an exception is included in the data context array, extract it. + * + * @param mixed[] $context + * + * @return mixed[] [exception, data (without exception)] + */ + private function handleException(array $context): array + { + if (isset($context['exception']) && $context['exception'] instanceof \Throwable) { + $exception = $context['exception']; + $exception_data = self::buildExceptionData($exception); + unset($context['exception']); + } else { + $exception_data = ''; + } + + return [$exception_data, $context]; + } + + /** + * Build the exception log data. + * + * @param \Throwable $e + * + * @return string JSON {message, code, file, line, trace} + */ + private static function buildExceptionData(\Throwable $e): string + { + try { + $str = json_encode([ + 'message' => $e->getMessage(), + 'code' => $e->getCode(), + ], JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE) . \PHP_EOL . + '#0 ' . $e->getFile() . ':' . $e->getLine() . \PHP_EOL . + self::formatTrace($e->getTrace()); + + if ($e->getPrevious() !== null) { + $str .= \PHP_EOL . 'Previous Exception: ' . self::buildExceptionData($e->getPrevious()); + } + return $str; + } catch (\JsonException $e) { + return '{"message":"' . $e->getMessage() . '"}'; + } + } + + private static function formatTrace(array $trace): string + { + $stack = ''; + $i = 1; + foreach ($trace as $node) { + $stack .= "#$i " . $node['file'] . ":" . $node['line'] . " "; + if (isset($node['class'])) { + $stack .= $node['class'] . "->"; + } + $stack .= $node['function'] . "()" . PHP_EOL; + $i++; + } + return $stack; + } + + /** + * Format the log line. + * ``` + * YYYY-mm-dd HH:ii:ss.uuuuuu [loglevel] [channel] Log message content {"Optional":"JSON Contextual Support Data"} {"Optional":"Exception Data"} + * Exception Trace if any + * ``` + */ + private function formatLogLine(string $level, string $message, string $data, string $formattedException): string + { + return + $this->getTime() . self::TAB . + "[$level]" . self::TAB . + "[{$this->channel}]" . self::TAB . + str_replace(\PHP_EOL, ' ', trim($message)) . self::TAB . + str_replace(\PHP_EOL, ' ', $data) . self::TAB . + $formattedException . \PHP_EOL; + } + + /** + * Get current date time, with microsecond precision. + * Format: YYYY-mm-dd HH:ii:ss.uuuuuu + * + * date('...') does not support microseconds (u) + */ + private function getTime(): string + { + return (new \DateTimeImmutable('now'))->format('Y-m-d H:i:s.u'); + } +} diff --git a/composer.json b/composer.json index 3a9b1abf..3af64ad3 100644 --- a/composer.json +++ b/composer.json @@ -21,8 +21,7 @@ "ext-dom": "*", "ext-gd": "*", "altorouter/altorouter": "dev-master", - "psr/log": "^1.1", - "johnykvsky/jklogger": "^0.1.2", + "psr/log": "^3.0", "s2/rose": "dev-master", "symfony/error-handler": "^7.0", "symfony/cache": "^7.0", diff --git a/composer.lock b/composer.lock index a02d2b5f..4625950e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "eacf4a69cb074fa512b8f4a81d2c0ed6", + "content-hash": "df6333a02a83789372b049607abc33fe", "packages": [ { "name": "altorouter/altorouter", @@ -66,74 +66,6 @@ }, "time": "2023-11-30T09:10:31+00:00" }, - { - "name": "johnykvsky/jklogger", - "version": "0.1.2", - "source": { - "type": "git", - "url": "https://github.com/johnykvsky/JKLogger.git", - "reference": "999b6533e64252037352732eb30ca82a3d6aa05c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/johnykvsky/JKLogger/zipball/999b6533e64252037352732eb30ca82a3d6aa05c", - "reference": "999b6533e64252037352732eb30ca82a3d6aa05c", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0", - "psr/log": "^1.0.0" - }, - "require-dev": { - "johnykvsky/faker": "~1.14", - "phpstan/extension-installer": "~1.1.0", - "phpstan/phpstan": "~0.12.92", - "phpunit/phpunit": "~8.5.2", - "thecodingmachine/phpstan-strict-rules": "~0.12.1" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0-dev" - } - }, - "autoload": { - "psr-4": { - "johnykvsky\\Utils\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Kenny Katzgrau", - "email": "katzgrau@gmail.com" - }, - { - "name": "Dan Horrigan", - "email": "dan@dhorrigan.com" - }, - { - "name": "johnykvsky", - "email": "johnykvsky@protonmail.com", - "homepage": "https://github.com/johnykvsky", - "role": "Developer" - } - ], - "description": "A Simple Logging Class", - "homepage": "https://github.com/johnykvsky/JKLogger", - "keywords": [ - "JKlogger", - "johnykvsky" - ], - "support": { - "issues": "https://github.com/johnykvsky/JKLogger/issues", - "source": "https://github.com/johnykvsky/JKLogger/tree/0.1.2" - }, - "time": "2021-07-13T18:48:12+00:00" - }, { "name": "matthiasmullie/minify", "version": "1.3.71", @@ -362,30 +294,30 @@ }, { "name": "psr/log", - "version": "1.1.4", + "version": "3.0.0", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "url": "https://api.github.com/repos/php-fig/log/zipball/fe5ea303b0887d5caefd3d431c3e61ad47037001", + "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": ">=8.0.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.1.x-dev" + "dev-master": "3.x-dev" } }, "autoload": { "psr-4": { - "Psr\\Log\\": "Psr/Log/" + "Psr\\Log\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -406,9 +338,9 @@ "psr-3" ], "support": { - "source": "https://github.com/php-fig/log/tree/1.1.4" + "source": "https://github.com/php-fig/log/tree/3.0.0" }, - "time": "2021-05-03T11:20:27+00:00" + "time": "2021-07-14T16:46:02+00:00" }, { "name": "s2/rose",