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.
- debugbundle/__init__.py +16 -0
- debugbundle/acknowledgement.py +3 -1
- debugbundle/before_send.py +35 -191
- debugbundle/canonical_event.py +202 -0
- debugbundle/config.py +6 -0
- debugbundle/core.py +76 -316
- debugbundle/delivery_hooks.py +14 -0
- debugbundle/diagnostic_budget.py +191 -0
- debugbundle/event-envelope.schema.json +4904 -0
- debugbundle/event_support.py +1 -1
- 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 +92 -0
- debugbundle/exception_evidence.py +199 -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/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/transport.py +49 -20
- {debugbundle_python-2.0.0.dist-info → debugbundle_python-2.1.0.dist-info}/METADATA +122 -1
- debugbundle_python-2.1.0.dist-info/RECORD +51 -0
- {debugbundle_python-2.0.0.dist-info → debugbundle_python-2.1.0.dist-info}/WHEEL +1 -1
- debugbundle_python-2.0.0.dist-info/RECORD +0 -36
- {debugbundle_python-2.0.0.dist-info → debugbundle_python-2.1.0.dist-info}/licenses/LICENSE +0 -0
- {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 .
|
|
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,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
|
|
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(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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
620
|
+
return bind_directives(self, directives)
|
|
861
621
|
|
|
862
622
|
def end_request(self, token: Token[list[RemoteProbeDirective] | None]) -> None:
|
|
863
|
-
self
|
|
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)
|
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] = {}
|