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