debugbundle-python 1.5.0__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,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,6 +59,7 @@ 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
64
  from .transport import HttpTransport, Transport, coerce_transport_response
57
65
  from .trigger_token import resolve_request_trigger_directives
@@ -60,13 +68,8 @@ 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,165 @@ 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()
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()
377
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()
480
+ return
481
+ request["events"] = batch
482
+
483
+ try:
484
+ response = coerce_transport_response(transport(request))
485
+ except BaseException:
486
+ response = None
378
487
 
379
- if 200 <= response.status_code < 300:
380
- acknowledgement = decide_acknowledgement(response.body, len(batch))
381
- if acknowledgement.kind == "protocol_failure":
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:
382
497
  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)
498
+ self._schedule_flush_locked()
391
499
  return
392
- if acknowledgement.kind == "legacy":
393
- 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
394
542
  self._retry_after = 0.0
395
- self._last_event_at = self._time_provider() * 1000
396
- self._consecutive_failures = 0
543
+ self._consecutive_failures = 0 if acknowledgement.accepted > 0 else 3
397
544
  return
398
545
 
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
546
+ self._consecutive_failures += 1
547
+ if response.status_code == 429:
416
548
  retry_after_ms = response.retry_after_ms if response.retry_after_ms is not None else 1_000
417
549
  self._retry_after = now + (retry_after_ms / 1000)
418
550
  self._schedule_flush_locked(delay=retry_after_ms / 1000)
419
551
  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)
429
- return
430
-
431
- if 400 <= response.status_code < 500:
432
- self._buffer = []
433
- self._retry_after = 0.0
434
- return
435
-
436
- self._schedule_flush_locked()
437
-
438
- def probe(self, label: str, data: object | Callable[[], object], opts: Mapping[str, object] | None = None) -> None:
439
- 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:
447
- return
448
- if label not in self._probe_buffers and len(self._probe_buffers) >= self._max_probe_labels:
449
- return
450
-
451
- 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):
552
+ if 400 <= response.status_code < 500:
553
+ self._buffer = self._buffer[len(batch) :]
554
+ self._retry_after = 0.0
458
555
  return
459
- label = protected_label
460
- except Exception:
461
- return
462
-
463
- if is_heavy:
464
- self._emit_probe_events(label, redacted_value, matching_directives)
465
- 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()
466
566
 
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)
567
+ def probe(self, label: str, data: object | Callable[[], object], opts: Mapping[str, object] | None = None) -> None:
568
+ capture_probe(self, label, data, opts)
475
569
 
476
570
  def capture_exceptions(self) -> None:
571
+ ensure_current_process(self)
477
572
  with self._lock:
478
573
  if self._original_excepthook is None:
479
574
  self._original_excepthook = sys.excepthook
@@ -486,6 +581,7 @@ class DebugBundleSdk:
486
581
  sys.excepthook = handler
487
582
 
488
583
  def capture_logging(self, logger: logging.Logger | None = None) -> None:
584
+ ensure_current_process(self)
489
585
  with self._lock:
490
586
  target_logger = logger or logging.getLogger()
491
587
  logger_id = id(target_logger)
@@ -500,6 +596,7 @@ class DebugBundleSdk:
500
596
  self._optional_logging_restorers = attach_optional_integrations(self, self._on_diagnostic)
501
597
 
502
598
  def capture_async(self, loop: asyncio.AbstractEventLoop | None = None) -> None:
599
+ ensure_current_process(self)
503
600
  with self._lock:
504
601
  target_loop = loop or asyncio.get_event_loop()
505
602
  if target_loop in self._async_handlers:
@@ -517,6 +614,7 @@ class DebugBundleSdk:
517
614
  target_loop.set_exception_handler(handler)
518
615
 
519
616
  def dispose(self) -> None:
617
+ ensure_current_process(self)
520
618
  with self._lock:
521
619
  if self._timer is not None:
522
620
  self._timer.cancel()
@@ -544,32 +642,43 @@ class DebugBundleSdk:
544
642
  with self._lock:
545
643
  if not self._enabled or self._fetch_impl is None:
546
644
  return
547
-
548
645
  request_headers: dict[str, str] = {}
549
646
  if self._remote_config_etag is not None:
550
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
551
652
 
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()
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:
568
659
  snapshot = parse_remote_config(
569
- payload,
570
- self._configured_probes_poll_interval_ms,
660
+ response.json(),
661
+ poll_interval,
571
662
  int(self._time_provider() * 1000),
572
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:
573
682
  if snapshot is None:
574
683
  self._emit_diagnostic(
575
684
  "remote_probe_config_invalid",
@@ -582,16 +691,14 @@ class DebugBundleSdk:
582
691
 
583
692
  self._remote_config_snapshot = snapshot
584
693
  self._capture_policy = snapshot.capture_policy
585
- headers = response.headers or {}
586
- etag = headers.get("etag")
587
694
  if isinstance(etag, str) and len(etag) > 0:
588
695
  self._remote_config_etag = etag
589
696
  self._schedule_next_remote_config_refresh()
590
- except Exception as error:
697
+ else:
591
698
  self._emit_diagnostic(
592
699
  "remote_probe_config_failed",
593
700
  "sdk-python failed to refresh remote probe config",
594
- metadata={"error": serialize_error(error)},
701
+ metadata={"error": serialize_error(failure) if isinstance(failure, Exception) else "fetch_failed"},
595
702
  )
596
703
  if initial:
597
704
  self._capture_policy = MINIMAL_CAPTURE_POLICY
@@ -646,37 +753,58 @@ class DebugBundleSdk:
646
753
  except UnsafeTelemetry:
647
754
  return None
648
755
 
649
- def _enqueue_event(self, event: dict[str, object]) -> None:
756
+ def _enqueue_event(self, event: dict[str, object]) -> bool:
650
757
  protected = self._protect_event(event)
651
758
  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()
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"))
658
788
 
659
789
  def _schedule_flush_locked(self, delay: float | None = None) -> None:
660
- if self._timer is not None:
661
- self._timer.cancel()
790
+ if self._sending:
791
+ return
662
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()
663
798
  self._timer = threading.Timer(next_delay, self.flush)
664
799
  self._timer.daemon = True
800
+ self._timer_due_at = due_at
665
801
  self._timer.start()
666
802
 
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)
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)
680
808
 
681
809
  def _apply_before_send_event(self, event: dict[str, object]) -> dict[str, object] | None:
682
810
  protected = self._protect_event(event)
@@ -712,60 +840,15 @@ class DebugBundleSdk:
712
840
  policy_threshold = self._capture_policy.capture_logs
713
841
  return self._log_level if LEVEL_RANKS[self._log_level] >= LEVEL_RANKS[policy_threshold] else policy_threshold
714
842
 
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)
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
+ )
767
849
 
768
850
  def begin_request(self, request: dict[str, Any]) -> Token[list[RemoteProbeDirective] | None]:
851
+ ensure_current_process(self)
769
852
  trigger_token_key = (
770
853
  self._remote_config_snapshot.trigger_token_key if self._remote_config_snapshot is not None else None
771
854
  )