1<?php declare(strict_types=1); 2 3/* 4 * This file is part of the Monolog package. 5 * 6 * (c) Jordi Boggiano <j.boggiano@seld.be> 7 * 8 * For the full copyright and license information, please view the LICENSE 9 * file that was distributed with this source code. 10 */ 11 12namespace Monolog\Handler; 13 14use Monolog\Logger; 15use 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 <j.boggiano@seld.be> 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 */ 41class 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