diff --git a/CHANGELOG.md b/CHANGELOG.md index 224103c6..0acbc517 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Yii Logging Library Change Log -## 2.0.1 under development +## 2.1.0 under development - Bug #84: Change the type of the `$level` parameter in the `Message` constructor to `string` (@dood-) - New #104: Add new static methods `Logger::assertLevelIsValid()`, `Logger::assertLevelIsString()` and @@ -9,6 +9,9 @@ - Bug #98: Fix error on formatting trace, when it doesn't contain "file" and "line" (@vjik) - New #108: Support of nested values in message templates' variables, e. g. `{foo.bar}` (@vjik) - Bug #89: Fix error on parse messages, that contains variables that cannot cast to a string (@vjik) +- New #109: Add context provider (@vjik) +- Chg #109: Deprecate `Logger` methods `setTraceLevel()` and `setExcludedTracePaths()` in favor of context provider + usage (@vjik) ## 2.0.0 May 22, 2022 diff --git a/README.md b/README.md index 3da224e8..2971cc50 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ $logger->info('Info message', ['key' => 'value']); $logger->debug('Debug message', ['key' => 'value']); ``` -## Message Flushing and Exporting +### Message Flushing and Exporting Log messages are collected and stored in memory. To limit memory consumption, the logger will flush the recorded messages to the log targets each time a certain number of log messages accumulate. @@ -76,7 +76,7 @@ $target->setExportInterval(100); // default is 1000 > Note: All message flushing and exporting also occurs when the application ends. -## Logging targets +### Logging targets This package contains two targets: @@ -90,6 +90,42 @@ Extra logging targets are implemented as separate packages: - [File](https://github.com/yiisoft/log-target-file) - [Syslog](https://github.com/yiisoft/log-target-syslog) +### Context providers + +Context providers are used to provide additional context data for log messages. You can define your own context provider +in `Logger` constructor: + +```php +$logger = new \Yiisoft\Log\Logger(contextProvider: $myContextProvider); +``` + +By default, the logger uses built-in `Yiisoft\Log\ContextProvider\ContextProvider` that added following data to context: + +- `time` — current Unix timestamp with microseconds (float value); +- `trace` — array of call stack information; +- `memory` — memory usage in bytes. +- `category` — category of the log message (always "application"). + +`Yiisoft\Log\ContextProvider\ContextProvider` constructor parameters: + +- `traceLevel` — how much call stack information (file name and line number) should be logged for each + log message. If it is greater than 0, at most that number of call stacks will be logged. Note that only + application call stacks are counted. +- `excludedTracePaths` — array of paths to exclude from tracing when tracing is enabled with `traceLevel`. + +Example of custom parameters' usage: + +```php +$logger = new \Yiisoft\Log\Logger( + contextProvider: new Yiisoft\Log\ContextProvider\ContextProvider( + traceLevel: 3, + excludedTracePaths: [ + '/vendor/yiisoft/di', + ], + ) +); +``` + ## Documentation - [Yii guide to logging](https://github.com/yiisoft/docs/blob/master/guide/en/runtime/logging.md) diff --git a/src/ContextProvider/CompositeContextProvider.php b/src/ContextProvider/CompositeContextProvider.php new file mode 100644 index 00000000..b31ef53f --- /dev/null +++ b/src/ContextProvider/CompositeContextProvider.php @@ -0,0 +1,31 @@ +providers = $providers; + } + + public function getContext(): array + { + $contexts = []; + foreach ($this->providers as $provider) { + $contexts[] = $provider->getContext(); + } + return array_merge(...$contexts); + } +} diff --git a/src/ContextProvider/ContextProvider.php b/src/ContextProvider/ContextProvider.php new file mode 100644 index 00000000..8fc3b59c --- /dev/null +++ b/src/ContextProvider/ContextProvider.php @@ -0,0 +1,133 @@ + + */ +final class ContextProvider implements ContextProviderInterface +{ + /** + * @var string[] $excludedTracePaths Array of paths to exclude from tracing when tracing is enabled. + */ + private array $excludedTracePaths; + + /** + * @param int $traceLevel How much call stack information (file name and line number) should be logged for each + * log message. If it is greater than 0, at most that number of call stacks will be logged. Note that only + * application call stacks are counted. + * @param string[] $excludedTracePaths Array of paths to exclude from tracing when tracing is enabled + * with {@see $traceLevel}. + */ + public function __construct( + private int $traceLevel = 0, + array $excludedTracePaths = [], + ) { + /** @psalm-suppress DeprecatedMethod `setExcludedTracePaths` will be private and not deprecated */ + $this->setExcludedTracePaths($excludedTracePaths); + } + + public function getContext(): array + { + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); + array_shift($trace); + return [ + 'time' => microtime(true), + 'trace' => $this->collectTrace($trace), + 'memory' => memory_get_usage(), + 'category' => CategoryFilter::DEFAULT, + ]; + } + + /** + * Sets how much call stack information (file name and line number) should be logged for each log message. + * + * @param int $traceLevel The number of call stack information. + * + * @see self::$traceLevel + * + * @deprecated since 2.1.0, to be removed in 3.0.0. Use constructor parameter "traceLevel" instead. + */ + public function setTraceLevel(int $traceLevel): self + { + $this->traceLevel = $traceLevel; + return $this; + } + + /** + * Sets an array of paths to exclude from tracing when tracing is enabled with {@see self::$traceLevel}. + * + * @param string[] $excludedTracePaths The paths to exclude from tracing. + * + * @throws InvalidArgumentException for non-string values. + * + * @see self::$excludedTracePaths + * + * @deprecated since 2.1.0, to be removed in 3.0.0. Use constructor parameter "excludedTracePaths" instead. + */ + public function setExcludedTracePaths(array $excludedTracePaths): self + { + foreach ($excludedTracePaths as $excludedTracePath) { + /** @psalm-suppress DocblockTypeContradiction */ + if (!is_string($excludedTracePath)) { + throw new InvalidArgumentException( + sprintf( + 'The trace path must be a string, %s received.', + gettype($excludedTracePath) + ) + ); + } + } + + $this->excludedTracePaths = $excludedTracePaths; + return $this; + } + + /** + * Collects a trace when tracing is enabled with {@see Logger::setTraceLevel()}. + * + * @param array $backtrace The list of call stack information. + * @psalm-param Backtrace|list $backtrace + * + * @return array Collected a list of call stack information. + * @psalm-return Backtrace + */ + private function collectTrace(array $backtrace): array + { + $traces = []; + + if ($this->traceLevel > 0) { + $count = 0; + + foreach ($backtrace as $trace) { + if (isset($trace['file'], $trace['line'])) { + $excludedMatch = array_filter( + $this->excludedTracePaths, + static fn($path) => str_contains($trace['file'], $path) + ); + + if (empty($excludedMatch)) { + unset($trace['object'], $trace['args']); + $traces[] = $trace; + if (++$count >= $this->traceLevel) { + break; + } + } + } + } + } + + return $traces; + } +} diff --git a/src/ContextProvider/ContextProviderInterface.php b/src/ContextProvider/ContextProviderInterface.php new file mode 100644 index 00000000..caaa95fd --- /dev/null +++ b/src/ContextProvider/ContextProviderInterface.php @@ -0,0 +1,13 @@ +setTargets($targets); + $this->contextProvider = $contextProvider ?? new ContextProvider(); register_shutdown_function(function () { // make regular flush before other shutdown functions, which allows session data collection and so on @@ -146,20 +136,15 @@ public function getTargets(): array return $this->targets; } - /** - * @psalm-param LogMessageContext $context - * @psalm-suppress MoreSpecificImplementedParamType,MixedArgumentTypeCoercion - */ public function log(mixed $level, string|Stringable $message, array $context = []): void { self::assertLevelIsString($level); - $context['time'] ??= microtime(true); - $context['trace'] ??= $this->collectTrace(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS)); - $context['memory'] ??= memory_get_usage(); - $context['category'] ??= CategoryFilter::DEFAULT; - - $this->messages[] = new Message($level, $message, $context); + $this->messages[] = new Message( + $level, + $message, + array_merge($this->contextProvider->getContext(), $context), + ); if ($this->flushInterval > 0 && count($this->messages) >= $this->flushInterval) { $this->flush(); @@ -199,11 +184,18 @@ public function setFlushInterval(int $flushInterval): self * * @param int $traceLevel The number of call stack information. * - * @see Logger::$traceLevel + * @deprecated since 2.1, to be removed in 3.0 version. Use {@see self::$contextProvider} + * and {@see ContextProvider::setTraceLevel()} instead. */ public function setTraceLevel(int $traceLevel): self { - $this->traceLevel = $traceLevel; + if (!$this->contextProvider instanceof ContextProvider) { + throw new RuntimeException( + '"Logger::setTraceLevel()" is unavailable when using a custom context provider.' + ); + } + /** @psalm-suppress DeprecatedMethod */ + $this->contextProvider->setTraceLevel($traceLevel); return $this; } @@ -214,21 +206,18 @@ public function setTraceLevel(int $traceLevel): self * * @throws InvalidArgumentException for non-string values. * - * @see Logger::$excludedTracePaths + * @deprecated since 2.1, to be removed in 3.0 version. Use {@see self::$contextProvider} + * and {@see ContextProvider::setExcludedTracePaths()} instead. */ public function setExcludedTracePaths(array $excludedTracePaths): self { - foreach ($excludedTracePaths as $excludedTracePath) { - /** @psalm-suppress DocblockTypeContradiction */ - if (!is_string($excludedTracePath)) { - throw new InvalidArgumentException(sprintf( - 'The trace path must be a string, %s received.', - gettype($excludedTracePath) - )); - } + if (!$this->contextProvider instanceof ContextProvider) { + throw new RuntimeException( + '"Logger::setExcludedTracePaths()" is unavailable when using a custom context provider.' + ); } - - $this->excludedTracePaths = $excludedTracePaths; + /** @psalm-suppress DeprecatedMethod */ + $this->contextProvider->setExcludedTracePaths($excludedTracePaths); return $this; } @@ -335,41 +324,4 @@ private function dispatch(array $messages, bool $final): void $this->dispatch($targetErrors, true); } } - - /** - * Collects a trace when tracing is enabled with {@see Logger::setTraceLevel()}. - * - * @param array $backtrace The list of call stack information. - * @psalm-param Backtrace|list $backtrace - * - * @return array Collected a list of call stack information. - * @psalm-return Backtrace - */ - private function collectTrace(array $backtrace): array - { - $traces = []; - - if ($this->traceLevel > 0) { - $count = 0; - - foreach ($backtrace as $trace) { - if (isset($trace['file'], $trace['line'])) { - $excludedMatch = array_filter( - $this->excludedTracePaths, - static fn ($path) => str_contains($trace['file'], $path) - ); - - if (empty($excludedMatch)) { - unset($trace['object'], $trace['args']); - $traces[] = $trace; - if (++$count >= $this->traceLevel) { - break; - } - } - } - } - } - - return $traces; - } } diff --git a/src/Message.php b/src/Message.php index a9ce4d08..519aed24 100644 --- a/src/Message.php +++ b/src/Message.php @@ -15,20 +15,6 @@ /** * Message is a data object that stores log message data. - * - * @psalm-type Backtrace = list - * @psalm-type LogMessageContext = array{ - * category?:string, - * memory?:int, - * time?:float, - * trace?:Backtrace, - * }&array */ final class Message { @@ -45,8 +31,7 @@ final class Message private string $message; /** - * @var array Log message context. - * @psalm-var LogMessageContext + * @var array Log message context. * * Message context has a following keys: * @@ -60,8 +45,7 @@ final class Message /** * @param string $level Log message level. * @param string|Stringable $message Log message. - * @param array $context Log message context. - * @psalm-param LogMessageContext $context + * @param array $context Log message context. * * @throws InvalidArgumentException for invalid log message level. * @@ -105,7 +89,6 @@ public function message(): string * @param mixed $default If the context parameter does not exist, the `$default` will be returned. * * @return mixed The context parameter value. - * @psalm-return LogMessageContext|mixed */ public function context(string $name = null, mixed $default = null): mixed { @@ -121,8 +104,7 @@ public function context(string $name = null, mixed $default = null): mixed * where foo will be replaced by the context data in key "foo". * * @param string|Stringable $message Raw log message. - * @param array $context Message context. - * @psalm-param LogMessageContext $context + * @param array $context Message context. * * @return string Parsed message. */ diff --git a/src/Message/Formatter.php b/src/Message/Formatter.php index 232259ce..29063130 100644 --- a/src/Message/Formatter.php +++ b/src/Message/Formatter.php @@ -21,8 +21,6 @@ * Formatter formats log messages. * * @internal - * - * @psalm-import-type Backtrace from Message */ final class Formatter { @@ -233,14 +231,17 @@ private function getContext(Message $message, array $commonContext): string */ private function getTrace(Message $message): string { - /** @psalm-var Backtrace $traces */ $traces = $message->context('trace', []); - if (empty($traces)) { + if (empty($traces) || !is_array($traces)) { return ''; } $lines = array_map( - static function (array $trace): string { + static function (mixed $trace): string { + if (!is_array($trace)) { + return '???'; + } + $file = $trace['file'] ?? null; $line = $trace['line'] ?? null; if (is_string($file) && is_int($line)) { diff --git a/tests/ContextProvider/CompositeContextProviderTest.php b/tests/ContextProvider/CompositeContextProviderTest.php new file mode 100644 index 00000000..4e99aa61 --- /dev/null +++ b/tests/ContextProvider/CompositeContextProviderTest.php @@ -0,0 +1,37 @@ + 1, + 'b' => 2, + ]); + $provider2 = new StubContextProvider([ + 'b' => 3, + 'c' => 4, + ]); + + $compositeProvider = new CompositeContextProvider($provider1, $provider2); + + $context = $compositeProvider->getContext(); + + $this->assertSame( + [ + 'a' => 1, + 'b' => 3, + 'c' => 4, + ], + $context + ); + } +} diff --git a/tests/LoggerTest.php b/tests/LoggerTest.php index 69b4a404..46c9a038 100644 --- a/tests/LoggerTest.php +++ b/tests/LoggerTest.php @@ -15,6 +15,7 @@ use Yiisoft\Log\Message; use Yiisoft\Log\Target; use Yiisoft\Log\Tests\TestAsset\DummyTarget; +use Yiisoft\Log\Tests\TestAsset\StubContextProvider; use function memory_get_usage; @@ -67,7 +68,7 @@ public function testLogWithTraceLevel(): void $this->assertSame('application', $messages[0]->context('category')); $this->assertSame([ 'file' => __FILE__, - 'line' => 61, + 'line' => 62, 'function' => 'log', 'class' => Logger::class, 'type' => '->', @@ -402,6 +403,24 @@ public function testDispatchWithFakeTarget2ThrowExceptionWhenCollect(): void $logger->flush(true); } + public function testSetTraceLevelWithCustomContextProvider(): void + { + $logger = new Logger(contextProvider: new StubContextProvider()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('"Logger::setTraceLevel()" is unavailable when using a custom context provider.'); + $logger->setTraceLevel(0); + } + + public function testSetExcludedTracePathsWithCustomContextProvider(): void + { + $logger = new Logger(contextProvider: new StubContextProvider()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('"Logger::setExcludedTracePaths()" is unavailable when using a custom context provider.'); + $logger->setExcludedTracePaths([]); + } + /** * Sets an inaccessible object property to a designated value. * diff --git a/tests/Message/FormatterTest.php b/tests/Message/FormatterTest.php index 1fc74bb0..1cc16de2 100644 --- a/tests/Message/FormatterTest.php +++ b/tests/Message/FormatterTest.php @@ -213,6 +213,23 @@ public function testFormatWithTraceInContext(string $expectedTrace, array $trace $this->assertSame($expected, $this->formatter->format($message, [])); } + public function testNonArrayTraceItem() + { + $timestamp = 1_508_160_390; + $this->formatter->setTimestampFormat('Y-m-d H:i:s'); + $message = new Message( + LogLevel::INFO, + 'message', + ['category' => 'app', 'time' => $timestamp, 'trace' => [new stdClass()]], + ); + + $expected = "2017-10-16 13:26:30 [info][app] message\n\nMessage context:\n\n" + . "trace:\n ???\n" + . "category: 'app'\ntime: $timestamp\n"; + + $this->assertSame($expected, $this->formatter->format($message, [])); + } + public function invalidCallableReturnStringProvider(): array { return [ diff --git a/tests/TestAsset/StubContextProvider.php b/tests/TestAsset/StubContextProvider.php new file mode 100644 index 00000000..6ec17b2f --- /dev/null +++ b/tests/TestAsset/StubContextProvider.php @@ -0,0 +1,20 @@ +context; + } +}