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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. debugbundle/__init__.py +16 -0
  2. debugbundle/before_send.py +35 -191
  3. debugbundle/canonical_event.py +202 -0
  4. debugbundle/config.py +6 -0
  5. debugbundle/core.py +76 -318
  6. debugbundle/delivery_hooks.py +14 -0
  7. debugbundle/diagnostic_budget.py +191 -0
  8. debugbundle/event-envelope.schema.json +4904 -0
  9. debugbundle/evidence_delivery.py +115 -0
  10. debugbundle/evidence_http.py +45 -0
  11. debugbundle/evidence_json.py +68 -0
  12. debugbundle/evidence_wire.py +409 -0
  13. debugbundle/exception_capture.py +92 -0
  14. debugbundle/exception_evidence.py +199 -0
  15. debugbundle/exception_snapshot.py +1 -1
  16. debugbundle/flush_delivery.py +203 -0
  17. debugbundle/ingestion_wire.py +75 -0
  18. debugbundle/integrations/relay_django.py +4 -1
  19. debugbundle/integrations/relay_fastapi.py +10 -2
  20. debugbundle/integrations/relay_flask.py +4 -1
  21. debugbundle/operation_context.py +121 -0
  22. debugbundle/process_support.py +7 -0
  23. debugbundle/queue_admission.py +42 -2
  24. debugbundle/recovery_capture.py +43 -0
  25. debugbundle/relay.py +93 -36
  26. debugbundle/relay_delivery.py +108 -17
  27. debugbundle/remote_config_client.py +81 -0
  28. debugbundle/scope_ownership.py +99 -0
  29. debugbundle/transport.py +20 -14
  30. {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.0.dist-info}/METADATA +116 -1
  31. debugbundle_python-2.1.0.dist-info/RECORD +51 -0
  32. {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.0.dist-info}/WHEEL +1 -1
  33. debugbundle_python-2.0.1.dist-info/RECORD +0 -36
  34. {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.0.dist-info}/licenses/LICENSE +0 -0
  35. {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,92 @@
1
+ """Native optional evidence preserves the protected legacy exception event."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping
7
+ from typing import TYPE_CHECKING, cast
8
+
9
+ from .capture_hooks import try_capture_lock
10
+ from .event_support import (
11
+ backend_exception_request_payload,
12
+ backend_exception_response_payload,
13
+ redact_mapping,
14
+ runtime_process_facts,
15
+ )
16
+ from .exception_evidence import snapshot_exception
17
+ from .exception_snapshot import exception_details
18
+ from .process_support import ensure_current_process
19
+ from .queue_admission import may_prepare_event
20
+
21
+ if TYPE_CHECKING:
22
+ from .core import DebugBundleSdk
23
+
24
+
25
+ def capture_exception(
26
+ sdk: DebugBundleSdk,
27
+ error: BaseException,
28
+ context: Mapping[str, object] | None,
29
+ handled: bool,
30
+ max_pending_events: int,
31
+ max_pending_bytes: int,
32
+ fatal: bool = False,
33
+ ) -> None:
34
+ ensure_current_process(sdk)
35
+ if not sdk._enabled or not may_prepare_event(sdk, True, max_pending_events, max_pending_bytes):
36
+ return
37
+ try:
38
+ name, message, stack = exception_details(error)
39
+ redacted_context = redact_mapping(dict(context or {}), sdk._redact_fields)
40
+ request_payload = backend_exception_request_payload(redacted_context.get("request"))
41
+ response_payload = backend_exception_response_payload(redacted_context.get("response"))
42
+
43
+ payload: dict[str, object] = {
44
+ "name": name,
45
+ "message": message,
46
+ "stack": stack,
47
+ "handled": handled,
48
+ "request": request_payload,
49
+ "response": response_payload,
50
+ "runtime": runtime_process_facts(),
51
+ }
52
+ if sdk._probe_flush_on_error and try_capture_lock(sdk):
53
+ try:
54
+ probe_data = sdk._build_probe_data()
55
+ finally:
56
+ sdk._lock.release()
57
+ if probe_data is not None:
58
+ payload["probe_data"] = probe_data
59
+
60
+ candidate = sdk._base_event("backend_exception", payload, context=redacted_context)
61
+ if sdk._capture_exception_evidence and sdk._evidence.supports("exception_evidence_v1"):
62
+ execution = sdk._operations.snapshot()
63
+ additions: dict[str, object] = {"execution": execution} if execution is not None else {}
64
+ selected_context = candidate.get("context")
65
+ if type(selected_context) is dict and "evidence_reference" in selected_context:
66
+ additions["evidence_reference"] = selected_context["evidence_reference"]
67
+ overhead = len(json.dumps(additions, ensure_ascii=False, separators=(",", ":")).encode("utf-8")) + 32
68
+ graph = snapshot_exception(error, fatal=fatal, budget_bytes=(4096 if fatal else 32768) - overhead)
69
+ if graph is not None:
70
+ payload["exception_evidence"] = graph
71
+ if execution is not None:
72
+ payload["execution"] = execution
73
+ event = sdk._protect_event(candidate)
74
+ if event is None or not sdk._passes_sample_rate():
75
+ return
76
+ event_payload = cast(dict[str, object], event["payload"])
77
+ suppression_key = (
78
+ f"{event['event_type']}:{event_payload.get('name', '')}:"
79
+ f"{event_payload.get('message', '')}:{event_payload.get('stack', '')}"
80
+ )
81
+ event_bytes = sdk._event_bytes(event)
82
+ if not try_capture_lock(sdk):
83
+ sdk._pressure_drops += 1
84
+ return
85
+ try:
86
+ if sdk._enabled and sdk._suppression.should_capture(suppression_key, sdk._time_provider()):
87
+ if sdk._offer_protected_event(event, event_bytes) and fatal:
88
+ sdk._hook_bypass.add(id(event))
89
+ finally:
90
+ sdk._lock.release()
91
+ except BaseException:
92
+ return
@@ -0,0 +1,199 @@
1
+ """Bounded native snapshots. No source reads, locals, custom renderers or raw-error retention."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import builtins
6
+ import json
7
+ from typing import TypedDict, cast
8
+ from urllib.parse import urlsplit, urlunsplit
9
+
10
+ from .exception_snapshot import _exception_name, _safe_message
11
+ from .redaction import UnsafeTelemetry, sanitize_telemetry
12
+
13
+
14
+ class Frame(TypedDict):
15
+ function: str | None
16
+ file: str | None
17
+ line: int | None
18
+ column: int | None
19
+ classification: str
20
+
21
+
22
+ class Relationship(TypedDict):
23
+ kind: str
24
+ target_id: int
25
+
26
+
27
+ class Node(TypedDict):
28
+ id: int
29
+ name: str
30
+ message: str
31
+ stack: str | None
32
+ trace_origin: str
33
+ parsing_status: str
34
+ frames: list[Frame]
35
+ relationships: list[Relationship]
36
+
37
+
38
+ class ExceptionEvidence(TypedDict):
39
+ version: int
40
+ runtime: str
41
+ root_id: int
42
+ nodes: list[Node]
43
+ truncated: bool
44
+ unavailable_reason: str | None
45
+
46
+
47
+ _GROUP = vars(builtins).get("BaseExceptionGroup")
48
+ _GROUP_CHILDREN = vars(_GROUP).get("exceptions") if _GROUP is not None else None
49
+ _GROUP_MESSAGE = vars(_GROUP).get("message") if _GROUP is not None else None
50
+
51
+
52
+ def _bytes(value: object) -> int:
53
+ return len(json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))
54
+
55
+
56
+ def snapshot_exception(
57
+ error: BaseException,
58
+ *,
59
+ fatal: bool = False,
60
+ budget_bytes: int | None = None,
61
+ ) -> ExceptionEvidence | None:
62
+ maximum = min(4096 if fatal else 32768, budget_bytes if budget_bytes is not None else 32768)
63
+ if maximum < 512:
64
+ return None
65
+ graph: ExceptionEvidence = {
66
+ "version": 1,
67
+ "runtime": "python",
68
+ "root_id": 0,
69
+ "nodes": [],
70
+ "truncated": False,
71
+ "unavailable_reason": None,
72
+ }
73
+ identities: dict[int, int] = {}
74
+
75
+ def text(value: str, limit: int) -> str:
76
+ # Native metadata may be very long; bound work before encoding/scrubbing.
77
+ bounded = value[:limit]
78
+ encoded = bounded.encode("utf-8", errors="replace")
79
+ result = encoded[:limit].decode("utf-8", errors="ignore")
80
+ if result != value:
81
+ graph["truncated"] = True
82
+ try:
83
+ return cast(str, sanitize_telemetry(result))
84
+ except UnsafeTelemetry:
85
+ return "[withheld]"
86
+
87
+ def location(value: str) -> str:
88
+ result = text(value, 2048)
89
+ if result.lower().startswith(("https://", "http://")):
90
+ try:
91
+ parts = urlsplit(result)
92
+ result = urlunsplit((parts.scheme, parts.netloc.rsplit("@", 1)[-1], parts.path, "", ""))
93
+ except ValueError:
94
+ return "[withheld]"
95
+ return result
96
+
97
+ def height(index: int) -> int:
98
+ return max((1 + height(edge["target_id"]) for edge in graph["nodes"][index]["relationships"]), default=0)
99
+
100
+ def visit(current: BaseException, depth: int, ancestors: set[int]) -> int | None:
101
+ identity = id(current)
102
+ if identity in ancestors or depth > 4:
103
+ graph["truncated"] = True
104
+ return None
105
+ existing = identities.get(identity)
106
+ if existing is not None:
107
+ if depth + height(existing) > 4:
108
+ graph["truncated"] = True
109
+ return None
110
+ return existing
111
+ if len(graph["nodes"]) >= (2 if fatal else 8):
112
+ graph["truncated"] = True
113
+ return None
114
+ name = text(_exception_name(current), 256) or "Error"
115
+ is_group = _GROUP is not None and issubclass(type(current), _GROUP)
116
+ message = _GROUP_MESSAGE.__get__(current) if is_group and _GROUP_MESSAGE is not None else _safe_message(current)
117
+ node: Node = {
118
+ "id": len(graph["nodes"]),
119
+ "name": name,
120
+ "message": text(message, 512 if fatal else 2048) or name,
121
+ "stack": None,
122
+ "trace_origin": "unavailable",
123
+ "parsing_status": "unavailable",
124
+ "frames": [],
125
+ "relationships": [],
126
+ }
127
+ graph["nodes"].append(node)
128
+ identities[identity] = node["id"]
129
+ # Final built-in descriptors bypass subclass properties/__getattribute__.
130
+ traceback = BaseException.__dict__["__traceback__"].__get__(current)
131
+ while traceback is not None and len(node["frames"]) < (8 if fatal else 32):
132
+ code = traceback.tb_frame.f_code
133
+ node["frames"].append(
134
+ {
135
+ "function": text(code.co_name, 256) or None,
136
+ "file": location(code.co_filename) or None,
137
+ "line": traceback.tb_lineno if traceback.tb_lineno > 0 else None,
138
+ "column": None,
139
+ "classification": "unknown",
140
+ }
141
+ )
142
+ traceback = traceback.tb_next
143
+ if node["frames"]:
144
+ node["trace_origin"] = "runtime_reported"
145
+ node["parsing_status"] = "parsed"
146
+ if traceback is not None:
147
+ graph["truncated"] = True
148
+ node["parsing_status"] = "truncated"
149
+ next_ancestors = ancestors | {identity}
150
+
151
+ def link(child: BaseException, kind: str) -> None:
152
+ if len(node["relationships"]) >= 5:
153
+ graph["truncated"] = True
154
+ return
155
+ target = visit(child, depth + 1, next_ancestors)
156
+ if target is not None:
157
+ edge: Relationship = {"kind": kind, "target_id": target}
158
+ if edge not in node["relationships"]:
159
+ node["relationships"].append(edge)
160
+
161
+ cause = BaseException.__dict__["__cause__"].__get__(current)
162
+ if cause is not None:
163
+ link(cause, "cause")
164
+ elif not BaseException.__dict__["__suppress_context__"].__get__(current):
165
+ context = BaseException.__dict__["__context__"].__get__(current)
166
+ if context is not None:
167
+ link(context, "context")
168
+ if is_group and _GROUP_CHILDREN is not None:
169
+ children = _GROUP_CHILDREN.__get__(current)
170
+ # The built-in tuple needs no application iterator, len or item hooks.
171
+ count = tuple.__len__(children)
172
+ for index in range(min(count, 5)):
173
+ link(tuple.__getitem__(children, index), "aggregate")
174
+ if count > 5:
175
+ graph["truncated"] = True
176
+ return node["id"]
177
+
178
+ try:
179
+ visit(error, 0, set())
180
+ if _bytes(graph) > maximum:
181
+ graph["truncated"] = True
182
+ graph["unavailable_reason"] = "budget_exceeded"
183
+ for node in reversed(graph["nodes"]):
184
+ while node["frames"] and _bytes(graph) > maximum:
185
+ node["frames"].pop()
186
+ node["parsing_status"] = "truncated"
187
+ if _bytes(graph) > maximum:
188
+ for node in graph["nodes"]:
189
+ node["name"] = text(node["name"], 64 if fatal else 128)
190
+ node["message"] = text(node["message"], 128 if fatal else 256)
191
+ while len(graph["nodes"]) > 1 and _bytes(graph) > maximum:
192
+ removed = graph["nodes"].pop()["id"]
193
+ for node in graph["nodes"]:
194
+ node["relationships"] = [edge for edge in node["relationships"] if edge["target_id"] != removed]
195
+ if graph["truncated"] and graph["unavailable_reason"] is None:
196
+ graph["unavailable_reason"] = "budget_exceeded"
197
+ return graph if graph["nodes"] and _bytes(graph) <= maximum else None
198
+ except BaseException:
199
+ return None
@@ -65,7 +65,7 @@ def _safe_message(error: BaseException) -> str:
65
65
  for value in args[:4]:
66
66
  if issubclass(type(value), str):
67
67
  parts.append(str.__getitem__(value, slice(0, 1024)))
68
- elif type(value) in (int, float, bool):
68
+ elif type(value) is int or type(value) is float or type(value) is bool:
69
69
  parts.append(str(value))
70
70
  else:
71
71
  parts.append("[unsupported argument]")
@@ -0,0 +1,203 @@
1
+ """Reserved bounded delivery, canonical ACK handling and retry ownership."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from .acknowledgement import decide_acknowledgement
8
+ from .delivery_hooks import finalize_batch
9
+ from .evidence_delivery import downgrade_rejections
10
+ from .flush_aggregates import prepare_flush_aggregates
11
+ from .ingestion_wire import prefix
12
+ from .process_support import ensure_current_process
13
+ from .queue_admission import high_priority_event
14
+ from .transport import HttpTransport, bounded_retry_after_ms, coerce_transport_response
15
+
16
+ if TYPE_CHECKING:
17
+ from .core import DebugBundleSdk
18
+
19
+
20
+ def flush_sdk(sdk: DebugBundleSdk, max_events: int, max_bytes: int) -> None:
21
+ ensure_current_process(sdk)
22
+ with sdk._lock:
23
+ if not sdk._enabled or sdk._transport is None or sdk._sending:
24
+ return
25
+ if sdk._timer is not None:
26
+ sdk._timer.cancel()
27
+ sdk._timer = None
28
+ now = sdk._time_provider()
29
+ if now < sdk._retry_after:
30
+ sdk._schedule_flush_locked(delay=sdk._retry_after - now)
31
+ return
32
+ sdk._sending = True
33
+ generation = sdk._generation
34
+ suppression = sdk._suppression.drain_aggregates(now)
35
+ pressure_count = (
36
+ sdk._pressure_drops
37
+ if sdk._pressure_drops and now - sdk._last_pressure_report_at >= 60.0 and len(sdk._buffer) < max_events
38
+ else 0
39
+ )
40
+
41
+ try:
42
+ aggregates, pressure = prepare_flush_aggregates(sdk, suppression, pressure_count)
43
+ except BaseException:
44
+ aggregates, pressure = [], None
45
+
46
+ with sdk._lock:
47
+ if generation != sdk._generation:
48
+ sdk._sending = False
49
+ sdk._inflight_count = 0
50
+ if sdk._buffer:
51
+ sdk._schedule_flush_locked()
52
+ return
53
+ for aggregate, aggregate_bytes in aggregates:
54
+ sdk._offer_protected_event(aggregate, aggregate_bytes)
55
+ if (
56
+ pressure is not None
57
+ and len(sdk._buffer) < max_events
58
+ and sdk._buffer_bytes + pressure[1] <= max_bytes
59
+ and sdk._offer_protected_event(*pressure)
60
+ ):
61
+ sdk._pressure_drops = max(0, sdk._pressure_drops - pressure_count)
62
+ sdk._last_pressure_report_at = now
63
+ if not sdk._buffer:
64
+ sdk._sending = False
65
+ sdk._schedule_pressure_report_locked()
66
+ return
67
+ batch = list(sdk._buffer)
68
+ sdk._inflight_count = len(batch)
69
+ sdk._pending_low_priority = 0
70
+ transport = sdk._transport
71
+ request = {
72
+ "project_token": sdk._project_token,
73
+ "events": batch,
74
+ }
75
+
76
+ finalized = finalize_batch(sdk, batch, generation, max_bytes)
77
+ # Native serialization stays outside the capture lock, like hook/privacy
78
+ # work. The committed queue still owns every finalized event meanwhile.
79
+ wire_batch = prefix(finalized) if finalized is not None and isinstance(transport, HttpTransport) else finalized
80
+
81
+ with sdk._lock:
82
+ if generation != sdk._generation or finalized is None:
83
+ sdk._sending = False
84
+ sdk._inflight_count = 0
85
+ if sdk._buffer:
86
+ sdk._schedule_flush_locked()
87
+ return
88
+ batch = finalized
89
+ if isinstance(transport, HttpTransport):
90
+ assert wire_batch is not None
91
+ batch = wire_batch
92
+ # Finalized unsent tails stay charged and must not execute an
93
+ # application hook again merely because the wire batch was split.
94
+ sdk._hook_bypass.update(id(event) for event in finalized[len(batch) :])
95
+ sdk._inflight_count = len(batch)
96
+ if not batch:
97
+ sdk._sending = False
98
+ if sdk._buffer:
99
+ sdk._schedule_flush_locked()
100
+ return
101
+ request["events"] = batch
102
+
103
+ try:
104
+ response = coerce_transport_response(transport(request))
105
+ except BaseException:
106
+ response = None
107
+
108
+ with sdk._lock:
109
+ sdk._sending = False
110
+ sdk._inflight_count = 0
111
+ if generation != sdk._generation:
112
+ if sdk._buffer:
113
+ sdk._schedule_flush_locked()
114
+ return
115
+ try:
116
+ if response is None:
117
+ sdk._consecutive_failures += 1
118
+ sdk._schedule_flush_locked()
119
+ return
120
+
121
+ if 200 <= response.status_code < 300:
122
+ acknowledgement = decide_acknowledgement(
123
+ response.body, len(batch), isinstance(transport, HttpTransport)
124
+ )
125
+ lost = sdk._evidence.observe(
126
+ response.body, isinstance(transport, HttpTransport), acknowledgement.kind == "acknowledged"
127
+ )
128
+ if acknowledgement.kind == "protocol_failure":
129
+ sdk._consecutive_failures += 1
130
+ retry_after_ms = bounded_retry_after_ms(response.retry_after_ms)
131
+ sdk._retry_after = sdk._time_provider() + (retry_after_ms / 1000)
132
+ sdk._emit_diagnostic(
133
+ "ingestion_acknowledgement_invalid",
134
+ "sdk-python retained a batch after an invalid ingestion acknowledgement",
135
+ metadata={"reason": acknowledgement.reason or "invalid"},
136
+ )
137
+ sdk._schedule_flush_locked(delay=retry_after_ms / 1000)
138
+ return
139
+ if acknowledgement.kind == "legacy":
140
+ sdk._buffer = sdk._buffer[len(batch) :]
141
+ sdk._retry_after = 0.0
142
+ sdk._last_event_at = sdk._time_provider() * 1000
143
+ sdk._consecutive_failures = 0
144
+ return
145
+
146
+ trailing_events = sdk._buffer[len(batch) :]
147
+ downgraded = downgrade_rejections(sdk, batch, acknowledgement.terminal_errors, lost)
148
+ sdk._buffer = (
149
+ [batch[index] for index in acknowledgement.retryable_indices if 0 <= index < len(batch)]
150
+ + downgraded
151
+ + trailing_events
152
+ )
153
+ if acknowledgement.terminal_errors:
154
+ sdk._emit_diagnostic(
155
+ "ingestion_events_rejected",
156
+ "sdk-python removed terminally rejected ingestion events",
157
+ metadata={
158
+ "rejected_count": len(acknowledgement.terminal_errors),
159
+ "reasons": sorted({reason for _, reason in acknowledgement.terminal_errors}),
160
+ },
161
+ )
162
+ if acknowledgement.accepted > 0:
163
+ sdk._last_event_at = sdk._time_provider() * 1000
164
+ if acknowledgement.retryable_indices or downgraded:
165
+ sdk._consecutive_failures += 1
166
+ retry_after_ms = bounded_retry_after_ms(response.retry_after_ms)
167
+ sdk._retry_after = sdk._time_provider() + (retry_after_ms / 1000)
168
+ sdk._schedule_flush_locked(delay=retry_after_ms / 1000)
169
+ return
170
+ sdk._retry_after = 0.0
171
+ sdk._consecutive_failures = 0 if acknowledgement.accepted > 0 else 3
172
+ return
173
+
174
+ sdk._consecutive_failures += 1
175
+ if response.status_code == 429 or (response.status_code >= 500 and response.retry_after_ms is not None):
176
+ retry_after_ms = bounded_retry_after_ms(response.retry_after_ms)
177
+ sdk._retry_after = sdk._time_provider() + (retry_after_ms / 1000)
178
+ sdk._schedule_flush_locked(delay=retry_after_ms / 1000)
179
+ return
180
+ if 400 <= response.status_code < 500:
181
+ sdk._buffer = sdk._buffer[len(batch) :]
182
+ sdk._retry_after = 0.0
183
+ return
184
+ sdk._schedule_flush_locked()
185
+ finally:
186
+ sdk._hook_bypass.intersection_update(id(event) for event in sdk._buffer)
187
+ sdk._diagnostics.retain(
188
+ {str(event["event_id"]) for event in sdk._buffer if event["event_type"] == "diagnostic_context"}
189
+ )
190
+ sdk._buffer_sizes = {id(event): sdk._buffer_sizes[id(event)] for event in sdk._buffer}
191
+ sdk._buffer_bytes = sum(sdk._buffer_sizes.values())
192
+ sdk._pending_low_priority = sum(not high_priority_event(event) for event in sdk._buffer)
193
+ if sdk._buffer and sdk._timer is None and sdk._retry_after <= sdk._time_provider():
194
+ sdk._schedule_flush_locked(
195
+ delay=0.0
196
+ if isinstance(transport, HttpTransport)
197
+ and response is not None
198
+ and 200 <= response.status_code < 300
199
+ and sdk._consecutive_failures == 0
200
+ else None
201
+ )
202
+ elif not sdk._buffer and sdk._timer is None:
203
+ sdk._schedule_pressure_report_locked()
@@ -0,0 +1,75 @@
1
+ """Size the final HTTP serializer; local/custom persistence keeps its own budget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from .evidence_wire import reference, without_optional
9
+
10
+ MAX_BODY_BYTES = 256 * 1024
11
+ MAX_EVENT_BYTES = MAX_BODY_BYTES - 13
12
+
13
+
14
+ def encode_body(events: object) -> bytes:
15
+ return json.dumps({"events": events}, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")
16
+
17
+
18
+ def event_bytes(event: dict[str, Any]) -> int:
19
+ return len(json.dumps(event, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8"))
20
+
21
+
22
+ def fit_event(event: dict[str, Any] | None) -> dict[str, Any] | None:
23
+ if event is None:
24
+ return None
25
+ if event_bytes(event) <= MAX_EVENT_BYTES:
26
+ return event
27
+ reduced = without_optional(event)
28
+ if reduced is not None:
29
+ fields = ("exception_evidence", "execution", "resource_observations")
30
+ marked = dict(
31
+ reduced,
32
+ context={
33
+ **event.get("context", {}),
34
+ "evidence_limits": {
35
+ "version": 1,
36
+ **{field: "budget_exceeded" for field in fields if field in event["payload"]},
37
+ },
38
+ },
39
+ )
40
+ if event_bytes(marked) <= MAX_EVENT_BYTES:
41
+ return marked
42
+ if event_bytes(reduced) <= MAX_EVENT_BYTES:
43
+ return reduced
44
+ else:
45
+ reduced = event
46
+ context = reduced.get("context")
47
+ if type(context) is dict and reference(context.get("evidence_reference")) is not None:
48
+ reduced = dict(reduced, context={key: value for key, value in context.items() if key != "evidence_reference"})
49
+ if event_bytes(reduced) <= MAX_EVENT_BYTES:
50
+ return reduced
51
+ return None
52
+
53
+
54
+ def prefix(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
55
+ size = 12 # {"events":[]}
56
+ for index, event in enumerate(events):
57
+ length = event_bytes(event)
58
+ cost = length + (1 if index else 0)
59
+ if length > MAX_EVENT_BYTES:
60
+ raise ValueError("event exceeds ingestion limit")
61
+ if size + cost > MAX_BODY_BYTES:
62
+ return events[:index]
63
+ size += cost
64
+ return events
65
+
66
+
67
+ def chunks(events: list[dict[str, Any]]) -> list[list[dict[str, Any]]]:
68
+ result = []
69
+ while events:
70
+ selected = prefix(events)
71
+ if not selected:
72
+ raise ValueError("event exceeds ingestion limit")
73
+ result.append(selected)
74
+ events = events[len(selected) :]
75
+ return result
@@ -20,6 +20,7 @@ def create_django_relay_view(
20
20
  service: str | None = None,
21
21
  environment: str | None = None,
22
22
  forward_transport: Any = None,
23
+ evidence_capabilities: object = None,
23
24
  ) -> Any:
24
25
  handler = BrowserRelayHandler(
25
26
  allowed_origins=allowed_origins or [],
@@ -35,6 +36,7 @@ def create_django_relay_view(
35
36
  service=service,
36
37
  environment=environment,
37
38
  forward_transport=forward_transport,
39
+ evidence_capabilities=evidence_capabilities,
38
40
  )
39
41
 
40
42
  def view(request: Any) -> Any:
@@ -47,8 +49,9 @@ def create_django_relay_view(
47
49
  response = handler.handle(
48
50
  {
49
51
  "method": request.method,
52
+ "url": request.get_full_path(),
50
53
  "headers": headers,
51
- "body": request.body.decode("utf-8") if isinstance(request.body, bytes) else str(request.body),
54
+ "body": request.read(handler.max_body_bytes + 1),
52
55
  "ipAddress": _get_client_ip(request),
53
56
  }
54
57
  )
@@ -23,6 +23,7 @@ def create_fastapi_relay_handler(
23
23
  service: str | None = None,
24
24
  environment: str | None = None,
25
25
  forward_transport: Any = None,
26
+ evidence_capabilities: object = None,
26
27
  route_path: str = "/debugbundle/browser",
27
28
  ) -> Any:
28
29
  handler = BrowserRelayHandler(
@@ -39,20 +40,27 @@ def create_fastapi_relay_handler(
39
40
  service=service,
40
41
  environment=environment,
41
42
  forward_transport=forward_transport,
43
+ evidence_capabilities=evidence_capabilities,
42
44
  )
43
45
 
44
46
  def register(app: Any) -> None:
45
47
  @app.api_route(route_path, methods=["POST", "OPTIONS"])
46
48
  async def debugbundle_browser_relay(request: Request) -> Response:
47
- body = await request.body()
49
+ body = bytearray()
50
+ async for chunk in request.stream():
51
+ remaining = handler.max_body_bytes + 1 - len(body)
52
+ body.extend(chunk[:remaining])
53
+ if len(body) > handler.max_body_bytes:
54
+ break
48
55
  headers = {str(key): str(value) for key, value in request.headers.items()}
49
56
  ip_address = request.client.host if request.client else None
50
57
 
51
58
  response = handler.handle(
52
59
  {
53
60
  "method": request.method,
61
+ "url": str(request.url),
54
62
  "headers": headers,
55
- "body": body.decode("utf-8") if isinstance(body, bytes) else str(body),
63
+ "body": bytes(body),
56
64
  "ipAddress": ip_address,
57
65
  }
58
66
  )
@@ -20,6 +20,7 @@ def create_flask_relay_handler(
20
20
  service: str | None = None,
21
21
  environment: str | None = None,
22
22
  forward_transport: Any = None,
23
+ evidence_capabilities: object = None,
23
24
  route_path: str = "/debugbundle/browser",
24
25
  ) -> Any:
25
26
  handler = BrowserRelayHandler(
@@ -36,6 +37,7 @@ def create_flask_relay_handler(
36
37
  service=service,
37
38
  environment=environment,
38
39
  forward_transport=forward_transport,
40
+ evidence_capabilities=evidence_capabilities,
39
41
  )
40
42
 
41
43
  def register(app: Any) -> None:
@@ -46,8 +48,9 @@ def create_flask_relay_handler(
46
48
  response = handler.handle(
47
49
  {
48
50
  "method": request.method,
51
+ "url": request.full_path,
49
52
  "headers": dict(request.headers.items()),
50
- "body": request.get_data(as_text=True),
53
+ "body": request.stream.read(handler.max_body_bytes + 1),
51
54
  "ipAddress": request.remote_addr,
52
55
  }
53
56
  )