DeduplicationHandler.php 5.7 KB

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