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