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.
- debugbundle/__init__.py +16 -0
- debugbundle/before_send.py +35 -191
- debugbundle/canonical_event.py +202 -0
- debugbundle/config.py +6 -0
- debugbundle/core.py +76 -318
- debugbundle/delivery_hooks.py +14 -0
- debugbundle/diagnostic_budget.py +191 -0
- debugbundle/event-envelope.schema.json +4904 -0
- debugbundle/evidence_delivery.py +115 -0
- debugbundle/evidence_http.py +45 -0
- debugbundle/evidence_json.py +68 -0
- debugbundle/evidence_wire.py +409 -0
- debugbundle/exception_capture.py +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 +20 -14
- {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.0.dist-info}/METADATA +116 -1
- debugbundle_python-2.1.0.dist-info/RECORD +51 -0
- {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.0.dist-info}/WHEEL +1 -1
- debugbundle_python-2.0.1.dist-info/RECORD +0 -36
- {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.0.dist-info}/licenses/LICENSE +0 -0
- {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)
|
|
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.
|
|
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 =
|
|
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":
|
|
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.
|
|
53
|
+
"body": request.stream.read(handler.max_body_bytes + 1),
|
|
51
54
|
"ipAddress": request.remote_addr,
|
|
52
55
|
}
|
|
53
56
|
)
|