debugbundle-python 2.0.0__py3-none-any.whl → 2.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. debugbundle/__init__.py +16 -0
  2. debugbundle/acknowledgement.py +3 -1
  3. debugbundle/before_send.py +35 -191
  4. debugbundle/canonical_event.py +202 -0
  5. debugbundle/config.py +6 -0
  6. debugbundle/core.py +76 -316
  7. debugbundle/delivery_hooks.py +14 -0
  8. debugbundle/diagnostic_budget.py +191 -0
  9. debugbundle/event-envelope.schema.json +4904 -0
  10. debugbundle/event_support.py +1 -1
  11. debugbundle/evidence_delivery.py +115 -0
  12. debugbundle/evidence_http.py +45 -0
  13. debugbundle/evidence_json.py +68 -0
  14. debugbundle/evidence_wire.py +409 -0
  15. debugbundle/exception_capture.py +92 -0
  16. debugbundle/exception_evidence.py +199 -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/operation_context.py +121 -0
  24. debugbundle/process_support.py +7 -0
  25. debugbundle/queue_admission.py +42 -2
  26. debugbundle/recovery_capture.py +43 -0
  27. debugbundle/relay.py +93 -36
  28. debugbundle/relay_delivery.py +108 -17
  29. debugbundle/remote_config_client.py +81 -0
  30. debugbundle/scope_ownership.py +99 -0
  31. debugbundle/transport.py +49 -20
  32. {debugbundle_python-2.0.0.dist-info → debugbundle_python-2.1.0.dist-info}/METADATA +122 -1
  33. debugbundle_python-2.1.0.dist-info/RECORD +51 -0
  34. {debugbundle_python-2.0.0.dist-info → debugbundle_python-2.1.0.dist-info}/WHEEL +1 -1
  35. debugbundle_python-2.0.0.dist-info/RECORD +0 -36
  36. {debugbundle_python-2.0.0.dist-info → debugbundle_python-2.1.0.dist-info}/licenses/LICENSE +0 -0
  37. {debugbundle_python-2.0.0.dist-info → debugbundle_python-2.1.0.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, 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,174 +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(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
542
- self._retry_after = 0.0
543
- self._consecutive_failures = 0 if acknowledgement.accepted > 0 else 3
544
- return
545
-
546
- self._consecutive_failures += 1
547
- if response.status_code == 429:
548
- retry_after_ms = response.retry_after_ms if response.retry_after_ms is not None else 1_000
549
- self._retry_after = now + (retry_after_ms / 1000)
550
- self._schedule_flush_locked(delay=retry_after_ms / 1000)
551
- return
552
- if 400 <= response.status_code < 500:
553
- self._buffer = self._buffer[len(batch) :]
554
- self._retry_after = 0.0
555
- return
556
- self._schedule_flush_locked()
557
- finally:
558
- self._buffer_sizes = {id(event): self._buffer_sizes[id(event)]
559
- for event in self._buffer}
560
- self._buffer_bytes = sum(self._buffer_sizes.values())
561
- self._pending_low_priority = sum(not high_priority_event(event) for event in self._buffer)
562
- if self._buffer and self._timer is None and self._retry_after <= self._time_provider():
563
- self._schedule_flush_locked()
564
- elif not self._buffer and self._timer is None:
565
- self._schedule_pressure_report_locked()
391
+ def flush(self) -> None:
392
+ flush_sdk(self, MAX_PENDING_EVENTS, MAX_PENDING_BYTES)
566
393
 
567
394
  def probe(self, label: str, data: object | Callable[[], object], opts: Mapping[str, object] | None = None) -> None:
568
395
  capture_probe(self, label, data, opts)
@@ -574,9 +401,13 @@ class DebugBundleSdk:
574
401
  self._original_excepthook = sys.excepthook
575
402
 
576
403
  def handler(exc_type: type[BaseException], error: BaseException, tb: Any) -> None:
577
- if error.__traceback__ is None:
578
- error.__traceback__ = tb
579
- 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
580
411
 
581
412
  sys.excepthook = handler
582
413
 
@@ -616,6 +447,10 @@ class DebugBundleSdk:
616
447
  def dispose(self) -> None:
617
448
  ensure_current_process(self)
618
449
  with self._lock:
450
+ self._operations.clear()
451
+ self._evidence = EvidenceDelivery()
452
+ self._diagnostics.clear()
453
+ self._hook_bypass.clear()
619
454
  if self._timer is not None:
620
455
  self._timer.cancel()
621
456
  self._timer = None
@@ -639,70 +474,7 @@ class DebugBundleSdk:
639
474
  self._http_transport = None
640
475
 
641
476
  def _refresh_remote_config(self, initial: bool = False) -> None:
642
- with self._lock:
643
- if not self._enabled or self._fetch_impl is None:
644
- return
645
- request_headers: dict[str, str] = {}
646
- if self._remote_config_etag is not None:
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
652
-
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:
659
- snapshot = parse_remote_config(
660
- response.json(),
661
- poll_interval,
662
- int(self._time_provider() * 1000),
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:
682
- if snapshot is None:
683
- self._emit_diagnostic(
684
- "remote_probe_config_invalid",
685
- "sdk-python received an invalid remote probe config payload",
686
- )
687
- if initial:
688
- self._capture_policy = MINIMAL_CAPTURE_POLICY
689
- self._schedule_next_remote_config_refresh(use_fallback=True)
690
- return
691
-
692
- self._remote_config_snapshot = snapshot
693
- self._capture_policy = snapshot.capture_policy
694
- if isinstance(etag, str) and len(etag) > 0:
695
- self._remote_config_etag = etag
696
- self._schedule_next_remote_config_refresh()
697
- else:
698
- self._emit_diagnostic(
699
- "remote_probe_config_failed",
700
- "sdk-python failed to refresh remote probe config",
701
- metadata={"error": serialize_error(failure) if isinstance(failure, Exception) else "fetch_failed"},
702
- )
703
- if initial:
704
- self._capture_policy = MINIMAL_CAPTURE_POLICY
705
- self._schedule_next_remote_config_refresh(use_fallback=True)
477
+ refresh_remote_config(self, initial)
706
478
 
707
479
  def _base_event(
708
480
  self,
@@ -710,7 +482,7 @@ class DebugBundleSdk:
710
482
  payload: dict[str, object],
711
483
  context: Mapping[str, object] | None = None,
712
484
  ) -> dict[str, object]:
713
- merged_context = self._merged_context(context)
485
+ merged_context = add_reference(self, self._merged_context(context))
714
486
  event: dict[str, object] = {
715
487
  "schema_version": SCHEMA_VERSION,
716
488
  "event_id": str(uuid.uuid4()),
@@ -734,24 +506,30 @@ class DebugBundleSdk:
734
506
 
735
507
  def _merged_context(self, context: Mapping[str, object] | None = None) -> dict[str, object]:
736
508
  merged = dict(self._context)
737
- scoped_context = self._scoped_context.get()
738
- if scoped_context is not None:
739
- merged.update(scoped_context)
509
+ merged.update(context_value(self))
740
510
  if context is not None:
741
511
  merged.update({str(key): value for key, value in context.items()})
742
512
  return cast(dict[str, object], redact_value(merged, self._redact_fields))
743
513
 
744
514
  def _protect_event(self, event: dict[str, object]) -> dict[str, object] | None:
745
515
  try:
516
+ selected = filter_evidence(self, event)
517
+ if selected is None:
518
+ return None
519
+ event = selected
746
520
  if not has_safe_event_identity(event, self._redact_fields):
747
521
  return None
748
522
  candidate = dict(event)
749
523
  for key in ("payload", "context", "service"):
750
524
  if key in candidate:
751
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
752
529
  return candidate
753
- except UnsafeTelemetry:
754
- return None
530
+ except (UnsafeTelemetry, ValidationBudgetExceeded):
531
+ fallback = without_optional(event)
532
+ return self._protect_event(fallback) if fallback is not None else None
755
533
 
756
534
  def _enqueue_event(self, event: dict[str, object]) -> bool:
757
535
  protected = self._protect_event(event)
@@ -760,27 +538,7 @@ class DebugBundleSdk:
760
538
  return self._offer_protected_event(protected, self._event_bytes(protected))
761
539
 
762
540
  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
541
+ return offer_protected_event(self, protected, event_bytes, MAX_PENDING_EVENTS, MAX_PENDING_BYTES)
784
542
 
785
543
  @staticmethod
786
544
  def _event_bytes(event: dict[str, object]) -> int:
@@ -810,6 +568,8 @@ class DebugBundleSdk:
810
568
  protected = self._protect_event(event)
811
569
  if protected is None:
812
570
  return None
571
+ if id(event) in self._hook_bypass:
572
+ return protected
813
573
  result = apply_before_send(
814
574
  protected,
815
575
  self._before_send,
@@ -857,15 +617,15 @@ class DebugBundleSdk:
857
617
  trigger_token_key,
858
618
  int(self._time_provider() * 1000),
859
619
  )
860
- return self._request_trigger_directives.set(directives)
620
+ return bind_directives(self, directives)
861
621
 
862
622
  def end_request(self, token: Token[list[RemoteProbeDirective] | None]) -> None:
863
- self._request_trigger_directives.reset(token)
623
+ release(self, token, directives=True)
864
624
 
865
625
  def _find_matching_probe_directives(self, label: str, now_ms: int) -> list[RemoteProbeDirective]:
866
626
  directives: list[RemoteProbeDirective] = []
867
627
  trigger_directives = self._request_trigger_directives.get()
868
- if trigger_directives is not None:
628
+ if trigger_directives is not None and alive(trigger_directives, self._generation):
869
629
  directives.extend(trigger_directives)
870
630
  if self._remote_config_snapshot is not None and self._remote_config_snapshot.remote_probes_enabled:
871
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] = {}