diff --git a/CHANGELOG.md b/CHANGELOG.md index 754ef600..d1dbcd66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2.2.2 under development +- New #140: Add `$contextFormat` and `$stringConverter` constructor parameters to `Target` for customizing context output (@WarLikeLaux) - Enh #146: Move `Target` configuration to constructor parameters and deprecate the corresponding setters (@WarLikeLaux) ## 2.2.1 March 22, 2026 diff --git a/README.md b/README.md index efa44e50..55bb849c 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,67 @@ $logger = new \Yiisoft\Log\Logger( ); ``` +### Customizing log format + +Each target formats log messages using a built-in `Formatter`. You can customize various aspects of formatting. + +To replace the entire message format, use `setFormat()`: + +```php +$target->setFormat(static function (\Yiisoft\Log\Message $message, array $commonContext): string { + return "[{$message->level()}][{$message->context('category')}] {$message->message()}"; +}); +``` + +To add a prefix to every message: + +```php +$target->setPrefix(static fn() => 'MyApp: '); +``` + +To change the timestamp format: + +```php +$target->setTimestampFormat('Y-m-d H:i:s'); +``` + +The context output is customized via constructor parameters. + +To replace how context values are converted to strings (default uses VarDumper), pass `stringConverter`: + +```php +$target = new \Yiisoft\Log\StreamTarget( + stringConverter: static fn(mixed $value): string => json_encode($value, JSON_THROW_ON_ERROR), +); +``` + +To reorder context sections (trace, message context, common context), pass a `contextFormat` template string +with `{trace}`, `{message}`, and `{common}` placeholders. Each placeholder expands to its section with +a header when non-empty, or an empty string when the section has no data: + +```php +$target = new \Yiisoft\Log\StreamTarget(contextFormat: "{common}{message}{trace}\n"); +``` + +For full control over context rendering, pass a `contextFormat` callable instead: + +```php +$target = new \Yiisoft\Log\StreamTarget( + contextFormat: static function (string $trace, string $messageContext, string $commonContext): string { + $result = ''; + if ($commonContext !== '') { + $result .= "\n\nCommon:\n" . $commonContext; + } + if ($messageContext !== '') { + $result .= "\n\nMessage:\n" . $messageContext; + } + return $result; + }, +); +``` + +`contextFormat` accepts either a template string or a callable. + ### Configuring `LoggerInterface` in Yii3 In a Yii3 application, `Psr\Log\LoggerInterface` is resolved through the DI container. diff --git a/src/Message/Formatter.php b/src/Message/Formatter.php index b939873f..a4df6ecf 100644 --- a/src/Message/Formatter.php +++ b/src/Message/Formatter.php @@ -6,13 +6,11 @@ use RuntimeException; use Yiisoft\Log\Message; -use Yiisoft\VarDumper\VarDumper; use function implode; use function is_string; -use function is_object; -use function method_exists; use function sprintf; +use function str_replace; use function is_int; /** @@ -24,6 +22,20 @@ final class Formatter { private const DEFAULT_TIMESTAMP_FORMAT = 'Y-m-d H:i:s.u'; + /** + * @var string|callable|null + * + * @see Formatter::__construct() + */ + private $contextFormat = null; + + /** + * @var callable + * + * @see Formatter::__construct() + */ + private $stringConverter; + /** * @param callable|null $format A PHP callable that returns a string representation of the log message. * If not set, {@see Formatter::defaultFormat()} is used. @@ -32,12 +44,24 @@ final class Formatter * If not set, {@see Formatter::getPrefix()} is used. * Its signature should be `function (Message $message, array $commonContext): string;`. * @param string|null $timestampFormat The date format for the log timestamp. Defaults to `Y-m-d H:i:s.u`. + * @param string|callable|null $contextFormat A context format. A template string supports `{trace}`, + * `{message}` and `{common}` placeholders, each replaced with its formatted section (including header) if + * non-empty, or an empty string otherwise. For example, `"{common}{message}{trace}\n"` outputs common + * context first, then message context, then trace. A PHP callable gives full control over context rendering; + * its signature should be `function (string $trace, string $messageContext, string $commonContext): string;`. + * @param callable|null $stringConverter A PHP callable that converts a context value to a string. Its + * signature should be `function (mixed $value): string;`. Defaults to {@see VarDumperValueConverter}. */ public function __construct( private $format = null, private $prefix = null, private ?string $timestampFormat = null, - ) {} + string|callable|null $contextFormat = null, + ?callable $stringConverter = null, + ) { + $this->contextFormat = $contextFormat; + $this->stringConverter = $stringConverter ?? new VarDumperValueConverter(); + } /** * Sets the format for the string representation of the log message. @@ -165,10 +189,6 @@ private function getContext(Message $message, array $commonContext): string $context = []; $common = []; - if ($trace !== '') { - $context[] = $trace; - } - /** * @var array-key $name * @var mixed $value @@ -186,8 +206,49 @@ private function getContext(Message $message, array $commonContext): string $common[] = "{$name}: " . $this->convertToString($value); } - return (empty($context) ? '' : "\n\nMessage context:\n\n" . implode("\n", $context)) - . (empty($common) ? '' : "\n\nCommon context:\n\n" . implode("\n", $common)) . "\n"; + $messageContext = implode("\n", $context); + $commonContextString = implode("\n", $common); + $contextFormat = $this->contextFormat; + + if (is_string($contextFormat)) { + return str_replace( + ['{trace}', '{message}', '{common}'], + [ + $trace === '' ? '' : "\n\nTrace:\n\n" . $trace, + $messageContext === '' ? '' : "\n\nMessage context:\n\n" . $messageContext, + $commonContextString === '' ? '' : "\n\nCommon context:\n\n" . $commonContextString, + ], + $contextFormat, + ); + } + + if ($contextFormat !== null) { + $result = $contextFormat($trace, $messageContext, $commonContextString); + + if (!is_string($result)) { + throw new RuntimeException(sprintf( + 'The PHP callable "contextFormat" must return a string, %s received.', + get_debug_type($result), + )); + } + + return $result; + } + + $messageItems = []; + + if ($trace !== '') { + $messageItems[] = $trace; + } + + if ($messageContext !== '') { + $messageItems[] = $messageContext; + } + + $messageSection = implode("\n", $messageItems); + + return ($messageSection === '' ? '' : "\n\nMessage context:\n\n" . $messageSection) + . ($commonContextString === '' ? '' : "\n\nCommon context:\n\n" . $commonContextString) . "\n"; } /** @@ -237,10 +298,15 @@ static function (mixed $trace): string { */ private function convertToString(mixed $value): string { - if (is_object($value) && method_exists($value, '__toString')) { - return (string) $value; + $result = ($this->stringConverter)($value); + + if (!is_string($result)) { + throw new RuntimeException(sprintf( + 'The PHP callable "convertToString" must return a string, %s received.', + get_debug_type($result), + )); } - return VarDumper::create($value)->asString(); + return $result; } } diff --git a/src/Message/VarDumperValueConverter.php b/src/Message/VarDumperValueConverter.php new file mode 100644 index 00000000..9ac57542 --- /dev/null +++ b/src/Message/VarDumperValueConverter.php @@ -0,0 +1,29 @@ +asString(); + } +} diff --git a/src/PsrTarget.php b/src/PsrTarget.php index 145f71ae..b8fb954c 100644 --- a/src/PsrTarget.php +++ b/src/PsrTarget.php @@ -22,6 +22,8 @@ final class PsrTarget extends Target * @param callable|null $format A PHP callable that returns a string representation of the log message. * @param callable|null $prefix A PHP callable that returns a string to be prefixed to every exported message. * @param string|null $timestampFormat The date format for the log timestamp. + * @param string|callable|null $contextFormat A context format for the log context output. See {@see Target::__construct()}. + * @param callable|null $stringConverter A PHP callable that converts a context value to a string. See {@see Target::__construct()}. * @param int $exportInterval How many messages should be accumulated before they are exported. * @param bool|callable $enabled Whether this target is enabled, or a PHP callable that returns a boolean. */ @@ -33,10 +35,12 @@ public function __construct( ?callable $format = null, ?callable $prefix = null, ?string $timestampFormat = null, + string|callable|null $contextFormat = null, + ?callable $stringConverter = null, int $exportInterval = self::DEFAULT_EXPORT_INTERVAL, bool|callable $enabled = true, ) { - parent::__construct($levels, $categories, $exceptCategories, $format, $prefix, $timestampFormat, $exportInterval, $enabled); + parent::__construct($levels, $categories, $exceptCategories, $format, $prefix, $timestampFormat, $contextFormat, $stringConverter, $exportInterval, $enabled); } /** diff --git a/src/StreamTarget.php b/src/StreamTarget.php index b27856dd..5d14b3c3 100644 --- a/src/StreamTarget.php +++ b/src/StreamTarget.php @@ -36,6 +36,8 @@ final class StreamTarget extends Target * @param callable|null $format A PHP callable that returns a string representation of the log message. * @param callable|null $prefix A PHP callable that returns a string to be prefixed to every exported message. * @param string|null $timestampFormat The date format for the log timestamp. + * @param string|callable|null $contextFormat A context format for the log context output. See {@see Target::__construct()}. + * @param callable|null $stringConverter A PHP callable that converts a context value to a string. See {@see Target::__construct()}. * @param int $exportInterval How many messages should be accumulated before they are exported. * @param bool|callable $enabled Whether this target is enabled, or a PHP callable that returns a boolean. */ @@ -47,10 +49,12 @@ public function __construct( ?callable $format = null, ?callable $prefix = null, ?string $timestampFormat = null, + string|callable|null $contextFormat = null, + ?callable $stringConverter = null, int $exportInterval = self::DEFAULT_EXPORT_INTERVAL, bool|callable $enabled = true, ) { - parent::__construct($levels, $categories, $exceptCategories, $format, $prefix, $timestampFormat, $exportInterval, $enabled); + parent::__construct($levels, $categories, $exceptCategories, $format, $prefix, $timestampFormat, $contextFormat, $stringConverter, $exportInterval, $enabled); } protected function export(): void diff --git a/src/Target.php b/src/Target.php index 43279a0a..d34c5d13 100644 --- a/src/Target.php +++ b/src/Target.php @@ -80,6 +80,11 @@ abstract class Target * @param callable|null $format A PHP callable that returns a string representation of the log message. * @param callable|null $prefix A PHP callable that returns a string to be prefixed to every exported message. * @param string|null $timestampFormat The date format for the log timestamp. + * @param string|callable|null $contextFormat A context format for the log context output. A template string + * supports `{trace}`, `{message}` and `{common}` placeholders, or a PHP callable for full control. See + * {@see Formatter::__construct()}. + * @param callable|null $stringConverter A PHP callable that converts a context value to a string. + * See {@see Formatter::__construct()}. * @param int $exportInterval How many messages should be accumulated before they are exported. * @param bool|callable $enabled Whether this target is enabled, or a PHP callable that returns a boolean. */ @@ -90,11 +95,13 @@ public function __construct( ?callable $format = null, ?callable $prefix = null, ?string $timestampFormat = null, + string|callable|null $contextFormat = null, + ?callable $stringConverter = null, private int $exportInterval = self::DEFAULT_EXPORT_INTERVAL, bool|callable $enabled = true, ) { $this->categories = new CategoryFilter($categories, $exceptCategories); - $this->formatter = new Formatter($format, $prefix, $timestampFormat); + $this->formatter = new Formatter($format, $prefix, $timestampFormat, $contextFormat, $stringConverter); /** @psalm-suppress DeprecatedMethod */ $this->setLevels($levels); $this->enabled = $enabled; diff --git a/tests/Message/FormatterTest.php b/tests/Message/FormatterTest.php index 0ebd2869..6c64914c 100644 --- a/tests/Message/FormatterTest.php +++ b/tests/Message/FormatterTest.php @@ -217,6 +217,202 @@ public function testFormatWithTraceInContext(string $expectedTrace, array $trace $this->assertSame($expected, $this->formatter->format($message, [])); } + public function testDefaultFormatWithStringConverter(): void + { + $formatter = new Formatter( + stringConverter: static fn(mixed $value): string => json_encode($value, JSON_THROW_ON_ERROR), + ); + $message = new Message(LogLevel::INFO, 'message', [ + 'category' => 'app', + 'time' => 1_508_160_390, + ]); + $expected = '2017-10-16 13:26:30.000000 [info][app] message' + . "\n\nMessage context:\n\ncategory: \"app\"\ntime: 1508160390" + . "\n\nCommon context:\n\nserver: \"web\"\n" + ; + $this->assertSame($expected, $formatter->format($message, ['server' => 'web'])); + } + + public function testDefaultFormatWithContextFormatCallable(): void + { + $formatter = new Formatter( + contextFormat: static function (string $trace, string $messageContext, string $commonContext): string { + $result = ''; + if ($commonContext !== '') { + $result .= "\n\nCommon:\n" . $commonContext; + } + if ($trace !== '') { + $result .= "\n\nTrace:\n" . $trace; + } + if ($messageContext !== '') { + $result .= "\n\nMessage:\n" . $messageContext; + } + return $result; + }, + ); + $formatter->setTimestampFormat('Y-m-d H:i:s'); + $message = new Message(LogLevel::INFO, 'message', [ + 'category' => 'app', + 'time' => 1_508_160_390, + 'trace' => [['file' => '/path/to/file', 'line' => 99]], + ]); + $expected = '2017-10-16 13:26:30 [info][app] message' + . "\n\nCommon:\nserver: 'web'" + . "\n\nTrace:\ntrace:\n in /path/to/file:99" + . "\n\nMessage:\ncategory: 'app'\ntime: 1508160390" + ; + $this->assertSame($expected, $formatter->format($message, ['server' => 'web'])); + } + + public function testDefaultFormatWithContextTemplate(): void + { + $formatter = new Formatter(contextFormat: "{common}{message}{trace}\n"); + $formatter->setTimestampFormat('Y-m-d H:i:s'); + $message = new Message(LogLevel::INFO, 'message', [ + 'category' => 'app', + 'time' => 1_508_160_390, + 'trace' => [['file' => '/path/to/file', 'line' => 99]], + ]); + $expected = '2017-10-16 13:26:30 [info][app] message' + . "\n\nCommon context:\n\nserver: 'web'" + . "\n\nMessage context:\n\ncategory: 'app'\ntime: 1508160390" + . "\n\nTrace:\n\ntrace:\n in /path/to/file:99" + . "\n" + ; + $this->assertSame($expected, $formatter->format($message, ['server' => 'web'])); + } + + public function testDefaultFormatWithContextTemplateEmptySections(): void + { + $formatter = new Formatter(contextFormat: "{trace}{message}{common}\n"); + $formatter->setTimestampFormat('Y-m-d H:i:s'); + $message = new Message(LogLevel::INFO, 'message', [ + 'category' => 'app', + 'time' => 1_508_160_390, + ]); + $expected = '2017-10-16 13:26:30 [info][app] message' + . "\n\nMessage context:\n\ncategory: 'app'\ntime: 1508160390" + . "\n" + ; + $this->assertSame($expected, $formatter->format($message, [])); + } + + public function testDefaultFormatWithContextTemplateOnlyCommon(): void + { + $formatter = new Formatter(contextFormat: "{common}\n"); + $formatter->setTimestampFormat('Y-m-d H:i:s'); + $message = new Message(LogLevel::INFO, 'message', [ + 'category' => 'app', + 'time' => 1_508_160_390, + ]); + $expected = '2017-10-16 13:26:30 [info][app] message' + . "\n\nCommon context:\n\nserver: 'web'" + . "\n" + ; + $this->assertSame($expected, $formatter->format($message, ['server' => 'web'])); + } + + public function testStringConverterOverridesStringableObject(): void + { + $formatter = new Formatter( + stringConverter: static fn(mixed $value): string => json_encode($value, JSON_THROW_ON_ERROR), + ); + $object = new class { + public function __toString(): string + { + return 'stringable-object'; + } + }; + $message = new Message(LogLevel::INFO, 'message', [ + 'category' => 'app', + 'time' => 1_508_160_390, + 'obj' => $object, + ]); + $result = $formatter->format($message, []); + $this->assertStringContainsString('obj: {}', $result); + $this->assertStringNotContainsString('stringable-object', $result); + } + + public function testStringConverterDoesNotAffectTrace(): void + { + $called = false; + $formatter = new Formatter( + stringConverter: static function (mixed $value) use (&$called): string { + $called = true; + return json_encode($value, JSON_THROW_ON_ERROR); + }, + ); + $formatter->setTimestampFormat('Y-m-d H:i:s'); + $message = new Message(LogLevel::INFO, 'message', [ + 'time' => 1_508_160_390, + 'trace' => [['file' => '/path/to/file', 'line' => 99]], + ]); + $result = $formatter->format($message, []); + $this->assertStringContainsString("trace:\n in /path/to/file:99", $result); + $this->assertTrue($called); + } + + public function testContextFormatReceivesEmptyTrace(): void + { + $receivedTrace = 'not-called'; + $formatter = new Formatter( + contextFormat: static function (string $trace, string $messageContext, string $commonContext) use (&$receivedTrace): string { + $receivedTrace = $trace; + return "\n" . $messageContext; + }, + ); + $message = new Message(LogLevel::INFO, 'message', [ + 'category' => 'app', + 'time' => 1_508_160_390, + ]); + $formatter->format($message, []); + $this->assertSame('', $receivedTrace); + } + + public function testContextFormatReceivesEmptyCommonContext(): void + { + $receivedCommon = 'not-called'; + $formatter = new Formatter( + contextFormat: static function (string $trace, string $messageContext, string $commonContext) use (&$receivedCommon): string { + $receivedCommon = $commonContext; + return "\n" . $messageContext; + }, + ); + $message = new Message(LogLevel::INFO, 'message', [ + 'category' => 'app', + 'time' => 1_508_160_390, + ]); + $formatter->format($message, []); + $this->assertSame('', $receivedCommon); + } + + public function testDefaultFormatWithStringConverterAndContextFormat(): void + { + $formatter = new Formatter( + contextFormat: static function (string $trace, string $messageContext, string $commonContext): string { + $result = ''; + if ($commonContext !== '') { + $result .= "\n[C] " . $commonContext; + } + if ($messageContext !== '') { + $result .= "\n[M] " . $messageContext; + } + return $result; + }, + stringConverter: static fn(mixed $value): string => json_encode($value, JSON_THROW_ON_ERROR), + ); + $formatter->setTimestampFormat('Y-m-d H:i:s'); + $message = new Message(LogLevel::INFO, 'message', [ + 'category' => 'app', + 'time' => 1_508_160_390, + ]); + $expected = '2017-10-16 13:26:30 [info][app] message' + . "\n[C] server: \"web\"" + . "\n[M] category: \"app\"\ntime: 1508160390" + ; + $this->assertSame($expected, $formatter->format($message, ['server' => 'web'])); + } + public function testTraceWithFileWithoutLineUsesFunction(): void { $this->formatter->setTimestampFormat('Y-m-d H:i:s'); @@ -288,6 +484,22 @@ public function testFormatMessageThrowExceptionForPrefixCallableReturnNotString( $this->formatter->format(new Message(LogLevel::INFO, 'test', ['foo' => 'bar']), []); } + public function testFormatThrowExceptionForStringConverterReturnNotString(): void + { + $formatter = new Formatter(stringConverter: static fn(mixed $value) => 123); + $this->expectException(RuntimeException::class); + $formatter->format(new Message(LogLevel::INFO, 'test', ['foo' => 'bar']), []); + } + + public function testFormatThrowExceptionForContextFormatCallableReturnNotString(): void + { + $formatter = new Formatter( + contextFormat: static fn(string $trace, string $messageContext, string $commonContext) => 123, + ); + $this->expectException(RuntimeException::class); + $formatter->format(new Message(LogLevel::INFO, 'test', ['foo' => 'bar']), []); + } + public static function dataTime(): array { return [ diff --git a/tests/Message/VarDumperValueConverterTest.php b/tests/Message/VarDumperValueConverterTest.php new file mode 100644 index 00000000..03f6c190 --- /dev/null +++ b/tests/Message/VarDumperValueConverterTest.php @@ -0,0 +1,53 @@ + ["'foo'", 'foo'], + 'int' => ['1', 1], + 'float' => ['1.1', 1.1], + 'null' => ['null', null], + 'array' => ['[]', []], + ]; + } + + /** + * @dataProvider dataConvert + */ + public function testConvert(string $expected, mixed $value): void + { + $converter = new VarDumperValueConverter(); + + $this->assertSame($expected, $converter($value)); + } + + public function testStringableObjectUsesToString(): void + { + $converter = new VarDumperValueConverter(); + $object = new class { + public function __toString(): string + { + return 'stringable-object'; + } + }; + + $this->assertSame('stringable-object', $converter($object)); + } + + public function testObjectWithoutToStringUsesVarDumper(): void + { + $converter = new VarDumperValueConverter(); + + $this->assertStringContainsString('stdClass', $converter(new stdClass())); + } +} diff --git a/tests/TargetTest.php b/tests/TargetTest.php index c78e1bf1..4aae4418 100644 --- a/tests/TargetTest.php +++ b/tests/TargetTest.php @@ -258,6 +258,67 @@ public function testSetLevelsThrowExceptionForNonStringList(array $list): void $this->target->setLevels($list); } + public function testStringConverter(): void + { + $this->target = new DummyTarget( + stringConverter: static fn(mixed $value): string => json_encode($value, JSON_THROW_ON_ERROR), + ); + $this->target->setCommonContext(['server' => 'web']); + $this->collectOneAndExport(LogLevel::INFO, 'message', [ + 'category' => 'app', + 'time' => 1_508_160_390, + ]); + $expected = '2017-10-16 13:26:30.000000 [info][app] message' + . "\n\nMessage context:\n\ncategory: \"app\"\ntime: 1508160390" + . "\n\nCommon context:\n\nserver: \"web\"\n" + ; + $this->assertSame($expected, $this->target->formatMessages()); + } + + public function testContextTemplate(): void + { + $this->target = new DummyTarget(contextFormat: "{common}{message}\n"); + $this->target->setTimestampFormat('Y-m-d H:i:s'); + $this->target->setCommonContext(['server' => 'web']); + $this->collectOneAndExport(LogLevel::INFO, 'message', [ + 'category' => 'app', + 'time' => 1_508_160_390, + ]); + $expected = "2017-10-16 13:26:30 [info][app] message" + . "\n\nCommon context:\n\nserver: 'web'" + . "\n\nMessage context:\n\ncategory: 'app'\ntime: 1508160390" + . "\n" + ; + $this->assertSame($expected, $this->target->formatMessages()); + } + + public function testContextFormat(): void + { + $this->target = new DummyTarget( + contextFormat: static function (string $trace, string $messageContext, string $commonContext): string { + $result = ''; + if ($commonContext !== '') { + $result .= "\n\nCommon:\n" . $commonContext; + } + if ($messageContext !== '') { + $result .= "\n\nMessage:\n" . $messageContext; + } + return $result; + }, + ); + $this->target->setTimestampFormat('Y-m-d H:i:s'); + $this->target->setCommonContext(['server' => 'web']); + $this->collectOneAndExport(LogLevel::INFO, 'message', [ + 'category' => 'app', + 'time' => 1_508_160_390, + ]); + $expected = "2017-10-16 13:26:30 [info][app] message" + . "\n\nCommon:\nserver: 'web'" + . "\n\nMessage:\ncategory: 'app'\ntime: 1508160390" + ; + $this->assertSame($expected, $this->target->formatMessages()); + } + public function testSetFormat(): void { $this->target->setFormat(static fn(Message $message) => "[{$message->level()}][{$message->context('category')}] {$message->message()}"); diff --git a/tests/TestAsset/DummyTarget.php b/tests/TestAsset/DummyTarget.php index 9bc7aee6..b0903087 100644 --- a/tests/TestAsset/DummyTarget.php +++ b/tests/TestAsset/DummyTarget.php @@ -21,11 +21,13 @@ public function __construct( ?callable $format = null, ?callable $prefix = null, ?string $timestampFormat = null, + string|callable|null $contextFormat = null, + ?callable $stringConverter = null, int $exportInterval = self::DEFAULT_EXPORT_INTERVAL, bool|callable $enabled = true, ) { - parent::__construct($levels, $categories, $exceptCategories, $format, $prefix, $timestampFormat, $exportInterval, $enabled); - $this->exportFormatter = new Formatter($format, $prefix, $timestampFormat); + parent::__construct($levels, $categories, $exceptCategories, $format, $prefix, $timestampFormat, $contextFormat, $stringConverter, $exportInterval, $enabled); + $this->exportFormatter = new Formatter($format, $prefix, $timestampFormat, $contextFormat, $stringConverter); } public function export(): void