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 +3 -0
- debugbundle/acknowledgement.py +71 -0
- debugbundle/before_send.py +218 -0
- debugbundle/core.py +139 -323
- debugbundle/event_support.py +281 -0
- debugbundle/transport.py +8 -2
- {debugbundle_python-1.2.0.dist-info → debugbundle_python-1.3.0.dist-info}/METADATA +1 -1
- {debugbundle_python-1.2.0.dist-info → debugbundle_python-1.3.0.dist-info}/RECORD +11 -8
- {debugbundle_python-1.2.0.dist-info → debugbundle_python-1.3.0.dist-info}/WHEEL +0 -0
- {debugbundle_python-1.2.0.dist-info → debugbundle_python-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {debugbundle_python-1.2.0.dist-info → debugbundle_python-1.3.0.dist-info}/top_level.txt +0 -0
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
|
|
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 =
|
|
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
|
|
227
|
+
if not self._enabled:
|
|
226
228
|
return
|
|
227
229
|
|
|
228
|
-
redacted_context =
|
|
229
|
-
request_payload =
|
|
230
|
-
response_payload =
|
|
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":
|
|
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.
|
|
247
|
-
|
|
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 =
|
|
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"] =
|
|
277
|
-
self.
|
|
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
|
-
|
|
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 =
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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.
|
|
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] =
|
|
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)] =
|
|
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":
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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=
|
|
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
|
-
|
|
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":
|
|
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":
|
|
590
|
+
"occurred_at": iso_now(self._time_provider),
|
|
537
591
|
"sdk_name": "debugbundle-python",
|
|
538
|
-
"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":
|
|
599
|
+
"correlation": correlation_payload(merged_context),
|
|
546
600
|
"payload": payload,
|
|
547
601
|
}
|
|
548
|
-
envelope_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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
848
|
-
except
|
|
849
|
-
return
|
|
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
|
-
|
|
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,13 +1,16 @@
|
|
|
1
|
-
debugbundle/__init__.py,sha256=
|
|
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=
|
|
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=
|
|
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.
|
|
21
|
-
debugbundle_python-1.
|
|
22
|
-
debugbundle_python-1.
|
|
23
|
-
debugbundle_python-1.
|
|
24
|
-
debugbundle_python-1.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|