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 ADDED
@@ -0,0 +1,170 @@
1
+ """
2
+ BufferLog — Python SDK
3
+
4
+ Buffer logs in memory per-request. Flush only on errors. Save 90%+ on APM costs.
5
+
6
+ Usage:
7
+ from bufferlog import BufferLog
8
+ from bufferlog.adapters import StdOutAdapter
9
+
10
+ bl = BufferLog(adapters=[StdOutAdapter(pretty=True)])
11
+ bl.init_flask(app)
12
+
13
+ # Or for FastAPI:
14
+ from bufferlog.middleware.fastapi_mw import BufferLogMiddleware
15
+ app.add_middleware(BufferLogMiddleware, **bl.asgi_kwargs())
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any, Dict, List, Optional, TYPE_CHECKING
21
+
22
+ from .config import BufferLogConfig, ControlPlaneConfig
23
+ from .ring_buffer import RingBuffer
24
+ from .log_event import LogEvent, LogLevel
25
+ from .buffer_manager import BufferManager, BufferManagerMetrics
26
+ from .flash_controller import FlashController
27
+ from .adapters import StdOutAdapter
28
+ from .integrations import BufferLogHandler
29
+ from .control_plane.policy_fetcher import PolicyFetcher
30
+ from .control_plane.telemetry_reporter import TelemetryReporter
31
+
32
+ if TYPE_CHECKING:
33
+ from flask import Flask
34
+
35
+ __version__ = "0.1.0"
36
+ __all__ = [
37
+ "BufferLog",
38
+ "BufferLogConfig",
39
+ "ControlPlaneConfig",
40
+ "RingBuffer",
41
+ "LogEvent",
42
+ "LogLevel",
43
+ "BufferManager",
44
+ "BufferManagerMetrics",
45
+ "FlashController",
46
+ "BufferLogHandler",
47
+ "StdOutAdapter",
48
+ "PolicyFetcher",
49
+ "TelemetryReporter",
50
+ ]
51
+
52
+
53
+ class BufferLog:
54
+ """High-level SDK entry point.
55
+
56
+ Creates and wires all components together. The recommended way to
57
+ use the BufferLog Python SDK.
58
+ """
59
+
60
+ def __init__(self, config: Optional[BufferLogConfig] = None, **kwargs: Any) -> None:
61
+ if config is None:
62
+ config = BufferLogConfig(**kwargs)
63
+ self.config = config
64
+
65
+ self.buffer_manager = BufferManager(config.buffer_capacity)
66
+
67
+ adapters = config.adapters if config.adapters else [StdOutAdapter(pretty=True)]
68
+ self.flash_controller = FlashController(
69
+ adapters=adapters,
70
+ fail_open=config.fail_open,
71
+ )
72
+
73
+ self.policy_fetcher: Optional[PolicyFetcher] = None
74
+ self.telemetry_reporter: Optional[TelemetryReporter] = None
75
+
76
+ if config.control_plane:
77
+ self.policy_fetcher = PolicyFetcher(
78
+ url=config.control_plane.url,
79
+ api_key=config.control_plane.api_key,
80
+ interval_s=config.control_plane.poll_interval_s,
81
+ )
82
+ self.telemetry_reporter = TelemetryReporter(
83
+ url=config.control_plane.url,
84
+ api_key=config.control_plane.api_key,
85
+ interval_s=config.control_plane.telemetry_interval_s,
86
+ metrics_provider=lambda: self.get_metrics_dict(),
87
+ )
88
+ self.policy_fetcher.start()
89
+ self.telemetry_reporter.start()
90
+
91
+ # ---- Framework integrations ----
92
+
93
+ def init_flask(self, app: "Flask") -> None:
94
+ """Register BufferLog middleware on a Flask app."""
95
+ from .middleware.flask_mw import init_flask
96
+
97
+ init_flask(app, self.buffer_manager, self.flash_controller, self.config)
98
+
99
+ def init_django(self) -> None:
100
+ """Configure the Django middleware class with this BufferLog instance.
101
+
102
+ After calling this, add 'bufferlog.middleware.django_mw.BufferLogDjangoMiddleware'
103
+ to your MIDDLEWARE list in settings.py.
104
+ """
105
+ from .middleware.django_mw import BufferLogDjangoMiddleware
106
+
107
+ BufferLogDjangoMiddleware._buffer_manager = self.buffer_manager
108
+ BufferLogDjangoMiddleware._flash_controller = self.flash_controller
109
+ BufferLogDjangoMiddleware._config = self.config
110
+
111
+ def asgi_kwargs(self) -> Dict[str, Any]:
112
+ """Return kwargs for adding the ASGI middleware.
113
+
114
+ Usage:
115
+ from bufferlog.middleware.fastapi_mw import BufferLogMiddleware
116
+ app.add_middleware(BufferLogMiddleware, **bl.asgi_kwargs())
117
+ """
118
+ return {
119
+ "buffer_manager": self.buffer_manager,
120
+ "flash_controller": self.flash_controller,
121
+ "config": self.config,
122
+ }
123
+
124
+ # ---- Logging integration ----
125
+
126
+ def logging_handler(self) -> BufferLogHandler:
127
+ """Create a logging.Handler for Python's built-in logging module."""
128
+ return BufferLogHandler(
129
+ flash_controller=self.flash_controller,
130
+ config=self.config,
131
+ )
132
+
133
+ # ---- Metrics ----
134
+
135
+ def get_metrics(self) -> Dict[str, Any]:
136
+ bm = self.buffer_manager.get_metrics()
137
+ return {
138
+ "buffers": {
139
+ "created": bm.created,
140
+ "discarded": bm.discarded,
141
+ "flushed": bm.flushed,
142
+ "active": bm.active,
143
+ },
144
+ "flash": {
145
+ "flush_count": self.flash_controller.flush_count,
146
+ "events_flushed": self.flash_controller.events_flushed,
147
+ "adapter_errors": self.flash_controller.adapter_errors,
148
+ },
149
+ }
150
+
151
+ def get_metrics_dict(self) -> Dict[str, Any]:
152
+ """Flat metrics dict for telemetry reporter."""
153
+ bm = self.buffer_manager.get_metrics()
154
+ return {
155
+ "logs_discarded": bm.discarded,
156
+ "logs_flushed": bm.flushed,
157
+ "requests_success": bm.discarded,
158
+ "requests_error": bm.flushed,
159
+ "buffers_active": bm.active,
160
+ }
161
+
162
+ # ---- Shutdown ----
163
+
164
+ def shutdown(self) -> None:
165
+ """Gracefully stop background tasks."""
166
+ if self.policy_fetcher:
167
+ self.policy_fetcher.stop()
168
+ if self.telemetry_reporter:
169
+ self.telemetry_reporter.send() # Final report
170
+ self.telemetry_reporter.stop()
@@ -0,0 +1,7 @@
1
+ """BufferLog adapters."""
2
+
3
+ from .stdout import StdOutAdapter
4
+ from .datadog import DatadogAdapter
5
+ from .splunk import SplunkAdapter
6
+
7
+ __all__ = ["StdOutAdapter", "DatadogAdapter", "SplunkAdapter"]
@@ -0,0 +1,16 @@
1
+ """BufferLog — Adapter protocol (abstract base)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List, Protocol, runtime_checkable
6
+
7
+ from ..log_event import LogEvent
8
+
9
+
10
+ @runtime_checkable
11
+ class Adapter(Protocol):
12
+ """Interface that all downstream adapters must implement."""
13
+
14
+ def send(self, events: List[LogEvent], context_id: str) -> None:
15
+ """Send a batch of log events to the downstream target."""
16
+ ...
@@ -0,0 +1,58 @@
1
+ """BufferLog — Datadog Adapter. Sends flushed logs to Datadog Logs API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.request
7
+ from typing import List, Optional
8
+
9
+ from ..log_event import LogEvent
10
+
11
+
12
+ class DatadogAdapter:
13
+ """Ships flushed log events to Datadog via the HTTP Logs API."""
14
+
15
+ DEFAULT_URL = "https://http-intake.logs.datadoghq.com/api/v2/logs"
16
+
17
+ def __init__(
18
+ self,
19
+ api_key: str,
20
+ url: Optional[str] = None,
21
+ service: str = "bufferlog",
22
+ source: str = "python",
23
+ ) -> None:
24
+ self._api_key = api_key
25
+ self._url = url or self.DEFAULT_URL
26
+ self._service = service
27
+ self._source = source
28
+
29
+ def send(self, events: List[LogEvent], context_id: str) -> None:
30
+ payload = [
31
+ {
32
+ "message": e.message,
33
+ "ddtags": f"context_id:{context_id}",
34
+ "ddsource": self._source,
35
+ "service": self._service,
36
+ "level": e.to_dict()["level"],
37
+ "timestamp": int(e.timestamp * 1000),
38
+ **(e.metadata or {}),
39
+ }
40
+ for e in events
41
+ ]
42
+
43
+ data = json.dumps(payload).encode("utf-8")
44
+ req = urllib.request.Request(
45
+ self._url,
46
+ data=data,
47
+ headers={
48
+ "Content-Type": "application/json",
49
+ "DD-API-KEY": self._api_key,
50
+ },
51
+ method="POST",
52
+ )
53
+
54
+ try:
55
+ with urllib.request.urlopen(req, timeout=10) as resp:
56
+ resp.read()
57
+ except Exception:
58
+ pass # Fail-open: don't crash the app if Datadog is unreachable
@@ -0,0 +1,55 @@
1
+ """BufferLog — Splunk HEC Adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.request
7
+ from typing import List, Optional
8
+
9
+ from ..log_event import LogEvent
10
+
11
+
12
+ class SplunkAdapter:
13
+ """Ships flushed log events to Splunk via the HTTP Event Collector."""
14
+
15
+ def __init__(
16
+ self,
17
+ token: str,
18
+ url: str,
19
+ source: str = "bufferlog",
20
+ sourcetype: str = "_json",
21
+ index: Optional[str] = None,
22
+ ) -> None:
23
+ self._token = token
24
+ self._url = url.rstrip("/") + "/services/collector/event"
25
+ self._source = source
26
+ self._sourcetype = sourcetype
27
+ self._index = index
28
+
29
+ def send(self, events: List[LogEvent], context_id: str) -> None:
30
+ for event in events:
31
+ payload = {
32
+ "event": event.to_dict(),
33
+ "source": self._source,
34
+ "sourcetype": self._sourcetype,
35
+ "time": event.timestamp,
36
+ }
37
+ if self._index:
38
+ payload["index"] = self._index
39
+
40
+ data = json.dumps(payload).encode("utf-8")
41
+ req = urllib.request.Request(
42
+ self._url,
43
+ data=data,
44
+ headers={
45
+ "Content-Type": "application/json",
46
+ "Authorization": f"Splunk {self._token}",
47
+ },
48
+ method="POST",
49
+ )
50
+
51
+ try:
52
+ with urllib.request.urlopen(req, timeout=10) as resp:
53
+ resp.read()
54
+ except Exception:
55
+ pass # Fail-open
@@ -0,0 +1,25 @@
1
+ """BufferLog — StdOut Adapter. Prints flushed logs to stdout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import List
8
+
9
+ from ..log_event import LogEvent
10
+
11
+
12
+ class StdOutAdapter:
13
+ """Writes flushed log events to stdout as JSON lines."""
14
+
15
+ def __init__(self, pretty: bool = False) -> None:
16
+ self._pretty = pretty
17
+
18
+ def send(self, events: List[LogEvent], context_id: str) -> None:
19
+ for event in events:
20
+ data = event.to_dict()
21
+ if self._pretty:
22
+ sys.stdout.write(json.dumps(data, indent=2, default=str) + "\n")
23
+ else:
24
+ sys.stdout.write(json.dumps(data, default=str) + "\n")
25
+ sys.stdout.flush()
@@ -0,0 +1,85 @@
1
+ """
2
+ BufferLog — Buffer Manager
3
+
4
+ Manages per-request ring buffers. Creates, retrieves, discards, and flushes
5
+ buffers identified by context_id. Tracks metrics for the ROI dashboard.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+ from dataclasses import dataclass
12
+ from typing import Dict, List, Optional
13
+
14
+ from .ring_buffer import RingBuffer
15
+ from .log_event import LogEvent
16
+
17
+
18
+ @dataclass
19
+ class BufferManagerMetrics:
20
+ created: int = 0
21
+ discarded: int = 0
22
+ flushed: int = 0
23
+ active: int = 0
24
+
25
+
26
+ class BufferManager:
27
+ """Lifecycle manager for per-request ring buffers."""
28
+
29
+ def __init__(self, default_capacity: int = 100) -> None:
30
+ self._default_capacity = default_capacity
31
+ self._buffers: Dict[str, RingBuffer[LogEvent]] = {}
32
+ self._metrics = BufferManagerMetrics()
33
+ self._lock = threading.Lock()
34
+
35
+ def create_buffer(
36
+ self, context_id: str, capacity: Optional[int] = None
37
+ ) -> RingBuffer[LogEvent]:
38
+ """Create a new ring buffer for a request context."""
39
+ buf = RingBuffer[LogEvent](capacity or self._default_capacity)
40
+ with self._lock:
41
+ self._buffers[context_id] = buf
42
+ self._metrics.created += 1
43
+ self._metrics.active += 1
44
+ return buf
45
+
46
+ def get_buffer(self, context_id: str) -> Optional[RingBuffer[LogEvent]]:
47
+ """Retrieve the buffer for a given context ID."""
48
+ with self._lock:
49
+ return self._buffers.get(context_id)
50
+
51
+ def discard_buffer(self, context_id: str) -> bool:
52
+ """Discard a buffer — the SUCCESS path. Logs are dropped, saving money."""
53
+ with self._lock:
54
+ buf = self._buffers.pop(context_id, None)
55
+ if buf is None:
56
+ return False
57
+ buf.clear()
58
+ self._metrics.discarded += 1
59
+ self._metrics.active -= 1
60
+ return True
61
+
62
+ def flush_buffer(self, context_id: str) -> List[LogEvent]:
63
+ """Flush a buffer — the ERROR path. Returns all buffered log events."""
64
+ with self._lock:
65
+ buf = self._buffers.pop(context_id, None)
66
+ if buf is None:
67
+ return []
68
+ self._metrics.flushed += 1
69
+ self._metrics.active -= 1
70
+ # drain() is thread-safe on its own
71
+ return buf.drain()
72
+
73
+ def get_metrics(self) -> BufferManagerMetrics:
74
+ with self._lock:
75
+ return BufferManagerMetrics(
76
+ created=self._metrics.created,
77
+ discarded=self._metrics.discarded,
78
+ flushed=self._metrics.flushed,
79
+ active=self._metrics.active,
80
+ )
81
+
82
+ @property
83
+ def active_count(self) -> int:
84
+ with self._lock:
85
+ return len(self._buffers)
bufferlog/config.py ADDED
@@ -0,0 +1,47 @@
1
+ """
2
+ BufferLog — Configuration
3
+
4
+ Defines all tunable parameters for the Python SDK with sensible defaults.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Callable, Dict, List, Optional, Set, Tuple
11
+
12
+
13
+ @dataclass
14
+ class ControlPlaneConfig:
15
+ """Control plane connection settings."""
16
+ url: str
17
+ api_key: str
18
+ poll_interval_s: float = 60.0
19
+ telemetry_interval_s: float = 60.0
20
+
21
+
22
+ @dataclass
23
+ class BufferLogConfig:
24
+ """User-facing configuration for the BufferLog SDK."""
25
+
26
+ buffer_capacity: int = 100
27
+ flush_on_levels: List[str] = field(
28
+ default_factory=lambda: ["error", "critical"]
29
+ )
30
+ flush_on_status_codes: List[int] = field(
31
+ default_factory=lambda: [500, 501, 502, 503, 504]
32
+ )
33
+ adapters: List[Any] = field(default_factory=list)
34
+ enabled: bool = True
35
+ fail_open: bool = True
36
+ scrubber: Optional[
37
+ Callable[[str, Optional[Dict[str, Any]]], Tuple[str, Optional[Dict[str, Any]]]]
38
+ ] = None
39
+ control_plane: Optional[ControlPlaneConfig] = None
40
+
41
+ @property
42
+ def flush_levels_set(self) -> Set[str]:
43
+ return {l.upper() for l in self.flush_on_levels}
44
+
45
+ @property
46
+ def flush_status_set(self) -> Set[int]:
47
+ return set(self.flush_on_status_codes)
bufferlog/context.py ADDED
@@ -0,0 +1,47 @@
1
+ """
2
+ BufferLog — Request Context
3
+
4
+ Uses Python's contextvars to track the active request's buffer.
5
+ This is the Python equivalent of Node.js AsyncLocalStorage —
6
+ each request gets its own isolated context without manual passing.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextvars
12
+ from typing import Optional
13
+
14
+ from .ring_buffer import RingBuffer
15
+ from .log_event import LogEvent
16
+
17
+ # Context variable holding the current request's context_id
18
+ _context_id_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
19
+ "bufferlog_context_id", default=None
20
+ )
21
+
22
+ # Context variable holding the current request's ring buffer
23
+ _buffer_var: contextvars.ContextVar[Optional[RingBuffer[LogEvent]]] = contextvars.ContextVar(
24
+ "bufferlog_buffer", default=None
25
+ )
26
+
27
+
28
+ def get_context_id() -> Optional[str]:
29
+ """Get the context_id for the current request."""
30
+ return _context_id_var.get()
31
+
32
+
33
+ def get_buffer() -> Optional[RingBuffer[LogEvent]]:
34
+ """Get the ring buffer for the current request."""
35
+ return _buffer_var.get()
36
+
37
+
38
+ def set_context(context_id: str, buffer: RingBuffer[LogEvent]) -> None:
39
+ """Set the context for the current request."""
40
+ _context_id_var.set(context_id)
41
+ _buffer_var.set(buffer)
42
+
43
+
44
+ def clear_context() -> None:
45
+ """Clear the context for the current request."""
46
+ _context_id_var.set(None)
47
+ _buffer_var.set(None)
@@ -0,0 +1 @@
1
+ """BufferLog control plane package."""
@@ -0,0 +1,112 @@
1
+ """
2
+ BufferLog — Policy Fetcher
3
+
4
+ Background thread that polls the control plane for dynamic configuration.
5
+ Uses urllib.request for zero external dependencies.
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 PolicyFetcher:
18
+ """Periodically fetches policy from the BufferLog Control Plane."""
19
+
20
+ def __init__(
21
+ self,
22
+ url: str,
23
+ api_key: str,
24
+ interval_s: float = 60.0,
25
+ on_update: Optional[Callable[[Dict[str, Any]], None]] = None,
26
+ on_error: Optional[Callable[[Exception], None]] = None,
27
+ ) -> None:
28
+ base = url.rstrip("/")
29
+ self._url = f"{base}/api/v1/policy"
30
+ self._api_key = api_key
31
+ self._interval = interval_s
32
+ self._on_update = on_update
33
+ self._on_error = on_error
34
+
35
+ self._timer: Optional[threading.Timer] = None
36
+ self._running = False
37
+ self._last_policy: Optional[Dict[str, Any]] = None
38
+ self._fetch_count = 0
39
+ self._error_count = 0
40
+ self._lock = threading.Lock()
41
+
42
+ def start(self) -> None:
43
+ if self._running:
44
+ return
45
+ self._running = True
46
+ # Fetch immediately, then schedule recurring
47
+ self._do_fetch()
48
+ self._schedule()
49
+
50
+ def stop(self) -> None:
51
+ self._running = False
52
+ if self._timer:
53
+ self._timer.cancel()
54
+ self._timer = None
55
+
56
+ def fetch(self) -> Optional[Dict[str, Any]]:
57
+ """Perform a single synchronous fetch."""
58
+ return self._do_fetch()
59
+
60
+ def _schedule(self) -> None:
61
+ if not self._running:
62
+ return
63
+ self._timer = threading.Timer(self._interval, self._tick)
64
+ self._timer.daemon = True
65
+ self._timer.start()
66
+
67
+ def _tick(self) -> None:
68
+ self._do_fetch()
69
+ self._schedule()
70
+
71
+ def _do_fetch(self) -> Optional[Dict[str, Any]]:
72
+ req = urllib.request.Request(
73
+ self._url,
74
+ headers={
75
+ "Authorization": f"Bearer {self._api_key}",
76
+ "User-Agent": f"bufferlog-sdk-python/0.1.0 (python/{platform.python_version()})",
77
+ },
78
+ )
79
+ try:
80
+ with urllib.request.urlopen(req, timeout=10) as resp:
81
+ data = json.loads(resp.read().decode("utf-8"))
82
+ with self._lock:
83
+ self._fetch_count += 1
84
+ self._last_policy = data
85
+ if self._on_update:
86
+ self._on_update(data)
87
+ return data
88
+ except Exception as e:
89
+ with self._lock:
90
+ self._error_count += 1
91
+ if self._on_error:
92
+ self._on_error(e)
93
+ return None
94
+
95
+ @property
96
+ def last_policy(self) -> Optional[Dict[str, Any]]:
97
+ with self._lock:
98
+ return self._last_policy
99
+
100
+ @property
101
+ def fetch_count(self) -> int:
102
+ with self._lock:
103
+ return self._fetch_count
104
+
105
+ @property
106
+ def error_count(self) -> int:
107
+ with self._lock:
108
+ return self._error_count
109
+
110
+ @property
111
+ def is_running(self) -> bool:
112
+ return self._running