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