| 1 |
<?php declare(strict_types=1); |
| 2 |
|
| 3 |
/* |
| 4 |
* This file is part of the Monolog package. |
| 5 |
* |
| 6 |
* (c) Jordi Boggiano <[email protected]> |
| 7 |
* |
| 8 |
* For the full copyright and license information, please view the LICENSE |
| 9 |
* file that was distributed with this source code. |
| 10 |
*/ |
| 11 |
|
| 12 |
namespace Monolog\Handler; |
| 13 |
|
| 14 |
use Monolog\Logger; |
| 15 |
use Psr\Log\LogLevel; |
| 16 |
|
| 17 |
/** |
| 18 |
* Simple handler wrapper that deduplicates log records across multiple requests |
| 19 |
* |
| 20 |
* It also includes the BufferHandler functionality and will buffer |
| 21 |
* all messages until the end of the request or flush() is called. |
| 22 |
* |
| 23 |
* This works by storing all log records' messages above $deduplicationLevel |
| 24 |
* to the file specified by $deduplicationStore. When further logs come in at the end of the |
| 25 |
* request (or when flush() is called), all those above $deduplicationLevel are checked |
| 26 |
* against the existing stored logs. If they match and the timestamps in the stored log is |
| 27 |
* not older than $time seconds, the new log record is discarded. If no log record is new, the |
| 28 |
* whole data set is discarded. |
| 29 |
* |
| 30 |
* This is mainly useful in combination with Mail handlers or things like Slack or HipChat handlers |
| 31 |
* that send messages to people, to avoid spamming with the same message over and over in case of |
| 32 |
* a major component failure like a database server being down which makes all requests fail in the |
| 33 |
* same way. |
| 34 |
* |
| 35 |
* @author Jordi Boggiano <[email protected]> |
| 36 |
* |
| 37 |
* @phpstan-import-type Record from \Monolog\Logger |
| 38 |
* @phpstan-import-type LevelName from \Monolog\Logger |
| 39 |
* @phpstan-import-type Level from \Monolog\Logger |
| 40 |
*/ |
| 41 |
class DeduplicationHandler extends BufferHandler |
| 42 |
{ |
| 43 |
/** |
| 44 |
* @var string |
| 45 |
*/ |
| 46 |
protected $deduplicationStore; |
| 47 |
|
| 48 |
/** |
| 49 |
* @var Level |
| 50 |
*/ |
| 51 |
protected $deduplicationLevel; |
| 52 |
|
| 53 |
/** |
| 54 |
* @var int |
| 55 |
*/ |
| 56 |
protected $time; |
| 57 |
|
| 58 |
/** |
| 59 |
* @var bool |
| 60 |
*/ |
| 61 |
private $gc = false; |
| 62 |
|
| 63 |
/** |
| 64 |
* @param HandlerInterface $handler Handler. |
| 65 |
* @param string $deduplicationStore The file/path where the deduplication log should be kept |
| 66 |
* @param string|int $deduplicationLevel The minimum logging level for log records to be looked at for deduplication purposes |
| 67 |
* @param int $time The period (in seconds) during which duplicate entries should be suppressed after a given log is sent through |
| 68 |
* @param bool $bubble Whether the messages that are handled can bubble up the stack or not |
| 69 |
* |
| 70 |
* @phpstan-param Level|LevelName|LogLevel::* $deduplicationLevel |
| 71 |
*/ |
| 72 |
public function __construct(HandlerInterface $handler, ?string $deduplicationStore = null, $deduplicationLevel = Logger::ERROR, int $time = 60, bool $bubble = true) |
| 73 |
{ |
| 74 |
parent::__construct($handler, 0, Logger::DEBUG, $bubble, false); |
| 75 |
|
| 76 |
$this->deduplicationStore = $deduplicationStore === null ? sys_get_temp_dir() . '/monolog-dedup-' . substr(md5(__FILE__), 0, 20) .'.log' : $deduplicationStore; |
| 77 |
$this->deduplicationLevel = Logger::toMonologLevel($deduplicationLevel); |
| 78 |
$this->time = $time; |
| 79 |
} |
| 80 |
|
| 81 |
public function flush(): void |
| 82 |
{ |
| 83 |
if ($this->bufferSize === 0) { |
| 84 |
return; |
| 85 |
} |
| 86 |
|
| 87 |
$passthru = null; |
| 88 |
|
| 89 |
foreach ($this->buffer as $record) { |
| 90 |
if ($record['level'] >= $this->deduplicationLevel) { |
| 91 |
$passthru = $passthru || !$this->isDuplicate($record); |
| 92 |
if ($passthru) { |
| 93 |
$this->appendRecord($record); |
| 94 |
} |
| 95 |
} |
| 96 |
} |
| 97 |
|
| 98 |
// default of null is valid as well as if no record matches duplicationLevel we just pass through |
| 99 |
if ($passthru === true || $passthru === null) { |
| 100 |
$this->handler->handleBatch($this->buffer); |
| 101 |
} |
| 102 |
|
| 103 |
$this->clear(); |
| 104 |
|
| 105 |
if ($this->gc) { |
| 106 |
$this->collectLogs(); |
| 107 |
} |
| 108 |
} |
| 109 |
|
| 110 |
/** |
| 111 |
* @phpstan-param Record $record |
| 112 |
*/ |
| 113 |
private function isDuplicate(array $record): bool |
| 114 |
{ |
| 115 |
if (!file_exists($this->deduplicationStore)) { |
| 116 |
return false; |
| 117 |
} |
| 118 |
|
| 119 |
$store = file($this->deduplicationStore, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); |
| 120 |
if (!is_array($store)) { |
| 121 |
return false; |
| 122 |
} |
| 123 |
|
| 124 |
$yesterday = time() - 86400; |
| 125 |
$timestampValidity = $record['datetime']->getTimestamp() - $this->time; |
| 126 |
$expectedMessage = preg_replace('{[\r\n].*}', '', $record['message']); |
| 127 |
|
| 128 |
for ($i = count($store) - 1; $i >= 0; $i--) { |
| 129 |
list($timestamp, $level, $message) = explode(':', $store[$i], 3); |
| 130 |
|
| 131 |
if ($level === $record['level_name'] && $message === $expectedMessage && $timestamp > $timestampValidity) { |
| 132 |
return true; |
| 133 |
} |
| 134 |
|
| 135 |
if ($timestamp < $yesterday) { |
| 136 |
$this->gc = true; |
| 137 |
} |
| 138 |
} |
| 139 |
|
| 140 |
return false; |
| 141 |
} |
| 142 |
|
| 143 |
private function collectLogs(): void |
| 144 |
{ |
| 145 |
if (!file_exists($this->deduplicationStore)) { |
| 146 |
return; |
| 147 |
} |
| 148 |
|
| 149 |
$handle = fopen($this->deduplicationStore, 'rw+'); |
| 150 |
|
| 151 |
if (!$handle) { |
| 152 |
throw new \RuntimeException('Failed to open file for reading and writing: ' . $this->deduplicationStore); |
| 153 |
} |
| 154 |
|
| 155 |
flock($handle, LOCK_EX); |
| 156 |
$validLogs = []; |
| 157 |
|
| 158 |
$timestampValidity = time() - $this->time; |
| 159 |
|
| 160 |
while (!feof($handle)) { |
| 161 |
$log = fgets($handle); |
| 162 |
if ($log && substr($log, 0, 10) >= $timestampValidity) { |
| 163 |
$validLogs[] = $log; |
| 164 |
} |
| 165 |
} |
| 166 |
|
| 167 |
ftruncate($handle, 0); |
| 168 |
rewind($handle); |
| 169 |
foreach ($validLogs as $log) { |
| 170 |
fwrite($handle, $log); |
| 171 |
} |
| 172 |
|
| 173 |
flock($handle, LOCK_UN); |
| 174 |
fclose($handle); |
| 175 |
|
| 176 |
$this->gc = false; |
| 177 |
} |
| 178 |
|
| 179 |
/** |
| 180 |
* @phpstan-param Record $record |
| 181 |
*/ |
| 182 |
private function appendRecord(array $record): void |
| 183 |
{ |
| 184 |
file_put_contents($this->deduplicationStore, $record['datetime']->getTimestamp() . ':' . $record['level_name'] . ':' . preg_replace('{[\r\n].*}', '', $record['message']) . "\n", FILE_APPEND); |
| 185 |
} |
| 186 |
} |
| 187 |
|