diff --git a/doc/02-handlers-formatters-processors.md b/doc/02-handlers-formatters-processors.md index 9987908c..aeb77efd 100644 --- a/doc/02-handlers-formatters-processors.md +++ b/doc/02-handlers-formatters-processors.md @@ -125,6 +125,10 @@ has accessors to read out the information. - [_HandlerWrapper_](../src/Monolog/Handler/HandlerWrapper.php): A simple handler wrapper you can inherit from to create your own wrappers easily. +- [_ThresholdHandler_](../src/Monolog/Handler/ThresholdHandler.php): This handler will buffer all the log messages it + receives, up until a configured threshold is reached, after it will pass all log messages to the wrapped handler. + Useful for applying in bath processing when you're only interested in significant failures instead of minor, single + erroneous events. ## Formatters diff --git a/src/Monolog/Handler/ThresholdHandler.php b/src/Monolog/Handler/ThresholdHandler.php new file mode 100644 index 00000000..2b0949da --- /dev/null +++ b/src/Monolog/Handler/ThresholdHandler.php @@ -0,0 +1,127 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Handler; + +use Monolog\Logger; + + +/** + * Handler to only pass log messages when a certain threshold of messages is reached. + * + * This can be useful in cases of processing a batch of data, but you're for example only interested + * in case it fails catastrophically instead of a warning for 1 or 2 events. Worse things can happen, right? + * + * Usage example: + * + * ``` + * $log = new Logger('application'); + * $handler = new SomeHandler(...) + * + * // Pass all warnings to the handler when more than 10 & all error messages when more then 5 + * $threshold = new ThresholdHandler($handler, [Logger::WARNING => 10, Logger::ERROR => 5]); + * + * $log->pushHandler($threshold); + *``` + * + * @author Kris Buist + */ +class ThresholdHandler extends AbstractHandler +{ + /** @var HandlerInterface */ + private $handler; + + /** @var int[] */ + private $thresholdMap = [ + Logger::DEBUG => 0, + Logger::INFO => 0, + Logger::NOTICE => 0, + Logger::WARNING => 0, + Logger::ERROR => 0, + Logger::CRITICAL => 0, + Logger::ALERT => 0, + Logger::EMERGENCY => 0, + ]; + + /** + * Buffer of all messages passed to the handler before the threshold was reached + * + * @var mixed[][] + */ + private $buffer = []; + + /** + * @param HandlerInterface $handler + * @param int[] $thresholdMap Dictionary of logger level => threshold + * @param int $level + * @param bool $bubble + */ + public function __construct( + HandlerInterface $handler, + array $thresholdMap = [], + int $level = Logger::DEBUG, + bool $bubble = true + ) { + $this->handler = $handler; + foreach ($thresholdMap as $thresholdLevel => $threshold) { + $this->thresholdMap[$thresholdLevel] = $threshold; + } + parent::__construct($level, $bubble); + } + + /** + * Handles a record. + * + * All records may be passed to this method, and the handler should discard + * those that it does not want to handle. + * + * The return value of this function controls the bubbling process of the handler stack. + * Unless the bubbling is interrupted (by returning true), the Logger class will keep on + * calling further handlers in the stack with a given log record. + * + * @param array $record The record to handle + * + * @return Boolean true means that this handler handled the record, and that bubbling is not permitted. + * false means the record was either not processed or that this handler allows bubbling. + */ + public function handle(array $record): bool + { + if ($record['level'] < $this->level) { + return false; + } + + $level = $record['level']; + + if (!isset($this->thresholdMap[$level])) { + $this->thresholdMap[$level] = 0; + } + + if ($this->thresholdMap[$level] > 0) { + // The threshold is not yet reached, so we're buffering the record and lowering the threshold by 1 + $this->thresholdMap[$level]--; + $this->buffer[$level][] = $record; + return false === $this->bubble; + } + + if ($this->thresholdMap[$level] == 0) { + // This current message is breaking the threshold. Flush the buffer and continue handling the current record + foreach ($this->buffer[$level] ?? [] as $buffered) { + $this->handler->handle($buffered); + } + $this->thresholdMap[$level]--; + unset($this->buffer[$level]); + } + + $this->handler->handle($record); + + return false === $this->bubble; + } +} diff --git a/tests/Monolog/Handler/ThresholdHandlerTest.php b/tests/Monolog/Handler/ThresholdHandlerTest.php new file mode 100644 index 00000000..4ad555d1 --- /dev/null +++ b/tests/Monolog/Handler/ThresholdHandlerTest.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Monolog\Handler; + +use Monolog\Logger; +use Monolog\Test\TestCase; + +/** + * @author Kris Buist + * @covers \Monolog\Handler\ThresholdHandler + */ +class ThresholdHandlerTest extends TestCase +{ + public function testNotPassingRecordsBeneathLogLevel() + { + $testHandler = new TestHandler(); + $handler = new ThresholdHandler($testHandler, [], Logger::INFO); + $handler->handle($this->getRecord(Logger::DEBUG)); + $this->assertFalse($testHandler->hasDebugRecords()); + } + + public function testPassThroughWithoutThreshold() + { + $testHandler = new TestHandler(); + $handler = new ThresholdHandler($testHandler, [], Logger::INFO); + + $handler->handle($this->getRecord(Logger::INFO, 'Info 1')); + $handler->handle($this->getRecord(Logger::INFO, 'Info 2')); + $handler->handle($this->getRecord(Logger::WARNING, 'Warning 1')); + + $this->assertTrue($testHandler->hasInfoThatContains('Info 1')); + $this->assertTrue($testHandler->hasInfoThatContains('Info 2')); + $this->assertTrue($testHandler->hasWarningThatContains('Warning 1')); + } + + /** + * @test + */ + public function testHoldingMessagesBeneathThreshold() + { + $testHandler = new TestHandler(); + $handler = new ThresholdHandler($testHandler, [Logger::INFO => 3]); + + $handler->handle($this->getRecord(Logger::DEBUG, 'debug 1')); + $handler->handle($this->getRecord(Logger::DEBUG, 'debug 2')); + + foreach (range(1, 3) as $i) { + $handler->handle($this->getRecord(Logger::INFO, 'info ' . $i)); + } + + $this->assertTrue($testHandler->hasDebugThatContains('debug 1')); + $this->assertTrue($testHandler->hasDebugThatContains('debug 2')); + $this->assertFalse($testHandler->hasInfoRecords()); + + $handler->handle($this->getRecord(Logger::INFO, 'info 4')); + + foreach (range(1, 4) as $i) { + $this->assertTrue($testHandler->hasInfoThatContains('info ' . $i)); + } + } + + /** + * @test + */ + public function testCombinedThresholds() + { + $testHandler = new TestHandler(); + $handler = new ThresholdHandler($testHandler, [Logger::INFO => 5, Logger::WARNING => 10]); + + $handler->handle($this->getRecord(Logger::DEBUG)); + + foreach (range(1, 5) as $i) { + $handler->handle($this->getRecord(Logger::INFO, 'info ' . $i)); + } + + foreach (range(1, 10) as $i) { + $handler->handle($this->getRecord(Logger::WARNING, 'warning ' . $i)); + } + + // Only 1 DEBUG records + $this->assertCount(1, $testHandler->getRecords()); + + $handler->handle($this->getRecord(Logger::INFO, 'info final')); + + // 1 DEBUG + 5 buffered INFO + 1 new INFO + $this->assertCount(7, $testHandler->getRecords()); + + $handler->handle($this->getRecord(Logger::WARNING, 'warning final')); + + // 1 DEBUG + 6 INFO + 10 buffered WARNING + 1 new WARNING + $this->assertCount(18, $testHandler->getRecords()); + + $handler->handle($this->getRecord(Logger::INFO, 'Another info')); + $handler->handle($this->getRecord(Logger::WARNING, 'Anther warning')); + + // 1 DEBUG + 6 INFO + 11 WARNING + 1 new INFO + 1 new WARNING + $this->assertCount(20, $testHandler->getRecords()); + } +}