debugbundle-python 1.4.2__py3-none-any.whl → 2.0.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 CHANGED
@@ -1,20 +1,22 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import json
4
5
  import logging
6
+ import os
5
7
  import sys
6
8
  import threading
7
- import traceback
9
+ import time
8
10
  import uuid
9
11
  from collections import deque
10
12
  from collections.abc import Callable, Mapping
11
13
  from contextvars import ContextVar, Token
12
- from dataclasses import dataclass
13
14
  from random import random
14
15
  from typing import Any, Protocol, cast
15
16
 
16
17
  from .acknowledgement import decide_acknowledgement
17
18
  from .before_send import BeforeSendHook, apply_before_send
19
+ from .capture_hooks import DebugBundleLogHandler, try_capture_lock
18
20
  from .config import (
19
21
  BALANCED_CAPTURE_POLICY,
20
22
  DEFAULT_PROBES_POLL_INTERVAL_MS,
@@ -25,6 +27,7 @@ from .config import (
25
27
  find_matching_remote_probe_directives,
26
28
  parse_remote_config,
27
29
  )
30
+ from .delivery_hooks import finalize_batch
28
31
  from .event_support import (
29
32
  DEFAULT_LOG_LEVEL,
30
33
  LEVEL_RANKS,
@@ -32,7 +35,6 @@ from .event_support import (
32
35
  backend_exception_response_payload,
33
36
  correlation_payload,
34
37
  event_context,
35
- is_immediate_request_incident_status,
36
38
  iso_now,
37
39
  level_enabled,
38
40
  normalize_level,
@@ -44,8 +46,20 @@ from .event_support import (
44
46
  serialize_error,
45
47
  time_now,
46
48
  )
49
+ from .exception_snapshot import exception_details
50
+ from .flush_aggregates import prepare_flush_aggregates
47
51
  from .logger_integrations import attach_optional_integrations
48
- from .redaction import DEFAULT_REDACT_FIELDS, redact_value
52
+ from .probe_capture import ProbeEntry, capture_probe
53
+ from .process_support import ensure_current_process
54
+ from .queue_admission import evict_low_priority, high_priority_event, may_prepare_event
55
+ from .redaction import (
56
+ DEFAULT_REDACT_FIELDS,
57
+ UnsafeTelemetry,
58
+ has_safe_event_identity,
59
+ redact_value,
60
+ sanitize_telemetry,
61
+ )
62
+ from .request_capture_policy import should_capture_request_event
49
63
  from .suppression import EventSuppressionTracker
50
64
  from .transport import HttpTransport, Transport, coerce_transport_response
51
65
  from .trigger_token import resolve_request_trigger_directives
@@ -54,13 +68,8 @@ DEFAULT_BATCH_SIZE = 25
54
68
  DEFAULT_FLUSH_INTERVAL = 5.0
55
69
  DEFAULT_ENDPOINT = "https://api.debugbundle.com/v1/events"
56
70
  SCHEMA_VERSION = "2026-03-01"
57
-
58
-
59
- @dataclass
60
- class ProbeEntry:
61
- label: str
62
- data: dict[str, object]
63
- timestamp: str
71
+ MAX_PENDING_EVENTS = 1_000
72
+ MAX_PENDING_BYTES = 8 * 1024 * 1024
64
73
 
65
74
 
66
75
  class ConfigFetchResponse(Protocol):
@@ -70,23 +79,6 @@ class ConfigFetchResponse(Protocol):
70
79
  def json(self) -> object: ...
71
80
 
72
81
 
73
- class DebugBundleLogHandler(logging.Handler):
74
- def __init__(self, sdk: DebugBundleSdk) -> None:
75
- super().__init__()
76
- self._sdk = sdk
77
-
78
- def emit(self, record: logging.LogRecord) -> None:
79
- self._sdk.capture_log(
80
- record.getMessage(),
81
- level=record.levelname.lower(),
82
- context={
83
- "logger_name": record.name,
84
- "pathname": record.pathname,
85
- "lineno": record.lineno,
86
- },
87
- )
88
-
89
-
90
82
  class DebugBundleSdk:
91
83
  def __init__(
92
84
  self,
@@ -94,10 +86,12 @@ class DebugBundleSdk:
94
86
  time_provider: Callable[[], float] | None = None,
95
87
  ) -> None:
96
88
  self._transport_override = transport
89
+ self._pid = os.getpid()
97
90
  self._time_provider = time_provider or time_now
98
91
  self._lock = threading.RLock()
99
92
  self._timer: threading.Timer | None = None
100
93
  self._remote_config_timer: threading.Timer | None = None
94
+ self._initial_config_ready = threading.Event()
101
95
  self._transport: Transport | None = None
102
96
  self._http_transport: HttpTransport | None = None
103
97
  self._enabled = False
@@ -111,6 +105,15 @@ class DebugBundleSdk:
111
105
  self._sample_rate = 1.0
112
106
  self._redact_fields = set(DEFAULT_REDACT_FIELDS)
113
107
  self._buffer: list[dict[str, object]] = []
108
+ self._buffer_sizes: dict[int, int] = {}
109
+ self._buffer_bytes = 0
110
+ self._pending_low_priority = 0
111
+ self._sending = False
112
+ self._inflight_count = 0
113
+ self._timer_due_at = 0.0
114
+ self._generation = 0
115
+ self._pressure_drops = 0
116
+ self._last_pressure_report_at = 0.0
114
117
  self._context: dict[str, object] = {}
115
118
  self._scoped_context: ContextVar[dict[str, object] | None] = ContextVar(
116
119
  "debugbundle_scoped_context",
@@ -141,6 +144,7 @@ class DebugBundleSdk:
141
144
 
142
145
  @property
143
146
  def status(self) -> str:
147
+ ensure_current_process(self)
144
148
  with self._lock:
145
149
  if not self._enabled:
146
150
  return "disconnected"
@@ -152,6 +156,7 @@ class DebugBundleSdk:
152
156
 
153
157
  @property
154
158
  def last_event_at(self) -> float | None:
159
+ ensure_current_process(self)
155
160
  with self._lock:
156
161
  return self._last_event_at
157
162
 
@@ -175,6 +180,7 @@ class DebugBundleSdk:
175
180
  before_send: BeforeSendHook | None = None,
176
181
  probes_poll_interval: int = DEFAULT_PROBES_POLL_INTERVAL_MS,
177
182
  ) -> None:
183
+ ensure_current_process(self)
178
184
  with self._lock:
179
185
  self.dispose()
180
186
  self._project_token = project_token.strip()
@@ -193,6 +199,10 @@ class DebugBundleSdk:
193
199
  self._max_probe_entries_per_label = max(1, max_probe_entries_per_label)
194
200
  self._probe_flush_on_error = probe_flush_on_error
195
201
  self._buffer = []
202
+ self._buffer_sizes = {}
203
+ self._buffer_bytes = 0
204
+ self._pending_low_priority = 0
205
+ self._generation += 1
196
206
  self._context = {}
197
207
  self._probe_buffers = {}
198
208
  self._suppression = EventSuppressionTracker()
@@ -205,14 +215,25 @@ class DebugBundleSdk:
205
215
  self._configured_probes_poll_interval_ms = max(1, int(probes_poll_interval))
206
216
  self._remote_config_etag = None
207
217
  self._remote_config_snapshot = None
208
- self._capture_policy = BALANCED_CAPTURE_POLICY
218
+ self._capture_policy = MINIMAL_CAPTURE_POLICY if fetch_impl is not None else BALANCED_CAPTURE_POLICY
209
219
  self._transport = self._transport_override
210
220
  if self._transport is None and self._enabled:
211
221
  self._http_transport = HttpTransport(self._endpoint)
212
222
  self._transport = self._http_transport
213
223
  self.capture_exceptions()
214
224
  if self._enabled and self._fetch_impl is not None:
215
- self._refresh_remote_config(initial=True)
225
+ self._initial_config_ready.clear()
226
+ self._remote_config_timer = threading.Timer(0.0, self._refresh_initial_remote_config)
227
+ self._remote_config_timer.daemon = True
228
+ self._remote_config_timer.start()
229
+ else:
230
+ self._initial_config_ready.set()
231
+
232
+ def _refresh_initial_remote_config(self) -> None:
233
+ try:
234
+ self._refresh_remote_config(initial=True)
235
+ finally:
236
+ self._initial_config_ready.set()
216
237
 
217
238
  def capture_exception(self, error: BaseException, context: Mapping[str, object] | None = None) -> None:
218
239
  self._capture_exception(error, context=context, handled=True)
@@ -223,31 +244,33 @@ class DebugBundleSdk:
223
244
  context: Mapping[str, object] | None = None,
224
245
  handled: bool = True,
225
246
  ) -> None:
226
- with self._lock:
227
- if not self._enabled:
228
- return
229
-
247
+ ensure_current_process(self)
248
+ if not self._enabled or not may_prepare_event(self, True, MAX_PENDING_EVENTS, MAX_PENDING_BYTES):
249
+ return
250
+ try:
251
+ name, message, stack = exception_details(error)
230
252
  redacted_context = redact_mapping(dict(context or {}), self._redact_fields)
231
253
  request_payload = backend_exception_request_payload(redacted_context.get("request"))
232
254
  response_payload = backend_exception_response_payload(redacted_context.get("response"))
233
255
 
234
256
  payload: dict[str, object] = {
235
- "name": type(error).__name__,
236
- "message": str(error),
237
- "stack": "".join(traceback.format_exception(type(error), error, error.__traceback__)),
257
+ "name": name,
258
+ "message": message,
259
+ "stack": stack,
238
260
  "handled": handled,
239
261
  "request": request_payload,
240
262
  "response": response_payload,
241
263
  "runtime": runtime_process_facts(),
242
264
  }
243
- if self._probe_flush_on_error:
244
- probe_data = self._build_probe_data()
265
+ if self._probe_flush_on_error and try_capture_lock(self):
266
+ try:
267
+ probe_data = self._build_probe_data()
268
+ finally:
269
+ self._lock.release()
245
270
  if probe_data is not None:
246
271
  payload["probe_data"] = probe_data
247
272
 
248
- event = self._apply_before_send_event(
249
- self._base_event("backend_exception", payload, context=redacted_context)
250
- )
273
+ event = self._protect_event(self._base_event("backend_exception", payload, context=redacted_context))
251
274
  if event is None or not self._passes_sample_rate():
252
275
  return
253
276
  event_payload = cast(dict[str, object], event["payload"])
@@ -255,9 +278,17 @@ class DebugBundleSdk:
255
278
  f"{event['event_type']}:{event_payload.get('name', '')}:"
256
279
  f"{event_payload.get('message', '')}:{event_payload.get('stack', '')}"
257
280
  )
258
- if not self._suppression.should_capture(suppression_key, self._time_provider()):
281
+ event_bytes = self._event_bytes(event)
282
+ if not try_capture_lock(self):
283
+ self._pressure_drops += 1
259
284
  return
260
- self._enqueue_event(event)
285
+ try:
286
+ if self._enabled and self._suppression.should_capture(suppression_key, self._time_provider()):
287
+ self._offer_protected_event(event, event_bytes)
288
+ finally:
289
+ self._lock.release()
290
+ except BaseException:
291
+ return
261
292
 
262
293
  def capture_error(self, error: BaseException, context: Mapping[str, object] | None = None) -> None:
263
294
  self.capture_exception(error, context=context)
@@ -268,9 +299,16 @@ class DebugBundleSdk:
268
299
  level: str = DEFAULT_LOG_LEVEL,
269
300
  context: Mapping[str, object] | None = None,
270
301
  ) -> None:
271
- normalized_level = normalize_level(level)
272
- with self._lock:
273
- if not self._enabled:
302
+ try:
303
+ normalized_level = normalize_level(level)
304
+ if not self._enabled or self._capture_policy.capture_logs == "off" or not level_enabled(
305
+ normalized_level, self._effective_log_threshold()
306
+ ):
307
+ return
308
+ ensure_current_process(self)
309
+ if not may_prepare_event(
310
+ self, normalized_level in ("error", "critical"), MAX_PENDING_EVENTS, MAX_PENDING_BYTES
311
+ ):
274
312
  return
275
313
  payload: dict[str, object] = {
276
314
  "message": message,
@@ -279,15 +317,23 @@ class DebugBundleSdk:
279
317
  }
280
318
  if context:
281
319
  payload["attributes"] = redact_mapping(dict(context), self._redact_fields)
282
- event = self._apply_before_send_event(self._base_event("log_event", payload, context=context))
320
+ event = self._protect_event(self._base_event("log_event", payload, context=context))
283
321
  if (
284
322
  event is None
285
323
  or not self._passes_sample_rate()
286
- or self._capture_policy.capture_logs == "off"
287
- or not level_enabled(normalized_level, self._effective_log_threshold())
288
324
  ):
289
325
  return
290
- self._enqueue_event(event)
326
+ event_bytes = self._event_bytes(event)
327
+ if not try_capture_lock(self):
328
+ self._pressure_drops += 1
329
+ return
330
+ try:
331
+ if self._log_level_eligible(normalized_level):
332
+ self._offer_protected_event(event, event_bytes)
333
+ finally:
334
+ self._lock.release()
335
+ except BaseException:
336
+ return
291
337
 
292
338
  def capture_request(
293
339
  self,
@@ -295,22 +341,34 @@ class DebugBundleSdk:
295
341
  response: Mapping[str, object] | None = None,
296
342
  context: Mapping[str, object] | None = None,
297
343
  ) -> None:
298
- with self._lock:
299
- if not self._enabled:
344
+ try:
345
+ if not self._enabled or not should_capture_request_event(self._capture_policy, request, response):
346
+ return
347
+ ensure_current_process(self)
348
+ status = None if response is None else response.get("status_code") or response.get("response_status")
349
+ if not may_prepare_event(
350
+ self, type(status) is int and status >= 500, MAX_PENDING_EVENTS, MAX_PENDING_BYTES
351
+ ):
300
352
  return
301
353
  payload = request_event_payload(
302
354
  redact_mapping(dict(request), self._redact_fields),
303
355
  redact_mapping(dict(response or {}), self._redact_fields),
304
356
  redact_mapping(dict(context or {}), self._redact_fields),
305
357
  )
306
- event = self._apply_before_send_event(self._base_event("request_event", payload, context=context))
307
- if (
308
- event is None
309
- or not self._passes_sample_rate()
310
- or not self._should_capture_request_event(request, response)
311
- ):
358
+ event = self._protect_event(self._base_event("request_event", payload, context=context))
359
+ if event is None or not self._passes_sample_rate():
360
+ return
361
+ event_bytes = self._event_bytes(event)
362
+ if not try_capture_lock(self):
363
+ self._pressure_drops += 1
312
364
  return
313
- self._enqueue_event(event)
365
+ try:
366
+ if self._enabled:
367
+ self._offer_protected_event(event, event_bytes)
368
+ finally:
369
+ self._lock.release()
370
+ except BaseException:
371
+ return
314
372
 
315
373
  def capture_message(
316
374
  self,
@@ -321,138 +379,196 @@ class DebugBundleSdk:
321
379
  self.capture_log(message, level=level or DEFAULT_LOG_LEVEL, context=context)
322
380
 
323
381
  def set_context(self, key: str, value: object) -> None:
324
- with self._lock:
325
- self._context[key] = redact_value({key: value}, self._redact_fields)[key]
382
+ try:
383
+ ensure_current_process(self)
384
+ protected = sanitize_telemetry({key: value}, self._redact_fields)
385
+ if not isinstance(protected, dict) or not try_capture_lock(self):
386
+ return
387
+ try:
388
+ next_context = dict(self._context)
389
+ next_context[key] = protected.get(key)
390
+ self._context = next_context
391
+ finally:
392
+ self._lock.release()
393
+ except BaseException:
394
+ return
326
395
 
327
396
  def _bind_scoped_context(self, context: Mapping[str, object]) -> Token[dict[str, object] | None]:
397
+ ensure_current_process(self)
328
398
  scoped_context = dict(self._scoped_context.get() or {})
329
399
  for key, value in context.items():
330
400
  if value is None:
331
401
  continue
332
402
  scoped_context[str(key)] = value
333
- return self._scoped_context.set(cast(dict[str, object], redact_value(scoped_context, self._redact_fields)))
403
+ try:
404
+ protected = sanitize_telemetry(scoped_context, self._redact_fields)
405
+ return self._scoped_context.set(cast(dict[str, object], protected))
406
+ except UnsafeTelemetry:
407
+ return self._scoped_context.set({})
334
408
 
335
409
  def _reset_scoped_context(self, token: Token[dict[str, object] | None]) -> None:
336
410
  self._scoped_context.reset(token)
337
411
 
338
412
  def flush(self) -> None:
413
+ ensure_current_process(self)
339
414
  with self._lock:
340
- if not self._enabled or self._transport is None:
341
- return
342
-
343
- self._append_suppression_aggregates()
344
- if not self._buffer:
415
+ if not self._enabled or self._transport is None or self._sending:
345
416
  return
346
-
417
+ if self._timer is not None:
418
+ self._timer.cancel()
419
+ self._timer = None
347
420
  now = self._time_provider()
348
421
  if now < self._retry_after:
422
+ self._schedule_flush_locked(delay=self._retry_after - now)
349
423
  return
424
+ self._sending = True
425
+ generation = self._generation
426
+ suppression = self._suppression.drain_aggregates(now)
427
+ pressure_count = (
428
+ self._pressure_drops
429
+ if self._pressure_drops and now - self._last_pressure_report_at >= 60.0
430
+ and len(self._buffer) < MAX_PENDING_EVENTS else 0
431
+ )
432
+
433
+ try:
434
+ aggregates, pressure = prepare_flush_aggregates(self, suppression, pressure_count)
435
+ except BaseException:
436
+ aggregates, pressure = [], None
350
437
 
351
- batch = [dict(event) for event in self._buffer]
438
+ with self._lock:
439
+ if generation != self._generation:
440
+ self._sending = False
441
+ self._inflight_count = 0
442
+ if self._buffer:
443
+ self._schedule_flush_locked()
444
+ return
445
+ for aggregate, aggregate_bytes in aggregates:
446
+ self._offer_protected_event(aggregate, aggregate_bytes)
447
+ if (pressure is not None and len(self._buffer) < MAX_PENDING_EVENTS
448
+ and self._buffer_bytes + pressure[1] <= MAX_PENDING_BYTES
449
+ and self._offer_protected_event(*pressure)):
450
+ self._pressure_drops = max(0, self._pressure_drops - pressure_count)
451
+ self._last_pressure_report_at = now
452
+ if not self._buffer:
453
+ self._sending = False
454
+ self._schedule_pressure_report_locked()
455
+ return
456
+ batch = list(self._buffer)
457
+ self._inflight_count = len(batch)
458
+ self._pending_low_priority = 0
459
+ transport = self._transport
352
460
  request = {
353
461
  "project_token": self._project_token,
354
462
  "events": batch,
355
463
  }
356
464
 
357
- try:
358
- response = coerce_transport_response(self._transport(request))
359
- except Exception:
360
- self._consecutive_failures += 1
361
- self._schedule_flush_locked()
465
+ finalized = finalize_batch(self, batch, generation, MAX_PENDING_BYTES)
466
+
467
+ with self._lock:
468
+ if generation != self._generation or finalized is None:
469
+ self._sending = False
470
+ self._inflight_count = 0
471
+ if self._buffer:
472
+ self._schedule_flush_locked()
473
+ return
474
+ batch = finalized
475
+ self._inflight_count = len(batch)
476
+ if not batch:
477
+ self._sending = False
478
+ if self._buffer:
479
+ self._schedule_flush_locked()
362
480
  return
481
+ request["events"] = batch
363
482
 
364
- if 200 <= response.status_code < 300:
365
- acknowledgement = decide_acknowledgement(response.body, len(batch))
366
- if acknowledgement.kind == "protocol_failure":
483
+ try:
484
+ response = coerce_transport_response(transport(request))
485
+ except BaseException:
486
+ response = None
487
+
488
+ with self._lock:
489
+ self._sending = False
490
+ self._inflight_count = 0
491
+ if generation != self._generation:
492
+ if self._buffer:
493
+ self._schedule_flush_locked()
494
+ return
495
+ try:
496
+ if response is None:
367
497
  self._consecutive_failures += 1
368
- retry_after_ms = response.retry_after_ms if response.retry_after_ms is not None else 1_000
369
- self._retry_after = now + (retry_after_ms / 1000)
370
- self._emit_diagnostic(
371
- "ingestion_acknowledgement_invalid",
372
- "sdk-python retained a batch after an invalid ingestion acknowledgement",
373
- metadata={"reason": acknowledgement.reason or "invalid"},
374
- )
375
- self._schedule_flush_locked(delay=retry_after_ms / 1000)
498
+ self._schedule_flush_locked()
376
499
  return
377
- if acknowledgement.kind == "legacy":
378
- self._buffer = self._buffer[len(batch) :]
500
+
501
+ if 200 <= response.status_code < 300:
502
+ acknowledgement = decide_acknowledgement(response.body, len(batch))
503
+ if acknowledgement.kind == "protocol_failure":
504
+ self._consecutive_failures += 1
505
+ retry_after_ms = response.retry_after_ms if response.retry_after_ms is not None else 1_000
506
+ self._retry_after = now + (retry_after_ms / 1000)
507
+ self._emit_diagnostic(
508
+ "ingestion_acknowledgement_invalid",
509
+ "sdk-python retained a batch after an invalid ingestion acknowledgement",
510
+ metadata={"reason": acknowledgement.reason or "invalid"},
511
+ )
512
+ self._schedule_flush_locked(delay=retry_after_ms / 1000)
513
+ return
514
+ if acknowledgement.kind == "legacy":
515
+ self._buffer = self._buffer[len(batch) :]
516
+ self._retry_after = 0.0
517
+ self._last_event_at = self._time_provider() * 1000
518
+ self._consecutive_failures = 0
519
+ return
520
+
521
+ trailing_events = self._buffer[len(batch) :]
522
+ self._buffer = [
523
+ batch[index] for index in acknowledgement.retryable_indices if 0 <= index < len(batch)
524
+ ] + trailing_events
525
+ if acknowledgement.terminal_errors:
526
+ self._emit_diagnostic(
527
+ "ingestion_events_rejected",
528
+ "sdk-python removed terminally rejected ingestion events",
529
+ metadata={
530
+ "rejected_count": len(acknowledgement.terminal_errors),
531
+ "reasons": sorted({reason for _, reason in acknowledgement.terminal_errors}),
532
+ },
533
+ )
534
+ if acknowledgement.accepted > 0:
535
+ self._last_event_at = self._time_provider() * 1000
536
+ if acknowledgement.retryable_indices:
537
+ self._consecutive_failures += 1
538
+ retry_after_ms = response.retry_after_ms if response.retry_after_ms is not None else 1_000
539
+ self._retry_after = now + (retry_after_ms / 1000)
540
+ self._schedule_flush_locked(delay=retry_after_ms / 1000)
541
+ return
379
542
  self._retry_after = 0.0
380
- self._last_event_at = self._time_provider() * 1000
381
- self._consecutive_failures = 0
543
+ self._consecutive_failures = 0 if acknowledgement.accepted > 0 else 3
382
544
  return
383
545
 
384
- trailing_events = self._buffer[len(batch) :]
385
- self._buffer = [
386
- batch[index] for index in acknowledgement.retryable_indices if 0 <= index < len(batch)
387
- ] + trailing_events
388
- if acknowledgement.terminal_errors:
389
- self._emit_diagnostic(
390
- "ingestion_events_rejected",
391
- "sdk-python removed terminally rejected ingestion events",
392
- metadata={
393
- "rejected_count": len(acknowledgement.terminal_errors),
394
- "reasons": sorted({reason for _, reason in acknowledgement.terminal_errors}),
395
- },
396
- )
397
- if acknowledgement.accepted > 0:
398
- self._last_event_at = self._time_provider() * 1000
399
- if acknowledgement.retryable_indices:
400
- self._consecutive_failures += 1
546
+ self._consecutive_failures += 1
547
+ if response.status_code == 429:
401
548
  retry_after_ms = response.retry_after_ms if response.retry_after_ms is not None else 1_000
402
549
  self._retry_after = now + (retry_after_ms / 1000)
403
550
  self._schedule_flush_locked(delay=retry_after_ms / 1000)
404
551
  return
405
- self._retry_after = 0.0
406
- self._consecutive_failures = 0 if acknowledgement.accepted > 0 else 3
407
- return
408
-
409
- self._consecutive_failures += 1
410
- if response.status_code == 429:
411
- retry_after_ms = response.retry_after_ms if response.retry_after_ms is not None else 1_000
412
- self._retry_after = now + (retry_after_ms / 1000)
413
- self._schedule_flush_locked(delay=retry_after_ms / 1000)
414
- return
415
-
416
- if 400 <= response.status_code < 500:
417
- self._buffer = []
418
- self._retry_after = 0.0
419
- return
420
-
421
- self._schedule_flush_locked()
552
+ if 400 <= response.status_code < 500:
553
+ self._buffer = self._buffer[len(batch) :]
554
+ self._retry_after = 0.0
555
+ return
556
+ self._schedule_flush_locked()
557
+ finally:
558
+ self._buffer_sizes = {id(event): self._buffer_sizes[id(event)]
559
+ for event in self._buffer}
560
+ self._buffer_bytes = sum(self._buffer_sizes.values())
561
+ self._pending_low_priority = sum(not high_priority_event(event) for event in self._buffer)
562
+ if self._buffer and self._timer is None and self._retry_after <= self._time_provider():
563
+ self._schedule_flush_locked()
564
+ elif not self._buffer and self._timer is None:
565
+ self._schedule_pressure_report_locked()
422
566
 
423
567
  def probe(self, label: str, data: object | Callable[[], object], opts: Mapping[str, object] | None = None) -> None:
424
- with self._lock:
425
- if not self._enabled:
426
- return
427
- options = dict(opts or {})
428
- now_ms = int(self._time_provider() * 1000)
429
- matching_directives = self._find_matching_probe_directives(label, now_ms)
430
- is_heavy = options.get("heavy") is True
431
- if is_heavy and not matching_directives:
432
- return
433
- if label not in self._probe_buffers and len(self._probe_buffers) >= self._max_probe_labels:
434
- return
435
-
436
- value = data() if callable(data) else data
437
- if not isinstance(value, Mapping):
438
- value = {"value": value}
439
-
440
- redacted_value = redact_mapping(dict(value), self._redact_fields)
441
-
442
- if is_heavy:
443
- self._emit_probe_events(label, redacted_value, matching_directives)
444
- return
445
-
446
- entry = ProbeEntry(
447
- label=label,
448
- data=redacted_value,
449
- timestamp=iso_now(self._time_provider),
450
- )
451
- bucket = self._probe_buffers.setdefault(label, deque(maxlen=self._max_probe_entries_per_label))
452
- bucket.append(entry)
453
- self._emit_probe_events(label, redacted_value, matching_directives)
568
+ capture_probe(self, label, data, opts)
454
569
 
455
570
  def capture_exceptions(self) -> None:
571
+ ensure_current_process(self)
456
572
  with self._lock:
457
573
  if self._original_excepthook is None:
458
574
  self._original_excepthook = sys.excepthook
@@ -465,6 +581,7 @@ class DebugBundleSdk:
465
581
  sys.excepthook = handler
466
582
 
467
583
  def capture_logging(self, logger: logging.Logger | None = None) -> None:
584
+ ensure_current_process(self)
468
585
  with self._lock:
469
586
  target_logger = logger or logging.getLogger()
470
587
  logger_id = id(target_logger)
@@ -479,6 +596,7 @@ class DebugBundleSdk:
479
596
  self._optional_logging_restorers = attach_optional_integrations(self, self._on_diagnostic)
480
597
 
481
598
  def capture_async(self, loop: asyncio.AbstractEventLoop | None = None) -> None:
599
+ ensure_current_process(self)
482
600
  with self._lock:
483
601
  target_loop = loop or asyncio.get_event_loop()
484
602
  if target_loop in self._async_handlers:
@@ -496,6 +614,7 @@ class DebugBundleSdk:
496
614
  target_loop.set_exception_handler(handler)
497
615
 
498
616
  def dispose(self) -> None:
617
+ ensure_current_process(self)
499
618
  with self._lock:
500
619
  if self._timer is not None:
501
620
  self._timer.cancel()
@@ -523,32 +642,43 @@ class DebugBundleSdk:
523
642
  with self._lock:
524
643
  if not self._enabled or self._fetch_impl is None:
525
644
  return
526
-
527
645
  request_headers: dict[str, str] = {}
528
646
  if self._remote_config_etag is not None:
529
647
  request_headers["if-none-match"] = self._remote_config_etag
648
+ fetch = self._fetch_impl
649
+ endpoint = sdk_config_endpoint(self._endpoint)
650
+ poll_interval = self._configured_probes_poll_interval_ms
651
+ generation = self._generation
530
652
 
531
- try:
532
- response = self._fetch_impl(
533
- sdk_config_endpoint(self._endpoint),
534
- {
535
- "method": "GET",
536
- "headers": request_headers,
537
- },
538
- )
539
- status_code = getattr(response, "status_code", None)
540
- if status_code == 304:
541
- self._schedule_next_remote_config_refresh()
542
- return
543
- if status_code != 200:
544
- raise RuntimeError(f"unexpected config status {status_code}")
545
-
546
- payload = response.json()
653
+ try:
654
+ response = fetch(endpoint, {"method": "GET", "headers": request_headers})
655
+ status_code = getattr(response, "status_code", None)
656
+ if status_code == 304:
657
+ snapshot = None
658
+ elif status_code == 200:
547
659
  snapshot = parse_remote_config(
548
- payload,
549
- self._configured_probes_poll_interval_ms,
660
+ response.json(),
661
+ poll_interval,
550
662
  int(self._time_provider() * 1000),
551
663
  )
664
+ else:
665
+ raise RuntimeError(f"unexpected config status {status_code}")
666
+ headers = response.headers or {}
667
+ etag = headers.get("etag")
668
+ failure: BaseException | None = None
669
+ except BaseException as error:
670
+ status_code = None
671
+ snapshot = None
672
+ etag = None
673
+ failure = error
674
+
675
+ with self._lock:
676
+ if generation != self._generation or not self._enabled:
677
+ return
678
+ if failure is None and status_code == 304:
679
+ self._schedule_next_remote_config_refresh()
680
+ return
681
+ if failure is None:
552
682
  if snapshot is None:
553
683
  self._emit_diagnostic(
554
684
  "remote_probe_config_invalid",
@@ -561,16 +691,14 @@ class DebugBundleSdk:
561
691
 
562
692
  self._remote_config_snapshot = snapshot
563
693
  self._capture_policy = snapshot.capture_policy
564
- headers = response.headers or {}
565
- etag = headers.get("etag")
566
694
  if isinstance(etag, str) and len(etag) > 0:
567
695
  self._remote_config_etag = etag
568
696
  self._schedule_next_remote_config_refresh()
569
- except Exception as error:
697
+ else:
570
698
  self._emit_diagnostic(
571
699
  "remote_probe_config_failed",
572
700
  "sdk-python failed to refresh remote probe config",
573
- metadata={"error": serialize_error(error)},
701
+ metadata={"error": serialize_error(failure) if isinstance(failure, Exception) else "fetch_failed"},
574
702
  )
575
703
  if initial:
576
704
  self._capture_policy = MINIMAL_CAPTURE_POLICY
@@ -613,39 +741,81 @@ class DebugBundleSdk:
613
741
  merged.update({str(key): value for key, value in context.items()})
614
742
  return cast(dict[str, object], redact_value(merged, self._redact_fields))
615
743
 
616
- def _enqueue_event(self, event: dict[str, object]) -> None:
617
- self._buffer.append(event)
618
- if len(self._buffer) >= self._batch_size:
619
- self.flush()
620
- return
621
- self._schedule_flush_locked()
744
+ def _protect_event(self, event: dict[str, object]) -> dict[str, object] | None:
745
+ try:
746
+ if not has_safe_event_identity(event, self._redact_fields):
747
+ return None
748
+ candidate = dict(event)
749
+ for key in ("payload", "context", "service"):
750
+ if key in candidate:
751
+ candidate[key] = sanitize_telemetry(candidate[key], self._redact_fields)
752
+ return candidate
753
+ except UnsafeTelemetry:
754
+ return None
755
+
756
+ def _enqueue_event(self, event: dict[str, object]) -> bool:
757
+ protected = self._protect_event(event)
758
+ if protected is None:
759
+ return False
760
+ return self._offer_protected_event(protected, self._event_bytes(protected))
761
+
762
+ def _offer_protected_event(self, protected: dict[str, object], event_bytes: int) -> bool:
763
+ if event_bytes > MAX_PENDING_BYTES:
764
+ self._pressure_drops += 1
765
+ return False
766
+ high_priority = high_priority_event(protected)
767
+ under_pressure = len(self._buffer) >= MAX_PENDING_EVENTS or self._buffer_bytes + event_bytes > MAX_PENDING_BYTES
768
+ while len(self._buffer) >= MAX_PENDING_EVENTS or self._buffer_bytes + event_bytes > MAX_PENDING_BYTES:
769
+ if not high_priority or not evict_low_priority(self):
770
+ self._pressure_drops += 1
771
+ return False
772
+ insertion = self._inflight_count
773
+ if high_priority and (protected.get("event_type") != "request_event" or under_pressure):
774
+ while insertion < len(self._buffer) and high_priority_event(self._buffer[insertion]):
775
+ insertion += 1
776
+ self._buffer.insert(insertion, protected)
777
+ else:
778
+ self._buffer.append(protected)
779
+ self._pending_low_priority += 1
780
+ self._buffer_bytes += event_bytes
781
+ self._buffer_sizes[id(protected)] = event_bytes
782
+ self._schedule_flush_locked(delay=0.0 if len(self._buffer) >= self._batch_size else None)
783
+ return True
784
+
785
+ @staticmethod
786
+ def _event_bytes(event: dict[str, object]) -> int:
787
+ return len(json.dumps(event, separators=(",", ":")).encode("utf-8"))
622
788
 
623
789
  def _schedule_flush_locked(self, delay: float | None = None) -> None:
624
- if self._timer is not None:
625
- self._timer.cancel()
790
+ if self._sending:
791
+ return
626
792
  next_delay = self._flush_interval if delay is None else max(delay, 0.0)
793
+ due_at = time.monotonic() + next_delay
794
+ if self._timer is not None and self._timer.is_alive():
795
+ if self._timer_due_at <= due_at:
796
+ return
797
+ self._timer.cancel()
627
798
  self._timer = threading.Timer(next_delay, self.flush)
628
799
  self._timer.daemon = True
800
+ self._timer_due_at = due_at
629
801
  self._timer.start()
630
802
 
631
- def _append_suppression_aggregates(self) -> None:
632
- aggregates = self._suppression.drain_aggregates(self._time_provider())
633
- for aggregate in aggregates:
634
- event_type = aggregate.get("event_type")
635
- payload = aggregate.get("payload")
636
- if not isinstance(event_type, str) or not isinstance(payload, dict):
637
- continue
638
- aggregate.update(self._base_event(event_type, cast(dict[str, object], payload)))
639
- prepared = self._apply_before_send_event(aggregate)
640
- if prepared is not None:
641
- self._buffer.append(prepared)
803
+ def _schedule_pressure_report_locked(self) -> None:
804
+ if self._pressure_drops == 0:
805
+ return
806
+ delay = max(self._flush_interval, self._last_pressure_report_at + 60.0 - self._time_provider())
807
+ self._schedule_flush_locked(delay=delay)
642
808
 
643
809
  def _apply_before_send_event(self, event: dict[str, object]) -> dict[str, object] | None:
644
- return apply_before_send(
645
- event,
810
+ protected = self._protect_event(event)
811
+ if protected is None:
812
+ return None
813
+ result = apply_before_send(
814
+ protected,
646
815
  self._before_send,
647
816
  lambda code, message: self._emit_diagnostic(code, message),
648
817
  )
818
+ return self._protect_event(result) if result is not None else None
649
819
 
650
820
  def _build_probe_data(self) -> dict[str, object] | None:
651
821
  items: list[dict[str, object]] = []
@@ -670,60 +840,15 @@ class DebugBundleSdk:
670
840
  policy_threshold = self._capture_policy.capture_logs
671
841
  return self._log_level if LEVEL_RANKS[self._log_level] >= LEVEL_RANKS[policy_threshold] else policy_threshold
672
842
 
673
- def _should_capture_request_event(
674
- self,
675
- request: Mapping[str, object] | None,
676
- response: Mapping[str, object] | None,
677
- ) -> bool:
678
- policy = self._capture_policy.capture_request_events
679
- status_code = None
680
- if response is not None:
681
- candidate = response.get("status_code") or response.get("response_status")
682
- if isinstance(candidate, int):
683
- status_code = candidate
684
- request_path = None
685
- http_method = None
686
- if request is not None:
687
- path_candidate = request.get("path") or request.get("url")
688
- method_candidate = request.get("method")
689
- request_path = path_candidate if isinstance(path_candidate, str) else None
690
- http_method = method_candidate if isinstance(method_candidate, str) else None
691
- if is_immediate_request_incident_status(
692
- status_code,
693
- self._capture_policy.preset,
694
- self._capture_policy.immediate_client_error_statuses,
695
- request_path,
696
- http_method,
697
- self._capture_policy.immediate_client_error_path_rules,
698
- ):
699
- return True
700
- if policy == "off":
701
- return False
702
- if policy == "all":
703
- return True
704
- if response is None:
705
- return policy == "filtered"
706
- if status_code is None:
707
- return policy == "filtered"
708
- if policy == "failures_only":
709
- return status_code >= 500
710
- if policy == "filtered":
711
- return False
712
- return True
713
-
714
- def _emit_probe_events(self, label: str, data: dict[str, object], directives: list[RemoteProbeDirective]) -> None:
715
- for directive in directives:
716
- payload = {
717
- "label": label,
718
- "activation_id": getattr(directive, "id"),
719
- "probe_label_pattern": getattr(directive, "label_pattern"),
720
- "data": dict(data),
721
- }
722
- event = self._apply_before_send_event(self._base_event("probe_event", payload))
723
- if event is not None and self._capture_policy.capture_probe_events == "standalone_when_activated":
724
- self._enqueue_event(event)
843
+ def _log_level_eligible(self, level: str) -> bool:
844
+ return (
845
+ self._enabled
846
+ and self._capture_policy.capture_logs != "off"
847
+ and level_enabled(normalize_level(level), self._effective_log_threshold())
848
+ )
725
849
 
726
850
  def begin_request(self, request: dict[str, Any]) -> Token[list[RemoteProbeDirective] | None]:
851
+ ensure_current_process(self)
727
852
  trigger_token_key = (
728
853
  self._remote_config_snapshot.trigger_token_key if self._remote_config_snapshot is not None else None
729
854
  )
@@ -780,7 +905,10 @@ class DebugBundleSdk:
780
905
  return
781
906
  diagnostic: dict[str, object] = {"code": code, "message": message}
782
907
  if metadata is not None:
783
- diagnostic["metadata"] = metadata
908
+ try:
909
+ diagnostic["metadata"] = sanitize_telemetry(metadata, self._redact_fields)
910
+ except UnsafeTelemetry:
911
+ pass
784
912
  try:
785
913
  self._on_diagnostic(diagnostic)
786
914
  except Exception: