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');
+ }
]
];