diff --git a/README.md b/README.md index 8aca83e..ca3767e 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ [i]: https://github.com/symfony/polyfill/tree/main/src/Ctype [j]: https://github.com/symfony/polyfill/tree/main/src/Mbstring [k]: https://github.com/php-fig/log +[l]: https://ndjson.org # Logger # @@ -325,19 +326,60 @@ class StreamHandler extends Handler { #### Options #### -_entryFormat_: The format of the outputted log entry. Defaults to _"%time% %channel% %level\_name% %message%"_ +_entryTransform_: A callable where the log entry can be manipulated. Defaults to _null_ -##### Entry Format ##### +##### Entry Transform ##### -The following are recognized in the _entryFormat_ option: +```php +function (string $time, int $level, string $levelName, string $channel, string $message, array $context): string; +``` + +###### Parameters ###### + +
+
time
+
The timestamp the log entry was dispatched at; can be altered with the timeFormat option
+ +
level
+
The RFC 5424 level integer for the entry
+ +
levelName
+
The RFC 5424 level name for the entry
+ +
channel
+
The channel defined when creating the logger
+ +
message
+
The message string; can be manipulated with the messageTransform option
+ +
context
+
The context array used when dispatching the entry
+
-| Format | Description | Example | -| ------------ | ----------------- | ---------------------------------------------------------------------- | -| %channel% | channel name | ook | -| %level% | log level integer | 1 | -| %level_name% | log level name | ALERT | -| %message% | log message | Uncaught Error: Call to undefined function ook() in /path/to/ook.php:7 | -| %time% | timestamp | Apr 08 09:58:12 | +Here is an example of how to use the _entryTransform_ option to output entries to `php://stdout` as [NDJSON][l]: + +```php +use MensBeam\Logger, + MensBeam\Logger\StreamHandler; + +$handler = new StreamHandler(options: [ + 'entryTransform' => function (string $time, int $level, string $levelName, string $channel, string $message, array $context): string { + $entry = [ + 'time' => $time, + 'level' => $level, + 'levelName' => $levelName, + 'channel' => $channel, + 'message' => $message + ]; + + return json_encode($entry, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); + } +]); + +$logger = new Logger('ook', $handler); +$logger->info("ook\neek"); +// {"time":"Feb 03 22:45:17","level":6,"levelName":"Info","channel":"ook","message":"ook\neek"} +``` #### MensBeam\Logger\StreamHandler::getStream #### diff --git a/lib/Logger/Handler.php b/lib/Logger/Handler.php index 19aec80..5df63dc 100644 --- a/lib/Logger/Handler.php +++ b/lib/Logger/Handler.php @@ -54,7 +54,9 @@ abstract class Handler { } if ($name === 'messageTransform' && !is_callable($value)) { - throw new TypeError(sprintf('Value of messageTransform option must be callable')); + $type = gettype($value); + $type = ($type === 'object') ? $value::class : $type; + throw new TypeError(sprintf('Value of messageTransform option must be callable, %s given', $type)); } $name = "_$name"; @@ -70,8 +72,12 @@ abstract class Handler { $message = trim($message); if ($this->_messageTransform !== null) { - $t = $this->_messageTransform; - $message = $t($message, $context); + $message = call_user_func($this->_messageTransform, $message, $context); + if (!is_string($message)) { + $type = gettype($message); + $type = ($type === 'object') ? $message::class : $type; + throw new TypeError(sprintf('Return value of messageTransform option callable must be a string, %s given', $type)); + } } $this->invokeCallback($time, $level, $channel ?? '', $message, $context); diff --git a/lib/Logger/StreamHandler.php b/lib/Logger/StreamHandler.php index e77df16..5923a49 100644 --- a/lib/Logger/StreamHandler.php +++ b/lib/Logger/StreamHandler.php @@ -19,7 +19,7 @@ class StreamHandler extends Handler { protected ?string $uri = null; protected ?string $uriScheme = null; - protected ?string $_entryFormat = '%time% %channel% %level_name% %message%'; + protected $_entryTransform = null; public function __construct($stream = 'php://stdout', array $levels = [ 0, 1, 2, 3, 4, 5, 6, 7 ], array $options = []) { @@ -41,12 +41,6 @@ class StreamHandler extends Handler { } $this->setStream($stream); - - // Bad dog, no biscuit! - if (($options['entryFormat'] ?? null) === '') { - $options['entryFormat'] = $this->_entryFormat; - } - parent::__construct($levels, $options); } @@ -61,6 +55,16 @@ class StreamHandler extends Handler { return $this->uri; } + public function setOption(string $name, mixed $value): void { + if ($name === 'entryTransform' && !is_callable($value)) { + $type = gettype($value); + $type = ($type === 'object') ? $value::class : $type; + throw new TypeError(sprintf('Value of entryTransform option must be callable, %s given', $type)); + } + + parent::setOption($name, $value); + } + public function setStream($value): void { $isResource = is_resource($value); if (!$isResource && !is_string($value)) { @@ -90,17 +94,17 @@ class StreamHandler extends Handler { protected function invokeCallback(string $time, int $level, string $channel, string $message, array $context = []): void { - // Do entry formatting here. - $output = trim(preg_replace_callback('/%([a-z_]+)%/', function($m) use ($time, $level, $channel, $message) { - switch ($m[1]) { - case 'channel': return $channel; - case 'level': return (string)$level; - case 'level_name': return strtoupper(Level::from($level)->name); - case 'message': return $message; - case 'time': return $time; - default: return ''; + if ($this->_entryTransform !== null) { + $output = call_user_func($this->_entryTransform, $time, $level, Level::from($level)->name, $channel, $message, $context); + if (!is_string($output)) { + $type = gettype($output); + $type = ($type === 'object') ? $output::class : $type; + throw new TypeError(sprintf('Return value of entryTransform option callable must be a string, %s given', $type)); } - }, $this->_entryFormat)); + } else { + $output = sprintf('%s %s %s %s', $time, $channel, strtoupper(Level::from($level)->name), $message); + } + // If output contains any newlines then add an additional newline to aid readability. if (str_contains($output, \PHP_EOL)) { $output .= \PHP_EOL; diff --git a/tests/cases/TestHandler.php b/tests/cases/TestHandler.php index 6fba728..055011d 100644 --- a/tests/cases/TestHandler.php +++ b/tests/cases/TestHandler.php @@ -98,16 +98,43 @@ class TestHandler extends ErrorHandlingTestCase { } + // 'Return value of messageTransform option callable must be a string, %s given' public static function provideFatalErrorTests(): iterable { $iterable = [ [ TypeError::class, 0, - 'Value of messageTransform option must be callable', + 'Value of messageTransform option must be callable, integer given', function (Handler $h): void { $h->setOption('messageTransform', 42); } ], + [ + TypeError::class, + 0, + 'Value of messageTransform option must be callable, DateTimeImmutable given', + function (Handler $h): void { + $h->setOption('messageTransform', new \DateTimeImmutable()); + } + ], + [ + TypeError::class, + 0, + 'Return value of messageTransform option callable must be a string, integer given', + function (Handler $h): void { + $h->setOption('messageTransform', fn() => 42); + $h(Level::Error->value, 'fail', 'fail'); + } + ], + [ + TypeError::class, + 0, + 'Return value of messageTransform option callable must be a string, DateTimeImmutable given', + function (Handler $h): void { + $h->setOption('messageTransform', fn() => new \DateTimeImmutable()); + $h(Level::Error->value, 'fail', 'fail'); + } + ], [ InvalidArgumentException::class, 0, diff --git a/tests/cases/TestStreamHandler.php b/tests/cases/TestStreamHandler.php index 8af26e1..b3fda34 100644 --- a/tests/cases/TestStreamHandler.php +++ b/tests/cases/TestStreamHandler.php @@ -7,19 +7,24 @@ declare(strict_types=1); namespace MensBeam\Logger\Test; -use MensBeam\Logger, - org\bovigo\vfs\vfsStream; +use org\bovigo\vfs\vfsStream; use MensBeam\Logger\{ InvalidArgumentException, - IOException, Level, - StreamHandler + StreamHandler, + TypeError }; +use PHPUnit\Framework\{ + TestCase, + Attributes\CoversClass, + Attributes\DataProvider +}; + +#[CoversClass('MensBeam\Logger\StreamHandler')] +class TestStreamHandler extends TestCase { -/** @covers \MensBeam\Logger\StreamHandler */ -class TestStreamHandler extends \PHPUnit\Framework\TestCase { - /** @dataProvider provideResourceTypesTests */ + #[DataProvider('provideResourceTypesTests')] public function testResourceTypes(\Closure $closure): void { $regex = '/^' . (new \DateTimeImmutable())->format('M d') . ' \d{2}:\d{2}:\d{2} ook ERROR Ook!\nEek!\n/'; $this->assertEquals(1, preg_match($regex, $closure())); @@ -39,20 +44,20 @@ class TestStreamHandler extends \PHPUnit\Framework\TestCase { $this->assertSame(CWD . '/ook', $h->getURI()); } - /** @dataProvider provideEntryFormattingTests */ - public function testEntryFormatting(string $entryFormat, string $regex): void { + #[DataProvider('provideEntryTransformingTests')] + public function testEntryTransforming(\Closure $entryTransform, string $regex): void { $s = fopen('php://memory', 'r+'); $h = new StreamHandler(stream: $s, options: [ - 'entryFormat' => $entryFormat + 'entryTransform' => $entryTransform ]); $h(Level::Error->value, 'ook', 'ook'); rewind($s); $o = stream_get_contents($s); - $this->assertEquals(1, preg_match($regex, $o)); + $this->assertMatchesRegularExpression($regex, $o); fclose($s); } - /** @dataProvider provideFatalErrorTests */ + #[DataProvider('provideFatalErrorTests')] public function testFatalErrors(string $throwableClassName, int $code, string $message, \Closure $closure): void { $this->expectException($throwableClassName); $this->expectExceptionMessage($message); @@ -60,7 +65,8 @@ class TestStreamHandler extends \PHPUnit\Framework\TestCase { $this->expectExceptionCode($code); } - $closure(new StreamHandler()); + $s = fopen('php://memory', 'r+'); + $closure(new StreamHandler($s)); } @@ -97,11 +103,20 @@ class TestStreamHandler extends \PHPUnit\Framework\TestCase { } } - public static function provideEntryFormattingTests(): iterable { + public static function provideEntryTransformingTests(): iterable { $iterable = [ - [ '%ook%', '/\n/' ], - [ '%channel% %channel% %channel% %channel% %level% %level_name%', '/ook ook ook ook 3 ERROR\n/' ], - [ '', '/^' . (new \DateTimeImmutable())->format('M d') . ' \d{2}:\d{2}:\d{2} ook ERROR ook\n/' ] + [ + function (): string { + return ''; + }, + '/\n/' + ], + [ + function (string $time, int $level, string $levelName, string $channel): string { + return "$channel $channel $channel $channel $level $levelName"; + }, + '/ook ook ook ook 3 Error\n/' + ] ]; foreach ($iterable as $i) { @@ -118,6 +133,48 @@ class TestStreamHandler extends \PHPUnit\Framework\TestCase { function (StreamHandler $h): void { new StreamHandler(42); } + ], + [ + InvalidArgumentException::class, + 0, + 'Argument #1 ($value) must be of type resource|string, DateTimeImmutable given', + function (StreamHandler $h): void { + new StreamHandler(new \DateTimeImmutable()); + } + ], + [ + TypeError::class, + 0, + 'Value of entryTransform option must be callable, integer given', + function (StreamHandler $h): void { + $h->setOption('entryTransform', 42); + } + ], + [ + TypeError::class, + 0, + 'Value of entryTransform option must be callable, DateTimeImmutable given', + function (StreamHandler $h): void { + $h->setOption('entryTransform', new \DateTimeImmutable()); + } + ], + [ + TypeError::class, + 0, + 'Return value of entryTransform option callable must be a string, integer given', + function (StreamHandler $h): void { + $h->setOption('entryTransform', fn() => 42); + $h(Level::Error->value, 'fail', 'fail'); + } + ], + [ + TypeError::class, + 0, + 'Return value of entryTransform option callable must be a string, DateTimeImmutable given', + function (StreamHandler $h): void { + $h->setOption('entryTransform', fn() => new \DateTimeImmutable()); + $h(Level::Error->value, 'fail', 'fail'); + } ] ];