debugbundle-python 1.4.2__py3-none-any.whl → 2.0.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.
@@ -0,0 +1,62 @@
1
+ """Finalize one reserved event at a time without retaining uncharged hook output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, cast
6
+
7
+ from .request_capture_policy import should_capture_request_event
8
+
9
+ if TYPE_CHECKING:
10
+ from .core import DebugBundleSdk
11
+
12
+
13
+ def replacement_allowed(sdk: DebugBundleSdk, event: dict[str, object]) -> bool:
14
+ payload = cast(dict[str, object], event["payload"])
15
+ if event["event_type"] == "log_event":
16
+ return sdk._log_level_eligible(cast(str, payload["level"]))
17
+ if event["event_type"] == "request_event":
18
+ return should_capture_request_event(sdk._capture_policy, payload, payload)
19
+ if event["event_type"] == "probe_event":
20
+ return sdk._capture_policy.capture_probe_events == "standalone_when_activated"
21
+ return True
22
+
23
+
24
+ def finalize_batch(
25
+ sdk: DebugBundleSdk,
26
+ batch: list[dict[str, object]],
27
+ generation: int,
28
+ max_bytes: int,
29
+ ) -> list[dict[str, object]] | None:
30
+ retained_index = 0
31
+ for snapshot_index, original in enumerate(batch):
32
+ try:
33
+ replacement = sdk._apply_before_send_event(original)
34
+ replacement_bytes = sdk._event_bytes(replacement) if replacement is not None else 0
35
+ except BaseException:
36
+ # Hook exceptions/invalid returns already use the protected original
37
+ # inside apply_before_send. Other failures must withhold telemetry.
38
+ replacement, replacement_bytes = None, 0
39
+ with sdk._lock:
40
+ if generation != sdk._generation:
41
+ return None
42
+ original_bytes = sdk._buffer_sizes[id(original)]
43
+ allowed = replacement is not None and replacement_allowed(sdk, replacement)
44
+ fits = sdk._buffer_bytes - original_bytes + replacement_bytes <= max_bytes
45
+ if allowed and fits:
46
+ assert replacement is not None
47
+ sdk._buffer[retained_index] = replacement
48
+ sdk._buffer_sizes.pop(id(original))
49
+ sdk._buffer_sizes[id(replacement)] = replacement_bytes
50
+ sdk._buffer_bytes += replacement_bytes - original_bytes
51
+ retained_index += 1
52
+ batch[snapshot_index] = replacement
53
+ else:
54
+ if allowed and not fits:
55
+ sdk._pressure_drops += 1
56
+ sdk._buffer.pop(retained_index)
57
+ sdk._buffer_sizes.pop(id(original))
58
+ sdk._buffer_bytes -= original_bytes
59
+ sdk._inflight_count -= 1
60
+ # Release the snapshot reference before another callback can wait.
61
+ batch[snapshot_index] = {}
62
+ return [event for event in batch if event]
@@ -264,7 +264,7 @@ def sdk_version() -> str:
264
264
  try:
265
265
  return metadata.version("debugbundle-python")
266
266
  except metadata.PackageNotFoundError:
267
- return "1.4.2"
267
+ return "2.0.0"
268
268
 
269
269
 
270
270
  def sdk_config_endpoint(events_endpoint: str) -> str:
@@ -0,0 +1,74 @@
1
+ """Bounded exception details without invoking application-defined string renderers or source reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ MAX_CAUSES = 8
6
+ MAX_FRAMES = 64
7
+ MAX_STACK_CHARS = 16 * 1024
8
+
9
+
10
+ def exception_details(error: BaseException) -> tuple[str, str, str]:
11
+ name = _exception_name(error)
12
+ message = _safe_message(error)
13
+ lines: list[str] = []
14
+ visited: set[int] = set()
15
+ current: BaseException | None = error
16
+ frames_seen = 0
17
+
18
+ while current is not None and len(visited) < MAX_CAUSES and id(current) not in visited:
19
+ visited.add(id(current))
20
+ current_name = _exception_name(current)
21
+ lines.append(f"{current_name}: {_safe_message(current)}")
22
+ try:
23
+ tb = BaseException.__dict__["__traceback__"].__get__(current)
24
+ except BaseException:
25
+ tb = None
26
+ while tb is not None and frames_seen < MAX_FRAMES:
27
+ try:
28
+ code = tb.tb_frame.f_code
29
+ filename = code.co_filename[:256]
30
+ function = code.co_name[:128]
31
+ line = tb.tb_lineno
32
+ tb = tb.tb_next
33
+ except BaseException:
34
+ break
35
+ lines.append(f" File {filename}, line {line}, in {function}")
36
+ frames_seen += 1
37
+ if frames_seen >= MAX_FRAMES:
38
+ lines.append(" ... additional frames omitted")
39
+ break
40
+ try:
41
+ next_error = BaseException.__dict__["__cause__"].__get__(current)
42
+ if next_error is None and not BaseException.__dict__["__suppress_context__"].__get__(current):
43
+ next_error = BaseException.__dict__["__context__"].__get__(current)
44
+ except BaseException:
45
+ next_error = None
46
+ current = next_error
47
+ if current is not None:
48
+ lines.append("Caused by:")
49
+
50
+ return name, message, "\n".join(lines)[:MAX_STACK_CHARS]
51
+
52
+
53
+ def _exception_name(error: BaseException) -> str:
54
+ # Invoke built-in descriptors directly: subclass properties, __getattribute__,
55
+ # metaclass accessors, and truthiness must never execute on capture callers.
56
+ return str(type.__dict__["__name__"].__get__(type(error)))[:256]
57
+
58
+
59
+ def _safe_message(error: BaseException) -> str:
60
+ try:
61
+ args = BaseException.__dict__["args"].__get__(error)
62
+ if not isinstance(args, tuple):
63
+ return "[message unavailable]"
64
+ parts: list[str] = []
65
+ for value in args[:4]:
66
+ if issubclass(type(value), str):
67
+ parts.append(str.__getitem__(value, slice(0, 1024)))
68
+ elif type(value) in (int, float, bool):
69
+ parts.append(str(value))
70
+ else:
71
+ parts.append("[unsupported argument]")
72
+ return ", ".join(parts)[:4096]
73
+ except BaseException:
74
+ return "[message unavailable]"
@@ -0,0 +1,37 @@
1
+ """Prepare bounded SDK summaries away from the capture admission lock."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, cast
6
+
7
+ if TYPE_CHECKING:
8
+ from .core import DebugBundleSdk
9
+
10
+
11
+ def prepare_flush_aggregates(
12
+ sdk: DebugBundleSdk,
13
+ suppression: list[dict[str, object]],
14
+ pressure_count: int,
15
+ ) -> tuple[list[tuple[dict[str, object], int]], tuple[dict[str, object], int] | None]:
16
+ prepared: list[tuple[dict[str, object], int]] = []
17
+ for aggregate in suppression:
18
+ event_type = aggregate.get("event_type")
19
+ payload = aggregate.get("payload")
20
+ if not isinstance(event_type, str) or not isinstance(payload, dict):
21
+ continue
22
+ aggregate.update(sdk._base_event(event_type, cast(dict[str, object], payload)))
23
+ protected = sdk._protect_event(aggregate)
24
+ if protected is not None:
25
+ prepared.append((protected, sdk._event_bytes(protected)))
26
+
27
+ pressure: tuple[dict[str, object], int] | None = None
28
+ if pressure_count:
29
+ event = sdk._base_event("log_event", {
30
+ "message": f"{pressure_count} SDK events suppressed due to queue pressure",
31
+ "level": "warning",
32
+ "attributes": {"suppressed_count": pressure_count, "reason": "queue_pressure"},
33
+ })
34
+ protected = sdk._protect_event(event)
35
+ if protected is not None:
36
+ pressure = (protected, sdk._event_bytes(protected))
37
+ return prepared, pressure
@@ -0,0 +1,101 @@
1
+ """Probe capture with short admission/commit locks and no application callback under them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from collections.abc import Callable, Mapping
7
+ from dataclasses import dataclass
8
+ from typing import TYPE_CHECKING, cast
9
+
10
+ from .capture_hooks import try_capture_lock
11
+ from .event_support import iso_now
12
+ from .process_support import ensure_current_process
13
+ from .redaction import sanitize_telemetry
14
+
15
+ if TYPE_CHECKING:
16
+ from .core import DebugBundleSdk
17
+
18
+
19
+ @dataclass
20
+ class ProbeEntry:
21
+ label: str
22
+ data: dict[str, object]
23
+ timestamp: str
24
+
25
+
26
+ def capture_probe(
27
+ sdk: DebugBundleSdk,
28
+ label: str,
29
+ data: object | Callable[[], object],
30
+ opts: Mapping[str, object] | None,
31
+ ) -> None:
32
+ try:
33
+ ensure_current_process(sdk)
34
+ options = dict(opts or {})
35
+ now_ms = int(sdk._time_provider() * 1000)
36
+ if not try_capture_lock(sdk):
37
+ sdk._pressure_drops += 1
38
+ return
39
+ try:
40
+ if not sdk._enabled:
41
+ return
42
+ heavy = options.get("heavy") is True
43
+ directives = sdk._find_matching_probe_directives(label, now_ms)
44
+ if heavy and not directives:
45
+ return
46
+ if label not in sdk._probe_buffers and len(sdk._probe_buffers) >= sdk._max_probe_labels:
47
+ return
48
+ redact_fields = set(sdk._redact_fields)
49
+ generation = sdk._generation
50
+ finally:
51
+ sdk._lock.release()
52
+
53
+ # A host supplier, mapping iterator, privacy walk, clock, or serializer may
54
+ # be slow or hostile. None may hold the capture lock or block exceptions.
55
+ protected_label = sanitize_telemetry(label, redact_fields)
56
+ value = data() if callable(data) else data
57
+ if not isinstance(value, Mapping):
58
+ value = {"value": value}
59
+ protected_value = sanitize_telemetry(dict(value), redact_fields)
60
+ if not isinstance(protected_label, str) or not isinstance(protected_value, dict):
61
+ return
62
+ label = protected_label
63
+ redacted_value = cast(dict[str, object], protected_value)
64
+ entry = None if heavy else ProbeEntry(label, redacted_value, iso_now(sdk._time_provider))
65
+
66
+ prepared: list[tuple[str, dict[str, object], int]] = []
67
+ for directive in directives:
68
+ payload: dict[str, object] = {
69
+ "label": label,
70
+ "activation_id": directive.id,
71
+ "probe_label_pattern": directive.label_pattern,
72
+ "data": dict(redacted_value),
73
+ }
74
+ event = sdk._protect_event(sdk._base_event("probe_event", payload))
75
+ if event is not None:
76
+ prepared.append((directive.id, event, sdk._event_bytes(event)))
77
+
78
+ refreshed_now_ms = int(sdk._time_provider() * 1000)
79
+ if not try_capture_lock(sdk):
80
+ sdk._pressure_drops += 1
81
+ return
82
+ try:
83
+ if not sdk._enabled or generation != sdk._generation:
84
+ return
85
+ if label not in sdk._probe_buffers and len(sdk._probe_buffers) >= sdk._max_probe_labels:
86
+ return
87
+ active_ids = {directive.id for directive in sdk._find_matching_probe_directives(label, refreshed_now_ms)}
88
+ if heavy and not active_ids:
89
+ return
90
+ if entry is not None:
91
+ bucket = sdk._probe_buffers.setdefault(label, deque(maxlen=sdk._max_probe_entries_per_label))
92
+ bucket.append(entry)
93
+ if sdk._capture_policy.capture_probe_events == "standalone_when_activated":
94
+ for directive_id, event, event_bytes in prepared:
95
+ if directive_id in active_ids:
96
+ sdk._offer_protected_event(event, event_bytes)
97
+ finally:
98
+ sdk._lock.release()
99
+ except BaseException:
100
+ # Probe callbacks and SDK failures must remain observational.
101
+ return
@@ -0,0 +1,59 @@
1
+ """Discard pre-fork SDK ownership without touching inherited thread locks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import threading
7
+ from contextvars import ContextVar
8
+ from typing import TYPE_CHECKING
9
+
10
+ from .suppression import EventSuppressionTracker
11
+ from .transport import HttpTransport
12
+
13
+ if TYPE_CHECKING:
14
+ from .core import DebugBundleSdk
15
+
16
+
17
+ def ensure_current_process(sdk: DebugBundleSdk) -> None:
18
+ pid = os.getpid()
19
+ if sdk._pid == pid:
20
+ return
21
+
22
+ # The parent may have forked while another thread owned its lock. Never
23
+ # acquire, cancel, close, or join any inherited thread-owned object here.
24
+ sdk._lock = threading.RLock()
25
+ sdk._timer = None
26
+ sdk._remote_config_timer = None
27
+ sdk._initial_config_ready = threading.Event()
28
+ sdk._initial_config_ready.set()
29
+ sdk._generation += 1
30
+ sdk._sending = False
31
+ sdk._inflight_count = 0
32
+ sdk._timer_due_at = 0.0
33
+ sdk._buffer = []
34
+ sdk._buffer_sizes = {}
35
+ sdk._buffer_bytes = 0
36
+ sdk._pending_low_priority = 0
37
+ sdk._context = {}
38
+ sdk._scoped_context = ContextVar("debugbundle_scoped_context", default=None)
39
+ sdk._request_trigger_directives = ContextVar("debugbundle_request_trigger_directives", default=None)
40
+ sdk._probe_buffers = {}
41
+ sdk._suppression = EventSuppressionTracker()
42
+ sdk._pressure_drops = 0
43
+ sdk._last_pressure_report_at = 0.0
44
+ sdk._retry_after = 0.0
45
+ sdk._last_event_at = None
46
+ sdk._consecutive_failures = 0
47
+ sdk._remote_config_etag = None
48
+ sdk._remote_config_snapshot = None
49
+
50
+ # Retain the parent's effective policy until the child explicitly reinitializes;
51
+ # resetting to a default here could widen capture after a restricted policy.
52
+ if sdk._http_transport is not None:
53
+ sdk._http_transport = None
54
+ try:
55
+ sdk._http_transport = HttpTransport(sdk._endpoint)
56
+ except Exception:
57
+ pass
58
+ sdk._transport = sdk._http_transport
59
+ sdk._pid = pid
@@ -0,0 +1,39 @@
1
+ """Constant-time overload admission and pending-event priority helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from .core import DebugBundleSdk
9
+
10
+
11
+ def high_priority_event(event: dict[str, object]) -> bool:
12
+ if event.get("event_type") == "backend_exception":
13
+ return True
14
+ payload = event.get("payload")
15
+ return isinstance(payload, dict) and (
16
+ payload.get("level") in ("error", "critical")
17
+ or (event.get("event_type") == "request_event" and type(payload.get("response_status")) is int
18
+ and payload["response_status"] >= 500)
19
+ )
20
+
21
+
22
+ def may_prepare_event(sdk: DebugBundleSdk, high_priority: bool, max_events: int, max_bytes: int) -> bool:
23
+ if len(sdk._buffer) < max_events and sdk._buffer_bytes < max_bytes:
24
+ return True
25
+ if high_priority and sdk._pending_low_priority > 0:
26
+ return True
27
+ sdk._pressure_drops = min(2**63 - 1, sdk._pressure_drops + 1)
28
+ return False
29
+
30
+
31
+ def evict_low_priority(sdk: DebugBundleSdk) -> bool:
32
+ for index in range(sdk._inflight_count, len(sdk._buffer)):
33
+ if not high_priority_event(sdk._buffer[index]):
34
+ evicted = sdk._buffer.pop(index)
35
+ sdk._buffer_bytes -= sdk._buffer_sizes.pop(id(evicted))
36
+ sdk._pending_low_priority -= 1
37
+ sdk._pressure_drops = min(2**63 - 1, sdk._pressure_drops + 1)
38
+ return True
39
+ return False
debugbundle/redaction.py CHANGED
@@ -1,7 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
4
+ import re
3
5
  from collections.abc import Mapping, Sequence
4
6
  from typing import Any
7
+ from urllib.parse import parse_qsl, unquote, urlencode, urlsplit, urlunsplit
5
8
 
6
9
  DEFAULT_REDACT_FIELDS = {
7
10
  "authorization",
@@ -14,6 +17,200 @@ DEFAULT_REDACT_FIELDS = {
14
17
  }
15
18
 
16
19
  REDACTED_VALUE = "[REDACTED]"
20
+ MANDATORY_FIELDS = DEFAULT_REDACT_FIELDS | {
21
+ "api_key", "apikey", "access_token", "refresh_token", "private_key", "bearer", "session_id",
22
+ "passwd", "card_number", "cvv", "cvc", "pin", "expiry", "phone", "otp", "verification_code",
23
+ }
24
+ _HEADER = re.compile(r"\b(Authorization|Proxy-Authorization|Cookie|Set-Cookie)\s*:\s*[^\r\n]*", re.I)
25
+ _BEARER = re.compile(r"\b(Bearer|Basic)\s+[A-Za-z0-9._~+/-]{6,}", re.I)
26
+ _TOKEN = re.compile(r"\bdbundle_(?:proj|mem|probe|agent)_[A-Za-z0-9_-]+\b")
27
+ _PEM = re.compile(
28
+ r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----",
29
+ re.I,
30
+ )
31
+ _PEM_START = re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----", re.I)
32
+ _CARD = re.compile(r"(?<![A-Za-z0-9_-])(?:\d[ -]?){12,18}\d(?![A-Za-z0-9_-])")
33
+ _URL = re.compile(r"\bhttps?://[^\s<>\"']+", re.I)
34
+ _LABELS = MANDATORY_FIELDS | {"client_secret", "x_api_key", "accessToken", "refreshToken", "privateKey", "clientSecret"}
35
+ _ASSIGNMENT = re.compile(
36
+ r"\b(" + "|".join(re.escape(key) for key in sorted(_LABELS, key=len, reverse=True)) +
37
+ r")\b([\"']?\s*[:=]\s*)(?:\"[^\"]*\"|'[^']*'|[^\s&,;]+)", re.I,
38
+ )
39
+
40
+
41
+ class UnsafeTelemetry(ValueError):
42
+ """A fixed safe failure marker; never includes caller content."""
43
+
44
+
45
+ def _sensitive_key(key: str, fields: set[str]) -> bool:
46
+ normalized = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", key.strip()).lower()
47
+ segments = [segment for segment in re.split(r"[^a-z0-9]+", normalized) if segment]
48
+ canonical = {re.sub(r"[^a-z0-9]", "", field.lower()) for field in fields}
49
+ for start in range(len(segments)):
50
+ combined = ""
51
+ for segment in segments[start:]:
52
+ combined += segment
53
+ if combined in canonical:
54
+ return True
55
+ return False
56
+
57
+
58
+ def _luhn(candidate: str) -> bool:
59
+ digits = re.sub(r"[^0-9]", "", candidate)
60
+ if not 13 <= len(digits) <= 19 or len(set(digits)) == 1:
61
+ return False
62
+ total = 0
63
+ for index, char in enumerate(reversed(digits)):
64
+ digit = int(char)
65
+ if index % 2:
66
+ digit *= 2
67
+ if digit > 9:
68
+ digit -= 9
69
+ total += digit
70
+ return total % 10 == 0
71
+
72
+
73
+ def _scrub_url(raw: str, fields: set[str]) -> str:
74
+ try:
75
+ parsed = urlsplit(raw)
76
+ if not parsed.scheme or not parsed.hostname:
77
+ raise ValueError("invalid_url")
78
+ _ = parsed.port # Invalid ports make the entire URL unscannable.
79
+ host = parsed.netloc.rsplit("@", 1)[-1]
80
+ authority = f"REDACTED@{host}" if parsed.username or parsed.password else host
81
+ query = urlencode([
82
+ (key, REDACTED_VALUE if _sensitive_key(key, fields)
83
+ or _scrub_text(value, fields, False) != value else value)
84
+ for key, value in parse_qsl(parsed.query, keep_blank_values=True)
85
+ if len(key) <= 128 and _scrub_text(key, fields, False) == key
86
+ ])
87
+ return urlunsplit((parsed.scheme.lower(), authority, parsed.path or "/", query, ""))
88
+ except ValueError:
89
+ return REDACTED_VALUE
90
+
91
+
92
+ def _scrub_text(value: str, extra: set[str], scan_urls: bool = True) -> str:
93
+ if _PEM_START.search(value) and not _PEM.search(value):
94
+ return REDACTED_VALUE
95
+ if re.search(r"(?:password|token|secret|authorization|cookie)%3[ad]", value, re.I):
96
+ try:
97
+ value = unquote(value, errors="strict")
98
+ except UnicodeError:
99
+ return REDACTED_VALUE
100
+ result = _PEM.sub(REDACTED_VALUE, value)
101
+ result = _HEADER.sub(lambda match: f"{match.group(1)}: {REDACTED_VALUE}", result)
102
+ result = _BEARER.sub(lambda match: f"{match.group(1)} {REDACTED_VALUE}", result)
103
+ result = _TOKEN.sub(REDACTED_VALUE, result)
104
+ result = _ASSIGNMENT.sub(lambda match: f"{match.group(1)}{match.group(2)}{REDACTED_VALUE}", result)
105
+ for field in extra:
106
+ named = re.compile(r"\b(" + re.escape(field) + r")\b([\"']?\s*[:=]\s*)(?:\"[^\"]*\"|'[^']*'|[^\s&,;]+)", re.I)
107
+ result = named.sub(lambda match: f"{match.group(1)}{match.group(2)}{REDACTED_VALUE}", result)
108
+ result = _CARD.sub(lambda match: REDACTED_VALUE if _luhn(match.group(0)) else match.group(0), result)
109
+
110
+ if not scan_urls:
111
+ return result
112
+
113
+ def replace_url(match: re.Match[str]) -> str:
114
+ raw = match.group(0)
115
+ suffix = re.search(r"[).,;]+$", raw)
116
+ trailing = suffix.group(0) if suffix else ""
117
+ return _scrub_url(raw[:len(raw) - len(trailing)] if trailing else raw, MANDATORY_FIELDS | extra) + trailing
118
+
119
+ return _URL.sub(replace_url, result)
120
+
121
+
122
+ def sanitize_telemetry(value: Any, additional_fields: set[str] | None = None) -> Any:
123
+ """Mandatory bounded telemetry policy. Failure withholds the input, never returns its original."""
124
+ extra = additional_fields or set()
125
+ if len(extra) > 128 or any(not isinstance(field, str) or not 0 < len(field) <= 64 for field in extra):
126
+ raise UnsafeTelemetry("unsafe_input")
127
+ state = {"nodes": 0, "bytes": 0}
128
+ seen: set[int] = set()
129
+ fields = MANDATORY_FIELDS | extra
130
+
131
+ def visit(candidate: Any, depth: int, structured: bool) -> Any:
132
+ state["nodes"] += 1
133
+ if state["nodes"] > 4096:
134
+ raise UnsafeTelemetry("budget_exceeded")
135
+ if depth > 16:
136
+ return REDACTED_VALUE
137
+ if isinstance(candidate, str):
138
+ if len(candidate) > 16 * 1024:
139
+ return REDACTED_VALUE
140
+ try:
141
+ length = len(candidate.encode("utf-8"))
142
+ except UnicodeError as error:
143
+ raise UnsafeTelemetry("unsafe_input") from error
144
+ if length > 16 * 1024:
145
+ return REDACTED_VALUE
146
+ state["bytes"] += length
147
+ if state["bytes"] > 256 * 1024:
148
+ raise UnsafeTelemetry("budget_exceeded")
149
+ if structured and candidate.startswith(("{", "[")):
150
+ try:
151
+ nested = json.loads(candidate)
152
+ except (ValueError, TypeError):
153
+ nested = None
154
+ if isinstance(nested, (dict, list)):
155
+ return json.dumps(visit(nested, 0, False), separators=(",", ":"), ensure_ascii=False)
156
+ result = _scrub_text(candidate, extra)
157
+ if candidate.startswith(("{", "[")) and result == candidate and re.search(
158
+ r"(?:password|token|secret|authorization|cookie)[\"']?\s*[:=]", candidate, re.I
159
+ ):
160
+ return REDACTED_VALUE
161
+ return result
162
+ if candidate is None or isinstance(candidate, (bool, int, float)):
163
+ return candidate
164
+ identity = id(candidate)
165
+ if identity in seen:
166
+ return "[Circular]"
167
+ seen.add(identity)
168
+ try:
169
+ if isinstance(candidate, (list, tuple)):
170
+ if len(candidate) > 256:
171
+ return REDACTED_VALUE
172
+ return [visit(item, depth + 1, structured) for item in candidate]
173
+ if isinstance(candidate, Mapping):
174
+ if len(candidate) > 256:
175
+ return REDACTED_VALUE
176
+ output: dict[str, Any] = {}
177
+ for key, nested in candidate.items():
178
+ if not isinstance(key, str):
179
+ raise UnsafeTelemetry("unsafe_input")
180
+ if len(key) > 128:
181
+ continue
182
+ state["bytes"] += len(key.encode("utf-8"))
183
+ if state["bytes"] > 256 * 1024:
184
+ raise UnsafeTelemetry("budget_exceeded")
185
+ if _scrub_text(key, extra) != key:
186
+ continue
187
+ output[key] = (
188
+ REDACTED_VALUE if _sensitive_key(key, fields) else visit(nested, depth + 1, structured)
189
+ )
190
+ return output
191
+ raise UnsafeTelemetry("unsafe_input")
192
+ finally:
193
+ seen.remove(identity)
194
+
195
+ try:
196
+ sanitized = visit(value, 0, True)
197
+ if len(json.dumps(sanitized, separators=(",", ":"), ensure_ascii=False).encode("utf-8")) > 256 * 1024:
198
+ raise UnsafeTelemetry("budget_exceeded")
199
+ return sanitized
200
+ except UnsafeTelemetry:
201
+ raise
202
+ except (TypeError, ValueError, UnicodeError, RuntimeError, OverflowError) as error:
203
+ raise UnsafeTelemetry("unsafe_input") from error
204
+
205
+
206
+ def has_safe_event_identity(event: Mapping[str, Any], extra: set[str] | None = None) -> bool:
207
+ correlation = event.get("correlation") or {}
208
+ if not isinstance(correlation, dict) or len(correlation) > 8:
209
+ return False
210
+ values = [event.get(key) for key in ("schema_version", "sdk_name", "sdk_version")]
211
+ values.extend(correlation.values())
212
+ return all(value is None or isinstance(value, str)
213
+ and sanitize_telemetry(value, extra) == value for value in values)
17
214
 
18
215
 
19
216
  def redact_value(value: Any, redact_fields: set[str]) -> Any:
debugbundle/relay.py CHANGED
@@ -6,6 +6,7 @@ from collections.abc import Callable, Mapping
6
6
  from dataclasses import dataclass, field
7
7
  from typing import Any
8
8
 
9
+ from .redaction import UnsafeTelemetry, has_safe_event_identity, sanitize_telemetry
9
10
  from .relay_delivery import (
10
11
  AtomicRelayFileTransport,
11
12
  RelayForwardTransport,
@@ -392,4 +393,11 @@ def _sanitize_event(
392
393
  if normalized_correlation:
393
394
  sanitized["correlation"] = normalized_correlation
394
395
 
396
+ try:
397
+ if not has_safe_event_identity(sanitized):
398
+ return None
399
+ sanitized["payload"] = sanitize_telemetry(sanitized["payload"])
400
+ sanitized["service"] = sanitize_telemetry(sanitized["service"])
401
+ except UnsafeTelemetry:
402
+ return None
395
403
  return sanitized
@@ -0,0 +1,45 @@
1
+ """Metadata-only request admission before request payload construction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+ from .config import CapturePolicy
8
+ from .event_support import is_immediate_request_incident_status
9
+
10
+
11
+ def should_capture_request_event(
12
+ policy: CapturePolicy,
13
+ request: Mapping[str, object] | None,
14
+ response: Mapping[str, object] | None,
15
+ ) -> bool:
16
+ status_code = None
17
+ if response is not None:
18
+ candidate = response.get("status_code") or response.get("response_status")
19
+ if isinstance(candidate, int):
20
+ status_code = candidate
21
+ request_path = None
22
+ http_method = None
23
+ if request is not None:
24
+ path_candidate = request.get("path") or request.get("url")
25
+ method_candidate = request.get("method")
26
+ request_path = path_candidate if isinstance(path_candidate, str) else None
27
+ http_method = method_candidate if isinstance(method_candidate, str) else None
28
+ if is_immediate_request_incident_status(
29
+ status_code,
30
+ policy.preset,
31
+ policy.immediate_client_error_statuses,
32
+ request_path,
33
+ http_method,
34
+ policy.immediate_client_error_path_rules,
35
+ ):
36
+ return True
37
+ if policy.capture_request_events == "off":
38
+ return False
39
+ if policy.capture_request_events == "all":
40
+ return True
41
+ if response is None or status_code is None:
42
+ return policy.capture_request_events == "filtered"
43
+ if policy.capture_request_events == "failures_only":
44
+ return status_code >= 500
45
+ return False