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/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
+ }