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.
- debugbundle/__init__.py +16 -0
- debugbundle/before_send.py +35 -191
- debugbundle/canonical_event.py +202 -0
- debugbundle/capture_hooks.py +14 -6
- debugbundle/config.py +6 -0
- debugbundle/core.py +76 -318
- debugbundle/delivery_hooks.py +14 -0
- debugbundle/diagnostic_budget.py +191 -0
- debugbundle/event-envelope.schema.json +4904 -0
- debugbundle/evidence_delivery.py +115 -0
- debugbundle/evidence_http.py +45 -0
- debugbundle/evidence_json.py +68 -0
- debugbundle/evidence_wire.py +409 -0
- debugbundle/exception_capture.py +98 -0
- debugbundle/exception_evidence.py +199 -0
- debugbundle/exception_identity.py +47 -0
- debugbundle/exception_snapshot.py +1 -1
- debugbundle/flush_delivery.py +203 -0
- debugbundle/ingestion_wire.py +75 -0
- debugbundle/integrations/relay_django.py +4 -1
- debugbundle/integrations/relay_fastapi.py +10 -2
- debugbundle/integrations/relay_flask.py +4 -1
- debugbundle/logger_integrations.py +16 -0
- debugbundle/operation_context.py +121 -0
- debugbundle/process_support.py +7 -0
- debugbundle/queue_admission.py +42 -2
- debugbundle/recovery_capture.py +43 -0
- debugbundle/relay.py +93 -36
- debugbundle/relay_delivery.py +108 -17
- debugbundle/remote_config_client.py +81 -0
- debugbundle/scope_ownership.py +99 -0
- debugbundle/structlog_integration.py +21 -0
- debugbundle/transport.py +20 -14
- {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.1.dist-info}/METADATA +117 -1
- debugbundle_python-2.1.1.dist-info/RECORD +52 -0
- {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.1.dist-info}/WHEEL +1 -1
- debugbundle_python-2.0.1.dist-info/RECORD +0 -36
- {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.1.dist-info}/licenses/LICENSE +0 -0
- {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 .
|
|
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 .
|
|
50
|
-
from .
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
305
|
-
|
|
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
|
|
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
|
|
380
|
+
return bind_context(self, cast(dict[str, object], protected))
|
|
406
381
|
except UnsafeTelemetry:
|
|
407
|
-
return self
|
|
382
|
+
return bind_context(self, {})
|
|
408
383
|
|
|
409
384
|
def _reset_scoped_context(self, token: Token[dict[str, object] | None]) -> None:
|
|
410
|
-
self
|
|
385
|
+
release(self, token)
|
|
411
386
|
|
|
412
|
-
def
|
|
387
|
+
def operation_context(self, options: object) -> Any:
|
|
413
388
|
ensure_current_process(self)
|
|
414
|
-
|
|
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
|
-
|
|
439
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
620
|
+
return bind_directives(self, directives)
|
|
863
621
|
|
|
864
622
|
def end_request(self, token: Token[list[RemoteProbeDirective] | None]) -> None:
|
|
865
|
-
self
|
|
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)
|
debugbundle/delivery_hooks.py
CHANGED
|
@@ -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] = {}
|