debugbundle-python 1.2.0__py3-none-any.whl → 1.3.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 CHANGED
@@ -7,6 +7,7 @@ from collections.abc import Callable, Mapping
7
7
  from contextvars import Token
8
8
  from typing import Any
9
9
 
10
+ from .before_send import BeforeSendHook
10
11
  from .config import RemoteProbeDirective
11
12
  from .core import ConfigFetchResponse, DebugBundleSdk
12
13
  from .relay import BrowserRelayAcceptedBatch, BrowserRelayHandler, BrowserRelayResponse
@@ -30,6 +31,7 @@ def init(
30
31
  probe_flush_on_error: bool = True,
31
32
  fetch_impl: Callable[[str, dict[str, object]], ConfigFetchResponse] | None = None,
32
33
  on_diagnostic: Callable[[dict[str, object]], None] | None = None,
34
+ before_send: BeforeSendHook | None = None,
33
35
  probes_poll_interval: int = 60_000,
34
36
  ) -> None:
35
37
  _sdk.init(
@@ -48,6 +50,7 @@ def init(
48
50
  probe_flush_on_error=probe_flush_on_error,
49
51
  fetch_impl=fetch_impl,
50
52
  on_diagnostic=on_diagnostic,
53
+ before_send=before_send,
51
54
  probes_poll_interval=probes_poll_interval,
52
55
  )
53
56
 
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Literal, TypeGuard
5
+
6
+ RETRYABLE_REASONS = {
7
+ "rate_limited",
8
+ "monthly_quota_exceeded",
9
+ "analytics_quota_exceeded",
10
+ }
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class AcknowledgementDecision:
15
+ kind: Literal["legacy", "protocol_failure", "acknowledged"]
16
+ accepted: int = 0
17
+ retryable_indices: tuple[int, ...] = ()
18
+ terminal_errors: tuple[tuple[int, str], ...] = ()
19
+ reason: str | None = None
20
+
21
+
22
+ def decide_acknowledgement(body: object | None, batch_length: int) -> AcknowledgementDecision:
23
+ if not isinstance(body, dict) or not any(key in body for key in ("accepted", "rejected", "errors")):
24
+ return AcknowledgementDecision(kind="legacy")
25
+
26
+ accepted = body.get("accepted")
27
+ rejected = body.get("rejected")
28
+ errors = body.get("errors")
29
+ if (
30
+ not _is_count(accepted)
31
+ or not _is_count(rejected)
32
+ or not isinstance(errors, list)
33
+ or accepted + rejected != batch_length
34
+ or len(errors) != rejected
35
+ ):
36
+ return AcknowledgementDecision(kind="protocol_failure", reason="inconsistent_counts")
37
+
38
+ seen: set[int] = set()
39
+ retryable: list[int] = []
40
+ terminal: list[tuple[int, str]] = []
41
+ for error in errors:
42
+ if not isinstance(error, dict):
43
+ return AcknowledgementDecision(kind="protocol_failure", reason="invalid_error_index")
44
+ index = error.get("index")
45
+ reason = error.get("reason")
46
+ if (
47
+ not isinstance(index, int)
48
+ or isinstance(index, bool)
49
+ or index < 0
50
+ or index >= batch_length
51
+ or index in seen
52
+ or not isinstance(reason, str)
53
+ or not reason
54
+ ):
55
+ return AcknowledgementDecision(kind="protocol_failure", reason="invalid_error_index")
56
+ seen.add(index)
57
+ if reason in RETRYABLE_REASONS:
58
+ retryable.append(index)
59
+ else:
60
+ terminal.append((index, reason))
61
+
62
+ return AcknowledgementDecision(
63
+ kind="acknowledged",
64
+ accepted=accepted,
65
+ retryable_indices=tuple(retryable),
66
+ terminal_errors=tuple(terminal),
67
+ )
68
+
69
+
70
+ def _is_count(value: object) -> TypeGuard[int]:
71
+ return isinstance(value, int) and not isinstance(value, bool) and value >= 0
@@ -0,0 +1,218 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import uuid
5
+ from collections.abc import Callable, Mapping
6
+ from datetime import datetime
7
+
8
+ BeforeSendHook = Callable[[dict[str, object]], dict[str, object] | None]
9
+
10
+ _EVENT_PAYLOAD_FIELDS: dict[str, tuple[str, ...]] = {
11
+ "backend_exception": ("name", "message", "stack", "handled", "request", "response", "runtime"),
12
+ "request_event": ("method", "path", "query", "headers", "response_status", "duration_ms"),
13
+ "log_event": ("level", "message", "attributes"),
14
+ "frontend_breadcrumb": ("breadcrumb_type", "data"),
15
+ "frontend_exception": ("name", "message", "stack"),
16
+ "deploy_metadata": ("commit_sha", "version", "branch", "environment", "deployed_at"),
17
+ "error_suppressed": (
18
+ "fingerprint",
19
+ "suppressed_count",
20
+ "window_seconds",
21
+ "first_seen",
22
+ "last_seen",
23
+ ),
24
+ "probe_event": ("label", "data", "activation_id", "probe_label_pattern"),
25
+ }
26
+ _EVENT_PAYLOAD_ALLOWED_FIELDS: dict[str, frozenset[str]] = {
27
+ "backend_exception": frozenset((*_EVENT_PAYLOAD_FIELDS["backend_exception"], "probe_data")),
28
+ "request_event": frozenset(
29
+ (
30
+ *_EVENT_PAYLOAD_FIELDS["request_event"],
31
+ "body",
32
+ "route_template",
33
+ "response_headers",
34
+ "response_body",
35
+ "device",
36
+ )
37
+ ),
38
+ "log_event": frozenset((*_EVENT_PAYLOAD_FIELDS["log_event"], "device")),
39
+ "frontend_breadcrumb": frozenset((*_EVENT_PAYLOAD_FIELDS["frontend_breadcrumb"], "route", "device")),
40
+ "frontend_exception": frozenset(
41
+ (
42
+ *_EVENT_PAYLOAD_FIELDS["frontend_exception"],
43
+ "route",
44
+ "browser",
45
+ "breadcrumbs",
46
+ "device",
47
+ "browser_event",
48
+ "rejection_reason",
49
+ "dom_context",
50
+ "probe_data",
51
+ )
52
+ ),
53
+ "deploy_metadata": frozenset(_EVENT_PAYLOAD_FIELDS["deploy_metadata"]),
54
+ "error_suppressed": frozenset((*_EVENT_PAYLOAD_FIELDS["error_suppressed"], "device")),
55
+ "probe_event": frozenset((*_EVENT_PAYLOAD_FIELDS["probe_event"], "device")),
56
+ }
57
+ _ROOT_FIELDS = frozenset(
58
+ (
59
+ "schema_version",
60
+ "event_id",
61
+ "event_type",
62
+ "project_token",
63
+ "project_id",
64
+ "sdk_name",
65
+ "sdk_version",
66
+ "service",
67
+ "occurred_at",
68
+ "correlation",
69
+ "context",
70
+ "payload",
71
+ )
72
+ )
73
+
74
+
75
+ def apply_before_send(
76
+ event: dict[str, object],
77
+ hook: BeforeSendHook | None,
78
+ emit_diagnostic: Callable[[str, str], None],
79
+ ) -> dict[str, object] | None:
80
+ if hook is None:
81
+ return event
82
+
83
+ try:
84
+ result = hook(copy.deepcopy(event))
85
+ except Exception:
86
+ emit_diagnostic("before_send_failed", "Python before_send hook failed")
87
+ return event
88
+
89
+ if result is None:
90
+ return None
91
+ if not _is_valid_event(result):
92
+ emit_diagnostic("before_send_invalid_event", "Python before_send returned an invalid event")
93
+ return event
94
+ return result
95
+
96
+
97
+ def _is_valid_event(event: object) -> bool:
98
+ if not isinstance(event, dict):
99
+ return False
100
+ if not set(event).issubset(_ROOT_FIELDS):
101
+ return False
102
+ event_type = event.get("event_type")
103
+ if not isinstance(event_type, str) or event_type not in _EVENT_PAYLOAD_FIELDS:
104
+ return False
105
+ if not all(
106
+ isinstance(event.get(field), str) and event[field]
107
+ for field in ("schema_version", "sdk_name", "sdk_version", "occurred_at")
108
+ ):
109
+ return False
110
+ if not _timestamp(event["occurred_at"]):
111
+ return False
112
+ try:
113
+ uuid.UUID(str(event.get("event_id")))
114
+ except (ValueError, TypeError, AttributeError):
115
+ return False
116
+ service = event.get("service")
117
+ if (
118
+ not isinstance(service, dict)
119
+ or not isinstance(service.get("name"), str)
120
+ or not service["name"]
121
+ or not isinstance(service.get("environment"), str)
122
+ or not service["environment"]
123
+ ):
124
+ return False
125
+ payload = event.get("payload")
126
+ if not isinstance(payload, dict):
127
+ return False
128
+ if not set(payload).issubset(_EVENT_PAYLOAD_ALLOWED_FIELDS[event_type]):
129
+ return False
130
+ if not all(field in payload for field in _EVENT_PAYLOAD_FIELDS[event_type]):
131
+ return False
132
+ return _has_valid_payload_shape(event_type, payload)
133
+
134
+
135
+ def _has_valid_payload_shape(event_type: str, payload: Mapping[object, object]) -> bool:
136
+ if event_type == "backend_exception":
137
+ return (
138
+ _has_nonempty_strings(payload, "name", "message", "stack")
139
+ and isinstance(payload["handled"], bool)
140
+ and all(isinstance(payload[field], dict) for field in ("request", "response", "runtime"))
141
+ and _optional_dict(payload, "probe_data")
142
+ )
143
+ if event_type == "request_event":
144
+ return (
145
+ _has_nonempty_strings(payload, "method", "path")
146
+ and all(isinstance(payload[field], dict) for field in ("query", "headers"))
147
+ and _nonnegative_number(payload["response_status"])
148
+ and _nonnegative_number(payload["duration_ms"])
149
+ and _optional_dict(payload, "response_headers")
150
+ )
151
+ if event_type == "log_event":
152
+ return _has_nonempty_strings(payload, "level", "message") and isinstance(payload["attributes"], dict)
153
+ if event_type == "frontend_breadcrumb":
154
+ return _has_nonempty_strings(payload, "breadcrumb_type") and isinstance(payload["data"], dict)
155
+ if event_type == "frontend_exception":
156
+ return (
157
+ _has_nonempty_strings(payload, "name", "message", "stack")
158
+ and (payload.get("breadcrumbs") is None or isinstance(payload["breadcrumbs"], list))
159
+ and _optional_dict(payload, "probe_data")
160
+ )
161
+ if event_type == "deploy_metadata":
162
+ return _has_nonempty_strings(payload, "commit_sha", "version", "branch", "environment") and _timestamp(
163
+ payload["deployed_at"]
164
+ )
165
+ if event_type == "error_suppressed":
166
+ return (
167
+ _has_nonempty_strings(payload, "fingerprint")
168
+ and _nonnegative_integer(payload["suppressed_count"])
169
+ and _positive_integer(payload["window_seconds"])
170
+ and _timestamp(payload["first_seen"])
171
+ and _timestamp(payload["last_seen"])
172
+ )
173
+ if event_type == "probe_event":
174
+ activation_id = payload["activation_id"]
175
+ return (
176
+ _has_nonempty_strings(payload, "label", "probe_label_pattern")
177
+ and isinstance(payload["data"], dict)
178
+ and (activation_id is None or _uuid(activation_id))
179
+ )
180
+ return False
181
+
182
+
183
+ def _has_nonempty_strings(payload: Mapping[object, object], *fields: str) -> bool:
184
+ return all(isinstance(payload.get(field), str) and bool(str(payload[field]).strip()) for field in fields)
185
+
186
+
187
+ def _optional_dict(payload: Mapping[object, object], field: str) -> bool:
188
+ return field not in payload or isinstance(payload[field], dict)
189
+
190
+
191
+ def _nonnegative_number(value: object) -> bool:
192
+ return isinstance(value, (int, float)) and not isinstance(value, bool) and value >= 0
193
+
194
+
195
+ def _nonnegative_integer(value: object) -> bool:
196
+ return isinstance(value, int) and not isinstance(value, bool) and value >= 0
197
+
198
+
199
+ def _positive_integer(value: object) -> bool:
200
+ return isinstance(value, int) and not isinstance(value, bool) and value > 0
201
+
202
+
203
+ def _uuid(value: object) -> bool:
204
+ try:
205
+ uuid.UUID(str(value))
206
+ except (ValueError, TypeError, AttributeError):
207
+ return False
208
+ return isinstance(value, str)
209
+
210
+
211
+ def _timestamp(value: object) -> bool:
212
+ if not isinstance(value, str):
213
+ return False
214
+ try:
215
+ datetime.fromisoformat(value.replace("Z", "+00:00"))
216
+ except ValueError:
217
+ return False
218
+ return True
debugbundle/core.py CHANGED
@@ -2,23 +2,19 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import logging
5
- import os
6
- import platform
7
- import socket
8
5
  import sys
9
6
  import threading
10
- import time
11
7
  import traceback
12
8
  import uuid
13
9
  from collections import deque
14
10
  from collections.abc import Callable, Mapping
15
11
  from contextvars import ContextVar, Token
16
12
  from dataclasses import dataclass
17
- from datetime import datetime, timezone
18
- from importlib import metadata
19
13
  from random import random
20
14
  from typing import Any, Protocol, cast
21
15
 
16
+ from .acknowledgement import decide_acknowledgement
17
+ from .before_send import BeforeSendHook, apply_before_send
22
18
  from .config import (
23
19
  BALANCED_CAPTURE_POLICY,
24
20
  DEFAULT_PROBES_POLL_INTERVAL_MS,
@@ -29,32 +25,35 @@ from .config import (
29
25
  find_matching_remote_probe_directives,
30
26
  parse_remote_config,
31
27
  )
28
+ from .event_support import (
29
+ DEFAULT_LOG_LEVEL,
30
+ LEVEL_RANKS,
31
+ backend_exception_request_payload,
32
+ backend_exception_response_payload,
33
+ correlation_payload,
34
+ event_context,
35
+ is_immediate_request_incident_status,
36
+ iso_now,
37
+ level_enabled,
38
+ normalize_level,
39
+ redact_mapping,
40
+ request_event_payload,
41
+ runtime_process_facts,
42
+ sdk_config_endpoint,
43
+ sdk_version,
44
+ serialize_error,
45
+ time_now,
46
+ )
32
47
  from .logger_integrations import attach_optional_integrations
33
48
  from .redaction import DEFAULT_REDACT_FIELDS, redact_value
34
49
  from .suppression import EventSuppressionTracker
35
50
  from .transport import HttpTransport, Transport, coerce_transport_response
36
51
  from .trigger_token import resolve_request_trigger_directives
37
52
 
38
- try:
39
- import resource
40
- except ImportError: # pragma: no cover - resource is unavailable on some platforms.
41
- resource = None # type: ignore[assignment]
42
-
43
53
  DEFAULT_BATCH_SIZE = 25
44
54
  DEFAULT_FLUSH_INTERVAL = 5.0
45
55
  DEFAULT_ENDPOINT = "https://api.debugbundle.com/v1/events"
46
- DEFAULT_LOG_LEVEL = "warning"
47
- PROCESS_START_MONOTONIC = time.monotonic()
48
56
  SCHEMA_VERSION = "2026-03-01"
49
- LEVEL_RANKS = {
50
- "debug": 10,
51
- "info": 20,
52
- "warning": 30,
53
- "error": 40,
54
- "critical": 50,
55
- }
56
- BALANCED_IMMEDIATE_REQUEST_STATUSES = {408, 423, 424, 425, 429}
57
- INVESTIGATIVE_IMMEDIATE_REQUEST_STATUSES = BALANCED_IMMEDIATE_REQUEST_STATUSES | {409}
58
57
 
59
58
 
60
59
  @dataclass
@@ -95,7 +94,7 @@ class DebugBundleSdk:
95
94
  time_provider: Callable[[], float] | None = None,
96
95
  ) -> None:
97
96
  self._transport_override = transport
98
- self._time_provider = time_provider or _time_now
97
+ self._time_provider = time_provider or time_now
99
98
  self._lock = threading.RLock()
100
99
  self._timer: threading.Timer | None = None
101
100
  self._remote_config_timer: threading.Timer | None = None
@@ -130,6 +129,7 @@ class DebugBundleSdk:
130
129
  self._async_handlers: dict[asyncio.AbstractEventLoop, Any] = {}
131
130
  self._fetch_impl: Callable[[str, dict[str, object]], ConfigFetchResponse] | None = None
132
131
  self._on_diagnostic: Callable[[dict[str, object]], None] | None = None
132
+ self._before_send: BeforeSendHook | None = None
133
133
  self._configured_probes_poll_interval_ms = DEFAULT_PROBES_POLL_INTERVAL_MS
134
134
  self._remote_config_etag: str | None = None
135
135
  self._remote_config_snapshot: RemoteConfigSnapshot | None = None
@@ -172,6 +172,7 @@ class DebugBundleSdk:
172
172
  probe_flush_on_error: bool = True,
173
173
  fetch_impl: Callable[[str, dict[str, object]], ConfigFetchResponse] | None = None,
174
174
  on_diagnostic: Callable[[dict[str, object]], None] | None = None,
175
+ before_send: BeforeSendHook | None = None,
175
176
  probes_poll_interval: int = DEFAULT_PROBES_POLL_INTERVAL_MS,
176
177
  ) -> None:
177
178
  with self._lock:
@@ -183,7 +184,7 @@ class DebugBundleSdk:
183
184
  self._endpoint = endpoint
184
185
  self._batch_size = max(1, batch_size)
185
186
  self._flush_interval = max(0.1, flush_interval)
186
- self._log_level = _normalize_level(log_level)
187
+ self._log_level = normalize_level(log_level)
187
188
  self._sample_rate = min(max(sample_rate, 0.0), 1.0)
188
189
  self._redact_fields = set(DEFAULT_REDACT_FIELDS)
189
190
  if redact_fields:
@@ -200,6 +201,7 @@ class DebugBundleSdk:
200
201
  self._consecutive_failures = 0
201
202
  self._fetch_impl = fetch_impl
202
203
  self._on_diagnostic = on_diagnostic
204
+ self._before_send = before_send
203
205
  self._configured_probes_poll_interval_ms = max(1, int(probes_poll_interval))
204
206
  self._remote_config_etag = None
205
207
  self._remote_config_snapshot = None
@@ -222,12 +224,12 @@ class DebugBundleSdk:
222
224
  handled: bool = True,
223
225
  ) -> None:
224
226
  with self._lock:
225
- if not self._enabled or not self._passes_sample_rate():
227
+ if not self._enabled:
226
228
  return
227
229
 
228
- redacted_context = _redact_mapping(dict(context or {}), self._redact_fields)
229
- request_payload = _backend_exception_request_payload(redacted_context.get("request"))
230
- response_payload = _backend_exception_response_payload(redacted_context.get("response"))
230
+ redacted_context = redact_mapping(dict(context or {}), self._redact_fields)
231
+ request_payload = backend_exception_request_payload(redacted_context.get("request"))
232
+ response_payload = backend_exception_response_payload(redacted_context.get("response"))
231
233
 
232
234
  payload: dict[str, object] = {
233
235
  "name": type(error).__name__,
@@ -236,15 +238,23 @@ class DebugBundleSdk:
236
238
  "handled": handled,
237
239
  "request": request_payload,
238
240
  "response": response_payload,
239
- "runtime": _runtime_process_facts(),
241
+ "runtime": runtime_process_facts(),
240
242
  }
241
243
  if self._probe_flush_on_error:
242
244
  probe_data = self._build_probe_data()
243
245
  if probe_data is not None:
244
246
  payload["probe_data"] = probe_data
245
247
 
246
- event = self._base_event("backend_exception", payload, context=redacted_context)
247
- suppression_key = f"backend_exception:{payload['name']}:{payload['message']}:{payload.get('stack', '')}"
248
+ event = self._apply_before_send_event(
249
+ self._base_event("backend_exception", payload, context=redacted_context)
250
+ )
251
+ if event is None or not self._passes_sample_rate():
252
+ return
253
+ event_payload = cast(dict[str, object], event["payload"])
254
+ suppression_key = (
255
+ f"{event['event_type']}:{event_payload.get('name', '')}:"
256
+ f"{event_payload.get('message', '')}:{event_payload.get('stack', '')}"
257
+ )
248
258
  if not self._suppression.should_capture(suppression_key, self._time_provider()):
249
259
  return
250
260
  self._enqueue_event(event)
@@ -258,14 +268,9 @@ class DebugBundleSdk:
258
268
  level: str = DEFAULT_LOG_LEVEL,
259
269
  context: Mapping[str, object] | None = None,
260
270
  ) -> None:
261
- normalized_level = _normalize_level(level)
271
+ normalized_level = normalize_level(level)
262
272
  with self._lock:
263
- if (
264
- not self._enabled
265
- or not self._passes_sample_rate()
266
- or self._capture_policy.capture_logs == "off"
267
- or not _level_enabled(normalized_level, self._effective_log_threshold())
268
- ):
273
+ if not self._enabled:
269
274
  return
270
275
  payload: dict[str, object] = {
271
276
  "message": message,
@@ -273,8 +278,16 @@ class DebugBundleSdk:
273
278
  "attributes": {},
274
279
  }
275
280
  if context:
276
- payload["attributes"] = _redact_mapping(dict(context), self._redact_fields)
277
- self._enqueue_event(self._base_event("log_event", payload, context=context))
281
+ payload["attributes"] = redact_mapping(dict(context), self._redact_fields)
282
+ event = self._apply_before_send_event(self._base_event("log_event", payload, context=context))
283
+ if (
284
+ event is None
285
+ or not self._passes_sample_rate()
286
+ or self._capture_policy.capture_logs == "off"
287
+ or not level_enabled(normalized_level, self._effective_log_threshold())
288
+ ):
289
+ return
290
+ self._enqueue_event(event)
278
291
 
279
292
  def capture_request(
280
293
  self,
@@ -283,19 +296,21 @@ class DebugBundleSdk:
283
296
  context: Mapping[str, object] | None = None,
284
297
  ) -> None:
285
298
  with self._lock:
286
- should_capture = (
287
- self._enabled
288
- and self._passes_sample_rate()
289
- and self._should_capture_request_event(request, response)
290
- )
291
- if not should_capture:
299
+ if not self._enabled:
292
300
  return
293
- payload = _request_event_payload(
294
- _redact_mapping(dict(request), self._redact_fields),
295
- _redact_mapping(dict(response or {}), self._redact_fields),
296
- _redact_mapping(dict(context or {}), self._redact_fields),
301
+ payload = request_event_payload(
302
+ redact_mapping(dict(request), self._redact_fields),
303
+ redact_mapping(dict(response or {}), self._redact_fields),
304
+ redact_mapping(dict(context or {}), self._redact_fields),
297
305
  )
298
- self._enqueue_event(self._base_event("request_event", payload, context=context))
306
+ event = self._apply_before_send_event(self._base_event("request_event", payload, context=context))
307
+ if (
308
+ event is None
309
+ or not self._passes_sample_rate()
310
+ or not self._should_capture_request_event(request, response)
311
+ ):
312
+ return
313
+ self._enqueue_event(event)
299
314
 
300
315
  def capture_message(
301
316
  self,
@@ -307,15 +322,15 @@ class DebugBundleSdk:
307
322
 
308
323
  def set_context(self, key: str, value: object) -> None:
309
324
  with self._lock:
310
- self._context[key] = _redact_mapping(value, self._redact_fields)
325
+ self._context[key] = redact_value({key: value}, self._redact_fields)[key]
311
326
 
312
327
  def _bind_scoped_context(self, context: Mapping[str, object]) -> Token[dict[str, object] | None]:
313
328
  scoped_context = dict(self._scoped_context.get() or {})
314
329
  for key, value in context.items():
315
330
  if value is None:
316
331
  continue
317
- scoped_context[str(key)] = _redact_mapping(value, self._redact_fields)
318
- return self._scoped_context.set(scoped_context)
332
+ scoped_context[str(key)] = value
333
+ return self._scoped_context.set(cast(dict[str, object], redact_value(scoped_context, self._redact_fields)))
319
334
 
320
335
  def _reset_scoped_context(self, token: Token[dict[str, object] | None]) -> None:
321
336
  self._scoped_context.reset(token)
@@ -333,9 +348,10 @@ class DebugBundleSdk:
333
348
  if now < self._retry_after:
334
349
  return
335
350
 
351
+ batch = [dict(event) for event in self._buffer]
336
352
  request = {
337
353
  "project_token": self._project_token,
338
- "events": [dict(event) for event in self._buffer],
354
+ "events": batch,
339
355
  }
340
356
 
341
357
  try:
@@ -346,10 +362,48 @@ class DebugBundleSdk:
346
362
  return
347
363
 
348
364
  if 200 <= response.status_code < 300:
349
- self._buffer = []
365
+ acknowledgement = decide_acknowledgement(response.body, len(batch))
366
+ if acknowledgement.kind == "protocol_failure":
367
+ self._consecutive_failures += 1
368
+ retry_after_ms = response.retry_after_ms if response.retry_after_ms is not None else 1_000
369
+ self._retry_after = now + (retry_after_ms / 1000)
370
+ self._emit_diagnostic(
371
+ "ingestion_acknowledgement_invalid",
372
+ "sdk-python retained a batch after an invalid ingestion acknowledgement",
373
+ metadata={"reason": acknowledgement.reason or "invalid"},
374
+ )
375
+ self._schedule_flush_locked(delay=retry_after_ms / 1000)
376
+ return
377
+ if acknowledgement.kind == "legacy":
378
+ self._buffer = self._buffer[len(batch) :]
379
+ self._retry_after = 0.0
380
+ self._last_event_at = self._time_provider() * 1000
381
+ self._consecutive_failures = 0
382
+ return
383
+
384
+ trailing_events = self._buffer[len(batch) :]
385
+ self._buffer = [
386
+ batch[index] for index in acknowledgement.retryable_indices if 0 <= index < len(batch)
387
+ ] + trailing_events
388
+ if acknowledgement.terminal_errors:
389
+ self._emit_diagnostic(
390
+ "ingestion_events_rejected",
391
+ "sdk-python removed terminally rejected ingestion events",
392
+ metadata={
393
+ "rejected_count": len(acknowledgement.terminal_errors),
394
+ "reasons": sorted({reason for _, reason in acknowledgement.terminal_errors}),
395
+ },
396
+ )
397
+ if acknowledgement.accepted > 0:
398
+ self._last_event_at = self._time_provider() * 1000
399
+ if acknowledgement.retryable_indices:
400
+ self._consecutive_failures += 1
401
+ retry_after_ms = response.retry_after_ms if response.retry_after_ms is not None else 1_000
402
+ self._retry_after = now + (retry_after_ms / 1000)
403
+ self._schedule_flush_locked(delay=retry_after_ms / 1000)
404
+ return
350
405
  self._retry_after = 0.0
351
- self._last_event_at = self._time_provider() * 1000
352
- self._consecutive_failures = 0
406
+ self._consecutive_failures = 0 if acknowledgement.accepted > 0 else 3
353
407
  return
354
408
 
355
409
  self._consecutive_failures += 1
@@ -383,7 +437,7 @@ class DebugBundleSdk:
383
437
  if not isinstance(value, Mapping):
384
438
  value = {"value": value}
385
439
 
386
- redacted_value = _redact_mapping(dict(value), self._redact_fields)
440
+ redacted_value = redact_mapping(dict(value), self._redact_fields)
387
441
 
388
442
  if is_heavy:
389
443
  self._emit_probe_events(label, redacted_value, matching_directives)
@@ -392,7 +446,7 @@ class DebugBundleSdk:
392
446
  entry = ProbeEntry(
393
447
  label=label,
394
448
  data=redacted_value,
395
- timestamp=_iso_now(self._time_provider),
449
+ timestamp=iso_now(self._time_provider),
396
450
  )
397
451
  bucket = self._probe_buffers.setdefault(label, deque(maxlen=self._max_probe_entries_per_label))
398
452
  bucket.append(entry)
@@ -476,7 +530,7 @@ class DebugBundleSdk:
476
530
 
477
531
  try:
478
532
  response = self._fetch_impl(
479
- _sdk_config_endpoint(self._endpoint),
533
+ sdk_config_endpoint(self._endpoint),
480
534
  {
481
535
  "method": "GET",
482
536
  "headers": request_headers,
@@ -516,7 +570,7 @@ class DebugBundleSdk:
516
570
  self._emit_diagnostic(
517
571
  "remote_probe_config_failed",
518
572
  "sdk-python failed to refresh remote probe config",
519
- metadata={"error": _serialize_error(error)},
573
+ metadata={"error": serialize_error(error)},
520
574
  )
521
575
  if initial:
522
576
  self._capture_policy = MINIMAL_CAPTURE_POLICY
@@ -533,19 +587,19 @@ class DebugBundleSdk:
533
587
  "schema_version": SCHEMA_VERSION,
534
588
  "event_id": str(uuid.uuid4()),
535
589
  "event_type": event_type,
536
- "occurred_at": _iso_now(self._time_provider),
590
+ "occurred_at": iso_now(self._time_provider),
537
591
  "sdk_name": "debugbundle-python",
538
- "sdk_version": _sdk_version(),
592
+ "sdk_version": sdk_version(),
539
593
  "service": {
540
594
  "name": self._service,
541
595
  "runtime": "python",
542
596
  "framework": None,
543
597
  "environment": self._environment,
544
598
  },
545
- "correlation": _correlation_payload(merged_context),
599
+ "correlation": correlation_payload(merged_context),
546
600
  "payload": payload,
547
601
  }
548
- envelope_context = _event_context(merged_context)
602
+ envelope_context = event_context(merged_context)
549
603
  if envelope_context:
550
604
  event["context"] = envelope_context
551
605
  return event
@@ -556,9 +610,8 @@ class DebugBundleSdk:
556
610
  if scoped_context is not None:
557
611
  merged.update(scoped_context)
558
612
  if context is not None:
559
- for key, value in context.items():
560
- merged[str(key)] = _redact_mapping(value, self._redact_fields)
561
- return merged
613
+ merged.update({str(key): value for key, value in context.items()})
614
+ return cast(dict[str, object], redact_value(merged, self._redact_fields))
562
615
 
563
616
  def _enqueue_event(self, event: dict[str, object]) -> None:
564
617
  self._buffer.append(event)
@@ -583,7 +636,16 @@ class DebugBundleSdk:
583
636
  if not isinstance(event_type, str) or not isinstance(payload, dict):
584
637
  continue
585
638
  aggregate.update(self._base_event(event_type, cast(dict[str, object], payload)))
586
- self._buffer.append(aggregate)
639
+ prepared = self._apply_before_send_event(aggregate)
640
+ if prepared is not None:
641
+ self._buffer.append(prepared)
642
+
643
+ def _apply_before_send_event(self, event: dict[str, object]) -> dict[str, object] | None:
644
+ return apply_before_send(
645
+ event,
646
+ self._before_send,
647
+ lambda code, message: self._emit_diagnostic(code, message),
648
+ )
587
649
 
588
650
  def _build_probe_data(self) -> dict[str, object] | None:
589
651
  items: list[dict[str, object]] = []
@@ -626,7 +688,7 @@ class DebugBundleSdk:
626
688
  method_candidate = request.get("method")
627
689
  request_path = path_candidate if isinstance(path_candidate, str) else None
628
690
  http_method = method_candidate if isinstance(method_candidate, str) else None
629
- if _is_immediate_request_incident_status(
691
+ if is_immediate_request_incident_status(
630
692
  status_code,
631
693
  self._capture_policy.preset,
632
694
  self._capture_policy.immediate_client_error_statuses,
@@ -650,8 +712,6 @@ class DebugBundleSdk:
650
712
  return True
651
713
 
652
714
  def _emit_probe_events(self, label: str, data: dict[str, object], directives: list[RemoteProbeDirective]) -> None:
653
- if self._capture_policy.capture_probe_events != "standalone_when_activated":
654
- return
655
715
  for directive in directives:
656
716
  payload = {
657
717
  "label": label,
@@ -659,7 +719,9 @@ class DebugBundleSdk:
659
719
  "probe_label_pattern": getattr(directive, "label_pattern"),
660
720
  "data": dict(data),
661
721
  }
662
- self._enqueue_event(self._base_event("probe_event", payload))
722
+ event = self._apply_before_send_event(self._base_event("probe_event", payload))
723
+ if event is not None and self._capture_policy.capture_probe_events == "standalone_when_activated":
724
+ self._enqueue_event(event)
663
725
 
664
726
  def begin_request(self, request: dict[str, Any]) -> Token[list[RemoteProbeDirective] | None]:
665
727
  trigger_token_key = (
@@ -719,253 +781,7 @@ class DebugBundleSdk:
719
781
  diagnostic: dict[str, object] = {"code": code, "message": message}
720
782
  if metadata is not None:
721
783
  diagnostic["metadata"] = metadata
722
- self._on_diagnostic(diagnostic)
723
-
724
-
725
- def _normalize_level(level: str) -> str:
726
- normalized = level.lower().strip()
727
- return normalized if normalized in LEVEL_RANKS else DEFAULT_LOG_LEVEL
728
-
729
-
730
- def _level_enabled(candidate: str, threshold: str) -> bool:
731
- return LEVEL_RANKS[candidate] >= LEVEL_RANKS[threshold]
732
-
733
-
734
- def _redact_mapping(value: object, redact_fields: set[str]) -> Any:
735
- if isinstance(value, Mapping):
736
- return redact_value(value, redact_fields)
737
- return value
738
-
739
-
740
- def _runtime_process_facts() -> dict[str, object]:
741
- return {
742
- "version": platform.python_version(),
743
- "platform": sys.platform,
744
- "arch": platform.machine() or None,
745
- "pid": os.getpid(),
746
- "cwd": _safe_cwd(),
747
- "uptime_sec": round(max(0.0, time.monotonic() - PROCESS_START_MONOTONIC), 3),
748
- "hostname": _safe_hostname(),
749
- "thread_id": threading.get_ident(),
750
- "memory": _memory_facts(),
751
- }
752
-
753
-
754
- def _safe_cwd() -> str | None:
755
- try:
756
- return os.getcwd()
757
- except OSError:
758
- return None
759
-
760
-
761
- def _safe_hostname() -> str | None:
762
- try:
763
- return socket.gethostname()
764
- except OSError:
765
- return None
766
-
767
-
768
- def _memory_facts() -> dict[str, object]:
769
- memory: dict[str, object] = {
770
- "rss": None,
771
- "heap_total": None,
772
- "heap_used": None,
773
- "external": None,
774
- "peak": None,
775
- }
776
- if resource is None:
777
- return memory
778
-
779
- usage = resource.getrusage(resource.RUSAGE_SELF)
780
- # ru_maxrss is KiB on Linux and bytes on macOS/BSD.
781
- memory["peak"] = usage.ru_maxrss if sys.platform == "darwin" else usage.ru_maxrss * 1024
782
- return memory
783
-
784
-
785
- def _backend_exception_request_payload(candidate: object | None) -> dict[str, object]:
786
- mapping = _dict_from_object(candidate)
787
- payload: dict[str, object] = {
788
- "method": str(mapping.get("method") or "UNKNOWN"),
789
- "path": str(mapping.get("path") or "/"),
790
- "query": _dict_from_object(mapping.get("query")),
791
- "headers": _dict_from_object(mapping.get("headers")),
792
- }
793
- if "body" in mapping:
794
- payload["body"] = mapping.get("body")
795
- return payload
796
-
797
-
798
- def _backend_exception_response_payload(candidate: object | None) -> dict[str, object]:
799
- mapping = _dict_from_object(candidate)
800
- payload: dict[str, object] = {
801
- "status_code": _coerce_int(mapping.get("status_code") or mapping.get("response_status"), 0),
802
- }
803
- if "headers" in mapping:
804
- payload["headers"] = _dict_from_object(mapping.get("headers"))
805
- if "body" in mapping:
806
- payload["body"] = mapping.get("body")
807
- return payload
808
-
809
-
810
- def _request_event_payload(
811
- request: Mapping[str, object],
812
- response: Mapping[str, object],
813
- context: Mapping[str, object],
814
- ) -> dict[str, object]:
815
- payload: dict[str, object] = {
816
- "method": str(request.get("method") or "UNKNOWN"),
817
- "path": str(request.get("path") or "/"),
818
- "query": _dict_from_object(request.get("query")),
819
- "headers": _dict_from_object(request.get("headers")),
820
- "response_status": _coerce_int(response.get("response_status") or response.get("status_code"), 0),
821
- "duration_ms": _coerce_int(response.get("duration_ms"), 0),
822
- }
823
- if "body" in request:
824
- payload["body"] = request.get("body")
825
- route_template = context.get("route_template") or response.get("route_template") or request.get("route_template")
826
- if route_template is not None:
827
- payload["route_template"] = str(route_template)
828
- response_headers = response.get("response_headers") or response.get("headers")
829
- if response_headers:
830
- payload["response_headers"] = _dict_from_object(response_headers)
831
- if "response_body" in response:
832
- payload["response_body"] = response.get("response_body")
833
- elif "body" in response and response.get("body") is not None:
834
- payload["response_body"] = response.get("body")
835
- return payload
836
-
837
-
838
- def _coerce_int(value: object, default: int) -> int:
839
- if isinstance(value, bool):
840
- return default
841
- if isinstance(value, int):
842
- return value
843
- if isinstance(value, float):
844
- return int(value)
845
- if isinstance(value, str):
846
784
  try:
847
- return int(value)
848
- except ValueError:
849
- return default
850
- return default
851
-
852
-
853
- def _dict_from_object(value: object | None) -> dict[str, object]:
854
- if isinstance(value, Mapping):
855
- return {str(key): cast(object, nested_value) for key, nested_value in value.items()}
856
- return {}
857
-
858
-
859
- def _correlation_payload(context: Mapping[str, object]) -> dict[str, str | None]:
860
- return {
861
- "request_id": _coerce_optional_string(context.get("request_id")),
862
- "trace_id": _coerce_optional_string(context.get("trace_id")),
863
- "session_id": _coerce_optional_string(context.get("session_id")),
864
- "user_id_hash": _coerce_optional_string(context.get("user_id_hash")),
865
- }
866
-
867
-
868
- def _event_context(context: Mapping[str, object]) -> dict[str, object]:
869
- return {
870
- str(key): value
871
- for key, value in context.items()
872
- if key not in {"request", "response", "correlation", "request_id", "trace_id", "session_id", "user_id_hash"}
873
- }
874
-
875
-
876
- def _coerce_optional_string(value: object) -> str | None:
877
- if value is None:
878
- return None
879
- return str(value)
880
-
881
-
882
- def _iso_now(time_provider: Callable[[], float]) -> str:
883
- return datetime.fromtimestamp(time_provider(), tz=timezone.utc).isoformat().replace("+00:00", "Z")
884
-
885
-
886
- def _is_immediate_request_incident_status(
887
- status_code: int | None,
888
- preset: str,
889
- immediate_client_error_statuses: tuple[int, ...],
890
- request_path: str | None = None,
891
- http_method: str | None = None,
892
- immediate_client_error_path_rules: tuple[object, ...] = (),
893
- ) -> bool:
894
- if status_code is None:
895
- return False
896
- if status_code >= 500:
897
- return True
898
- if status_code in immediate_client_error_statuses:
899
- return True
900
- if _matches_immediate_client_error_path_rule(
901
- status_code,
902
- request_path,
903
- http_method,
904
- immediate_client_error_path_rules,
905
- ):
906
- return True
907
- if preset == "investigative":
908
- return status_code in INVESTIGATIVE_IMMEDIATE_REQUEST_STATUSES
909
- if preset == "balanced":
910
- return status_code in BALANCED_IMMEDIATE_REQUEST_STATUSES
911
- return False
912
-
913
-
914
- def _matches_immediate_client_error_path_rule(
915
- status_code: int,
916
- request_path: str | None,
917
- http_method: str | None,
918
- rules: tuple[object, ...],
919
- ) -> bool:
920
- if status_code < 400 or status_code > 499 or request_path is None:
921
- return False
922
- normalized_path = _normalize_request_path(request_path)
923
- normalized_method = http_method.upper() if isinstance(http_method, str) else None
924
- for rule in rules:
925
- rule_status = getattr(rule, "status_code", None)
926
- path_pattern = getattr(rule, "path_pattern", None)
927
- methods = getattr(rule, "methods", ())
928
- if rule_status != status_code or not isinstance(path_pattern, str):
929
- continue
930
- if methods and (normalized_method is None or normalized_method not in methods):
931
- continue
932
- if path_pattern.endswith("*"):
933
- if normalized_path.startswith(path_pattern[:-1]):
934
- return True
935
- elif normalized_path == path_pattern:
936
- return True
937
- return False
938
-
939
-
940
- def _normalize_request_path(value: str) -> str:
941
- from urllib.parse import urlparse
942
-
943
- parsed = urlparse(value)
944
- if parsed.path:
945
- return parsed.path
946
- return value.split("?", 1)[0].split("#", 1)[0] if value.startswith("/") else "/"
947
-
948
-
949
- def _time_now() -> float:
950
- return datetime.now(tz=timezone.utc).timestamp()
951
-
952
-
953
- def _sdk_version() -> str:
954
- try:
955
- return metadata.version("debugbundle-python")
956
- except metadata.PackageNotFoundError:
957
- return "1.2.0"
958
-
959
-
960
- def _sdk_config_endpoint(events_endpoint: str) -> str:
961
- if events_endpoint.endswith("/v1/events"):
962
- return f"{events_endpoint[:-len('/v1/events')]}/v1/sdk/config"
963
- return f"{events_endpoint.rstrip('/')}/sdk/config"
964
-
965
-
966
- def _serialize_error(error: Exception) -> dict[str, object]:
967
- return {
968
- "name": type(error).__name__,
969
- "message": str(error),
970
- "stack": "".join(traceback.format_exception(type(error), error, error.__traceback__)),
971
- }
785
+ self._on_diagnostic(diagnostic)
786
+ except Exception:
787
+ return
@@ -0,0 +1,281 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import platform
5
+ import socket
6
+ import sys
7
+ import threading
8
+ import time
9
+ import traceback
10
+ from collections.abc import Callable, Mapping
11
+ from datetime import datetime, timezone
12
+ from importlib import metadata
13
+ from typing import Any, cast
14
+
15
+ from .redaction import redact_value
16
+
17
+ try:
18
+ import resource
19
+ except ImportError: # pragma: no cover - resource is unavailable on some platforms.
20
+ resource = None # type: ignore[assignment]
21
+
22
+ DEFAULT_LOG_LEVEL = "warning"
23
+ PROCESS_START_MONOTONIC = time.monotonic()
24
+ LEVEL_RANKS = {
25
+ "debug": 10,
26
+ "info": 20,
27
+ "warning": 30,
28
+ "error": 40,
29
+ "critical": 50,
30
+ }
31
+ BALANCED_IMMEDIATE_REQUEST_STATUSES = {408, 423, 424, 425, 429}
32
+ INVESTIGATIVE_IMMEDIATE_REQUEST_STATUSES = BALANCED_IMMEDIATE_REQUEST_STATUSES | {409}
33
+
34
+
35
+ def normalize_level(level: str) -> str:
36
+ normalized = level.lower().strip()
37
+ return normalized if normalized in LEVEL_RANKS else DEFAULT_LOG_LEVEL
38
+
39
+
40
+ def level_enabled(candidate: str, threshold: str) -> bool:
41
+ return LEVEL_RANKS[candidate] >= LEVEL_RANKS[threshold]
42
+
43
+
44
+ def redact_mapping(value: object, redact_fields: set[str]) -> Any:
45
+ if isinstance(value, Mapping):
46
+ return redact_value(value, redact_fields)
47
+ return value
48
+
49
+
50
+ def runtime_process_facts() -> dict[str, object]:
51
+ return {
52
+ "version": platform.python_version(),
53
+ "platform": sys.platform,
54
+ "arch": platform.machine() or None,
55
+ "pid": os.getpid(),
56
+ "cwd": _safe_cwd(),
57
+ "uptime_sec": round(max(0.0, time.monotonic() - PROCESS_START_MONOTONIC), 3),
58
+ "hostname": _safe_hostname(),
59
+ "thread_id": threading.get_ident(),
60
+ "memory": _memory_facts(),
61
+ }
62
+
63
+
64
+ def _safe_cwd() -> str | None:
65
+ try:
66
+ return os.getcwd()
67
+ except OSError:
68
+ return None
69
+
70
+
71
+ def _safe_hostname() -> str | None:
72
+ try:
73
+ return socket.gethostname()
74
+ except OSError:
75
+ return None
76
+
77
+
78
+ def _memory_facts() -> dict[str, object]:
79
+ memory: dict[str, object] = {
80
+ "rss": None,
81
+ "heap_total": None,
82
+ "heap_used": None,
83
+ "external": None,
84
+ "peak": None,
85
+ }
86
+ if resource is None:
87
+ return memory
88
+
89
+ usage = resource.getrusage(resource.RUSAGE_SELF)
90
+ # ru_maxrss is KiB on Linux and bytes on macOS/BSD.
91
+ memory["peak"] = usage.ru_maxrss if sys.platform == "darwin" else usage.ru_maxrss * 1024
92
+ return memory
93
+
94
+
95
+ def backend_exception_request_payload(candidate: object | None) -> dict[str, object]:
96
+ mapping = dict_from_object(candidate)
97
+ payload: dict[str, object] = {
98
+ "method": str(mapping.get("method") or "UNKNOWN"),
99
+ "path": str(mapping.get("path") or "/"),
100
+ "query": dict_from_object(mapping.get("query")),
101
+ "headers": dict_from_object(mapping.get("headers")),
102
+ }
103
+ if "body" in mapping:
104
+ payload["body"] = mapping.get("body")
105
+ return payload
106
+
107
+
108
+ def backend_exception_response_payload(candidate: object | None) -> dict[str, object]:
109
+ mapping = dict_from_object(candidate)
110
+ payload: dict[str, object] = {
111
+ "status_code": coerce_int(mapping.get("status_code") or mapping.get("response_status"), 0),
112
+ }
113
+ if "headers" in mapping:
114
+ payload["headers"] = dict_from_object(mapping.get("headers"))
115
+ if "body" in mapping:
116
+ payload["body"] = mapping.get("body")
117
+ return payload
118
+
119
+
120
+ def request_event_payload(
121
+ request: Mapping[str, object],
122
+ response: Mapping[str, object],
123
+ context: Mapping[str, object],
124
+ ) -> dict[str, object]:
125
+ payload: dict[str, object] = {
126
+ "method": str(request.get("method") or "UNKNOWN"),
127
+ "path": str(request.get("path") or "/"),
128
+ "query": dict_from_object(request.get("query")),
129
+ "headers": dict_from_object(request.get("headers")),
130
+ "response_status": coerce_int(response.get("response_status") or response.get("status_code"), 0),
131
+ "duration_ms": coerce_int(response.get("duration_ms"), 0),
132
+ }
133
+ if "body" in request:
134
+ payload["body"] = request.get("body")
135
+ route_template = context.get("route_template") or response.get("route_template") or request.get("route_template")
136
+ if route_template is not None:
137
+ payload["route_template"] = str(route_template)
138
+ response_headers = response.get("response_headers") or response.get("headers")
139
+ if response_headers:
140
+ payload["response_headers"] = dict_from_object(response_headers)
141
+ if "response_body" in response:
142
+ payload["response_body"] = response.get("response_body")
143
+ elif "body" in response and response.get("body") is not None:
144
+ payload["response_body"] = response.get("body")
145
+ return payload
146
+
147
+
148
+ def coerce_int(value: object, default: int) -> int:
149
+ if isinstance(value, bool):
150
+ return default
151
+ if isinstance(value, int):
152
+ return value
153
+ if isinstance(value, float):
154
+ return int(value)
155
+ if isinstance(value, str):
156
+ try:
157
+ return int(value)
158
+ except ValueError:
159
+ return default
160
+ return default
161
+
162
+
163
+ def dict_from_object(value: object | None) -> dict[str, object]:
164
+ if isinstance(value, Mapping):
165
+ return {str(key): cast(object, nested_value) for key, nested_value in value.items()}
166
+ return {}
167
+
168
+
169
+ def correlation_payload(context: Mapping[str, object]) -> dict[str, str | None]:
170
+ return {
171
+ "request_id": _coerce_optional_string(context.get("request_id")),
172
+ "trace_id": _coerce_optional_string(context.get("trace_id")),
173
+ "session_id": _coerce_optional_string(context.get("session_id")),
174
+ "user_id_hash": _coerce_optional_string(context.get("user_id_hash")),
175
+ }
176
+
177
+
178
+ def event_context(context: Mapping[str, object]) -> dict[str, object]:
179
+ return {
180
+ str(key): value
181
+ for key, value in context.items()
182
+ if key not in {"request", "response", "correlation", "request_id", "trace_id", "session_id", "user_id_hash"}
183
+ }
184
+
185
+
186
+ def _coerce_optional_string(value: object) -> str | None:
187
+ if value is None:
188
+ return None
189
+ return str(value)
190
+
191
+
192
+ def iso_now(time_provider: Callable[[], float]) -> str:
193
+ return datetime.fromtimestamp(time_provider(), tz=timezone.utc).isoformat().replace("+00:00", "Z")
194
+
195
+
196
+ def is_immediate_request_incident_status(
197
+ status_code: int | None,
198
+ preset: str,
199
+ immediate_client_error_statuses: tuple[int, ...],
200
+ request_path: str | None = None,
201
+ http_method: str | None = None,
202
+ immediate_client_error_path_rules: tuple[object, ...] = (),
203
+ ) -> bool:
204
+ if status_code is None:
205
+ return False
206
+ if status_code >= 500:
207
+ return True
208
+ if status_code in immediate_client_error_statuses:
209
+ return True
210
+ if _matches_immediate_client_error_path_rule(
211
+ status_code,
212
+ request_path,
213
+ http_method,
214
+ immediate_client_error_path_rules,
215
+ ):
216
+ return True
217
+ if preset == "investigative":
218
+ return status_code in INVESTIGATIVE_IMMEDIATE_REQUEST_STATUSES
219
+ if preset == "balanced":
220
+ return status_code in BALANCED_IMMEDIATE_REQUEST_STATUSES
221
+ return False
222
+
223
+
224
+ def _matches_immediate_client_error_path_rule(
225
+ status_code: int,
226
+ request_path: str | None,
227
+ http_method: str | None,
228
+ rules: tuple[object, ...],
229
+ ) -> bool:
230
+ if status_code < 400 or status_code > 499 or request_path is None:
231
+ return False
232
+ normalized_path = _normalize_request_path(request_path)
233
+ normalized_method = http_method.upper() if isinstance(http_method, str) else None
234
+ for rule in rules:
235
+ rule_status = getattr(rule, "status_code", None)
236
+ path_pattern = getattr(rule, "path_pattern", None)
237
+ methods = getattr(rule, "methods", ())
238
+ if rule_status != status_code or not isinstance(path_pattern, str):
239
+ continue
240
+ if methods and (normalized_method is None or normalized_method not in methods):
241
+ continue
242
+ if path_pattern.endswith("*"):
243
+ if normalized_path.startswith(path_pattern[:-1]):
244
+ return True
245
+ elif normalized_path == path_pattern:
246
+ return True
247
+ return False
248
+
249
+
250
+ def _normalize_request_path(value: str) -> str:
251
+ from urllib.parse import urlparse
252
+
253
+ parsed = urlparse(value)
254
+ if parsed.path:
255
+ return parsed.path
256
+ return value.split("?", 1)[0].split("#", 1)[0] if value.startswith("/") else "/"
257
+
258
+
259
+ def time_now() -> float:
260
+ return datetime.now(tz=timezone.utc).timestamp()
261
+
262
+
263
+ def sdk_version() -> str:
264
+ try:
265
+ return metadata.version("debugbundle-python")
266
+ except metadata.PackageNotFoundError:
267
+ return "1.3.0"
268
+
269
+
270
+ def sdk_config_endpoint(events_endpoint: str) -> str:
271
+ if events_endpoint.endswith("/v1/events"):
272
+ return f"{events_endpoint[: -len('/v1/events')]}/v1/sdk/config"
273
+ return f"{events_endpoint.rstrip('/')}/sdk/config"
274
+
275
+
276
+ def serialize_error(error: Exception) -> dict[str, object]:
277
+ return {
278
+ "name": type(error).__name__,
279
+ "message": str(error),
280
+ "stack": "".join(traceback.format_exception(type(error), error, error.__traceback__)),
281
+ }
debugbundle/transport.py CHANGED
@@ -11,6 +11,7 @@ import httpx
11
11
  class TransportResponse:
12
12
  status_code: int
13
13
  retry_after_ms: int | None = None
14
+ body: object | None = None
14
15
 
15
16
 
16
17
  class Transport(Protocol):
@@ -41,7 +42,11 @@ class HttpTransport:
41
42
  except ValueError:
42
43
  retry_after_ms = None
43
44
 
44
- return TransportResponse(status_code=response.status_code, retry_after_ms=retry_after_ms)
45
+ try:
46
+ body: object | None = response.json()
47
+ except (ValueError, TypeError):
48
+ body = None
49
+ return TransportResponse(status_code=response.status_code, retry_after_ms=retry_after_ms, body=body)
45
50
 
46
51
  def close(self) -> None:
47
52
  self._client.close()
@@ -53,7 +58,8 @@ def coerce_transport_response(response: Any) -> TransportResponse:
53
58
 
54
59
  status_code = getattr(response, "status_code", None)
55
60
  retry_after_ms = getattr(response, "retry_after_ms", None)
61
+ body = getattr(response, "body", None)
56
62
  if isinstance(status_code, int):
57
- return TransportResponse(status_code=status_code, retry_after_ms=retry_after_ms)
63
+ return TransportResponse(status_code=status_code, retry_after_ms=retry_after_ms, body=body)
58
64
 
59
65
  raise TypeError("Unsupported transport response")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: debugbundle-python
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: DebugBundle SDK for Python
5
5
  Author: DebugBundle
6
6
  License-Expression: AGPL-3.0-only
@@ -1,13 +1,16 @@
1
- debugbundle/__init__.py,sha256=mqibduUrdcr_fnb6k3Gwf93H7iqVBS8zd6SceX-U3Q4,5011
1
+ debugbundle/__init__.py,sha256=QH5RY2Cdp3Ry5PNSAhiV9akOMoS2pERRqowFnZ94roc,5131
2
+ debugbundle/acknowledgement.py,sha256=tyI8XLRG9N3-Weo6uiGrAlf_O4tjptVe1x1cG7RjrMc,2314
3
+ debugbundle/before_send.py,sha256=_tmV6ovHUBIxfQPMQ8a0C6r91IAnd4DVs8UcjsKhqX0,7792
2
4
  debugbundle/config.py,sha256=ENRSR5jUI7xYdBqUakWws3GIjBk00GWFmwXVE_iQp2Y,9296
3
- debugbundle/core.py,sha256=WWaOg_NP44yEu2G66MQ7CmmgLM7eF3Wczor8QrNDdAw,37776
5
+ debugbundle/core.py,sha256=ox4fNADe2kRic9aUePB_RJmT81vCQ9SFW6PaCSL-YL0,32888
6
+ debugbundle/event_support.py,sha256=40WVezmibrVOFI_bBAO4pUpYJSrmfrWMSeedb-buuII,9046
4
7
  debugbundle/logger_integrations.py,sha256=RuTNaD9RRVmiE-BBkksAXWVEGaMzLrWavVpQdgGZBpE,4564
5
8
  debugbundle/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
9
  debugbundle/redaction.py,sha256=QTaPkSsYv54yR1mz8SB6Q4vWjvp7Ddcui4WXaH2RVz8,736
7
10
  debugbundle/relay.py,sha256=p5-tFXsgH8TZfB7Va3C2XFJsi8mYOMnGjr01W_kApME,14366
8
11
  debugbundle/relay_delivery.py,sha256=VL-nIgJR6mYXqKy-EbA0USWeY_iS_uAr1KBzrnwBnnk,4676
9
12
  debugbundle/suppression.py,sha256=XMn0GfF_-WZk2wHWk3KfbO5_u5QhEunues5h3jOInLs,4098
10
- debugbundle/transport.py,sha256=oOk0xazHxq9h4CneJdRODKtwDciwikvpHVJM_6iBYXU,1791
13
+ debugbundle/transport.py,sha256=V289FwFWL6KV5gyzOsesluEMbpRobm_FHu3x8BpOYKU,2014
11
14
  debugbundle/trigger_token.py,sha256=YUwIWnnxu1klAVaB8Ltgk_PwWW_PPjq9rzmEbWXeFeM,4455
12
15
  debugbundle/integrations/__init__.py,sha256=Jr87ImWXzUXOuFP4WlGleK14VARajSSdGZN_nHt5emw,1161
13
16
  debugbundle/integrations/common.py,sha256=iiwf5wDlpujFuWfbO-ziFTn4MtiHBXSIZNXtamhCavg,2014
@@ -17,8 +20,8 @@ debugbundle/integrations/flask.py,sha256=cgyHbsAH96gpNPf_5IGJYzp2va28JdDgXROBsF-
17
20
  debugbundle/integrations/relay_django.py,sha256=Wj8BE9D5otU2KmtjgqdKIJ-BvXVcagCKgpLYvnPZ6WE,2281
18
21
  debugbundle/integrations/relay_fastapi.py,sha256=-Zl6bvhYMLii__mP6-UvSdwxS5VKsb9OZvk-yy-3hEw,2227
19
22
  debugbundle/integrations/relay_flask.py,sha256=VFHkDTJy4LkZzL_Ry_Ba-e_gW6UTG4PBwnLcvB_oXuQ,2011
20
- debugbundle_python-1.2.0.dist-info/licenses/LICENSE,sha256=AKZZ5DQAHrOKGwt24VoRd-SXJLM9OloxlsX8ZgENdEY,735
21
- debugbundle_python-1.2.0.dist-info/METADATA,sha256=Fp9n6Tt2wgf2lJnxw3yrteaykQ7kwj5TVDu6OntJcZw,13841
22
- debugbundle_python-1.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
23
- debugbundle_python-1.2.0.dist-info/top_level.txt,sha256=RCB9STTFnl1OKdojxz-xhaks2zkRFs1meZXsKnm18LM,12
24
- debugbundle_python-1.2.0.dist-info/RECORD,,
23
+ debugbundle_python-1.3.0.dist-info/licenses/LICENSE,sha256=AKZZ5DQAHrOKGwt24VoRd-SXJLM9OloxlsX8ZgENdEY,735
24
+ debugbundle_python-1.3.0.dist-info/METADATA,sha256=tNoi9S3qguVNM5NPnzsgTOk4fM-PlRJ4eRbIIpKxQhg,13841
25
+ debugbundle_python-1.3.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
26
+ debugbundle_python-1.3.0.dist-info/top_level.txt,sha256=RCB9STTFnl1OKdojxz-xhaks2zkRFs1meZXsKnm18LM,12
27
+ debugbundle_python-1.3.0.dist-info/RECORD,,