debugbundle-python 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.
- debugbundle/__init__.py +165 -0
- debugbundle/config.py +192 -0
- debugbundle/core.py +811 -0
- debugbundle/integrations/__init__.py +16 -0
- debugbundle/integrations/common.py +77 -0
- debugbundle/integrations/django.py +53 -0
- debugbundle/integrations/fastapi.py +94 -0
- debugbundle/integrations/flask.py +67 -0
- debugbundle/integrations/relay_django.py +50 -0
- debugbundle/integrations/relay_fastapi.py +46 -0
- debugbundle/integrations/relay_flask.py +46 -0
- debugbundle/logger_integrations.py +154 -0
- debugbundle/py.typed +0 -0
- debugbundle/redaction.py +29 -0
- debugbundle/relay.py +261 -0
- debugbundle/suppression.py +124 -0
- debugbundle/transport.py +59 -0
- debugbundle/trigger_token.py +143 -0
- debugbundle_python-0.1.0.dist-info/METADATA +66 -0
- debugbundle_python-0.1.0.dist-info/RECORD +23 -0
- debugbundle_python-0.1.0.dist-info/WHEEL +5 -0
- debugbundle_python-0.1.0.dist-info/licenses/LICENSE +17 -0
- debugbundle_python-0.1.0.dist-info/top_level.txt +1 -0
debugbundle/core.py
ADDED
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import platform
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import traceback
|
|
9
|
+
import uuid
|
|
10
|
+
from collections import deque
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
from contextvars import ContextVar, Token
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from importlib import metadata
|
|
16
|
+
from random import random
|
|
17
|
+
from typing import Any, Protocol, cast
|
|
18
|
+
|
|
19
|
+
from .config import (
|
|
20
|
+
BALANCED_CAPTURE_POLICY,
|
|
21
|
+
DEFAULT_PROBES_POLL_INTERVAL_MS,
|
|
22
|
+
MINIMAL_CAPTURE_POLICY,
|
|
23
|
+
CapturePolicy,
|
|
24
|
+
RemoteConfigSnapshot,
|
|
25
|
+
RemoteProbeDirective,
|
|
26
|
+
find_matching_remote_probe_directives,
|
|
27
|
+
parse_remote_config,
|
|
28
|
+
)
|
|
29
|
+
from .logger_integrations import attach_optional_integrations
|
|
30
|
+
from .redaction import DEFAULT_REDACT_FIELDS, redact_value
|
|
31
|
+
from .suppression import EventSuppressionTracker
|
|
32
|
+
from .transport import HttpTransport, Transport, coerce_transport_response
|
|
33
|
+
from .trigger_token import resolve_request_trigger_directives
|
|
34
|
+
|
|
35
|
+
DEFAULT_BATCH_SIZE = 25
|
|
36
|
+
DEFAULT_FLUSH_INTERVAL = 5.0
|
|
37
|
+
DEFAULT_ENDPOINT = "https://api.debugbundle.com/v1/events"
|
|
38
|
+
DEFAULT_LOG_LEVEL = "warning"
|
|
39
|
+
SCHEMA_VERSION = "2026-03-01"
|
|
40
|
+
LEVEL_RANKS = {
|
|
41
|
+
"debug": 10,
|
|
42
|
+
"info": 20,
|
|
43
|
+
"warning": 30,
|
|
44
|
+
"error": 40,
|
|
45
|
+
"critical": 50,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class ProbeEntry:
|
|
51
|
+
label: str
|
|
52
|
+
data: dict[str, object]
|
|
53
|
+
timestamp: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ConfigFetchResponse(Protocol):
|
|
57
|
+
status_code: int
|
|
58
|
+
headers: Mapping[str, str]
|
|
59
|
+
|
|
60
|
+
def json(self) -> object: ...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class DebugBundleLogHandler(logging.Handler):
|
|
64
|
+
def __init__(self, sdk: DebugBundleSdk) -> None:
|
|
65
|
+
super().__init__()
|
|
66
|
+
self._sdk = sdk
|
|
67
|
+
|
|
68
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
69
|
+
self._sdk.capture_log(
|
|
70
|
+
record.getMessage(),
|
|
71
|
+
level=record.levelname.lower(),
|
|
72
|
+
context={
|
|
73
|
+
"logger_name": record.name,
|
|
74
|
+
"pathname": record.pathname,
|
|
75
|
+
"lineno": record.lineno,
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class DebugBundleSdk:
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
transport: Transport | None = None,
|
|
84
|
+
time_provider: Callable[[], float] | None = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
self._transport_override = transport
|
|
87
|
+
self._time_provider = time_provider or _time_now
|
|
88
|
+
self._lock = threading.RLock()
|
|
89
|
+
self._timer: threading.Timer | None = None
|
|
90
|
+
self._remote_config_timer: threading.Timer | None = None
|
|
91
|
+
self._transport: Transport | None = None
|
|
92
|
+
self._http_transport: HttpTransport | None = None
|
|
93
|
+
self._enabled = False
|
|
94
|
+
self._project_token = ""
|
|
95
|
+
self._service = "python-service"
|
|
96
|
+
self._environment = "development"
|
|
97
|
+
self._endpoint = DEFAULT_ENDPOINT
|
|
98
|
+
self._batch_size = DEFAULT_BATCH_SIZE
|
|
99
|
+
self._flush_interval = DEFAULT_FLUSH_INTERVAL
|
|
100
|
+
self._log_level = DEFAULT_LOG_LEVEL
|
|
101
|
+
self._sample_rate = 1.0
|
|
102
|
+
self._redact_fields = set(DEFAULT_REDACT_FIELDS)
|
|
103
|
+
self._buffer: list[dict[str, object]] = []
|
|
104
|
+
self._context: dict[str, object] = {}
|
|
105
|
+
self._scoped_context: ContextVar[dict[str, object] | None] = ContextVar(
|
|
106
|
+
"debugbundle_scoped_context",
|
|
107
|
+
default=None,
|
|
108
|
+
)
|
|
109
|
+
self._suppression = EventSuppressionTracker()
|
|
110
|
+
self._retry_after = 0.0
|
|
111
|
+
self._last_event_at: float | None = None
|
|
112
|
+
self._consecutive_failures = 0
|
|
113
|
+
self._max_probe_labels = 50
|
|
114
|
+
self._max_probe_entries_per_label = 10
|
|
115
|
+
self._probe_buffers: dict[str, deque[ProbeEntry]] = {}
|
|
116
|
+
self._logging_bindings: dict[int, tuple[logging.Logger, DebugBundleLogHandler]] = {}
|
|
117
|
+
self._optional_logging_restorers: list[Callable[[], None]] = []
|
|
118
|
+
self._original_excepthook: Any = None
|
|
119
|
+
self._async_handlers: dict[asyncio.AbstractEventLoop, Any] = {}
|
|
120
|
+
self._fetch_impl: Callable[[str, dict[str, object]], ConfigFetchResponse] | None = None
|
|
121
|
+
self._on_diagnostic: Callable[[dict[str, object]], None] | None = None
|
|
122
|
+
self._configured_probes_poll_interval_ms = DEFAULT_PROBES_POLL_INTERVAL_MS
|
|
123
|
+
self._remote_config_etag: str | None = None
|
|
124
|
+
self._remote_config_snapshot: RemoteConfigSnapshot | None = None
|
|
125
|
+
self._capture_policy: CapturePolicy = BALANCED_CAPTURE_POLICY
|
|
126
|
+
self._request_trigger_directives: ContextVar[list[RemoteProbeDirective] | None] = ContextVar(
|
|
127
|
+
"debugbundle_request_trigger_directives",
|
|
128
|
+
default=None,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def status(self) -> str:
|
|
133
|
+
with self._lock:
|
|
134
|
+
if not self._enabled:
|
|
135
|
+
return "disconnected"
|
|
136
|
+
if self._consecutive_failures >= 3:
|
|
137
|
+
return "disconnected"
|
|
138
|
+
if self._retry_after > 0.0 and self._time_provider() < self._retry_after:
|
|
139
|
+
return "degraded"
|
|
140
|
+
return "healthy"
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def last_event_at(self) -> float | None:
|
|
144
|
+
with self._lock:
|
|
145
|
+
return self._last_event_at
|
|
146
|
+
|
|
147
|
+
def init(
|
|
148
|
+
self,
|
|
149
|
+
project_token: str,
|
|
150
|
+
environment: str | None = None,
|
|
151
|
+
service: str | None = None,
|
|
152
|
+
enabled: bool = True,
|
|
153
|
+
redact_fields: list[str] | None = None,
|
|
154
|
+
sample_rate: float = 1.0,
|
|
155
|
+
batch_size: int = DEFAULT_BATCH_SIZE,
|
|
156
|
+
flush_interval: float = DEFAULT_FLUSH_INTERVAL,
|
|
157
|
+
endpoint: str = DEFAULT_ENDPOINT,
|
|
158
|
+
log_level: str = DEFAULT_LOG_LEVEL,
|
|
159
|
+
max_probe_labels: int = 50,
|
|
160
|
+
max_probe_entries_per_label: int = 10,
|
|
161
|
+
probe_flush_on_error: bool = True,
|
|
162
|
+
fetch_impl: Callable[[str, dict[str, object]], ConfigFetchResponse] | None = None,
|
|
163
|
+
on_diagnostic: Callable[[dict[str, object]], None] | None = None,
|
|
164
|
+
probes_poll_interval: int = DEFAULT_PROBES_POLL_INTERVAL_MS,
|
|
165
|
+
) -> None:
|
|
166
|
+
with self._lock:
|
|
167
|
+
self.dispose()
|
|
168
|
+
self._project_token = project_token.strip()
|
|
169
|
+
self._service = service or "python-service"
|
|
170
|
+
self._environment = environment or "development"
|
|
171
|
+
self._enabled = enabled and len(self._project_token) > 0
|
|
172
|
+
self._endpoint = endpoint
|
|
173
|
+
self._batch_size = max(1, batch_size)
|
|
174
|
+
self._flush_interval = max(0.1, flush_interval)
|
|
175
|
+
self._log_level = _normalize_level(log_level)
|
|
176
|
+
self._sample_rate = min(max(sample_rate, 0.0), 1.0)
|
|
177
|
+
self._redact_fields = set(DEFAULT_REDACT_FIELDS)
|
|
178
|
+
if redact_fields:
|
|
179
|
+
self._redact_fields.update(field.lower() for field in redact_fields)
|
|
180
|
+
self._max_probe_labels = max(1, max_probe_labels)
|
|
181
|
+
self._max_probe_entries_per_label = max(1, max_probe_entries_per_label)
|
|
182
|
+
self._probe_flush_on_error = probe_flush_on_error
|
|
183
|
+
self._buffer = []
|
|
184
|
+
self._context = {}
|
|
185
|
+
self._probe_buffers = {}
|
|
186
|
+
self._suppression = EventSuppressionTracker()
|
|
187
|
+
self._retry_after = 0.0
|
|
188
|
+
self._last_event_at = None
|
|
189
|
+
self._consecutive_failures = 0
|
|
190
|
+
self._fetch_impl = fetch_impl
|
|
191
|
+
self._on_diagnostic = on_diagnostic
|
|
192
|
+
self._configured_probes_poll_interval_ms = max(1, int(probes_poll_interval))
|
|
193
|
+
self._remote_config_etag = None
|
|
194
|
+
self._remote_config_snapshot = None
|
|
195
|
+
self._capture_policy = BALANCED_CAPTURE_POLICY
|
|
196
|
+
self._transport = self._transport_override
|
|
197
|
+
if self._transport is None and self._enabled:
|
|
198
|
+
self._http_transport = HttpTransport(self._endpoint)
|
|
199
|
+
self._transport = self._http_transport
|
|
200
|
+
self.capture_exceptions()
|
|
201
|
+
if self._enabled and self._fetch_impl is not None:
|
|
202
|
+
self._refresh_remote_config(initial=True)
|
|
203
|
+
|
|
204
|
+
def capture_exception(self, error: BaseException, context: Mapping[str, object] | None = None) -> None:
|
|
205
|
+
self._capture_exception(error, context=context, handled=True)
|
|
206
|
+
|
|
207
|
+
def _capture_exception(
|
|
208
|
+
self,
|
|
209
|
+
error: BaseException,
|
|
210
|
+
context: Mapping[str, object] | None = None,
|
|
211
|
+
handled: bool = True,
|
|
212
|
+
) -> None:
|
|
213
|
+
with self._lock:
|
|
214
|
+
if not self._enabled or not self._passes_sample_rate():
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
redacted_context = _redact_mapping(dict(context or {}), self._redact_fields)
|
|
218
|
+
request_payload = _backend_exception_request_payload(redacted_context.get("request"))
|
|
219
|
+
response_payload = _backend_exception_response_payload(redacted_context.get("response"))
|
|
220
|
+
|
|
221
|
+
payload: dict[str, object] = {
|
|
222
|
+
"name": type(error).__name__,
|
|
223
|
+
"message": str(error),
|
|
224
|
+
"stack": "".join(traceback.format_exception(type(error), error, error.__traceback__)),
|
|
225
|
+
"handled": handled,
|
|
226
|
+
"request": request_payload,
|
|
227
|
+
"response": response_payload,
|
|
228
|
+
"runtime": {"version": platform.python_version()},
|
|
229
|
+
}
|
|
230
|
+
if self._probe_flush_on_error:
|
|
231
|
+
probe_data = self._build_probe_data()
|
|
232
|
+
if probe_data is not None:
|
|
233
|
+
payload["probe_data"] = probe_data
|
|
234
|
+
|
|
235
|
+
event = self._base_event("backend_exception", payload, context=redacted_context)
|
|
236
|
+
suppression_key = f"backend_exception:{payload['name']}:{payload['message']}:{payload.get('stack', '')}"
|
|
237
|
+
if not self._suppression.should_capture(suppression_key, self._time_provider()):
|
|
238
|
+
return
|
|
239
|
+
self._enqueue_event(event)
|
|
240
|
+
|
|
241
|
+
def capture_error(self, error: BaseException, context: Mapping[str, object] | None = None) -> None:
|
|
242
|
+
self.capture_exception(error, context=context)
|
|
243
|
+
|
|
244
|
+
def capture_log(
|
|
245
|
+
self,
|
|
246
|
+
message: str,
|
|
247
|
+
level: str = DEFAULT_LOG_LEVEL,
|
|
248
|
+
context: Mapping[str, object] | None = None,
|
|
249
|
+
) -> None:
|
|
250
|
+
normalized_level = _normalize_level(level)
|
|
251
|
+
with self._lock:
|
|
252
|
+
if (
|
|
253
|
+
not self._enabled
|
|
254
|
+
or not self._passes_sample_rate()
|
|
255
|
+
or self._capture_policy.capture_logs == "off"
|
|
256
|
+
or not _level_enabled(normalized_level, self._effective_log_threshold())
|
|
257
|
+
):
|
|
258
|
+
return
|
|
259
|
+
payload: dict[str, object] = {
|
|
260
|
+
"message": message,
|
|
261
|
+
"level": normalized_level,
|
|
262
|
+
"attributes": {},
|
|
263
|
+
}
|
|
264
|
+
if context:
|
|
265
|
+
payload["attributes"] = _redact_mapping(dict(context), self._redact_fields)
|
|
266
|
+
self._enqueue_event(self._base_event("log_event", payload, context=context))
|
|
267
|
+
|
|
268
|
+
def capture_request(
|
|
269
|
+
self,
|
|
270
|
+
request: Mapping[str, object],
|
|
271
|
+
response: Mapping[str, object] | None = None,
|
|
272
|
+
context: Mapping[str, object] | None = None,
|
|
273
|
+
) -> None:
|
|
274
|
+
with self._lock:
|
|
275
|
+
if not self._enabled or not self._passes_sample_rate() or not self._should_capture_request_event(response):
|
|
276
|
+
return
|
|
277
|
+
payload = _request_event_payload(
|
|
278
|
+
_redact_mapping(dict(request), self._redact_fields),
|
|
279
|
+
_redact_mapping(dict(response or {}), self._redact_fields),
|
|
280
|
+
_redact_mapping(dict(context or {}), self._redact_fields),
|
|
281
|
+
)
|
|
282
|
+
self._enqueue_event(self._base_event("request_event", payload, context=context))
|
|
283
|
+
|
|
284
|
+
def capture_message(
|
|
285
|
+
self,
|
|
286
|
+
message: str,
|
|
287
|
+
level: str | None = None,
|
|
288
|
+
context: Mapping[str, object] | None = None,
|
|
289
|
+
) -> None:
|
|
290
|
+
self.capture_log(message, level=level or DEFAULT_LOG_LEVEL, context=context)
|
|
291
|
+
|
|
292
|
+
def set_context(self, key: str, value: object) -> None:
|
|
293
|
+
with self._lock:
|
|
294
|
+
self._context[key] = _redact_mapping(value, self._redact_fields)
|
|
295
|
+
|
|
296
|
+
def _bind_scoped_context(self, context: Mapping[str, object]) -> Token[dict[str, object] | None]:
|
|
297
|
+
scoped_context = dict(self._scoped_context.get() or {})
|
|
298
|
+
for key, value in context.items():
|
|
299
|
+
if value is None:
|
|
300
|
+
continue
|
|
301
|
+
scoped_context[str(key)] = _redact_mapping(value, self._redact_fields)
|
|
302
|
+
return self._scoped_context.set(scoped_context)
|
|
303
|
+
|
|
304
|
+
def _reset_scoped_context(self, token: Token[dict[str, object] | None]) -> None:
|
|
305
|
+
self._scoped_context.reset(token)
|
|
306
|
+
|
|
307
|
+
def flush(self) -> None:
|
|
308
|
+
with self._lock:
|
|
309
|
+
if not self._enabled or self._transport is None:
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
self._append_suppression_aggregates()
|
|
313
|
+
if not self._buffer:
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
now = self._time_provider()
|
|
317
|
+
if now < self._retry_after:
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
request = {
|
|
321
|
+
"project_token": self._project_token,
|
|
322
|
+
"events": [dict(event) for event in self._buffer],
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
response = coerce_transport_response(self._transport(request))
|
|
327
|
+
except Exception:
|
|
328
|
+
self._consecutive_failures += 1
|
|
329
|
+
self._schedule_flush_locked()
|
|
330
|
+
return
|
|
331
|
+
|
|
332
|
+
if 200 <= response.status_code < 300:
|
|
333
|
+
self._buffer = []
|
|
334
|
+
self._retry_after = 0.0
|
|
335
|
+
self._last_event_at = self._time_provider() * 1000
|
|
336
|
+
self._consecutive_failures = 0
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
self._consecutive_failures += 1
|
|
340
|
+
if response.status_code == 429:
|
|
341
|
+
retry_after_ms = response.retry_after_ms if response.retry_after_ms is not None else 1_000
|
|
342
|
+
self._retry_after = now + (retry_after_ms / 1000)
|
|
343
|
+
self._schedule_flush_locked(delay=retry_after_ms / 1000)
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
if 400 <= response.status_code < 500:
|
|
347
|
+
self._buffer = []
|
|
348
|
+
self._retry_after = 0.0
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
self._schedule_flush_locked()
|
|
352
|
+
|
|
353
|
+
def probe(self, label: str, data: object | Callable[[], object], opts: Mapping[str, object] | None = None) -> None:
|
|
354
|
+
with self._lock:
|
|
355
|
+
if not self._enabled:
|
|
356
|
+
return
|
|
357
|
+
options = dict(opts or {})
|
|
358
|
+
now_ms = int(self._time_provider() * 1000)
|
|
359
|
+
matching_directives = self._find_matching_probe_directives(label, now_ms)
|
|
360
|
+
is_heavy = options.get("heavy") is True
|
|
361
|
+
if is_heavy and not matching_directives:
|
|
362
|
+
return
|
|
363
|
+
if label not in self._probe_buffers and len(self._probe_buffers) >= self._max_probe_labels:
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
value = data() if callable(data) else data
|
|
367
|
+
if not isinstance(value, Mapping):
|
|
368
|
+
value = {"value": value}
|
|
369
|
+
|
|
370
|
+
redacted_value = _redact_mapping(dict(value), self._redact_fields)
|
|
371
|
+
|
|
372
|
+
if is_heavy:
|
|
373
|
+
self._emit_probe_events(label, redacted_value, matching_directives)
|
|
374
|
+
return
|
|
375
|
+
|
|
376
|
+
entry = ProbeEntry(
|
|
377
|
+
label=label,
|
|
378
|
+
data=redacted_value,
|
|
379
|
+
timestamp=_iso_now(self._time_provider),
|
|
380
|
+
)
|
|
381
|
+
bucket = self._probe_buffers.setdefault(label, deque(maxlen=self._max_probe_entries_per_label))
|
|
382
|
+
bucket.append(entry)
|
|
383
|
+
self._emit_probe_events(label, redacted_value, matching_directives)
|
|
384
|
+
|
|
385
|
+
def capture_exceptions(self) -> None:
|
|
386
|
+
with self._lock:
|
|
387
|
+
if self._original_excepthook is None:
|
|
388
|
+
self._original_excepthook = sys.excepthook
|
|
389
|
+
|
|
390
|
+
def handler(exc_type: type[BaseException], error: BaseException, tb: Any) -> None:
|
|
391
|
+
if error.__traceback__ is None:
|
|
392
|
+
error.__traceback__ = tb
|
|
393
|
+
self._capture_exception(error, handled=False)
|
|
394
|
+
|
|
395
|
+
sys.excepthook = handler
|
|
396
|
+
|
|
397
|
+
def capture_logging(self, logger: logging.Logger | None = None) -> None:
|
|
398
|
+
with self._lock:
|
|
399
|
+
target_logger = logger or logging.getLogger()
|
|
400
|
+
logger_id = id(target_logger)
|
|
401
|
+
if logger_id in self._logging_bindings:
|
|
402
|
+
if not self._optional_logging_restorers:
|
|
403
|
+
self._optional_logging_restorers = attach_optional_integrations(self, self._on_diagnostic)
|
|
404
|
+
return
|
|
405
|
+
handler = DebugBundleLogHandler(self)
|
|
406
|
+
target_logger.addHandler(handler)
|
|
407
|
+
self._logging_bindings[logger_id] = (target_logger, handler)
|
|
408
|
+
if not self._optional_logging_restorers:
|
|
409
|
+
self._optional_logging_restorers = attach_optional_integrations(self, self._on_diagnostic)
|
|
410
|
+
|
|
411
|
+
def capture_async(self, loop: asyncio.AbstractEventLoop | None = None) -> None:
|
|
412
|
+
with self._lock:
|
|
413
|
+
target_loop = loop or asyncio.get_event_loop()
|
|
414
|
+
if target_loop in self._async_handlers:
|
|
415
|
+
return
|
|
416
|
+
self._async_handlers[target_loop] = target_loop.get_exception_handler()
|
|
417
|
+
|
|
418
|
+
def handler(async_loop: asyncio.AbstractEventLoop, context: dict[str, object]) -> None:
|
|
419
|
+
error = context.get("exception")
|
|
420
|
+
if isinstance(error, BaseException):
|
|
421
|
+
self._capture_exception(error, handled=False)
|
|
422
|
+
return
|
|
423
|
+
message = str(context.get("message") or "asyncio exception")
|
|
424
|
+
self.capture_message(message, level="error")
|
|
425
|
+
|
|
426
|
+
target_loop.set_exception_handler(handler)
|
|
427
|
+
|
|
428
|
+
def dispose(self) -> None:
|
|
429
|
+
with self._lock:
|
|
430
|
+
if self._timer is not None:
|
|
431
|
+
self._timer.cancel()
|
|
432
|
+
self._timer = None
|
|
433
|
+
if self._remote_config_timer is not None:
|
|
434
|
+
self._remote_config_timer.cancel()
|
|
435
|
+
self._remote_config_timer = None
|
|
436
|
+
for logger, handler in self._logging_bindings.values():
|
|
437
|
+
logger.removeHandler(handler)
|
|
438
|
+
self._logging_bindings.clear()
|
|
439
|
+
for restore in self._optional_logging_restorers:
|
|
440
|
+
restore()
|
|
441
|
+
self._optional_logging_restorers.clear()
|
|
442
|
+
if self._original_excepthook is not None:
|
|
443
|
+
sys.excepthook = self._original_excepthook
|
|
444
|
+
self._original_excepthook = None
|
|
445
|
+
for loop, handler in list(self._async_handlers.items()):
|
|
446
|
+
loop.set_exception_handler(handler)
|
|
447
|
+
self._async_handlers.clear()
|
|
448
|
+
if self._http_transport is not None:
|
|
449
|
+
self._http_transport.close()
|
|
450
|
+
self._http_transport = None
|
|
451
|
+
|
|
452
|
+
def _refresh_remote_config(self, initial: bool = False) -> None:
|
|
453
|
+
with self._lock:
|
|
454
|
+
if not self._enabled or self._fetch_impl is None:
|
|
455
|
+
return
|
|
456
|
+
|
|
457
|
+
request_headers: dict[str, str] = {}
|
|
458
|
+
if self._remote_config_etag is not None:
|
|
459
|
+
request_headers["if-none-match"] = self._remote_config_etag
|
|
460
|
+
|
|
461
|
+
try:
|
|
462
|
+
response = self._fetch_impl(
|
|
463
|
+
_sdk_config_endpoint(self._endpoint),
|
|
464
|
+
{
|
|
465
|
+
"method": "GET",
|
|
466
|
+
"headers": request_headers,
|
|
467
|
+
},
|
|
468
|
+
)
|
|
469
|
+
status_code = getattr(response, "status_code", None)
|
|
470
|
+
if status_code == 304:
|
|
471
|
+
self._schedule_next_remote_config_refresh()
|
|
472
|
+
return
|
|
473
|
+
if status_code != 200:
|
|
474
|
+
raise RuntimeError(f"unexpected config status {status_code}")
|
|
475
|
+
|
|
476
|
+
payload = response.json()
|
|
477
|
+
snapshot = parse_remote_config(
|
|
478
|
+
payload,
|
|
479
|
+
self._configured_probes_poll_interval_ms,
|
|
480
|
+
int(self._time_provider() * 1000),
|
|
481
|
+
)
|
|
482
|
+
if snapshot is None:
|
|
483
|
+
self._emit_diagnostic(
|
|
484
|
+
"remote_probe_config_invalid",
|
|
485
|
+
"sdk-python received an invalid remote probe config payload",
|
|
486
|
+
)
|
|
487
|
+
if initial:
|
|
488
|
+
self._capture_policy = MINIMAL_CAPTURE_POLICY
|
|
489
|
+
self._schedule_next_remote_config_refresh(use_fallback=True)
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
self._remote_config_snapshot = snapshot
|
|
493
|
+
self._capture_policy = snapshot.capture_policy
|
|
494
|
+
headers = response.headers or {}
|
|
495
|
+
etag = headers.get("etag")
|
|
496
|
+
if isinstance(etag, str) and len(etag) > 0:
|
|
497
|
+
self._remote_config_etag = etag
|
|
498
|
+
self._schedule_next_remote_config_refresh()
|
|
499
|
+
except Exception as error:
|
|
500
|
+
self._emit_diagnostic(
|
|
501
|
+
"remote_probe_config_failed",
|
|
502
|
+
"sdk-python failed to refresh remote probe config",
|
|
503
|
+
metadata={"error": _serialize_error(error)},
|
|
504
|
+
)
|
|
505
|
+
if initial:
|
|
506
|
+
self._capture_policy = MINIMAL_CAPTURE_POLICY
|
|
507
|
+
self._schedule_next_remote_config_refresh(use_fallback=True)
|
|
508
|
+
|
|
509
|
+
def _base_event(
|
|
510
|
+
self,
|
|
511
|
+
event_type: str,
|
|
512
|
+
payload: dict[str, object],
|
|
513
|
+
context: Mapping[str, object] | None = None,
|
|
514
|
+
) -> dict[str, object]:
|
|
515
|
+
return {
|
|
516
|
+
"schema_version": SCHEMA_VERSION,
|
|
517
|
+
"event_id": str(uuid.uuid4()),
|
|
518
|
+
"event_type": event_type,
|
|
519
|
+
"occurred_at": _iso_now(self._time_provider),
|
|
520
|
+
"sdk_name": "debugbundle-python",
|
|
521
|
+
"sdk_version": _sdk_version(),
|
|
522
|
+
"sdk_language": "python",
|
|
523
|
+
"service": {
|
|
524
|
+
"name": self._service,
|
|
525
|
+
"runtime": "python",
|
|
526
|
+
"framework": None,
|
|
527
|
+
"environment": self._environment,
|
|
528
|
+
},
|
|
529
|
+
"correlation": _correlation_payload(self._merged_context(context)),
|
|
530
|
+
"payload": payload,
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
def _merged_context(self, context: Mapping[str, object] | None = None) -> dict[str, object]:
|
|
534
|
+
merged = dict(self._context)
|
|
535
|
+
scoped_context = self._scoped_context.get()
|
|
536
|
+
if scoped_context is not None:
|
|
537
|
+
merged.update(scoped_context)
|
|
538
|
+
if context is not None:
|
|
539
|
+
for key, value in context.items():
|
|
540
|
+
merged[str(key)] = _redact_mapping(value, self._redact_fields)
|
|
541
|
+
return merged
|
|
542
|
+
|
|
543
|
+
def _enqueue_event(self, event: dict[str, object]) -> None:
|
|
544
|
+
self._buffer.append(event)
|
|
545
|
+
if len(self._buffer) >= self._batch_size:
|
|
546
|
+
self.flush()
|
|
547
|
+
return
|
|
548
|
+
self._schedule_flush_locked()
|
|
549
|
+
|
|
550
|
+
def _schedule_flush_locked(self, delay: float | None = None) -> None:
|
|
551
|
+
if self._timer is not None:
|
|
552
|
+
self._timer.cancel()
|
|
553
|
+
next_delay = self._flush_interval if delay is None else max(delay, 0.0)
|
|
554
|
+
self._timer = threading.Timer(next_delay, self.flush)
|
|
555
|
+
self._timer.daemon = True
|
|
556
|
+
self._timer.start()
|
|
557
|
+
|
|
558
|
+
def _append_suppression_aggregates(self) -> None:
|
|
559
|
+
aggregates = self._suppression.drain_aggregates(self._time_provider())
|
|
560
|
+
for aggregate in aggregates:
|
|
561
|
+
event_type = aggregate.get("event_type")
|
|
562
|
+
payload = aggregate.get("payload")
|
|
563
|
+
if not isinstance(event_type, str) or not isinstance(payload, dict):
|
|
564
|
+
continue
|
|
565
|
+
aggregate.update(self._base_event(event_type, cast(dict[str, object], payload)))
|
|
566
|
+
self._buffer.append(aggregate)
|
|
567
|
+
|
|
568
|
+
def _build_probe_data(self) -> dict[str, object] | None:
|
|
569
|
+
items: list[dict[str, object]] = []
|
|
570
|
+
for label, bucket in self._probe_buffers.items():
|
|
571
|
+
for entry in bucket:
|
|
572
|
+
items.append(
|
|
573
|
+
{
|
|
574
|
+
"label": label,
|
|
575
|
+
"activation_id": None,
|
|
576
|
+
"timestamp": entry.timestamp,
|
|
577
|
+
"data": dict(entry.data),
|
|
578
|
+
}
|
|
579
|
+
)
|
|
580
|
+
if not items:
|
|
581
|
+
return None
|
|
582
|
+
return {"version": 1, "items": items}
|
|
583
|
+
|
|
584
|
+
def _passes_sample_rate(self) -> bool:
|
|
585
|
+
return self._sample_rate >= 1.0 or random() <= self._sample_rate
|
|
586
|
+
|
|
587
|
+
def _effective_log_threshold(self) -> str:
|
|
588
|
+
policy_threshold = self._capture_policy.capture_logs
|
|
589
|
+
return self._log_level if LEVEL_RANKS[self._log_level] >= LEVEL_RANKS[policy_threshold] else policy_threshold
|
|
590
|
+
|
|
591
|
+
def _should_capture_request_event(self, response: Mapping[str, object] | None) -> bool:
|
|
592
|
+
policy = self._capture_policy.capture_request_events
|
|
593
|
+
if policy == "off":
|
|
594
|
+
return False
|
|
595
|
+
if policy == "all":
|
|
596
|
+
return True
|
|
597
|
+
if response is None:
|
|
598
|
+
return policy == "filtered"
|
|
599
|
+
status_code = response.get("status_code") or response.get("response_status")
|
|
600
|
+
if not isinstance(status_code, int):
|
|
601
|
+
return policy == "filtered"
|
|
602
|
+
if policy == "failures_only":
|
|
603
|
+
return status_code >= 500
|
|
604
|
+
if policy == "filtered":
|
|
605
|
+
return status_code >= 500
|
|
606
|
+
return True
|
|
607
|
+
|
|
608
|
+
def _emit_probe_events(self, label: str, data: dict[str, object], directives: list[RemoteProbeDirective]) -> None:
|
|
609
|
+
if self._capture_policy.capture_probe_events != "standalone_when_activated":
|
|
610
|
+
return
|
|
611
|
+
for directive in directives:
|
|
612
|
+
payload = {
|
|
613
|
+
"label": label,
|
|
614
|
+
"activation_id": getattr(directive, "id"),
|
|
615
|
+
"probe_label_pattern": getattr(directive, "label_pattern"),
|
|
616
|
+
"data": dict(data),
|
|
617
|
+
}
|
|
618
|
+
self._enqueue_event(self._base_event("probe_event", payload))
|
|
619
|
+
|
|
620
|
+
def begin_request(self, request: dict[str, Any]) -> Token[list[RemoteProbeDirective] | None]:
|
|
621
|
+
trigger_token_key = (
|
|
622
|
+
self._remote_config_snapshot.trigger_token_key if self._remote_config_snapshot is not None else None
|
|
623
|
+
)
|
|
624
|
+
directives = resolve_request_trigger_directives(
|
|
625
|
+
request,
|
|
626
|
+
trigger_token_key,
|
|
627
|
+
int(self._time_provider() * 1000),
|
|
628
|
+
)
|
|
629
|
+
return self._request_trigger_directives.set(directives)
|
|
630
|
+
|
|
631
|
+
def end_request(self, token: Token[list[RemoteProbeDirective] | None]) -> None:
|
|
632
|
+
self._request_trigger_directives.reset(token)
|
|
633
|
+
|
|
634
|
+
def _find_matching_probe_directives(self, label: str, now_ms: int) -> list[RemoteProbeDirective]:
|
|
635
|
+
directives: list[RemoteProbeDirective] = []
|
|
636
|
+
trigger_directives = self._request_trigger_directives.get()
|
|
637
|
+
if trigger_directives is not None:
|
|
638
|
+
directives.extend(trigger_directives)
|
|
639
|
+
if self._remote_config_snapshot is not None and self._remote_config_snapshot.remote_probes_enabled:
|
|
640
|
+
directives.extend(self._remote_config_snapshot.directives)
|
|
641
|
+
if not directives:
|
|
642
|
+
return []
|
|
643
|
+
return find_matching_remote_probe_directives(
|
|
644
|
+
directives,
|
|
645
|
+
label,
|
|
646
|
+
self._service,
|
|
647
|
+
self._environment,
|
|
648
|
+
now_ms,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
def _schedule_next_remote_config_refresh(self, use_fallback: bool = False) -> None:
|
|
652
|
+
if self._remote_config_timer is not None:
|
|
653
|
+
self._remote_config_timer.cancel()
|
|
654
|
+
self._remote_config_timer = None
|
|
655
|
+
if self._fetch_impl is None:
|
|
656
|
+
return
|
|
657
|
+
if (
|
|
658
|
+
not use_fallback
|
|
659
|
+
and self._remote_config_snapshot is not None
|
|
660
|
+
and not self._remote_config_snapshot.remote_probes_enabled
|
|
661
|
+
):
|
|
662
|
+
return
|
|
663
|
+
delay_ms = (
|
|
664
|
+
self._configured_probes_poll_interval_ms
|
|
665
|
+
if use_fallback or self._remote_config_snapshot is None
|
|
666
|
+
else self._remote_config_snapshot.poll_interval_ms
|
|
667
|
+
)
|
|
668
|
+
self._remote_config_timer = threading.Timer(delay_ms / 1000, self._refresh_remote_config)
|
|
669
|
+
self._remote_config_timer.daemon = True
|
|
670
|
+
self._remote_config_timer.start()
|
|
671
|
+
|
|
672
|
+
def _emit_diagnostic(self, code: str, message: str, metadata: dict[str, object] | None = None) -> None:
|
|
673
|
+
if self._on_diagnostic is None:
|
|
674
|
+
return
|
|
675
|
+
diagnostic: dict[str, object] = {"code": code, "message": message}
|
|
676
|
+
if metadata is not None:
|
|
677
|
+
diagnostic["metadata"] = metadata
|
|
678
|
+
self._on_diagnostic(diagnostic)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _normalize_level(level: str) -> str:
|
|
682
|
+
normalized = level.lower().strip()
|
|
683
|
+
return normalized if normalized in LEVEL_RANKS else DEFAULT_LOG_LEVEL
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _level_enabled(candidate: str, threshold: str) -> bool:
|
|
687
|
+
return LEVEL_RANKS[candidate] >= LEVEL_RANKS[threshold]
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _redact_mapping(value: object, redact_fields: set[str]) -> Any:
|
|
691
|
+
if isinstance(value, Mapping):
|
|
692
|
+
return redact_value(value, redact_fields)
|
|
693
|
+
return value
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _backend_exception_request_payload(candidate: object | None) -> dict[str, object]:
|
|
697
|
+
mapping = _dict_from_object(candidate)
|
|
698
|
+
payload: dict[str, object] = {
|
|
699
|
+
"method": str(mapping.get("method") or "UNKNOWN"),
|
|
700
|
+
"path": str(mapping.get("path") or "/"),
|
|
701
|
+
"query": _dict_from_object(mapping.get("query")),
|
|
702
|
+
"headers": _dict_from_object(mapping.get("headers")),
|
|
703
|
+
}
|
|
704
|
+
if "body" in mapping:
|
|
705
|
+
payload["body"] = mapping.get("body")
|
|
706
|
+
return payload
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _backend_exception_response_payload(candidate: object | None) -> dict[str, object]:
|
|
710
|
+
mapping = _dict_from_object(candidate)
|
|
711
|
+
payload: dict[str, object] = {
|
|
712
|
+
"status_code": _coerce_int(mapping.get("status_code") or mapping.get("response_status"), 0),
|
|
713
|
+
}
|
|
714
|
+
if "headers" in mapping:
|
|
715
|
+
payload["headers"] = _dict_from_object(mapping.get("headers"))
|
|
716
|
+
if "body" in mapping:
|
|
717
|
+
payload["body"] = mapping.get("body")
|
|
718
|
+
return payload
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _request_event_payload(
|
|
722
|
+
request: Mapping[str, object],
|
|
723
|
+
response: Mapping[str, object],
|
|
724
|
+
context: Mapping[str, object],
|
|
725
|
+
) -> dict[str, object]:
|
|
726
|
+
payload: dict[str, object] = {
|
|
727
|
+
"method": str(request.get("method") or "UNKNOWN"),
|
|
728
|
+
"path": str(request.get("path") or "/"),
|
|
729
|
+
"query": _dict_from_object(request.get("query")),
|
|
730
|
+
"headers": _dict_from_object(request.get("headers")),
|
|
731
|
+
"response_status": _coerce_int(response.get("response_status") or response.get("status_code"), 0),
|
|
732
|
+
"duration_ms": _coerce_int(response.get("duration_ms"), 0),
|
|
733
|
+
}
|
|
734
|
+
if "body" in request:
|
|
735
|
+
payload["body"] = request.get("body")
|
|
736
|
+
route_template = context.get("route_template") or response.get("route_template") or request.get("route_template")
|
|
737
|
+
if route_template is not None:
|
|
738
|
+
payload["route_template"] = str(route_template)
|
|
739
|
+
response_headers = response.get("response_headers") or response.get("headers")
|
|
740
|
+
if response_headers:
|
|
741
|
+
payload["response_headers"] = _dict_from_object(response_headers)
|
|
742
|
+
if "response_body" in response:
|
|
743
|
+
payload["response_body"] = response.get("response_body")
|
|
744
|
+
elif "body" in response and response.get("body") is not None:
|
|
745
|
+
payload["response_body"] = response.get("body")
|
|
746
|
+
return payload
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _coerce_int(value: object, default: int) -> int:
|
|
750
|
+
if isinstance(value, bool):
|
|
751
|
+
return default
|
|
752
|
+
if isinstance(value, int):
|
|
753
|
+
return value
|
|
754
|
+
if isinstance(value, float):
|
|
755
|
+
return int(value)
|
|
756
|
+
if isinstance(value, str):
|
|
757
|
+
try:
|
|
758
|
+
return int(value)
|
|
759
|
+
except ValueError:
|
|
760
|
+
return default
|
|
761
|
+
return default
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _dict_from_object(value: object | None) -> dict[str, object]:
|
|
765
|
+
if isinstance(value, Mapping):
|
|
766
|
+
return {str(key): cast(object, nested_value) for key, nested_value in value.items()}
|
|
767
|
+
return {}
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _correlation_payload(context: Mapping[str, object]) -> dict[str, str | None]:
|
|
771
|
+
return {
|
|
772
|
+
"request_id": _coerce_optional_string(context.get("request_id")),
|
|
773
|
+
"trace_id": _coerce_optional_string(context.get("trace_id")),
|
|
774
|
+
"session_id": _coerce_optional_string(context.get("session_id")),
|
|
775
|
+
"user_id_hash": _coerce_optional_string(context.get("user_id_hash")),
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _coerce_optional_string(value: object) -> str | None:
|
|
780
|
+
if value is None:
|
|
781
|
+
return None
|
|
782
|
+
return str(value)
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _iso_now(time_provider: Callable[[], float]) -> str:
|
|
786
|
+
return datetime.fromtimestamp(time_provider(), tz=timezone.utc).isoformat().replace("+00:00", "Z")
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _time_now() -> float:
|
|
790
|
+
return datetime.now(tz=timezone.utc).timestamp()
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _sdk_version() -> str:
|
|
794
|
+
try:
|
|
795
|
+
return metadata.version("debugbundle-python")
|
|
796
|
+
except metadata.PackageNotFoundError:
|
|
797
|
+
return "0.1.0"
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _sdk_config_endpoint(events_endpoint: str) -> str:
|
|
801
|
+
if events_endpoint.endswith("/v1/events"):
|
|
802
|
+
return f"{events_endpoint[:-len('/v1/events')]}/v1/sdk/config"
|
|
803
|
+
return f"{events_endpoint.rstrip('/')}/sdk/config"
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _serialize_error(error: Exception) -> dict[str, object]:
|
|
807
|
+
return {
|
|
808
|
+
"name": type(error).__name__,
|
|
809
|
+
"message": str(error),
|
|
810
|
+
"stack": "".join(traceback.format_exception(type(error), error, error.__traceback__)),
|
|
811
|
+
}
|