bufferlog 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.
@@ -0,0 +1,118 @@
1
+ """
2
+ BufferLog — Telemetry Reporter
3
+
4
+ Background thread that pushes SDK metrics to the control plane.
5
+ Sends counters only — never log data.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import threading
12
+ import urllib.request
13
+ from typing import Any, Callable, Dict, Optional
14
+ import platform
15
+
16
+
17
+ class TelemetryReporter:
18
+ """Periodically sends metric snapshots to the BufferLog Control Plane."""
19
+
20
+ def __init__(
21
+ self,
22
+ url: str,
23
+ api_key: str,
24
+ interval_s: float = 60.0,
25
+ metrics_provider: Optional[Callable[[], Dict[str, Any]]] = None,
26
+ on_error: Optional[Callable[[Exception], None]] = None,
27
+ ) -> None:
28
+ base = url.rstrip("/")
29
+ self._url = f"{base}/api/v1/telemetry"
30
+ self._api_key = api_key
31
+ self._interval = interval_s
32
+ self._metrics_provider = metrics_provider
33
+ self._on_error = on_error
34
+
35
+ self._timer: Optional[threading.Timer] = None
36
+ self._running = False
37
+ self._send_count = 0
38
+ self._error_count = 0
39
+ self._lock = threading.Lock()
40
+
41
+ def start(self) -> None:
42
+ if self._running:
43
+ return
44
+ self._running = True
45
+ self._schedule()
46
+
47
+ def stop(self) -> None:
48
+ self._running = False
49
+ if self._timer:
50
+ self._timer.cancel()
51
+ self._timer = None
52
+
53
+ def send(self) -> bool:
54
+ """Send a single telemetry snapshot."""
55
+ return self._do_send()
56
+
57
+ def _schedule(self) -> None:
58
+ if not self._running:
59
+ return
60
+ self._timer = threading.Timer(self._interval, self._tick)
61
+ self._timer.daemon = True
62
+ self._timer.start()
63
+
64
+ def _tick(self) -> None:
65
+ self._do_send()
66
+ self._schedule()
67
+
68
+ def _do_send(self) -> bool:
69
+ if not self._metrics_provider:
70
+ return False
71
+
72
+ try:
73
+ metrics = self._metrics_provider()
74
+ payload = {
75
+ "sdkVersion": "0.1.0",
76
+ "runtime": "python",
77
+ "runtimeVersion": platform.python_version(),
78
+ "metrics": metrics,
79
+ }
80
+
81
+ data = json.dumps(payload).encode("utf-8")
82
+ req = urllib.request.Request(
83
+ self._url,
84
+ data=data,
85
+ headers={
86
+ "Content-Type": "application/json",
87
+ "Authorization": f"Bearer {self._api_key}",
88
+ "User-Agent": f"bufferlog-sdk-python/0.1.0 (python/{platform.python_version()})",
89
+ },
90
+ method="POST",
91
+ )
92
+
93
+ with urllib.request.urlopen(req, timeout=10) as resp:
94
+ resp.read()
95
+
96
+ with self._lock:
97
+ self._send_count += 1
98
+ return True
99
+ except Exception as e:
100
+ with self._lock:
101
+ self._error_count += 1
102
+ if self._on_error:
103
+ self._on_error(e)
104
+ return False
105
+
106
+ @property
107
+ def send_count(self) -> int:
108
+ with self._lock:
109
+ return self._send_count
110
+
111
+ @property
112
+ def error_count(self) -> int:
113
+ with self._lock:
114
+ return self._error_count
115
+
116
+ @property
117
+ def is_running(self) -> bool:
118
+ return self._running
@@ -0,0 +1,78 @@
1
+ """
2
+ BufferLog — Flash Controller
3
+
4
+ Triggers a non-blocking flush of the ring buffer when an error is detected.
5
+ Dispatches log events to all configured adapters in a background thread
6
+ to avoid blocking the request.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ import threading
13
+ from typing import List, Any
14
+
15
+ from .ring_buffer import RingBuffer
16
+ from .log_event import LogEvent
17
+
18
+
19
+ class FlashController:
20
+ """Dispatches buffered log events to adapters on error."""
21
+
22
+ def __init__(self, adapters: List[Any], fail_open: bool = True) -> None:
23
+ self._adapters = adapters
24
+ self._fail_open = fail_open
25
+ self._flush_count = 0
26
+ self._events_flushed = 0
27
+ self._adapter_errors = 0
28
+ self._lock = threading.Lock()
29
+
30
+ def trigger(
31
+ self,
32
+ context_id: str,
33
+ buffer: RingBuffer[LogEvent],
34
+ error_event: LogEvent,
35
+ ) -> None:
36
+ """Flush the buffer contents + error event to all adapters."""
37
+ events = buffer.drain()
38
+ events.append(error_event)
39
+
40
+ with self._lock:
41
+ self._flush_count += 1
42
+ self._events_flushed += len(events)
43
+
44
+ # Dispatch in background thread to avoid blocking
45
+ t = threading.Thread(
46
+ target=self._dispatch,
47
+ args=(events, context_id),
48
+ daemon=True,
49
+ )
50
+ t.start()
51
+
52
+ def _dispatch(self, events: List[LogEvent], context_id: str) -> None:
53
+ for adapter in self._adapters:
54
+ try:
55
+ adapter.send(events, context_id)
56
+ except Exception as e:
57
+ with self._lock:
58
+ self._adapter_errors += 1
59
+ if self._fail_open:
60
+ print(
61
+ f"[BufferLog] Adapter error: {e}",
62
+ file=sys.stderr,
63
+ )
64
+
65
+ @property
66
+ def flush_count(self) -> int:
67
+ with self._lock:
68
+ return self._flush_count
69
+
70
+ @property
71
+ def events_flushed(self) -> int:
72
+ with self._lock:
73
+ return self._events_flushed
74
+
75
+ @property
76
+ def adapter_errors(self) -> int:
77
+ with self._lock:
78
+ return self._adapter_errors
@@ -0,0 +1,82 @@
1
+ """
2
+ BufferLog — Python logging.Handler integration
3
+
4
+ Drop-in handler for Python's built-in logging module.
5
+ Routes log records into the per-request ring buffer.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import Any, Dict, Optional
12
+
13
+ from ..log_event import LogEvent, LogLevel
14
+ from ..flash_controller import FlashController
15
+ from ..config import BufferLogConfig
16
+ from .. import context as ctx
17
+
18
+
19
+ # Map Python logging levels to BufferLog levels
20
+ _LEVEL_MAP = {
21
+ logging.DEBUG: LogLevel.DEBUG,
22
+ logging.INFO: LogLevel.INFO,
23
+ logging.WARNING: LogLevel.WARNING,
24
+ logging.ERROR: LogLevel.ERROR,
25
+ logging.CRITICAL: LogLevel.CRITICAL,
26
+ }
27
+
28
+
29
+ class BufferLogHandler(logging.Handler):
30
+ """A logging.Handler that routes records into the BufferLog ring buffer.
31
+
32
+ Usage:
33
+ handler = BufferLogHandler(flash_controller=fc, config=config)
34
+ logger = logging.getLogger("myapp")
35
+ logger.addHandler(handler)
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ flash_controller: FlashController,
41
+ config: BufferLogConfig,
42
+ level: int = logging.DEBUG,
43
+ ) -> None:
44
+ super().__init__(level)
45
+ self._flash = flash_controller
46
+ self._config = config
47
+
48
+ def emit(self, record: logging.LogRecord) -> None:
49
+ try:
50
+ context_id = ctx.get_context_id()
51
+ buffer = ctx.get_buffer()
52
+
53
+ if context_id is None or buffer is None:
54
+ # Outside of a request context — pass through
55
+ return
56
+
57
+ bl_level = _LEVEL_MAP.get(record.levelno, LogLevel.INFO)
58
+ message = self.format(record) if self.formatter else record.getMessage()
59
+
60
+ metadata: Dict[str, Any] = {
61
+ "logger": record.name,
62
+ "pathname": record.pathname,
63
+ "lineno": record.lineno,
64
+ }
65
+
66
+ event = LogEvent(
67
+ level=bl_level,
68
+ message=message,
69
+ context_id=context_id,
70
+ metadata=metadata,
71
+ )
72
+
73
+ buffer.write(event)
74
+
75
+ # Check if this level triggers a flush
76
+ level_name = record.levelname.upper()
77
+ if level_name in self._config.flush_levels_set:
78
+ self._flash.trigger(context_id, buffer, event)
79
+
80
+ except Exception:
81
+ if self._config.fail_open:
82
+ self.handleError(record)
bufferlog/log_event.py ADDED
@@ -0,0 +1,50 @@
1
+ """
2
+ BufferLog — Log Event
3
+
4
+ Dataclass representing a single log event captured by the SDK.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ import enum
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Dict, Optional
13
+
14
+
15
+ class LogLevel(enum.IntEnum):
16
+ """Standard log levels, ordered by severity."""
17
+ DEBUG = 10
18
+ INFO = 20
19
+ WARNING = 30
20
+ ERROR = 40
21
+ CRITICAL = 50
22
+
23
+
24
+ LOG_LEVEL_NAMES: Dict[int, str] = {
25
+ LogLevel.DEBUG: "DEBUG",
26
+ LogLevel.INFO: "INFO",
27
+ LogLevel.WARNING: "WARNING",
28
+ LogLevel.ERROR: "ERROR",
29
+ LogLevel.CRITICAL: "CRITICAL",
30
+ }
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class LogEvent:
35
+ """An immutable log event stored in the ring buffer."""
36
+ level: LogLevel
37
+ message: str
38
+ context_id: str
39
+ timestamp: float = field(default_factory=time.time)
40
+ metadata: Optional[Dict[str, Any]] = None
41
+
42
+ def to_dict(self) -> Dict[str, Any]:
43
+ """Serialize for adapter dispatch."""
44
+ return {
45
+ "level": LOG_LEVEL_NAMES.get(self.level, str(self.level)),
46
+ "message": self.message,
47
+ "context_id": self.context_id,
48
+ "timestamp": self.timestamp,
49
+ "metadata": self.metadata or {},
50
+ }
@@ -0,0 +1 @@
1
+ """BufferLog middleware package."""
@@ -0,0 +1,85 @@
1
+ """
2
+ BufferLog — Django Middleware
3
+
4
+ Django-style middleware class that wraps each request in a BufferLog context.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import uuid
10
+ from typing import Any, Callable, Optional
11
+
12
+ from ..buffer_manager import BufferManager
13
+ from ..flash_controller import FlashController
14
+ from ..log_event import LogEvent, LogLevel
15
+ from ..config import BufferLogConfig
16
+ from .. import context as ctx
17
+
18
+
19
+ class BufferLogDjangoMiddleware:
20
+ """Django middleware for BufferLog.
21
+
22
+ Add to MIDDLEWARE in settings.py. Requires BufferLog.init_django() to be called
23
+ first to inject the BufferLog instance.
24
+ """
25
+
26
+ # Class-level references set by BufferLog.init_django()
27
+ _buffer_manager: Optional[BufferManager] = None
28
+ _flash_controller: Optional[FlashController] = None
29
+ _config: Optional[BufferLogConfig] = None
30
+
31
+ def __init__(self, get_response: Callable) -> None:
32
+ self.get_response = get_response
33
+
34
+ def __call__(self, request: Any) -> Any:
35
+ bm = self._buffer_manager
36
+ fc = self._flash_controller
37
+ config = self._config
38
+
39
+ if bm is None or fc is None or config is None or not config.enabled:
40
+ return self.get_response(request)
41
+
42
+ context_id = uuid.uuid4().hex
43
+ buffer = bm.create_buffer(context_id, config.buffer_capacity)
44
+ ctx.set_context(context_id, buffer)
45
+
46
+ try:
47
+ response = self.get_response(request)
48
+ except Exception as exc:
49
+ buf = ctx.get_buffer()
50
+ if buf is not None:
51
+ error_event = LogEvent(
52
+ level=LogLevel.ERROR,
53
+ message=f"Unhandled exception: {exc}",
54
+ context_id=context_id,
55
+ metadata={"exception": str(exc)},
56
+ )
57
+ fc.trigger(context_id, buf, error_event)
58
+ bm.discard_buffer(context_id)
59
+ ctx.clear_context()
60
+ raise
61
+ else:
62
+ status = getattr(response, "status_code", 200)
63
+ if status in config.flush_status_set:
64
+ buf = ctx.get_buffer()
65
+ if buf is not None:
66
+ method = getattr(request, "method", "")
67
+ path = getattr(request, "path", "")
68
+ error_event = LogEvent(
69
+ level=LogLevel.ERROR,
70
+ message=f"HTTP {status} {method} {path}",
71
+ context_id=context_id,
72
+ metadata={
73
+ "http_method": method,
74
+ "http_path": path,
75
+ "http_status": status,
76
+ },
77
+ )
78
+ fc.trigger(context_id, buf, error_event)
79
+ bm.discard_buffer(context_id)
80
+ else:
81
+ bm.discard_buffer(context_id)
82
+ finally:
83
+ ctx.clear_context()
84
+
85
+ return response
@@ -0,0 +1,92 @@
1
+ """
2
+ BufferLog — FastAPI / Starlette ASGI Middleware
3
+
4
+ ASGI middleware that wraps each request in a BufferLog context.
5
+ Works with both FastAPI and raw Starlette.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+ from typing import Any, Callable, TYPE_CHECKING
12
+
13
+ from ..buffer_manager import BufferManager
14
+ from ..flash_controller import FlashController
15
+ from ..log_event import LogEvent, LogLevel
16
+ from ..config import BufferLogConfig
17
+ from .. import context as ctx
18
+
19
+ if TYPE_CHECKING:
20
+ pass
21
+
22
+
23
+ class BufferLogMiddleware:
24
+ """ASGI middleware for FastAPI / Starlette."""
25
+
26
+ def __init__(
27
+ self,
28
+ app: Any,
29
+ buffer_manager: BufferManager,
30
+ flash_controller: FlashController,
31
+ config: BufferLogConfig,
32
+ ) -> None:
33
+ self.app = app
34
+ self._bm = buffer_manager
35
+ self._fc = flash_controller
36
+ self._config = config
37
+
38
+ async def __call__(self, scope: dict, receive: Callable, send: Callable) -> None:
39
+ if scope["type"] != "http" or not self._config.enabled:
40
+ await self.app(scope, receive, send)
41
+ return
42
+
43
+ context_id = uuid.uuid4().hex
44
+ buffer = self._bm.create_buffer(context_id, self._config.buffer_capacity)
45
+ ctx.set_context(context_id, buffer)
46
+
47
+ status_code = 200
48
+
49
+ async def send_wrapper(message: dict) -> None:
50
+ nonlocal status_code
51
+ if message["type"] == "http.response.start":
52
+ status_code = message.get("status", 200)
53
+ await send(message)
54
+
55
+ try:
56
+ await self.app(scope, receive, send_wrapper)
57
+ except Exception as exc:
58
+ # Unhandled exception — flush
59
+ buf = ctx.get_buffer()
60
+ if buf is not None:
61
+ error_event = LogEvent(
62
+ level=LogLevel.ERROR,
63
+ message=f"Unhandled exception: {exc}",
64
+ context_id=context_id,
65
+ metadata={"exception": str(exc)},
66
+ )
67
+ self._fc.trigger(context_id, buf, error_event)
68
+ self._bm.discard_buffer(context_id)
69
+ ctx.clear_context()
70
+ raise
71
+ else:
72
+ if status_code in self._config.flush_status_set:
73
+ buf = ctx.get_buffer()
74
+ if buf is not None:
75
+ method = scope.get("method", "")
76
+ path = scope.get("path", "")
77
+ error_event = LogEvent(
78
+ level=LogLevel.ERROR,
79
+ message=f"HTTP {status_code} {method} {path}",
80
+ context_id=context_id,
81
+ metadata={
82
+ "http_method": method,
83
+ "http_path": path,
84
+ "http_status": status_code,
85
+ },
86
+ )
87
+ self._fc.trigger(context_id, buf, error_event)
88
+ self._bm.discard_buffer(context_id)
89
+ else:
90
+ self._bm.discard_buffer(context_id)
91
+ finally:
92
+ ctx.clear_context()
@@ -0,0 +1,71 @@
1
+ """
2
+ BufferLog — Flask Middleware
3
+
4
+ Uses Flask's before_request / after_request hooks to wrap each request
5
+ in a BufferLog context. Buffers are discarded on success and flushed on error.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+ from typing import TYPE_CHECKING
12
+
13
+ from ..buffer_manager import BufferManager
14
+ from ..flash_controller import FlashController
15
+ from ..log_event import LogEvent, LogLevel
16
+ from ..config import BufferLogConfig
17
+ from .. import context as ctx
18
+
19
+ if TYPE_CHECKING:
20
+ from flask import Flask
21
+
22
+
23
+ def init_flask(
24
+ app: "Flask",
25
+ buffer_manager: BufferManager,
26
+ flash_controller: FlashController,
27
+ config: BufferLogConfig,
28
+ ) -> None:
29
+ """Register BufferLog hooks on a Flask app."""
30
+
31
+ @app.before_request
32
+ def _bufferlog_before() -> None:
33
+ if not config.enabled:
34
+ return
35
+ context_id = uuid.uuid4().hex
36
+ buffer = buffer_manager.create_buffer(context_id, config.buffer_capacity)
37
+ ctx.set_context(context_id, buffer)
38
+
39
+ @app.after_request
40
+ def _bufferlog_after(response): # type: ignore[no-untyped-def]
41
+ context_id = ctx.get_context_id()
42
+ if context_id is None:
43
+ return response
44
+
45
+ if response.status_code in config.flush_status_set:
46
+ buffer = ctx.get_buffer()
47
+ if buffer is not None:
48
+ from flask import request
49
+
50
+ error_event = LogEvent(
51
+ level=LogLevel.ERROR,
52
+ message=f"HTTP {response.status_code} {request.method} {request.path}",
53
+ context_id=context_id,
54
+ metadata={
55
+ "http_method": request.method,
56
+ "http_path": request.path,
57
+ "http_status": response.status_code,
58
+ },
59
+ )
60
+ flash_controller.trigger(context_id, buffer, error_event)
61
+ buffer_manager.discard_buffer(context_id)
62
+ else:
63
+ buffer_manager.discard_buffer(context_id)
64
+
65
+ ctx.clear_context()
66
+ return response
67
+
68
+ @app.teardown_request
69
+ def _bufferlog_teardown(exc=None): # type: ignore[no-untyped-def]
70
+ # Safety net: ensure context is always cleared
71
+ ctx.clear_context()
@@ -0,0 +1,94 @@
1
+ """
2
+ BufferLog — Ring Buffer
3
+
4
+ Fixed-size circular buffer that overwrites the oldest entry when full.
5
+ Used to store log events in memory per-request without growing unbounded.
6
+
7
+ Design notes:
8
+ - Uses a pre-allocated list for O(1) writes with zero allocation after init
9
+ - Thread-safe via threading.Lock (needed for async frameworks)
10
+ - Generic over T for reuse
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import threading
16
+ from typing import Generic, TypeVar, List, Optional
17
+
18
+ T = TypeVar("T")
19
+
20
+
21
+ class RingBuffer(Generic[T]):
22
+ """Fixed-size circular buffer.
23
+
24
+ When the buffer is full, new items overwrite the oldest entries.
25
+ This prevents unbounded memory growth while retaining the most
26
+ recent N log events for error context.
27
+ """
28
+
29
+ __slots__ = ("_capacity", "_buf", "_head", "_count", "_lock")
30
+
31
+ def __init__(self, capacity: int = 100) -> None:
32
+ if capacity < 1:
33
+ raise ValueError("capacity must be >= 1")
34
+ self._capacity = capacity
35
+ self._buf: List[Optional[T]] = [None] * capacity
36
+ self._head = 0 # Next write position
37
+ self._count = 0
38
+ self._lock = threading.Lock()
39
+
40
+ @property
41
+ def capacity(self) -> int:
42
+ return self._capacity
43
+
44
+ @property
45
+ def count(self) -> int:
46
+ with self._lock:
47
+ return self._count
48
+
49
+ @property
50
+ def is_full(self) -> bool:
51
+ with self._lock:
52
+ return self._count >= self._capacity
53
+
54
+ def write(self, item: T) -> None:
55
+ """Write an item. Overwrites the oldest if full."""
56
+ with self._lock:
57
+ self._buf[self._head] = item
58
+ self._head = (self._head + 1) % self._capacity
59
+ if self._count < self._capacity:
60
+ self._count += 1
61
+
62
+ def drain(self) -> List[T]:
63
+ """Remove and return all items in chronological order."""
64
+ with self._lock:
65
+ if self._count == 0:
66
+ return []
67
+
68
+ result: List[T] = []
69
+ if self._count < self._capacity:
70
+ # Buffer not yet wrapped
71
+ for i in range(self._count):
72
+ item = self._buf[i]
73
+ if item is not None:
74
+ result.append(item)
75
+ else:
76
+ # Buffer has wrapped — read from head (oldest) forward
77
+ for i in range(self._capacity):
78
+ idx = (self._head + i) % self._capacity
79
+ item = self._buf[idx]
80
+ if item is not None:
81
+ result.append(item)
82
+
83
+ self._clear_unlocked()
84
+ return result
85
+
86
+ def clear(self) -> None:
87
+ """Clear all items."""
88
+ with self._lock:
89
+ self._clear_unlocked()
90
+
91
+ def _clear_unlocked(self) -> None:
92
+ self._buf = [None] * self._capacity
93
+ self._head = 0
94
+ self._count = 0