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 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
+ )