logalert 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- logalert/__init__.py +356 -0
- logalert/_dedupe.py +274 -0
- logalert/_delivery.py +424 -0
- logalert/_fingerprint.py +230 -0
- logalert/_guard.py +126 -0
- logalert/_privacy.py +252 -0
- logalert/_queue.py +279 -0
- logalert/_state.py +62 -0
- logalert/_version.py +7 -0
- logalert/capture.py +728 -0
- logalert/cli.py +453 -0
- logalert/client.py +473 -0
- logalert/exceptions.py +91 -0
- logalert/handler.py +213 -0
- logalert/options.py +275 -0
- logalert/py.typed +0 -0
- logalert/render.py +548 -0
- logalert/scope.py +306 -0
- logalert/sinks/__init__.py +71 -0
- logalert/sinks/base.py +135 -0
- logalert/sinks/matrix.py +308 -0
- logalert/sinks/matrix_e2ee.py +539 -0
- logalert/sinks/stderr.py +88 -0
- logalert/sinks/telegram.py +359 -0
- logalert/transport/__init__.py +34 -0
- logalert/transport/base.py +205 -0
- logalert/transport/redis.py +525 -0
- logalert/types.py +430 -0
- logalert/worker.py +161 -0
- logalert-0.1.0.dist-info/METADATA +336 -0
- logalert-0.1.0.dist-info/RECORD +34 -0
- logalert-0.1.0.dist-info/WHEEL +4 -0
- logalert-0.1.0.dist-info/entry_points.txt +2 -0
- logalert-0.1.0.dist-info/licenses/LICENSE +21 -0
logalert/__init__.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Logalert
|
|
3
|
+
|
|
4
|
+
Capture errors and tracebacks, delivered to Telegram and Matrix
|
|
5
|
+
|
|
6
|
+
:copyright: (c) 2025 Aiko Sora
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import threading
|
|
11
|
+
from collections.abc import Mapping, Sequence
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ._state import get_client, is_initialized, set_client
|
|
15
|
+
from ._version import __version__
|
|
16
|
+
from .capture import HookHandle, install_hooks, install_loop_hooks
|
|
17
|
+
from .client import Client
|
|
18
|
+
from .exceptions import (
|
|
19
|
+
EncryptedRoomError,
|
|
20
|
+
InvalidOption,
|
|
21
|
+
LogAlertError,
|
|
22
|
+
MissingDependencyError,
|
|
23
|
+
SinkError,
|
|
24
|
+
SinkPermanentError,
|
|
25
|
+
SinkRateLimited,
|
|
26
|
+
SinkTransientError,
|
|
27
|
+
TransportError,
|
|
28
|
+
)
|
|
29
|
+
from .handler import AlertHandler, BreadcrumbHandler
|
|
30
|
+
from .options import Options
|
|
31
|
+
from .scope import Scope
|
|
32
|
+
from .sinks import MatrixSink, Sink, StderrSink, TelegramSink
|
|
33
|
+
from .types import Alert, Breadcrumb, EventLevel
|
|
34
|
+
|
|
35
|
+
_log = logging.getLogger('logalert.debug')
|
|
36
|
+
_lock = threading.RLock()
|
|
37
|
+
|
|
38
|
+
_client: Client | None = None
|
|
39
|
+
_hooks: HookHandle | None = None
|
|
40
|
+
_handler: AlertHandler | None = None
|
|
41
|
+
_breadcrumb_handler: BreadcrumbHandler | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def init(
|
|
45
|
+
*,
|
|
46
|
+
sinks: Sequence[str | Sink] = (),
|
|
47
|
+
telegram: Mapping[str, Any] | None = None,
|
|
48
|
+
matrix: Mapping[str, Any] | None = None,
|
|
49
|
+
redis_url: str | None = None,
|
|
50
|
+
project: str | None = None,
|
|
51
|
+
options: Options | None = None,
|
|
52
|
+
capture_logs: bool = True,
|
|
53
|
+
**kwargs: Any,
|
|
54
|
+
) -> Client:
|
|
55
|
+
"""
|
|
56
|
+
Configure logalert and switch automatic capture on
|
|
57
|
+
|
|
58
|
+
:param sinks: Sink names or sink objects to deliver with
|
|
59
|
+
:param telegram: The ``telegram`` config section
|
|
60
|
+
:param matrix: The ``matrix`` config section
|
|
61
|
+
:param redis_url: Redis URL for the durable queue
|
|
62
|
+
:param project: Project name, isolates the keys in Redis
|
|
63
|
+
:param options: A ready :class:`Options`, instead of the arguments above
|
|
64
|
+
:param capture_logs: Install the logging handler that captures records
|
|
65
|
+
:param kwargs: Extra option fields, passed on to :class:`Options`
|
|
66
|
+
|
|
67
|
+
:return Client:
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
global _client, _hooks, _handler, _breadcrumb_handler
|
|
71
|
+
|
|
72
|
+
with _lock:
|
|
73
|
+
_teardown()
|
|
74
|
+
|
|
75
|
+
if options is not None:
|
|
76
|
+
opts = options
|
|
77
|
+
|
|
78
|
+
else:
|
|
79
|
+
opts = Options(
|
|
80
|
+
sinks=sinks,
|
|
81
|
+
telegram=dict(telegram) if telegram else None,
|
|
82
|
+
matrix=dict(matrix) if matrix else None,
|
|
83
|
+
redis_url=redis_url,
|
|
84
|
+
project=project,
|
|
85
|
+
**kwargs,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if redis_url and not opts.redis_url:
|
|
89
|
+
opts.redis_url = redis_url
|
|
90
|
+
|
|
91
|
+
if project and not opts.project:
|
|
92
|
+
opts.project = project
|
|
93
|
+
|
|
94
|
+
client = Client(opts)
|
|
95
|
+
_client = client
|
|
96
|
+
set_client(client)
|
|
97
|
+
_hooks = install_hooks(client, opts)
|
|
98
|
+
|
|
99
|
+
if capture_logs:
|
|
100
|
+
_handler = AlertHandler(client=client)
|
|
101
|
+
logging.getLogger().addHandler(_handler)
|
|
102
|
+
|
|
103
|
+
if opts.breadcrumbs > 0 and opts.breadcrumb_level:
|
|
104
|
+
_breadcrumb_handler = BreadcrumbHandler(
|
|
105
|
+
level=opts.resolved_level_for_breadcrumb(), client=client
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
logging.getLogger().addHandler(_breadcrumb_handler)
|
|
109
|
+
|
|
110
|
+
if opts.default_integrations:
|
|
111
|
+
root = logging.getLogger()
|
|
112
|
+
|
|
113
|
+
if root.level > logging.ERROR or root.level == logging.NOTSET:
|
|
114
|
+
root.setLevel(logging.INFO)
|
|
115
|
+
|
|
116
|
+
_log.debug('logalert initialized: %r', client)
|
|
117
|
+
|
|
118
|
+
return client
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def alert(
|
|
122
|
+
exc_or_message: BaseException | str | None = None,
|
|
123
|
+
*,
|
|
124
|
+
level: EventLevel | None = None,
|
|
125
|
+
logger_name: str = '',
|
|
126
|
+
) -> str | None:
|
|
127
|
+
"""
|
|
128
|
+
Send an event by hand
|
|
129
|
+
|
|
130
|
+
:param exc_or_message: The exception, the message, or ``None``
|
|
131
|
+
:param level: The event level, ``error`` unless stated otherwise
|
|
132
|
+
:param logger_name: The logger name to record on the event
|
|
133
|
+
|
|
134
|
+
:return str | None:
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
client = _require_client()
|
|
138
|
+
|
|
139
|
+
if isinstance(exc_or_message, BaseException):
|
|
140
|
+
return client.capture(exc_or_message, level=level, logger_name=logger_name)
|
|
141
|
+
|
|
142
|
+
message = None if exc_or_message is None else str(exc_or_message)
|
|
143
|
+
|
|
144
|
+
return client.capture(None, message=message, level=level or 'error', logger_name=logger_name)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def flush(timeout: float | None = None) -> bool:
|
|
148
|
+
"""
|
|
149
|
+
Wait for everything buffered to be sent out
|
|
150
|
+
|
|
151
|
+
:param timeout: How long to wait, in seconds
|
|
152
|
+
|
|
153
|
+
:return bool:
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
client = get_client()
|
|
157
|
+
|
|
158
|
+
if client is None:
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
return client.flush(timeout)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def shutdown() -> None:
|
|
165
|
+
"""
|
|
166
|
+
Stop the library: drain the queue, remove hooks and handlers
|
|
167
|
+
|
|
168
|
+
:return None:
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
with _lock:
|
|
172
|
+
_teardown()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _teardown() -> None:
|
|
176
|
+
"""
|
|
177
|
+
Remove what `init` installed and close the client
|
|
178
|
+
|
|
179
|
+
:return None:
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
global _client, _hooks, _handler, _breadcrumb_handler
|
|
183
|
+
|
|
184
|
+
if _handler is not None:
|
|
185
|
+
logging.getLogger().removeHandler(_handler)
|
|
186
|
+
_handler = None
|
|
187
|
+
|
|
188
|
+
if _breadcrumb_handler is not None:
|
|
189
|
+
logging.getLogger().removeHandler(_breadcrumb_handler)
|
|
190
|
+
_breadcrumb_handler = None
|
|
191
|
+
|
|
192
|
+
if _hooks is not None:
|
|
193
|
+
_hooks.uninstall()
|
|
194
|
+
_hooks = None
|
|
195
|
+
|
|
196
|
+
if _client is not None:
|
|
197
|
+
try:
|
|
198
|
+
_client.flush()
|
|
199
|
+
|
|
200
|
+
finally:
|
|
201
|
+
_client.close()
|
|
202
|
+
|
|
203
|
+
_client = None
|
|
204
|
+
|
|
205
|
+
set_client(None)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _require_client() -> Client:
|
|
209
|
+
"""
|
|
210
|
+
Return the current client, refusing to go on without one
|
|
211
|
+
|
|
212
|
+
:return Client:
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
client = get_client()
|
|
216
|
+
|
|
217
|
+
if client is None:
|
|
218
|
+
raise LogAlertError('logalert is not initialized: call logalert.init(...) before sending')
|
|
219
|
+
|
|
220
|
+
return client
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def tag(key: str, value: str) -> None:
|
|
224
|
+
"""
|
|
225
|
+
Add a tag to every subsequent event
|
|
226
|
+
|
|
227
|
+
:param key: The tag name
|
|
228
|
+
:param value: The tag value
|
|
229
|
+
|
|
230
|
+
:return None:
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
client = get_client()
|
|
234
|
+
|
|
235
|
+
if client is not None:
|
|
236
|
+
client.scope.set_tag(key, value)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def context(name: str, value: Mapping[str, Any]) -> None:
|
|
240
|
+
"""
|
|
241
|
+
Add a context block. Secrets inside it are scrubbed before sending
|
|
242
|
+
|
|
243
|
+
:param name: The name of the context block
|
|
244
|
+
:param value: The context payload
|
|
245
|
+
|
|
246
|
+
:return None:
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
client = get_client()
|
|
250
|
+
|
|
251
|
+
if client is not None:
|
|
252
|
+
client.scope.set_context(name, value)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def user(value: Mapping[str, str] | None) -> None:
|
|
256
|
+
"""
|
|
257
|
+
Set the user. With ``send_default_pii=False`` only ``id`` goes out
|
|
258
|
+
|
|
259
|
+
:param value: The user fields, or ``None`` to clear the user
|
|
260
|
+
|
|
261
|
+
:return None:
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
client = get_client()
|
|
265
|
+
|
|
266
|
+
if client is not None:
|
|
267
|
+
client.scope.set_user(value)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def breadcrumb(message: str, **kwargs: Any) -> None:
|
|
271
|
+
"""
|
|
272
|
+
Record an event in the buffer that gets attached to the next alert
|
|
273
|
+
|
|
274
|
+
:param message: The breadcrumb message
|
|
275
|
+
:param kwargs: Extra breadcrumb fields
|
|
276
|
+
|
|
277
|
+
:return None:
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
client = get_client()
|
|
281
|
+
|
|
282
|
+
if client is not None:
|
|
283
|
+
client.add_breadcrumb(message, **kwargs)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def override_fingerprint(fingerprint: Sequence[str] | None) -> None:
|
|
287
|
+
"""
|
|
288
|
+
Set the grouping by hand, bypassing the fingerprint algorithm
|
|
289
|
+
|
|
290
|
+
:param fingerprint: The fingerprint to use, or ``None`` for the algorithm
|
|
291
|
+
|
|
292
|
+
:return None:
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
client = get_client()
|
|
296
|
+
|
|
297
|
+
if client is not None:
|
|
298
|
+
client.scope.set_fingerprint(tuple(fingerprint) if fingerprint else None)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def dict_config(**init_kwargs: Any) -> dict[str, Any]:
|
|
302
|
+
"""
|
|
303
|
+
A configuration fragment for ``logging.config.dictConfig``
|
|
304
|
+
|
|
305
|
+
:param init_kwargs: Accepted and ignored: the fragment does not depend on
|
|
306
|
+
them
|
|
307
|
+
|
|
308
|
+
:return dict[str, Any]:
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
handler: dict[str, Any] = {'class': 'logalert.AlertHandler', 'level': 'ERROR'}
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
'version': 1,
|
|
315
|
+
'handlers': {'logalert': handler},
|
|
316
|
+
'loggers': {'': {'handlers': ['logalert'], 'level': 'INFO'}},
|
|
317
|
+
'disable_existing_loggers': False,
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
__all__ = (
|
|
322
|
+
'MissingDependencyError',
|
|
323
|
+
'override_fingerprint',
|
|
324
|
+
'EncryptedRoomError',
|
|
325
|
+
'SinkPermanentError',
|
|
326
|
+
'SinkTransientError',
|
|
327
|
+
'install_loop_hooks',
|
|
328
|
+
'BreadcrumbHandler',
|
|
329
|
+
'SinkRateLimited',
|
|
330
|
+
'TransportError',
|
|
331
|
+
'is_initialized',
|
|
332
|
+
'InvalidOption',
|
|
333
|
+
'LogAlertError',
|
|
334
|
+
'AlertHandler',
|
|
335
|
+
'TelegramSink',
|
|
336
|
+
'__version__',
|
|
337
|
+
'dict_config',
|
|
338
|
+
'Breadcrumb',
|
|
339
|
+
'EventLevel',
|
|
340
|
+
'MatrixSink',
|
|
341
|
+
'StderrSink',
|
|
342
|
+
'breadcrumb',
|
|
343
|
+
'get_client',
|
|
344
|
+
'SinkError',
|
|
345
|
+
'shutdown',
|
|
346
|
+
'Options',
|
|
347
|
+
'context',
|
|
348
|
+
'Alert',
|
|
349
|
+
'Scope',
|
|
350
|
+
'alert',
|
|
351
|
+
'flush',
|
|
352
|
+
'Sink',
|
|
353
|
+
'init',
|
|
354
|
+
'user',
|
|
355
|
+
'tag',
|
|
356
|
+
)
|
logalert/_dedupe.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Logalert Dedupe
|
|
3
|
+
|
|
4
|
+
Suppression window for repeats
|
|
5
|
+
|
|
6
|
+
:copyright: (c) 2025 Aiko Sora
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from collections import OrderedDict
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import TYPE_CHECKING, NamedTuple
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from redis import Redis
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_DEDUPE_LUA = """
|
|
20
|
+
local is_new = redis.call('SET', KEYS[1], '1', 'NX', 'EX', ARGV[1])
|
|
21
|
+
local prev = tonumber(redis.call('GET', KEYS[2]) or '0')
|
|
22
|
+
if is_new then
|
|
23
|
+
-- The counter is given a longer life than the window marker on purpose. The summary
|
|
24
|
+
-- is assembled on the first event *after* the window, and it is assembled from this
|
|
25
|
+
-- key. With the marker's own TTL the two expired at the same instant, so the count was
|
|
26
|
+
-- always already gone by the time anyone read it and the summary never fired at all.
|
|
27
|
+
redis.call('SET', KEYS[2], '1', 'EX', ARGV[2])
|
|
28
|
+
return {0, prev}
|
|
29
|
+
end
|
|
30
|
+
local n = redis.call('INCR', KEYS[2])
|
|
31
|
+
return {1, n}
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DedupeResult(NamedTuple):
|
|
36
|
+
"""
|
|
37
|
+
Verdict of one dedup check
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
send: bool
|
|
41
|
+
occurrences: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _NoDedupe:
|
|
45
|
+
"""
|
|
46
|
+
Stub used when ``dedup_window=0``
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def check(self, fingerprint: str) -> DedupeResult:
|
|
50
|
+
"""
|
|
51
|
+
Always report the event as new
|
|
52
|
+
|
|
53
|
+
:param fingerprint: Fingerprint of the event
|
|
54
|
+
|
|
55
|
+
:return DedupeResult:
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
return DedupeResult(send=True, occurrences=1)
|
|
59
|
+
|
|
60
|
+
def close(self) -> None:
|
|
61
|
+
"""
|
|
62
|
+
Nothing to release
|
|
63
|
+
|
|
64
|
+
:return None:
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class InProcessDedupe:
|
|
71
|
+
"""
|
|
72
|
+
Suppression window in process memory
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
window: float,
|
|
79
|
+
maxsize: int,
|
|
80
|
+
clock: Callable[[], float] = time.monotonic,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""
|
|
83
|
+
Initialize the dedupe window
|
|
84
|
+
|
|
85
|
+
:param window: Suppression window in seconds
|
|
86
|
+
:param maxsize: Maximum number of fingerprints kept
|
|
87
|
+
:param clock: Monotonic clock used to stamp the windows
|
|
88
|
+
|
|
89
|
+
:return None:
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
self._window = window
|
|
93
|
+
self._maxsize = maxsize
|
|
94
|
+
self._clock = clock
|
|
95
|
+
self._lock = threading.Lock()
|
|
96
|
+
self._entries: OrderedDict[str, list[float]] = OrderedDict()
|
|
97
|
+
|
|
98
|
+
def check(self, fingerprint: str) -> DedupeResult:
|
|
99
|
+
"""
|
|
100
|
+
Count the fingerprint and decide whether the event goes out
|
|
101
|
+
|
|
102
|
+
:param fingerprint: Fingerprint of the event
|
|
103
|
+
|
|
104
|
+
:return DedupeResult:
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
if self._window <= 0:
|
|
108
|
+
return DedupeResult(send=True, occurrences=1)
|
|
109
|
+
|
|
110
|
+
now = self._clock()
|
|
111
|
+
|
|
112
|
+
with self._lock:
|
|
113
|
+
entry = self._entries.get(fingerprint)
|
|
114
|
+
|
|
115
|
+
if entry is None:
|
|
116
|
+
self._remember(fingerprint, now)
|
|
117
|
+
|
|
118
|
+
return DedupeResult(send=True, occurrences=1)
|
|
119
|
+
|
|
120
|
+
window_start, count = entry
|
|
121
|
+
|
|
122
|
+
if now - window_start < self._window:
|
|
123
|
+
entry[1] = count + 1
|
|
124
|
+
self._entries.move_to_end(fingerprint)
|
|
125
|
+
|
|
126
|
+
return DedupeResult(send=False, occurrences=int(count) + 1)
|
|
127
|
+
|
|
128
|
+
previous = int(count)
|
|
129
|
+
self._remember(fingerprint, now)
|
|
130
|
+
|
|
131
|
+
return DedupeResult(send=True, occurrences=previous)
|
|
132
|
+
|
|
133
|
+
def _remember(self, fingerprint: str, now: float) -> None:
|
|
134
|
+
"""
|
|
135
|
+
Open a window for the fingerprint and drop the oldest entries
|
|
136
|
+
|
|
137
|
+
:param fingerprint: Fingerprint of the event
|
|
138
|
+
:param now: Current time, as given by the clock
|
|
139
|
+
|
|
140
|
+
:return None:
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
self._entries[fingerprint] = [now, 1]
|
|
144
|
+
self._entries.move_to_end(fingerprint)
|
|
145
|
+
|
|
146
|
+
while len(self._entries) > self._maxsize:
|
|
147
|
+
self._entries.popitem(last=False)
|
|
148
|
+
|
|
149
|
+
def close(self) -> None:
|
|
150
|
+
"""
|
|
151
|
+
Forget every remembered fingerprint
|
|
152
|
+
|
|
153
|
+
:return None:
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
with self._lock:
|
|
157
|
+
self._entries.clear()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class RedisDedupe:
|
|
161
|
+
"""
|
|
162
|
+
Suppression window shared by all workers
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
def __init__(self, client: 'Redis', *, project: str, window: float) -> None:
|
|
166
|
+
"""
|
|
167
|
+
Register the dedup script with the Redis client
|
|
168
|
+
|
|
169
|
+
:param client: Connected Redis client
|
|
170
|
+
:param project: Project name the keys are namespaced with
|
|
171
|
+
:param window: Suppression window in seconds
|
|
172
|
+
|
|
173
|
+
:return None:
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
self._client = client
|
|
177
|
+
self._project = project
|
|
178
|
+
self._window = window
|
|
179
|
+
self._script = client.register_script(_DEDUPE_LUA)
|
|
180
|
+
|
|
181
|
+
def key_seen(self, fingerprint: str) -> str:
|
|
182
|
+
"""
|
|
183
|
+
Redis key holding the seen marker of a fingerprint
|
|
184
|
+
|
|
185
|
+
:param fingerprint: Fingerprint of the event
|
|
186
|
+
|
|
187
|
+
:return str:
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
return f'logalert:{self._project}:seen:{fingerprint}'
|
|
191
|
+
|
|
192
|
+
def key_count(self, fingerprint: str) -> str:
|
|
193
|
+
"""
|
|
194
|
+
Redis key holding the recurrence counter of a fingerprint
|
|
195
|
+
|
|
196
|
+
:param fingerprint: Fingerprint of the event
|
|
197
|
+
|
|
198
|
+
:return str:
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
return f'logalert:{self._project}:count:{fingerprint}'
|
|
202
|
+
|
|
203
|
+
def check(self, fingerprint: str) -> DedupeResult:
|
|
204
|
+
"""
|
|
205
|
+
Run the dedup script and translate its answer
|
|
206
|
+
|
|
207
|
+
:param fingerprint: Fingerprint of the event
|
|
208
|
+
|
|
209
|
+
:return DedupeResult:
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
if self._window <= 0:
|
|
213
|
+
return DedupeResult(send=True, occurrences=1)
|
|
214
|
+
|
|
215
|
+
seconds = max(1, int(self._window))
|
|
216
|
+
suppressed, count = self._script(
|
|
217
|
+
keys=[self.key_seen(fingerprint), self.key_count(fingerprint)],
|
|
218
|
+
# Twice the window for the counter: the window marker has to be gone
|
|
219
|
+
# before the next event opens a new one, and the counter has to
|
|
220
|
+
# still be there to supply the summary for the window that just
|
|
221
|
+
# closed.
|
|
222
|
+
args=[seconds, seconds * 2],
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
count = int(count)
|
|
226
|
+
|
|
227
|
+
if int(suppressed):
|
|
228
|
+
return DedupeResult(send=False, occurrences=count)
|
|
229
|
+
|
|
230
|
+
return DedupeResult(send=True, occurrences=max(count, 1))
|
|
231
|
+
|
|
232
|
+
def close(self) -> None:
|
|
233
|
+
"""
|
|
234
|
+
Nothing to release - the Redis keys expire on their own
|
|
235
|
+
|
|
236
|
+
:return None:
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def build_dedupe(
|
|
243
|
+
*,
|
|
244
|
+
window: float,
|
|
245
|
+
maxsize: int,
|
|
246
|
+
redis_client: 'Redis | None' = None,
|
|
247
|
+
project: str | None = None,
|
|
248
|
+
) -> InProcessDedupe | RedisDedupe | _NoDedupe:
|
|
249
|
+
"""
|
|
250
|
+
Pick the implementation that matches the configuration
|
|
251
|
+
|
|
252
|
+
:param window: Suppression window in seconds; 0 turns dedup off entirely
|
|
253
|
+
:param maxsize: How many fingerprints the in-process window keeps
|
|
254
|
+
:param redis_client: Redis client, when a shared window is available
|
|
255
|
+
:param project: Project name the Redis keys are namespaced with
|
|
256
|
+
|
|
257
|
+
:return InProcessDedupe | RedisDedupe | _NoDedupe:
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
if window <= 0:
|
|
261
|
+
return _NoDedupe()
|
|
262
|
+
|
|
263
|
+
if redis_client is not None and project:
|
|
264
|
+
return RedisDedupe(redis_client, project=project, window=window)
|
|
265
|
+
|
|
266
|
+
return InProcessDedupe(window=window, maxsize=maxsize)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
__all__ = (
|
|
270
|
+
'InProcessDedupe',
|
|
271
|
+
'DedupeResult',
|
|
272
|
+
'build_dedupe',
|
|
273
|
+
'RedisDedupe',
|
|
274
|
+
)
|