allstak 0.1.2__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.
- allstak/__init__.py +410 -0
- allstak/buffer.py +151 -0
- allstak/client.py +738 -0
- allstak/config.py +378 -0
- allstak/excepthook.py +198 -0
- allstak/integrations/__init__.py +36 -0
- allstak/integrations/auto_breadcrumbs.py +116 -0
- allstak/integrations/celery.py +170 -0
- allstak/integrations/django.py +174 -0
- allstak/integrations/fastapi.py +210 -0
- allstak/integrations/flask.py +173 -0
- allstak/integrations/httpx.py +170 -0
- allstak/integrations/logging.py +174 -0
- allstak/integrations/requests.py +126 -0
- allstak/integrations/sqlalchemy.py +110 -0
- allstak/models/__init__.py +18 -0
- allstak/models/breadcrumb.py +51 -0
- allstak/models/errors.py +165 -0
- allstak/models/heartbeat.py +53 -0
- allstak/models/http_requests.py +129 -0
- allstak/models/logs.py +80 -0
- allstak/models/replay.py +91 -0
- allstak/modules/__init__.py +1 -0
- allstak/modules/cron.py +178 -0
- allstak/modules/database.py +301 -0
- allstak/modules/errors.py +295 -0
- allstak/modules/flags.py +219 -0
- allstak/modules/http_monitor.py +251 -0
- allstak/modules/logs.py +168 -0
- allstak/modules/replay.py +205 -0
- allstak/modules/tracing.py +326 -0
- allstak/propagation.py +101 -0
- allstak/sanitize.py +354 -0
- allstak/session.py +247 -0
- allstak/spool.py +368 -0
- allstak/transport.py +318 -0
- allstak-0.1.2.dist-info/METADATA +159 -0
- allstak-0.1.2.dist-info/RECORD +40 -0
- allstak-0.1.2.dist-info/WHEEL +4 -0
- allstak-0.1.2.dist-info/licenses/LICENSE +21 -0
allstak/__init__.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AllStak Python SDK
|
|
3
|
+
|
|
4
|
+
Observability, error tracking, logging, HTTP monitoring,
|
|
5
|
+
session replay, cron monitoring, and feature flags for Python applications.
|
|
6
|
+
|
|
7
|
+
Quick start::
|
|
8
|
+
|
|
9
|
+
import allstak
|
|
10
|
+
|
|
11
|
+
allstak.init(api_key="ask_live_...", host="http://localhost:8080")
|
|
12
|
+
|
|
13
|
+
# Capture exceptions
|
|
14
|
+
try:
|
|
15
|
+
risky()
|
|
16
|
+
except Exception as e:
|
|
17
|
+
allstak.capture_exception(e)
|
|
18
|
+
|
|
19
|
+
# Logs
|
|
20
|
+
allstak.log.info("Hello from AllStak!")
|
|
21
|
+
|
|
22
|
+
# HTTP monitoring
|
|
23
|
+
allstak.http.record(
|
|
24
|
+
direction="outbound",
|
|
25
|
+
method="GET",
|
|
26
|
+
host="api.example.com",
|
|
27
|
+
path="/v1/data",
|
|
28
|
+
status_code=200,
|
|
29
|
+
duration_ms=142,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Cron jobs
|
|
33
|
+
with allstak.cron.job("my-job-slug"):
|
|
34
|
+
run_job()
|
|
35
|
+
|
|
36
|
+
# Flush on shutdown
|
|
37
|
+
allstak.flush()
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
from typing import Any, Dict, List, Optional
|
|
43
|
+
|
|
44
|
+
from .client import (
|
|
45
|
+
AllStakClient,
|
|
46
|
+
_require_client,
|
|
47
|
+
get_client,
|
|
48
|
+
init,
|
|
49
|
+
)
|
|
50
|
+
from .config import AllStakConfig
|
|
51
|
+
from .spool import EventSpool
|
|
52
|
+
from .models.breadcrumb import Breadcrumb
|
|
53
|
+
from .models.errors import RequestContext, UserContext
|
|
54
|
+
from .models.logs import LOG_LEVELS
|
|
55
|
+
from .models.http_requests import HttpRequestItem
|
|
56
|
+
from .models.replay import ReplayEvent, ReplayPayload
|
|
57
|
+
from .models.heartbeat import HeartbeatPayload
|
|
58
|
+
from .modules.tracing import Span, TracingModule
|
|
59
|
+
|
|
60
|
+
__version__ = "0.1.2"
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
"__version__",
|
|
64
|
+
# Init
|
|
65
|
+
"init",
|
|
66
|
+
"get_client",
|
|
67
|
+
# Config
|
|
68
|
+
"AllStakConfig",
|
|
69
|
+
"AllStakClient",
|
|
70
|
+
# Offline persistence
|
|
71
|
+
"EventSpool",
|
|
72
|
+
# Models
|
|
73
|
+
"Breadcrumb",
|
|
74
|
+
"UserContext",
|
|
75
|
+
"RequestContext",
|
|
76
|
+
"HttpRequestItem",
|
|
77
|
+
"ReplayEvent",
|
|
78
|
+
"ReplayPayload",
|
|
79
|
+
"HeartbeatPayload",
|
|
80
|
+
"Span",
|
|
81
|
+
"TracingModule",
|
|
82
|
+
# Module-level shortcuts
|
|
83
|
+
"capture_exception",
|
|
84
|
+
"capture_error",
|
|
85
|
+
"add_breadcrumb",
|
|
86
|
+
"clear_breadcrumbs",
|
|
87
|
+
"set_user",
|
|
88
|
+
"clear_user",
|
|
89
|
+
"flush",
|
|
90
|
+
"log",
|
|
91
|
+
"http",
|
|
92
|
+
"replay",
|
|
93
|
+
"cron",
|
|
94
|
+
"flags",
|
|
95
|
+
"tracing",
|
|
96
|
+
"shutdown",
|
|
97
|
+
"database",
|
|
98
|
+
"start_span",
|
|
99
|
+
"get_trace_id",
|
|
100
|
+
"set_trace_id",
|
|
101
|
+
"get_current_span_id",
|
|
102
|
+
"reset_trace",
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Module-level proxy helpers
|
|
108
|
+
# These delegate to the singleton client.
|
|
109
|
+
# They are no-ops if init() has not been called — they never raise.
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def capture_exception(
|
|
113
|
+
exc: BaseException,
|
|
114
|
+
*,
|
|
115
|
+
level: str = "error",
|
|
116
|
+
environment: Optional[str] = None,
|
|
117
|
+
release: Optional[str] = None,
|
|
118
|
+
session_id: Optional[str] = None,
|
|
119
|
+
user: Optional[UserContext] = None,
|
|
120
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
121
|
+
) -> Optional[str]:
|
|
122
|
+
"""
|
|
123
|
+
Capture a Python exception and send it to AllStak.
|
|
124
|
+
|
|
125
|
+
Returns the event ID on success, None on failure. Never raises.
|
|
126
|
+
No-op if :func:`init` has not been called.
|
|
127
|
+
"""
|
|
128
|
+
client = get_client()
|
|
129
|
+
if client is None:
|
|
130
|
+
return None
|
|
131
|
+
return client.capture_exception(
|
|
132
|
+
exc,
|
|
133
|
+
level=level,
|
|
134
|
+
environment=environment,
|
|
135
|
+
release=release,
|
|
136
|
+
session_id=session_id,
|
|
137
|
+
user=user,
|
|
138
|
+
metadata=metadata,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def capture_error(
|
|
143
|
+
exception_class: str,
|
|
144
|
+
message: str,
|
|
145
|
+
*,
|
|
146
|
+
stack_trace: Optional[List[str]] = None,
|
|
147
|
+
level: str = "error",
|
|
148
|
+
environment: Optional[str] = None,
|
|
149
|
+
release: Optional[str] = None,
|
|
150
|
+
session_id: Optional[str] = None,
|
|
151
|
+
user: Optional[UserContext] = None,
|
|
152
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
153
|
+
) -> Optional[str]:
|
|
154
|
+
"""
|
|
155
|
+
Capture an error by name + message without a Python exception object.
|
|
156
|
+
|
|
157
|
+
Returns the event ID on success, None on failure. Never raises.
|
|
158
|
+
No-op if :func:`init` has not been called.
|
|
159
|
+
"""
|
|
160
|
+
client = get_client()
|
|
161
|
+
if client is None:
|
|
162
|
+
return None
|
|
163
|
+
return client.capture_error(
|
|
164
|
+
exception_class,
|
|
165
|
+
message,
|
|
166
|
+
stack_trace=stack_trace,
|
|
167
|
+
level=level,
|
|
168
|
+
environment=environment,
|
|
169
|
+
release=release,
|
|
170
|
+
session_id=session_id,
|
|
171
|
+
user=user,
|
|
172
|
+
metadata=metadata,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def add_breadcrumb(
|
|
177
|
+
type: str,
|
|
178
|
+
message: str,
|
|
179
|
+
level: Optional[str] = None,
|
|
180
|
+
data: Optional[Dict[str, Any]] = None,
|
|
181
|
+
) -> None:
|
|
182
|
+
"""
|
|
183
|
+
Add a breadcrumb to the internal buffer.
|
|
184
|
+
|
|
185
|
+
Breadcrumbs are attached to the next captured error and then cleared.
|
|
186
|
+
No-op if :func:`init` has not been called.
|
|
187
|
+
"""
|
|
188
|
+
client = get_client()
|
|
189
|
+
if client is not None:
|
|
190
|
+
client.add_breadcrumb(type, message, level, data)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def clear_breadcrumbs() -> None:
|
|
194
|
+
"""Clear all breadcrumbs from the buffer."""
|
|
195
|
+
client = get_client()
|
|
196
|
+
if client is not None:
|
|
197
|
+
client.clear_breadcrumbs()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def set_user(
|
|
201
|
+
user_id: Optional[str] = None,
|
|
202
|
+
email: Optional[str] = None,
|
|
203
|
+
ip: Optional[str] = None,
|
|
204
|
+
) -> None:
|
|
205
|
+
"""Set the current user context for subsequent error events."""
|
|
206
|
+
client = get_client()
|
|
207
|
+
if client:
|
|
208
|
+
client.set_user(user_id=user_id, email=email, ip=ip)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def clear_user() -> None:
|
|
212
|
+
"""Clear the current user context."""
|
|
213
|
+
client = get_client()
|
|
214
|
+
if client:
|
|
215
|
+
client.clear_user()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def flush() -> None:
|
|
219
|
+
"""Flush all pending events synchronously."""
|
|
220
|
+
client = get_client()
|
|
221
|
+
if client:
|
|
222
|
+
client.flush()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def shutdown() -> None:
|
|
226
|
+
"""Flush all pending events and shut down background threads."""
|
|
227
|
+
client = get_client()
|
|
228
|
+
if client:
|
|
229
|
+
client._shutdown()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
# Module-level proxy properties
|
|
234
|
+
# These return the module objects from the singleton client.
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
class _LogProxy:
|
|
238
|
+
"""Proxy to the LogModule on the singleton client."""
|
|
239
|
+
|
|
240
|
+
def __getattr__(self, name: str) -> Any:
|
|
241
|
+
client = get_client()
|
|
242
|
+
if client is None:
|
|
243
|
+
# Return a no-op callable
|
|
244
|
+
def _noop(*args: Any, **kwargs: Any) -> None:
|
|
245
|
+
pass
|
|
246
|
+
return _noop
|
|
247
|
+
return getattr(client.log, name)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class _HttpProxy:
|
|
251
|
+
"""Proxy to the HttpMonitorModule on the singleton client."""
|
|
252
|
+
|
|
253
|
+
def __getattr__(self, name: str) -> Any:
|
|
254
|
+
client = get_client()
|
|
255
|
+
if client is None:
|
|
256
|
+
def _noop(*args: Any, **kwargs: Any) -> None:
|
|
257
|
+
pass
|
|
258
|
+
return _noop
|
|
259
|
+
return getattr(client.http, name)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class _ReplayProxy:
|
|
263
|
+
"""Proxy to the ReplayModule on the singleton client."""
|
|
264
|
+
|
|
265
|
+
def __getattr__(self, name: str) -> Any:
|
|
266
|
+
client = get_client()
|
|
267
|
+
if client is None:
|
|
268
|
+
def _noop(*args: Any, **kwargs: Any) -> None:
|
|
269
|
+
pass
|
|
270
|
+
return _noop
|
|
271
|
+
return getattr(client.replay, name)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class _CronProxy:
|
|
275
|
+
"""Proxy to the CronModule on the singleton client."""
|
|
276
|
+
|
|
277
|
+
def __getattr__(self, name: str) -> Any:
|
|
278
|
+
client = get_client()
|
|
279
|
+
if client is None:
|
|
280
|
+
if name == "job":
|
|
281
|
+
# Return a no-op context manager so `with allstak.cron.job(...):`
|
|
282
|
+
# keeps working when the SDK is not initialized.
|
|
283
|
+
from contextlib import contextmanager
|
|
284
|
+
|
|
285
|
+
@contextmanager
|
|
286
|
+
def _noop_job(*args: Any, **kwargs: Any) -> Any:
|
|
287
|
+
yield None
|
|
288
|
+
return _noop_job
|
|
289
|
+
|
|
290
|
+
def _noop(*args: Any, **kwargs: Any) -> None:
|
|
291
|
+
return None
|
|
292
|
+
return _noop
|
|
293
|
+
return getattr(client.cron, name)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class _FlagsProxy:
|
|
297
|
+
"""Proxy to the FeatureFlagModule on the singleton client."""
|
|
298
|
+
|
|
299
|
+
def __getattr__(self, name: str) -> Any:
|
|
300
|
+
client = get_client()
|
|
301
|
+
if client is None:
|
|
302
|
+
def _noop(*args: Any, **kwargs: Any) -> None:
|
|
303
|
+
pass
|
|
304
|
+
return _noop
|
|
305
|
+
return getattr(client.flags, name)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class _TracingProxy:
|
|
309
|
+
"""Proxy to the TracingModule on the singleton client."""
|
|
310
|
+
|
|
311
|
+
def __getattr__(self, name: str) -> Any:
|
|
312
|
+
client = get_client()
|
|
313
|
+
if client is None:
|
|
314
|
+
def _noop(*args: Any, **kwargs: Any) -> None:
|
|
315
|
+
pass
|
|
316
|
+
return _noop
|
|
317
|
+
return getattr(client.tracing, name)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class _DatabaseProxy:
|
|
321
|
+
"""Proxy to the DatabaseModule on the singleton client."""
|
|
322
|
+
|
|
323
|
+
def __getattr__(self, name: str) -> Any:
|
|
324
|
+
client = get_client()
|
|
325
|
+
if client is None:
|
|
326
|
+
def _noop(*args: Any, **kwargs: Any) -> None:
|
|
327
|
+
pass
|
|
328
|
+
return _noop
|
|
329
|
+
return getattr(client.database, name)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# Singleton proxy instances
|
|
333
|
+
log = _LogProxy()
|
|
334
|
+
http = _HttpProxy()
|
|
335
|
+
replay = _ReplayProxy()
|
|
336
|
+
cron = _CronProxy()
|
|
337
|
+
flags = _FlagsProxy()
|
|
338
|
+
tracing = _TracingProxy()
|
|
339
|
+
database = _DatabaseProxy()
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
# Module-level tracing shortcuts
|
|
344
|
+
# ---------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
def start_span(
|
|
347
|
+
operation: str,
|
|
348
|
+
*,
|
|
349
|
+
description: str = "",
|
|
350
|
+
tags: Optional[Dict[str, Any]] = None,
|
|
351
|
+
) -> "Span":
|
|
352
|
+
"""
|
|
353
|
+
Start a new distributed tracing span.
|
|
354
|
+
|
|
355
|
+
Can be used as a context manager::
|
|
356
|
+
|
|
357
|
+
with allstak.start_span("db.query") as span:
|
|
358
|
+
span.set_tag("db.type", "postgresql")
|
|
359
|
+
result = db.execute(query)
|
|
360
|
+
|
|
361
|
+
No-op if :func:`init` has not been called (returns a span that
|
|
362
|
+
finishes silently).
|
|
363
|
+
"""
|
|
364
|
+
client = get_client()
|
|
365
|
+
if client is None:
|
|
366
|
+
# Return a dummy span that does nothing
|
|
367
|
+
from .modules.tracing import Span as _Span
|
|
368
|
+
return _Span(
|
|
369
|
+
trace_id="",
|
|
370
|
+
span_id="",
|
|
371
|
+
parent_span_id="",
|
|
372
|
+
operation=operation,
|
|
373
|
+
description=description,
|
|
374
|
+
service="",
|
|
375
|
+
environment="",
|
|
376
|
+
tags=tags or {},
|
|
377
|
+
start_time_millis=0,
|
|
378
|
+
on_finish=lambda s: None,
|
|
379
|
+
)
|
|
380
|
+
return client.start_span(operation, description=description, tags=tags)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def get_trace_id() -> str:
|
|
384
|
+
"""Get the current trace ID (creates one if none exists)."""
|
|
385
|
+
client = get_client()
|
|
386
|
+
if client is None:
|
|
387
|
+
return ""
|
|
388
|
+
return client.get_trace_id()
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def set_trace_id(trace_id: str) -> None:
|
|
392
|
+
"""Set the trace ID explicitly (e.g. from an incoming request header)."""
|
|
393
|
+
client = get_client()
|
|
394
|
+
if client is not None:
|
|
395
|
+
client.set_trace_id(trace_id)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def get_current_span_id() -> Optional[str]:
|
|
399
|
+
"""Get the current active span ID, or None."""
|
|
400
|
+
client = get_client()
|
|
401
|
+
if client is None:
|
|
402
|
+
return None
|
|
403
|
+
return client.get_current_span_id()
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def reset_trace() -> None:
|
|
407
|
+
"""Reset trace context (trace ID and span stack)."""
|
|
408
|
+
client = get_client()
|
|
409
|
+
if client is not None:
|
|
410
|
+
client.reset_trace()
|
allstak/buffer.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bounded ring buffer with background flush timer.
|
|
3
|
+
|
|
4
|
+
Contract from SDK guidelines:
|
|
5
|
+
- Default size: 500 items per feature
|
|
6
|
+
- Eviction: oldest item dropped when full (tail-drop)
|
|
7
|
+
- Flush triggers: timer (5s default), 80% capacity, explicit flush(), shutdown
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import threading
|
|
14
|
+
from collections import deque
|
|
15
|
+
from typing import Any, Callable, Deque, Generic, List, Optional, TypeVar
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("allstak.sdk")
|
|
18
|
+
|
|
19
|
+
T = TypeVar("T")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RingBuffer(Generic[T]):
|
|
23
|
+
"""
|
|
24
|
+
Thread-safe bounded FIFO buffer.
|
|
25
|
+
|
|
26
|
+
When ``maxsize`` items are held and a new item is pushed,
|
|
27
|
+
the **oldest** item is silently dropped (tail-drop policy).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, maxsize: int = 500) -> None:
|
|
31
|
+
self._maxsize = maxsize
|
|
32
|
+
self._buf: Deque[T] = deque()
|
|
33
|
+
self._lock = threading.Lock()
|
|
34
|
+
self._overflow_warned = False
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def capacity(self) -> int:
|
|
38
|
+
return self._maxsize
|
|
39
|
+
|
|
40
|
+
def push(self, item: T) -> None:
|
|
41
|
+
with self._lock:
|
|
42
|
+
if len(self._buf) >= self._maxsize:
|
|
43
|
+
self._buf.popleft() # drop oldest
|
|
44
|
+
if not self._overflow_warned:
|
|
45
|
+
logger.warning(
|
|
46
|
+
"[AllStak] Buffer is full (%d items); oldest events are being dropped. "
|
|
47
|
+
"Increase buffer_size or reduce flush_interval_ms.",
|
|
48
|
+
self._maxsize,
|
|
49
|
+
)
|
|
50
|
+
self._overflow_warned = True
|
|
51
|
+
else:
|
|
52
|
+
self._overflow_warned = False
|
|
53
|
+
self._buf.append(item)
|
|
54
|
+
|
|
55
|
+
def drain(self) -> List[T]:
|
|
56
|
+
"""Remove and return all current items atomically."""
|
|
57
|
+
with self._lock:
|
|
58
|
+
items = list(self._buf)
|
|
59
|
+
self._buf.clear()
|
|
60
|
+
return items
|
|
61
|
+
|
|
62
|
+
def peek(self) -> List[T]:
|
|
63
|
+
"""Return all items without removing them."""
|
|
64
|
+
with self._lock:
|
|
65
|
+
return list(self._buf)
|
|
66
|
+
|
|
67
|
+
def __len__(self) -> int:
|
|
68
|
+
with self._lock:
|
|
69
|
+
return len(self._buf)
|
|
70
|
+
|
|
71
|
+
def is_nearly_full(self, threshold: float = 0.8) -> bool:
|
|
72
|
+
with self._lock:
|
|
73
|
+
return len(self._buf) >= self._maxsize * threshold
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class FlushBuffer(Generic[T]):
|
|
77
|
+
"""
|
|
78
|
+
Ring buffer with a background timer thread that periodically
|
|
79
|
+
drains the buffer and calls ``flush_fn``.
|
|
80
|
+
|
|
81
|
+
Flush is triggered when:
|
|
82
|
+
- The timer fires (every ``interval_ms`` ms)
|
|
83
|
+
- ``len(buffer) >= maxsize * 0.8``
|
|
84
|
+
- ``flush()`` is called explicitly
|
|
85
|
+
- ``shutdown()`` is called (best-effort drain)
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
flush_fn: Callable[[List[T]], None],
|
|
91
|
+
maxsize: int = 500,
|
|
92
|
+
interval_ms: int = 5_000,
|
|
93
|
+
name: str = "allstak-flush",
|
|
94
|
+
) -> None:
|
|
95
|
+
self._flush_fn = flush_fn
|
|
96
|
+
self._buffer: RingBuffer[T] = RingBuffer(maxsize)
|
|
97
|
+
self._interval_s = interval_ms / 1_000
|
|
98
|
+
self._name = name
|
|
99
|
+
self._stop_event = threading.Event()
|
|
100
|
+
self._flush_lock = threading.Lock()
|
|
101
|
+
self._thread: Optional[threading.Thread] = None
|
|
102
|
+
|
|
103
|
+
def start(self) -> None:
|
|
104
|
+
"""Start the background flush timer thread."""
|
|
105
|
+
if self._thread and self._thread.is_alive():
|
|
106
|
+
return
|
|
107
|
+
self._stop_event.clear()
|
|
108
|
+
self._thread = threading.Thread(
|
|
109
|
+
target=self._run_timer,
|
|
110
|
+
name=self._name,
|
|
111
|
+
daemon=True, # doesn't prevent interpreter exit
|
|
112
|
+
)
|
|
113
|
+
self._thread.start()
|
|
114
|
+
|
|
115
|
+
def stop(self) -> None:
|
|
116
|
+
"""Stop the timer and do a best-effort final flush (5s deadline)."""
|
|
117
|
+
self._stop_event.set()
|
|
118
|
+
self.flush() # drain remaining
|
|
119
|
+
if self._thread:
|
|
120
|
+
self._thread.join(timeout=5.0)
|
|
121
|
+
|
|
122
|
+
def push(self, item: T) -> None:
|
|
123
|
+
self._buffer.push(item)
|
|
124
|
+
if self._buffer.is_nearly_full():
|
|
125
|
+
self._trigger_flush()
|
|
126
|
+
|
|
127
|
+
def flush(self) -> None:
|
|
128
|
+
"""Synchronously drain the buffer and call flush_fn."""
|
|
129
|
+
self._trigger_flush()
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def _run_timer(self) -> None:
|
|
134
|
+
while not self._stop_event.wait(timeout=self._interval_s):
|
|
135
|
+
self._trigger_flush()
|
|
136
|
+
|
|
137
|
+
def _trigger_flush(self) -> None:
|
|
138
|
+
items = self._buffer.drain()
|
|
139
|
+
if not items:
|
|
140
|
+
return
|
|
141
|
+
# Serialize flushes so we don't double-send
|
|
142
|
+
with self._flush_lock:
|
|
143
|
+
try:
|
|
144
|
+
self._flush_fn(items)
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
logger.debug(
|
|
147
|
+
"[AllStak] Flush error for %s: %s", self._name, exc
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def __len__(self) -> int:
|
|
151
|
+
return len(self._buffer)
|