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.
- bufferlog/__init__.py +170 -0
- bufferlog/adapters/__init__.py +7 -0
- bufferlog/adapters/base.py +16 -0
- bufferlog/adapters/datadog.py +58 -0
- bufferlog/adapters/splunk.py +55 -0
- bufferlog/adapters/stdout.py +25 -0
- bufferlog/buffer_manager.py +85 -0
- bufferlog/config.py +47 -0
- bufferlog/context.py +47 -0
- bufferlog/control_plane/__init__.py +1 -0
- bufferlog/control_plane/policy_fetcher.py +112 -0
- bufferlog/control_plane/telemetry_reporter.py +118 -0
- bufferlog/flash_controller.py +78 -0
- bufferlog/integrations/__init__.py +82 -0
- bufferlog/log_event.py +50 -0
- bufferlog/middleware/__init__.py +1 -0
- bufferlog/middleware/django_mw.py +85 -0
- bufferlog/middleware/fastapi_mw.py +92 -0
- bufferlog/middleware/flask_mw.py +71 -0
- bufferlog/ring_buffer.py +94 -0
- bufferlog-0.1.0.dist-info/METADATA +219 -0
- bufferlog-0.1.0.dist-info/RECORD +24 -0
- bufferlog-0.1.0.dist-info/WHEEL +5 -0
- bufferlog-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|
bufferlog/ring_buffer.py
ADDED
|
@@ -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
|