debugbundle-python 2.0.1__py3-none-any.whl → 2.1.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.
Files changed (39) hide show
  1. debugbundle/__init__.py +16 -0
  2. debugbundle/before_send.py +35 -191
  3. debugbundle/canonical_event.py +202 -0
  4. debugbundle/capture_hooks.py +14 -6
  5. debugbundle/config.py +6 -0
  6. debugbundle/core.py +76 -318
  7. debugbundle/delivery_hooks.py +14 -0
  8. debugbundle/diagnostic_budget.py +191 -0
  9. debugbundle/event-envelope.schema.json +4904 -0
  10. debugbundle/evidence_delivery.py +115 -0
  11. debugbundle/evidence_http.py +45 -0
  12. debugbundle/evidence_json.py +68 -0
  13. debugbundle/evidence_wire.py +409 -0
  14. debugbundle/exception_capture.py +98 -0
  15. debugbundle/exception_evidence.py +199 -0
  16. debugbundle/exception_identity.py +47 -0
  17. debugbundle/exception_snapshot.py +1 -1
  18. debugbundle/flush_delivery.py +203 -0
  19. debugbundle/ingestion_wire.py +75 -0
  20. debugbundle/integrations/relay_django.py +4 -1
  21. debugbundle/integrations/relay_fastapi.py +10 -2
  22. debugbundle/integrations/relay_flask.py +4 -1
  23. debugbundle/logger_integrations.py +16 -0
  24. debugbundle/operation_context.py +121 -0
  25. debugbundle/process_support.py +7 -0
  26. debugbundle/queue_admission.py +42 -2
  27. debugbundle/recovery_capture.py +43 -0
  28. debugbundle/relay.py +93 -36
  29. debugbundle/relay_delivery.py +108 -17
  30. debugbundle/remote_config_client.py +81 -0
  31. debugbundle/scope_ownership.py +99 -0
  32. debugbundle/structlog_integration.py +21 -0
  33. debugbundle/transport.py +20 -14
  34. {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.1.dist-info}/METADATA +117 -1
  35. debugbundle_python-2.1.1.dist-info/RECORD +52 -0
  36. {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.1.dist-info}/WHEEL +1 -1
  37. debugbundle_python-2.0.1.dist-info/RECORD +0 -36
  38. {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.1.dist-info}/licenses/LICENSE +0 -0
  39. {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.1.dist-info}/top_level.txt +0 -0
debugbundle/core.py CHANGED
@@ -14,8 +14,8 @@ from contextvars import ContextVar, Token
14
14
  from random import random
15
15
  from typing import Any, Protocol, cast
16
16
 
17
- from .acknowledgement import decide_acknowledgement
18
17
  from .before_send import BeforeSendHook, apply_before_send
18
+ from .canonical_event import ValidationBudgetExceeded, valid_owned_event
19
19
  from .capture_hooks import DebugBundleLogHandler, try_capture_lock
20
20
  from .config import (
21
21
  BALANCED_CAPTURE_POLICY,
@@ -25,14 +25,11 @@ from .config import (
25
25
  RemoteConfigSnapshot,
26
26
  RemoteProbeDirective,
27
27
  find_matching_remote_probe_directives,
28
- parse_remote_config,
29
28
  )
30
- from .delivery_hooks import finalize_batch
29
+ from .diagnostic_budget import DiagnosticCaptureBudget
31
30
  from .event_support import (
32
31
  DEFAULT_LOG_LEVEL,
33
32
  LEVEL_RANKS,
34
- backend_exception_request_payload,
35
- backend_exception_response_payload,
36
33
  correlation_payload,
37
34
  event_context,
38
35
  iso_now,
@@ -40,18 +37,19 @@ from .event_support import (
40
37
  normalize_level,
41
38
  redact_mapping,
42
39
  request_event_payload,
43
- runtime_process_facts,
44
- sdk_config_endpoint,
45
40
  sdk_version,
46
- serialize_error,
47
41
  time_now,
48
42
  )
49
- from .exception_snapshot import exception_details
50
- from .flush_aggregates import prepare_flush_aggregates
43
+ from .evidence_delivery import EvidenceDelivery, add_reference, filter_evidence
44
+ from .evidence_wire import validate_evidence, without_optional
45
+ from .exception_capture import capture_exception as capture_native_exception
46
+ from .flush_delivery import flush_sdk
51
47
  from .logger_integrations import attach_optional_integrations
48
+ from .operation_context import OperationContexts
52
49
  from .probe_capture import ProbeEntry, capture_probe
53
50
  from .process_support import ensure_current_process
54
- from .queue_admission import evict_low_priority, high_priority_event, may_prepare_event
51
+ from .queue_admission import may_prepare_event, offer_protected_event
52
+ from .recovery_capture import capture_recovery
55
53
  from .redaction import (
56
54
  DEFAULT_REDACT_FIELDS,
57
55
  UnsafeTelemetry,
@@ -59,9 +57,11 @@ from .redaction import (
59
57
  redact_value,
60
58
  sanitize_telemetry,
61
59
  )
60
+ from .remote_config_client import refresh_remote_config
62
61
  from .request_capture_policy import should_capture_request_event
62
+ from .scope_ownership import alive, bind_context, bind_directives, context_value, release
63
63
  from .suppression import EventSuppressionTracker
64
- from .transport import HttpTransport, Transport, bounded_retry_after_ms, coerce_transport_response
64
+ from .transport import HttpTransport, Transport
65
65
  from .trigger_token import resolve_request_trigger_directives
66
66
 
67
67
  DEFAULT_BATCH_SIZE = 25
@@ -137,6 +137,12 @@ class DebugBundleSdk:
137
137
  self._remote_config_etag: str | None = None
138
138
  self._remote_config_snapshot: RemoteConfigSnapshot | None = None
139
139
  self._capture_policy: CapturePolicy = BALANCED_CAPTURE_POLICY
140
+ self._operations = OperationContexts()
141
+ self._evidence = EvidenceDelivery()
142
+ self._capture_exception_evidence = True
143
+ self._capture_recovery_observations = False
144
+ self._diagnostics = DiagnosticCaptureBudget()
145
+ self._hook_bypass: set[int] = set()
140
146
  self._request_trigger_directives: ContextVar[list[RemoteProbeDirective] | None] = ContextVar(
141
147
  "debugbundle_request_trigger_directives",
142
148
  default=None,
@@ -179,6 +185,9 @@ class DebugBundleSdk:
179
185
  on_diagnostic: Callable[[dict[str, object]], None] | None = None,
180
186
  before_send: BeforeSendHook | None = None,
181
187
  probes_poll_interval: int = DEFAULT_PROBES_POLL_INTERVAL_MS,
188
+ capture_exception_evidence: bool = True,
189
+ evidence_capabilities: object = None,
190
+ capture_recovery_observations: bool = False,
182
191
  ) -> None:
183
192
  ensure_current_process(self)
184
193
  with self._lock:
@@ -203,6 +212,12 @@ class DebugBundleSdk:
203
212
  self._buffer_bytes = 0
204
213
  self._pending_low_priority = 0
205
214
  self._generation += 1
215
+ self._operations.clear()
216
+ self._evidence = EvidenceDelivery(evidence_capabilities)
217
+ self._capture_exception_evidence = capture_exception_evidence is True
218
+ self._capture_recovery_observations = capture_recovery_observations is True
219
+ self._diagnostics.clear()
220
+ self._hook_bypass.clear()
206
221
  self._context = {}
207
222
  self._probe_buffers = {}
208
223
  self._suppression = EventSuppressionTracker()
@@ -243,56 +258,17 @@ class DebugBundleSdk:
243
258
  error: BaseException,
244
259
  context: Mapping[str, object] | None = None,
245
260
  handled: bool = True,
261
+ fatal: bool = False,
246
262
  ) -> None:
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)
252
- redacted_context = redact_mapping(dict(context or {}), self._redact_fields)
253
- request_payload = backend_exception_request_payload(redacted_context.get("request"))
254
- response_payload = backend_exception_response_payload(redacted_context.get("response"))
255
-
256
- payload: dict[str, object] = {
257
- "name": name,
258
- "message": message,
259
- "stack": stack,
260
- "handled": handled,
261
- "request": request_payload,
262
- "response": response_payload,
263
- "runtime": runtime_process_facts(),
264
- }
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()
270
- if probe_data is not None:
271
- payload["probe_data"] = probe_data
272
-
273
- event = self._protect_event(self._base_event("backend_exception", payload, context=redacted_context))
274
- if event is None or not self._passes_sample_rate():
275
- return
276
- event_payload = cast(dict[str, object], event["payload"])
277
- suppression_key = (
278
- f"{event['event_type']}:{event_payload.get('name', '')}:"
279
- f"{event_payload.get('message', '')}:{event_payload.get('stack', '')}"
280
- )
281
- event_bytes = self._event_bytes(event)
282
- if not try_capture_lock(self):
283
- self._pressure_drops += 1
284
- return
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
263
+ capture_native_exception(self, error, context, handled, MAX_PENDING_EVENTS, MAX_PENDING_BYTES, fatal)
292
264
 
293
265
  def capture_error(self, error: BaseException, context: Mapping[str, object] | None = None) -> None:
294
266
  self.capture_exception(error, context=context)
295
267
 
268
+ def capture_recovery(self, observation: object) -> None:
269
+ ensure_current_process(self)
270
+ capture_recovery(self, observation)
271
+
296
272
  def capture_log(
297
273
  self,
298
274
  message: str,
@@ -301,8 +277,10 @@ class DebugBundleSdk:
301
277
  ) -> None:
302
278
  try:
303
279
  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()
280
+ if (
281
+ not self._enabled
282
+ or self._capture_policy.capture_logs == "off"
283
+ or not level_enabled(normalized_level, self._effective_log_threshold())
306
284
  ):
307
285
  return
308
286
  ensure_current_process(self)
@@ -318,10 +296,7 @@ class DebugBundleSdk:
318
296
  if context:
319
297
  payload["attributes"] = redact_mapping(dict(context), self._redact_fields)
320
298
  event = self._protect_event(self._base_event("log_event", payload, context=context))
321
- if (
322
- event is None
323
- or not self._passes_sample_rate()
324
- ):
299
+ if event is None or not self._passes_sample_rate():
325
300
  return
326
301
  event_bytes = self._event_bytes(event)
327
302
  if not try_capture_lock(self):
@@ -395,176 +370,26 @@ class DebugBundleSdk:
395
370
 
396
371
  def _bind_scoped_context(self, context: Mapping[str, object]) -> Token[dict[str, object] | None]:
397
372
  ensure_current_process(self)
398
- scoped_context = dict(self._scoped_context.get() or {})
373
+ scoped_context = dict(context_value(self))
399
374
  for key, value in context.items():
400
375
  if value is None:
401
376
  continue
402
377
  scoped_context[str(key)] = value
403
378
  try:
404
379
  protected = sanitize_telemetry(scoped_context, self._redact_fields)
405
- return self._scoped_context.set(cast(dict[str, object], protected))
380
+ return bind_context(self, cast(dict[str, object], protected))
406
381
  except UnsafeTelemetry:
407
- return self._scoped_context.set({})
382
+ return bind_context(self, {})
408
383
 
409
384
  def _reset_scoped_context(self, token: Token[dict[str, object] | None]) -> None:
410
- self._scoped_context.reset(token)
385
+ release(self, token)
411
386
 
412
- def flush(self) -> None:
387
+ def operation_context(self, options: object) -> Any:
413
388
  ensure_current_process(self)
414
- with self._lock:
415
- if not self._enabled or self._transport is None or self._sending:
416
- return
417
- if self._timer is not None:
418
- self._timer.cancel()
419
- self._timer = None
420
- now = self._time_provider()
421
- if now < self._retry_after:
422
- self._schedule_flush_locked(delay=self._retry_after - now)
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
389
+ return self._operations.context(options)
437
390
 
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
460
- request = {
461
- "project_token": self._project_token,
462
- "events": batch,
463
- }
464
-
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()
480
- return
481
- request["events"] = batch
482
-
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:
497
- self._consecutive_failures += 1
498
- self._schedule_flush_locked()
499
- return
500
-
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
547
-
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()
391
+ def flush(self) -> None:
392
+ flush_sdk(self, MAX_PENDING_EVENTS, MAX_PENDING_BYTES)
568
393
 
569
394
  def probe(self, label: str, data: object | Callable[[], object], opts: Mapping[str, object] | None = None) -> None:
570
395
  capture_probe(self, label, data, opts)
@@ -576,9 +401,13 @@ class DebugBundleSdk:
576
401
  self._original_excepthook = sys.excepthook
577
402
 
578
403
  def handler(exc_type: type[BaseException], error: BaseException, tb: Any) -> None:
579
- if error.__traceback__ is None:
580
- error.__traceback__ = tb
581
- self._capture_exception(error, handled=False)
404
+ try:
405
+ native_traceback = BaseException.__dict__["__traceback__"]
406
+ if native_traceback.__get__(error) is None:
407
+ native_traceback.__set__(error, tb)
408
+ self._capture_exception(error, handled=False, fatal=True)
409
+ except BaseException:
410
+ return
582
411
 
583
412
  sys.excepthook = handler
584
413
 
@@ -618,6 +447,10 @@ class DebugBundleSdk:
618
447
  def dispose(self) -> None:
619
448
  ensure_current_process(self)
620
449
  with self._lock:
450
+ self._operations.clear()
451
+ self._evidence = EvidenceDelivery()
452
+ self._diagnostics.clear()
453
+ self._hook_bypass.clear()
621
454
  if self._timer is not None:
622
455
  self._timer.cancel()
623
456
  self._timer = None
@@ -641,70 +474,7 @@ class DebugBundleSdk:
641
474
  self._http_transport = None
642
475
 
643
476
  def _refresh_remote_config(self, initial: bool = False) -> None:
644
- with self._lock:
645
- if not self._enabled or self._fetch_impl is None:
646
- return
647
- request_headers: dict[str, str] = {}
648
- if self._remote_config_etag is not None:
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
654
-
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:
661
- snapshot = parse_remote_config(
662
- response.json(),
663
- poll_interval,
664
- int(self._time_provider() * 1000),
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:
684
- if snapshot is None:
685
- self._emit_diagnostic(
686
- "remote_probe_config_invalid",
687
- "sdk-python received an invalid remote probe config payload",
688
- )
689
- if initial:
690
- self._capture_policy = MINIMAL_CAPTURE_POLICY
691
- self._schedule_next_remote_config_refresh(use_fallback=True)
692
- return
693
-
694
- self._remote_config_snapshot = snapshot
695
- self._capture_policy = snapshot.capture_policy
696
- if isinstance(etag, str) and len(etag) > 0:
697
- self._remote_config_etag = etag
698
- self._schedule_next_remote_config_refresh()
699
- else:
700
- self._emit_diagnostic(
701
- "remote_probe_config_failed",
702
- "sdk-python failed to refresh remote probe config",
703
- metadata={"error": serialize_error(failure) if isinstance(failure, Exception) else "fetch_failed"},
704
- )
705
- if initial:
706
- self._capture_policy = MINIMAL_CAPTURE_POLICY
707
- self._schedule_next_remote_config_refresh(use_fallback=True)
477
+ refresh_remote_config(self, initial)
708
478
 
709
479
  def _base_event(
710
480
  self,
@@ -712,7 +482,7 @@ class DebugBundleSdk:
712
482
  payload: dict[str, object],
713
483
  context: Mapping[str, object] | None = None,
714
484
  ) -> dict[str, object]:
715
- merged_context = self._merged_context(context)
485
+ merged_context = add_reference(self, self._merged_context(context))
716
486
  event: dict[str, object] = {
717
487
  "schema_version": SCHEMA_VERSION,
718
488
  "event_id": str(uuid.uuid4()),
@@ -736,24 +506,30 @@ class DebugBundleSdk:
736
506
 
737
507
  def _merged_context(self, context: Mapping[str, object] | None = None) -> dict[str, object]:
738
508
  merged = dict(self._context)
739
- scoped_context = self._scoped_context.get()
740
- if scoped_context is not None:
741
- merged.update(scoped_context)
509
+ merged.update(context_value(self))
742
510
  if context is not None:
743
511
  merged.update({str(key): value for key, value in context.items()})
744
512
  return cast(dict[str, object], redact_value(merged, self._redact_fields))
745
513
 
746
514
  def _protect_event(self, event: dict[str, object]) -> dict[str, object] | None:
747
515
  try:
516
+ selected = filter_evidence(self, event)
517
+ if selected is None:
518
+ return None
519
+ event = selected
748
520
  if not has_safe_event_identity(event, self._redact_fields):
749
521
  return None
750
522
  candidate = dict(event)
751
523
  for key in ("payload", "context", "service"):
752
524
  if key in candidate:
753
525
  candidate[key] = sanitize_telemetry(candidate[key], self._redact_fields)
526
+ if not valid_owned_event(candidate) or not validate_evidence(candidate):
527
+ fallback = without_optional(candidate)
528
+ return self._protect_event(fallback) if fallback is not None else None
754
529
  return candidate
755
- except UnsafeTelemetry:
756
- return None
530
+ except (UnsafeTelemetry, ValidationBudgetExceeded):
531
+ fallback = without_optional(event)
532
+ return self._protect_event(fallback) if fallback is not None else None
757
533
 
758
534
  def _enqueue_event(self, event: dict[str, object]) -> bool:
759
535
  protected = self._protect_event(event)
@@ -762,27 +538,7 @@ class DebugBundleSdk:
762
538
  return self._offer_protected_event(protected, self._event_bytes(protected))
763
539
 
764
540
  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
541
+ return offer_protected_event(self, protected, event_bytes, MAX_PENDING_EVENTS, MAX_PENDING_BYTES)
786
542
 
787
543
  @staticmethod
788
544
  def _event_bytes(event: dict[str, object]) -> int:
@@ -812,6 +568,8 @@ class DebugBundleSdk:
812
568
  protected = self._protect_event(event)
813
569
  if protected is None:
814
570
  return None
571
+ if id(event) in self._hook_bypass:
572
+ return protected
815
573
  result = apply_before_send(
816
574
  protected,
817
575
  self._before_send,
@@ -859,15 +617,15 @@ class DebugBundleSdk:
859
617
  trigger_token_key,
860
618
  int(self._time_provider() * 1000),
861
619
  )
862
- return self._request_trigger_directives.set(directives)
620
+ return bind_directives(self, directives)
863
621
 
864
622
  def end_request(self, token: Token[list[RemoteProbeDirective] | None]) -> None:
865
- self._request_trigger_directives.reset(token)
623
+ release(self, token, directives=True)
866
624
 
867
625
  def _find_matching_probe_directives(self, label: str, now_ms: int) -> list[RemoteProbeDirective]:
868
626
  directives: list[RemoteProbeDirective] = []
869
627
  trigger_directives = self._request_trigger_directives.get()
870
- if trigger_directives is not None:
628
+ if trigger_directives is not None and alive(trigger_directives, self._generation):
871
629
  directives.extend(trigger_directives)
872
630
  if self._remote_config_snapshot is not None and self._remote_config_snapshot.remote_probes_enabled:
873
631
  directives.extend(self._remote_config_snapshot.directives)
@@ -4,7 +4,10 @@ from __future__ import annotations
4
4
 
5
5
  from typing import TYPE_CHECKING, cast
6
6
 
7
+ from .evidence_delivery import allows_recovery, reconcile_diagnostic
8
+ from .ingestion_wire import fit_event
7
9
  from .request_capture_policy import should_capture_request_event
10
+ from .transport import HttpTransport
8
11
 
9
12
  if TYPE_CHECKING:
10
13
  from .core import DebugBundleSdk
@@ -18,6 +21,8 @@ def replacement_allowed(sdk: DebugBundleSdk, event: dict[str, object]) -> bool:
18
21
  return should_capture_request_event(sdk._capture_policy, payload, payload)
19
22
  if event["event_type"] == "probe_event":
20
23
  return sdk._capture_policy.capture_probe_events == "standalone_when_activated"
24
+ if event["event_type"] == "diagnostic_context":
25
+ return payload["kind"] == "recovery" and allows_recovery(sdk)
21
26
  return True
22
27
 
23
28
 
@@ -31,6 +36,8 @@ def finalize_batch(
31
36
  for snapshot_index, original in enumerate(batch):
32
37
  try:
33
38
  replacement = sdk._apply_before_send_event(original)
39
+ if isinstance(sdk._transport, HttpTransport):
40
+ replacement = fit_event(replacement)
34
41
  replacement_bytes = sdk._event_bytes(replacement) if replacement is not None else 0
35
42
  except BaseException:
36
43
  # Hook exceptions/invalid returns already use the protected original
@@ -42,20 +49,27 @@ def finalize_batch(
42
49
  original_bytes = sdk._buffer_sizes[id(original)]
43
50
  allowed = replacement is not None and replacement_allowed(sdk, replacement)
44
51
  fits = sdk._buffer_bytes - original_bytes + replacement_bytes <= max_bytes
52
+ if allowed and fits:
53
+ allowed = reconcile_diagnostic(sdk, original, replacement)
45
54
  if allowed and fits:
46
55
  assert replacement is not None
47
56
  sdk._buffer[retained_index] = replacement
48
57
  sdk._buffer_sizes.pop(id(original))
49
58
  sdk._buffer_sizes[id(replacement)] = replacement_bytes
50
59
  sdk._buffer_bytes += replacement_bytes - original_bytes
60
+ if id(original) in sdk._hook_bypass:
61
+ sdk._hook_bypass.add(id(replacement))
62
+ sdk._hook_bypass.discard(id(original))
51
63
  retained_index += 1
52
64
  batch[snapshot_index] = replacement
53
65
  else:
66
+ reconcile_diagnostic(sdk, original, None)
54
67
  if allowed and not fits:
55
68
  sdk._pressure_drops += 1
56
69
  sdk._buffer.pop(retained_index)
57
70
  sdk._buffer_sizes.pop(id(original))
58
71
  sdk._buffer_bytes -= original_bytes
72
+ sdk._hook_bypass.discard(id(original))
59
73
  sdk._inflight_count -= 1
60
74
  # Release the snapshot reference before another callback can wait.
61
75
  batch[snapshot_index] = {}