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