realuptime-errors 0.4.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,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: realuptime-errors
3
+ Version: 0.4.0
4
+ Summary: Zero-dependency error tracking SDK for Python (WSGI/ASGI), part of RealUptime Errors.
5
+ Author-email: RealUptime <support@realuptime.io>
6
+ License: MIT
7
+ Project-URL: Homepage, https://realuptime.io/error-tracking
8
+ Project-URL: Repository, https://github.com/RealUptimeHQ/realuptime-errors-py
9
+ Project-URL: Issues, https://github.com/RealUptimeHQ/realuptime-errors-py/issues
10
+ Requires-Python: >=3.8
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Dynamic: license-file
14
+
15
+ # realuptime-errors
16
+
17
+ Zero-dependency error tracking SDK for Python (WSGI/ASGI middleware plus
18
+ manual capture): part of
19
+ [RealUptime Errors](https://realuptime.io/error-tracking).
20
+
21
+ This is a **mirror**. It is published from `packages/errors-py` in the
22
+ [realuptime monorepo](https://github.com/RealUptimeHQ/realuptime) by
23
+ `scripts/publish-sdk-mirrors.mjs` and is not edited directly; open issues
24
+ and PRs against this repo, but expect source changes to land here after
25
+ they merge upstream.
26
+
27
+ ## Install
28
+
29
+ Installable from the package registry (primary) or straight from this mirror's git history (e.g. to pin an exact commit).
30
+
31
+ ```bash
32
+ pip install realuptime-errors
33
+ ```
34
+
35
+ ```bash
36
+ # or, straight from this mirror
37
+ pip install "git+https://github.com/RealUptimeHQ/realuptime-errors-py"
38
+ ```
39
+
40
+ ## Usage
41
+
42
+ ```python
43
+ import realuptime_errors
44
+
45
+ realuptime_errors.init(
46
+ dsn="https://realuptime.io/api/errors/v1/ingest/rue_...", # from your project's dashboard
47
+ release="v2.4.1",
48
+ environment="production",
49
+ )
50
+
51
+ realuptime_errors.capture_exception(some_exception)
52
+ ```
53
+
54
+ WSGI: `app = realuptime_errors.WsgiMiddleware(app)`
55
+ ASGI: `app = realuptime_errors.AsgiMiddleware(app)`
56
+
57
+ ## What this SDK actually does
58
+
59
+ - **Never raises out of a public function.** Every entry point catches
60
+ everything; a broken SDK logs once to stderr (prefixed
61
+ `[realuptime-errors]`) and goes quiet. An error tracker that crashes the
62
+ app it watches is worse than none.
63
+ - **Scrubs PII by default, client-side**, before anything serializes:
64
+ `Authorization`/`Proxy-Authorization`/`Cookie`/`Set-Cookie` headers are
65
+ replaced whole; card-shaped digit runs (Luhn-checked), JWTs, prefixed API
66
+ keys, and long hex/base64 runs are pattern-scrubbed anywhere they appear
67
+ in the message, request context, breadcrumbs, or captured source lines.
68
+ Opt one field back in with `allow_fields=["x-request-id"]`; there is no
69
+ global "disable scrubbing" switch, by design.
70
+ - **Never silently drops.** The in-memory buffer (200 events) evicts the
71
+ oldest event when full, counts every eviction, and reports the count on
72
+ the next successful batch, so drops show up on your dashboard instead of
73
+ disappearing.
74
+ - **Standard library only.** No third-party imports, ever, checked by a
75
+ test in this repo (`tests/test_wire_contract.py`) that scans the source
76
+ for anything outside an explicit allow-list.
77
+
78
+ ## API
79
+
80
+ | Function | Notes |
81
+ | --- | --- |
82
+ | `init(dsn, release=None, environment=None, allow_fields=None, capture_unhandled=True)` | Installs a `sys.excepthook` for unhandled exceptions unless `capture_unhandled=False`. Safe to call twice; a missing DSN logs once and stays inert. |
83
+ | `capture_exception(exc, request=None, fingerprint=None, release=None, environment=None)` | Reports an exception. |
84
+ | `capture_message(message, ...)` | Reports a plain message. |
85
+ | `add_breadcrumb(message, category=None, data=None)` | Bounded trail (last 20), rides the next event. |
86
+ | `flush()` | Delivers anything buffered. |
87
+ | `WsgiMiddleware(app)` / `AsgiMiddleware(app)` | Captures unhandled exceptions with minimal request context (method, path), then re-raises. |
88
+
89
+ ## Version
90
+
91
+ This mirror tracks SDK_VERSION `0.4.0` in `realuptime_errors.py`, the
92
+ string every event actually carries on the wire.
93
+
94
+ ## License
95
+
96
+ MIT, see [LICENSE](./LICENSE).
@@ -0,0 +1,6 @@
1
+ realuptime_errors.py,sha256=ZYzUBYZFJI9Y3_kcFYoRMCCNM-6MUC-4SSSEz1gAJx4,43399
2
+ realuptime_errors-0.4.0.dist-info/licenses/LICENSE,sha256=JM-0Puq3-jGE7nxqEu3bXeo5HaYTPx1N7S5STstKUEM,1067
3
+ realuptime_errors-0.4.0.dist-info/METADATA,sha256=Tr28k3FYxi47esluHWMEq3nxDaqlY2pnYn2dcP8QSIA,3835
4
+ realuptime_errors-0.4.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
+ realuptime_errors-0.4.0.dist-info/top_level.txt,sha256=27H_LHYmzcJCY3U_rOOy9_oXldPyBRK4bP7fEZ5QcwM,18
6
+ realuptime_errors-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 RealUptime
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ realuptime_errors
realuptime_errors.py ADDED
@@ -0,0 +1,1152 @@
1
+ """realuptime-errors: minimal Python error tracking SDK, Phase 1
2
+ (docs/errors-plan.md, Linear REA-57).
3
+
4
+ One wire contract with the JS SDK (packages/errors-js/types.ts); the shared
5
+ scrub vectors (packages/errors-js/scrub-vectors.json) pin both SDKs and the
6
+ server's second net to byte-identical scrubbing. Python 3 stdlib only, no
7
+ dependencies, same rule as deploy/vps: this file runs inside customer
8
+ processes and its dependency graph must be auditable at a glance.
9
+
10
+ Contract with the host app, non-negotiable: THIS SDK NEVER RAISES out of a
11
+ public function. A broken SDK logs once and goes quiet; an error tracker
12
+ that crashes the app it watches is worse than none.
13
+
14
+ Usage:
15
+
16
+ import realuptime_errors
17
+ realuptime_errors.init(dsn="https://realuptime.io/api/errors/v1/ingest/rue_...",
18
+ release="v2.4.1", environment="production")
19
+ ...
20
+ realuptime_errors.capture_exception(exc)
21
+
22
+ WSGI: app = realuptime_errors.WsgiMiddleware(app)
23
+ ASGI: app = realuptime_errors.AsgiMiddleware(app)
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import linecache
30
+ import platform
31
+ import re
32
+ import sys
33
+ import threading
34
+ import time
35
+ import urllib.error
36
+ import urllib.request
37
+
38
+ SDK_NAME = "realuptime-errors-py"
39
+ SDK_VERSION = "0.4.0"
40
+
41
+ # Mirrored from packages/errors-js/types.ts and pinned by
42
+ # tests/test_wire_contract.py.
43
+ MAX_EVENTS_PER_BATCH = 50
44
+ MAX_MESSAGE_LENGTH = 4000
45
+ MAX_FRAMES_PER_EVENT = 50
46
+ MAX_STRING_LENGTH = 512
47
+ MAX_BREADCRUMBS_PER_EVENT = 20
48
+ MAX_BREADCRUMB_DATA_ENTRIES = 10
49
+ BUFFER_MAX = 200
50
+
51
+ # v2 caps (REA-182), mirrored from packages/errors-js/types.ts.
52
+ MAX_TAGS_PER_EVENT = 20
53
+ MAX_CONTEXT_ENTRIES = 20
54
+ MAX_CONTEXT_KEY_LENGTH = 64
55
+ MAX_LOCAL_VARS_PER_FRAME = 20
56
+ MAX_LOCAL_VAR_LENGTH = 256
57
+
58
+ # Context lines captured either side of each frame's own line (Phase 3:
59
+ # "Python symbolication depth" -- Python needs no source maps; the depth is
60
+ # real source context read IN the customer's process, where the source is).
61
+ CONTEXT_LINES = 3
62
+
63
+ SEND_TIMEOUT_S = 10.0
64
+ BACKOFF_START_S = 5.0
65
+ BACKOFF_MAX_S = 300.0
66
+
67
+ SCRUBBED = "[scrubbed]"
68
+
69
+ _REMOVED_HEADERS = {"authorization", "proxy-authorization", "cookie", "set-cookie"}
70
+
71
+ # v2 (REA-182): identity fields removed whole unless allow-listed by these
72
+ # exact dotted names. "user.id" is deliberately absent -- an opaque
73
+ # identifier in the customer's own key space is the field that makes "how
74
+ # many users hit this" answerable, and it is not contact data.
75
+ _REMOVED_USER_FIELDS = ("user.email", "user.username")
76
+
77
+ _JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b")
78
+ _PREFIXED_KEY_RE = re.compile(r"\b(?:sk|pk|rk|ghp|gho|ghs|xox[a-z]|rua|rue|ru_live)_[A-Za-z0-9_-]{8,}")
79
+ _CARD_RE = re.compile(r"(?<!\d)(?:\d[ -]?){12,18}\d(?!\d)")
80
+ _HEX_RE = re.compile(r"\b[0-9a-fA-F]{32,}\b")
81
+ _BASE64_RE = re.compile(r"(?<![A-Za-z0-9+_=-])[A-Za-z0-9+_-]{40,}={0,2}")
82
+
83
+
84
+ def _luhn_valid(digits: str) -> bool:
85
+ total = 0
86
+ double = False
87
+ for ch in reversed(digits):
88
+ d = ord(ch) - 48
89
+ if double:
90
+ d *= 2
91
+ if d > 9:
92
+ d -= 9
93
+ total += d
94
+ double = not double
95
+ return total % 10 == 0
96
+
97
+
98
+ def _scrub_card(match: "re.Match[str]") -> str:
99
+ run = match.group(0)
100
+ digits = run.replace(" ", "").replace("-", "")
101
+ if 13 <= len(digits) <= 19 and _luhn_valid(digits):
102
+ return SCRUBBED
103
+ return run
104
+
105
+
106
+ def scrub_string(value: str) -> str:
107
+ """The five pattern rules, in the shared order (see scrub-vectors.json)."""
108
+ out = _JWT_RE.sub(SCRUBBED, value)
109
+ out = _PREFIXED_KEY_RE.sub(SCRUBBED, out)
110
+ out = _CARD_RE.sub(_scrub_card, out)
111
+ out = _HEX_RE.sub(SCRUBBED, out)
112
+ out = _BASE64_RE.sub(SCRUBBED, out)
113
+ return out
114
+
115
+
116
+ def _scrub_breadcrumb(crumb: dict) -> dict:
117
+ out = dict(crumb)
118
+ if isinstance(out.get("message"), str):
119
+ out["message"] = scrub_string(out["message"])
120
+ data = out.get("data")
121
+ if isinstance(data, dict):
122
+ out["data"] = {
123
+ name: scrub_string(value) if isinstance(value, str) else value for name, value in data.items()
124
+ }
125
+ return out
126
+
127
+
128
+ def _scrub_string_map(value):
129
+ """v2: a flat string map (tags, custom context, device, frame locals).
130
+ Values pass the pattern rules; KEYS are left alone -- a key is a label
131
+ the integrator chose, and scrubbing it would make an issue's tag filter
132
+ depend on whether the value beside it looked like a card number."""
133
+ if not isinstance(value, dict):
134
+ return value
135
+ return {name: scrub_string(entry) if isinstance(entry, str) else entry for name, entry in value.items()}
136
+
137
+
138
+ def _scrub_user(user, allowed):
139
+ """v2: `id` passes the pattern rules; `email` and `username` are removed
140
+ whole unless the customer opted that exact dotted name in. An opted-in
141
+ value still passes the pattern rules, so opting one back in can never
142
+ smuggle a raw token through."""
143
+ if not isinstance(user, dict):
144
+ return user
145
+ out = dict(user)
146
+ if isinstance(out.get("id"), str):
147
+ out["id"] = scrub_string(out["id"])
148
+ for field in _REMOVED_USER_FIELDS:
149
+ key = field[len("user.") :]
150
+ value = out.get(key)
151
+ if not isinstance(value, str):
152
+ continue
153
+ out[key] = scrub_string(value) if field in allowed else SCRUBBED
154
+ return out
155
+
156
+
157
+ def _scrub_frame_context(frame: dict) -> dict:
158
+ if not isinstance(frame, dict):
159
+ return frame
160
+ if not (
161
+ frame.get("contextLine")
162
+ or frame.get("preContext")
163
+ or frame.get("postContext")
164
+ or frame.get("vars")
165
+ ):
166
+ return frame
167
+ out = dict(frame)
168
+ if isinstance(out.get("contextLine"), str):
169
+ out["contextLine"] = scrub_string(out["contextLine"])
170
+ for key in ("preContext", "postContext"):
171
+ lines = out.get(key)
172
+ if isinstance(lines, list):
173
+ out[key] = [scrub_string(line) if isinstance(line, str) else line for line in lines]
174
+ if isinstance(out.get("vars"), dict):
175
+ out["vars"] = _scrub_string_map(out["vars"])
176
+ return out
177
+
178
+
179
+ def scrub_event(event: dict, allow_fields: "list[str] | None" = None) -> dict:
180
+ """Scrubs one wire event. Returns a new dict; never mutates the input."""
181
+ allowed = {f.lower() for f in (allow_fields or [])}
182
+ out = dict(event)
183
+ if isinstance(out.get("message"), str):
184
+ out["message"] = scrub_string(out["message"])
185
+ breadcrumbs = out.get("breadcrumbs")
186
+ if isinstance(breadcrumbs, list):
187
+ out["breadcrumbs"] = [_scrub_breadcrumb(c) if isinstance(c, dict) else c for c in breadcrumbs]
188
+ frames = out.get("frames")
189
+ if isinstance(frames, list):
190
+ out["frames"] = [_scrub_frame_context(f) for f in frames]
191
+ for key in ("tags", "context", "device"):
192
+ if isinstance(out.get(key), dict):
193
+ out[key] = _scrub_string_map(out[key])
194
+ if isinstance(out.get("user"), dict):
195
+ out["user"] = _scrub_user(out["user"], allowed)
196
+ request = out.get("request")
197
+ if isinstance(request, dict):
198
+ request = dict(request)
199
+ if isinstance(request.get("path"), str):
200
+ request["path"] = scrub_string(request["path"])
201
+ if isinstance(request.get("route"), str):
202
+ request["route"] = scrub_string(request["route"])
203
+ headers = request.get("headers")
204
+ if isinstance(headers, dict):
205
+ scrubbed_headers = {}
206
+ for name, value in headers.items():
207
+ lower = name.lower()
208
+ if lower in _REMOVED_HEADERS and lower not in allowed:
209
+ scrubbed_headers[name] = SCRUBBED
210
+ else:
211
+ scrubbed_headers[name] = scrub_string(value) if isinstance(value, str) else value
212
+ request["headers"] = scrubbed_headers
213
+ out["request"] = request
214
+ return out
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # REA-264: customer-defined ADDITIONAL scrub rules. Mirrors
219
+ # packages/db/error-scrub-rules.ts and packages/errors-js/custom-scrub.ts;
220
+ # packages/errors-js/custom-scrub-vectors.json pins the three to identical
221
+ # output (tests/test_custom_scrub.py). Applied AFTER scrub_event, never
222
+ # instead of it: nothing here can relax a default. Rules arrive from the
223
+ # project's settings via GET /api/errors/v1/scrub-rules/<key> at init, or
224
+ # inline through init(scrub_rules=[...]); either way they only add.
225
+ # ---------------------------------------------------------------------------
226
+
227
+ CUSTOM_SCRUB_RULE_KINDS = ("field_name", "key_glob", "value_regex")
228
+ MAX_CUSTOM_SCRUB_RULES = 50
229
+ MAX_CUSTOM_SCRUB_PATTERN_LENGTH = 200
230
+ SCRUB_RULES_TIMEOUT_S = 5.0
231
+ _EMPTY_CUSTOM_SCRUB = {"names": frozenset(), "globs": (), "values": (), "count": 0}
232
+
233
+
234
+ def _glob_to_regex(glob: str) -> "re.Pattern[str]":
235
+ out = "^"
236
+ for ch in glob:
237
+ if ch == "*":
238
+ out += ".*"
239
+ elif ch == "?":
240
+ out += "."
241
+ else:
242
+ out += re.escape(ch)
243
+ return re.compile(out + "$", re.IGNORECASE)
244
+
245
+
246
+ def validate_custom_scrub_rule(rule) -> bool:
247
+ """The server validates every rule it stores with the full safe-subset
248
+ check (no lookaround, backreferences, nested quantifiers, large repeats);
249
+ this SDK only ever receives rules that passed it, so it carries the cheap
250
+ half: shape, bounds, engine-portable syntax, compiles, non-empty match.
251
+ Anything failing is skipped, never raised on."""
252
+ if not isinstance(rule, dict):
253
+ return False
254
+ kind = rule.get("kind")
255
+ pattern = rule.get("pattern")
256
+ if kind not in CUSTOM_SCRUB_RULE_KINDS or not isinstance(pattern, str):
257
+ return False
258
+ if not pattern.strip() or len(pattern) > MAX_CUSTOM_SCRUB_PATTERN_LENGTH or "\n" in pattern or "\r" in pattern:
259
+ return False
260
+ if kind in ("field_name", "key_glob"):
261
+ if pattern != pattern.strip():
262
+ return False
263
+ has_wildcard = "*" in pattern or "?" in pattern
264
+ return has_wildcard if kind == "key_glob" else not has_wildcard
265
+ # value_regex: portability guards the server also applies.
266
+ if re.search(r"(?<!\\)\(\?(?!:)", pattern):
267
+ return False
268
+ if re.search(r"\\[1-9kpPuc]", pattern):
269
+ return False
270
+ try:
271
+ compiled = re.compile(pattern)
272
+ except re.error:
273
+ return False
274
+ return compiled.match("") is None
275
+
276
+
277
+ def compile_custom_scrub_rules(rules) -> dict:
278
+ """Compiles rules, skipping invalid ones. Never raises."""
279
+ names = set()
280
+ globs = []
281
+ values = []
282
+ count = 0
283
+ if not isinstance(rules, (list, tuple)):
284
+ return dict(_EMPTY_CUSTOM_SCRUB)
285
+ for rule in list(rules)[:MAX_CUSTOM_SCRUB_RULES]:
286
+ if not validate_custom_scrub_rule(rule):
287
+ continue
288
+ count += 1
289
+ kind = rule["kind"]
290
+ pattern = rule["pattern"]
291
+ if kind == "field_name":
292
+ names.add(pattern.lower())
293
+ elif kind == "key_glob":
294
+ globs.append(_glob_to_regex(pattern))
295
+ else:
296
+ values.append(re.compile(pattern))
297
+ return {"names": frozenset(names), "globs": tuple(globs), "values": tuple(values), "count": count}
298
+
299
+
300
+ def _custom_matches_key(name: str, compiled: dict) -> bool:
301
+ lower = name.lower()
302
+ if lower in compiled["names"]:
303
+ return True
304
+ for glob in compiled["globs"]:
305
+ if glob.match(lower):
306
+ return True
307
+ return False
308
+
309
+
310
+ def _custom_value(value: str, compiled: dict) -> str:
311
+ out = value
312
+ for pattern in compiled["values"]:
313
+ out = pattern.sub(SCRUBBED, out)
314
+ return out
315
+
316
+
317
+ def _custom_entry(name: str, value: str, compiled: dict) -> str:
318
+ if _custom_matches_key(name, compiled):
319
+ return SCRUBBED
320
+ return _custom_value(value, compiled)
321
+
322
+
323
+ def _custom_map(mapping, compiled: dict, prefix: str = ""):
324
+ if not isinstance(mapping, dict):
325
+ return mapping
326
+ return {
327
+ name: _custom_entry(prefix + name, value, compiled) if isinstance(value, str) else value
328
+ for name, value in mapping.items()
329
+ }
330
+
331
+
332
+ def apply_custom_scrub_rules(event: dict, compiled: dict) -> dict:
333
+ """Applies compiled rules to an event that has ALREADY been through
334
+ scrub_event. Returns a new dict; never mutates the input."""
335
+ if not compiled or compiled.get("count", 0) == 0:
336
+ return event
337
+ out = dict(event)
338
+ if isinstance(out.get("message"), str):
339
+ out["message"] = _custom_value(out["message"], compiled)
340
+ request = out.get("request")
341
+ if isinstance(request, dict):
342
+ request = dict(request)
343
+ for key in ("path", "route"):
344
+ if isinstance(request.get(key), str):
345
+ request[key] = _custom_value(request[key], compiled)
346
+ if isinstance(request.get("headers"), dict):
347
+ request["headers"] = _custom_map(request["headers"], compiled)
348
+ out["request"] = request
349
+ breadcrumbs = out.get("breadcrumbs")
350
+ if isinstance(breadcrumbs, list):
351
+ next_crumbs = []
352
+ for crumb in breadcrumbs:
353
+ if not isinstance(crumb, dict):
354
+ next_crumbs.append(crumb)
355
+ continue
356
+ c = dict(crumb)
357
+ if isinstance(c.get("message"), str):
358
+ c["message"] = _custom_value(c["message"], compiled)
359
+ if isinstance(c.get("data"), dict):
360
+ c["data"] = _custom_map(c["data"], compiled)
361
+ next_crumbs.append(c)
362
+ out["breadcrumbs"] = next_crumbs
363
+ frames = out.get("frames")
364
+ if isinstance(frames, list):
365
+ next_frames = []
366
+ for frame in frames:
367
+ if not isinstance(frame, dict) or not (
368
+ frame.get("contextLine") or frame.get("preContext") or frame.get("postContext") or frame.get("vars")
369
+ ):
370
+ next_frames.append(frame)
371
+ continue
372
+ f = dict(frame)
373
+ if isinstance(f.get("contextLine"), str):
374
+ f["contextLine"] = _custom_value(f["contextLine"], compiled)
375
+ for key in ("preContext", "postContext"):
376
+ if isinstance(f.get(key), list):
377
+ f[key] = [_custom_value(line, compiled) if isinstance(line, str) else line for line in f[key]]
378
+ if isinstance(f.get("vars"), dict):
379
+ f["vars"] = _custom_map(f["vars"], compiled)
380
+ next_frames.append(f)
381
+ out["frames"] = next_frames
382
+ if isinstance(out.get("user"), dict):
383
+ out["user"] = _custom_map(out["user"], compiled, "user.")
384
+ for key in ("tags", "context", "device"):
385
+ if isinstance(out.get(key), dict):
386
+ out[key] = _custom_map(out[key], compiled)
387
+ return out
388
+
389
+
390
+ def scrub_rules_url_for(dsn: str):
391
+ """The rules endpoint for a DSN, or None for a DSN that is not
392
+ ingest-shaped (no fetch is attempted then)."""
393
+ marker = "/api/errors/v1/ingest/"
394
+ at = dsn.find(marker)
395
+ if at == -1:
396
+ return None
397
+ return dsn[:at] + "/api/errors/v1/scrub-rules/" + dsn[at + len(marker):]
398
+
399
+
400
+ def _http_get(url: str):
401
+ req = urllib.request.Request(url, method="GET")
402
+ try:
403
+ with urllib.request.urlopen(req, timeout=SCRUB_RULES_TIMEOUT_S) as res:
404
+ return res.status, res.read()
405
+ except urllib.error.HTTPError as err:
406
+ return err.code, err.read()
407
+
408
+
409
+ def _clip(value: str, limit: int) -> str:
410
+ return value[:limit] if len(value) > limit else value
411
+
412
+
413
+ def _source_context(filename: str, lineno: "int | None") -> dict:
414
+ """Reads CONTEXT_LINES either side of the frame's line via linecache
415
+ (Phase 3). Best-effort: an unreadable file yields no context rather than
416
+ an error, and the lines are pattern-scrubbed with the event."""
417
+ if not lineno or not filename or filename.startswith("<"):
418
+ return {}
419
+ try:
420
+ context_line = linecache.getline(filename, lineno).rstrip("\n")
421
+ if not context_line:
422
+ return {}
423
+ pre = [
424
+ linecache.getline(filename, n).rstrip("\n")
425
+ for n in range(max(1, lineno - CONTEXT_LINES), lineno)
426
+ ]
427
+ post = [linecache.getline(filename, n).rstrip("\n") for n in range(lineno + 1, lineno + 1 + CONTEXT_LINES)]
428
+ while post and post[-1] == "":
429
+ post.pop()
430
+ return {
431
+ "contextLine": _clip(context_line, MAX_STRING_LENGTH),
432
+ "preContext": [_clip(line, MAX_STRING_LENGTH) for line in pre],
433
+ "postContext": [_clip(line, MAX_STRING_LENGTH) for line in post],
434
+ }
435
+ except Exception:
436
+ return {}
437
+
438
+
439
+ def _local_vars(frame_obj) -> dict:
440
+ """v2 (REA-182): this frame's locals, stringified, bounded hard.
441
+
442
+ OFF BY DEFAULT (init(include_local_variables=True) turns it on), because
443
+ locals are the leakiest thing an error tracker can capture: the variable
444
+ holding the password is, definitionally, in scope at the frame that
445
+ failed to use it. The values pass the pattern scrub with everything
446
+ else, but a regex net is not a guarantee, which is why the switch
447
+ defaults to off rather than to "trust the patterns".
448
+
449
+ Bounds: MAX_LOCAL_VARS_PER_FRAME entries, MAX_LOCAL_VAR_LENGTH
450
+ characters each. Dunder names are skipped (they are interpreter
451
+ bookkeeping, never the customer's data). A value whose repr() raises is
452
+ reported as <unrepresentable> rather than swallowing the whole frame."""
453
+ out = {}
454
+ try:
455
+ for name, value in list(frame_obj.f_locals.items()):
456
+ if len(out) >= MAX_LOCAL_VARS_PER_FRAME:
457
+ break
458
+ if not isinstance(name, str) or name.startswith("__"):
459
+ continue
460
+ try:
461
+ text = repr(value)
462
+ except Exception:
463
+ text = "<unrepresentable>"
464
+ out[_clip(name, MAX_CONTEXT_KEY_LENGTH)] = _clip(text, MAX_LOCAL_VAR_LENGTH)
465
+ except Exception:
466
+ return {}
467
+ return out
468
+
469
+
470
+ def _frames_from_exception(exc: BaseException, include_locals: bool = False) -> "list[dict] | None":
471
+ """Walks the traceback directly rather than via traceback.extract_tb, so
472
+ each wire frame can be paired with its own frame OBJECT when local-
473
+ variable capture is on. The file/function/line values are identical to
474
+ what extract_tb produced before (same co_filename, co_name, tb_lineno)."""
475
+ frames = []
476
+ tb = exc.__traceback__
477
+ while tb is not None:
478
+ frame_obj = tb.tb_frame
479
+ filename = frame_obj.f_code.co_filename or ""
480
+ name = frame_obj.f_code.co_name
481
+ lineno = tb.tb_lineno
482
+ in_app = "site-packages" not in filename and "dist-packages" not in filename and not filename.startswith("<")
483
+ entry = {
484
+ "file": _clip(filename, MAX_STRING_LENGTH),
485
+ "function": _clip(name, MAX_STRING_LENGTH) if name else None,
486
+ "line": lineno,
487
+ "inApp": in_app,
488
+ }
489
+ entry.update(_source_context(filename, lineno))
490
+ if include_locals:
491
+ captured = _local_vars(frame_obj)
492
+ if captured:
493
+ entry["vars"] = captured
494
+ frames.append(entry)
495
+ if len(frames) >= MAX_FRAMES_PER_EVENT:
496
+ break
497
+ tb = tb.tb_next
498
+ # Innermost first on the wire, matching the JS SDK's stack order.
499
+ frames.reverse()
500
+ return frames or None
501
+
502
+
503
+ def _detect_device() -> "dict | None":
504
+ """v2: the runtime facts, read out of this process. Never the hostname,
505
+ the local IP or a container id: those identify a MACHINE rather than a
506
+ runtime and add nothing `release` + `environment` does not already say."""
507
+ try:
508
+ info = sys.version_info
509
+ device = {
510
+ "runtime": "python",
511
+ "runtimeVersion": "%d.%d.%d" % (info[0], info[1], info[2]),
512
+ "platform": _clip(str(sys.platform), MAX_STRING_LENGTH),
513
+ }
514
+ try:
515
+ machine = platform.machine()
516
+ if machine:
517
+ device["arch"] = _clip(str(machine), MAX_STRING_LENGTH)
518
+ except Exception:
519
+ pass
520
+ return device
521
+ except Exception:
522
+ return None
523
+
524
+
525
+ class _Transport:
526
+ """Batched, buffered, backed-off delivery. Mirrors
527
+ packages/errors-js/client.ts: over_quota pauses until the reset instant,
528
+ key_revoked disables for the process lifetime, transient failures back
529
+ off exponentially, and client-side drops ride the next successful batch
530
+ as droppedClient so they reach the product's visible drop counters."""
531
+
532
+ def __init__(self, endpoint, opener=None, now=None, log=None):
533
+ self._endpoint = endpoint
534
+ self._opener = opener or self._http_post
535
+ self._now = now or time.time
536
+ self._log_fn = log or (lambda message: print(message, file=sys.stderr))
537
+ self._lock = threading.Lock()
538
+ self._events: "list[dict]" = []
539
+ self._dropped_since_report = 0
540
+ self._backoff_s = 0.0
541
+ self._next_attempt_at = 0.0
542
+ self._paused_until = 0.0
543
+ self._disabled = False
544
+ self._logged_kinds: "set[str]" = set()
545
+
546
+ def _http_post(self, url: str, body: bytes):
547
+ req = urllib.request.Request(
548
+ url, data=body, headers={"content-type": "application/json"}, method="POST"
549
+ )
550
+ try:
551
+ with urllib.request.urlopen(req, timeout=SEND_TIMEOUT_S) as res:
552
+ return res.status, res.read()
553
+ except urllib.error.HTTPError as err:
554
+ return err.code, err.read()
555
+
556
+ def _log_once(self, kind: str, message: str) -> None:
557
+ if kind in self._logged_kinds:
558
+ return
559
+ self._logged_kinds.add(kind)
560
+ try:
561
+ self._log_fn("[realuptime-errors] " + message)
562
+ except Exception:
563
+ pass
564
+
565
+ def enqueue(self, event: dict) -> None:
566
+ with self._lock:
567
+ if self._disabled:
568
+ return
569
+ if len(self._events) >= BUFFER_MAX:
570
+ self._events.pop(0)
571
+ self._dropped_since_report += 1
572
+ self._events.append(event)
573
+ self.flush()
574
+
575
+ def flush(self) -> None:
576
+ """Sends what is due. Never raises."""
577
+ try:
578
+ self._deliver()
579
+ except Exception as failure: # belt and braces: never crash the host
580
+ self._log_once("internal", "delivery failed internally: %r" % (failure,))
581
+
582
+ def _deliver(self) -> None:
583
+ while True:
584
+ with self._lock:
585
+ now = self._now()
586
+ if self._disabled or not self._events or now < self._next_attempt_at or now < self._paused_until:
587
+ return
588
+ events = self._events[:MAX_EVENTS_PER_BATCH]
589
+ dropped_client = self._dropped_since_report
590
+ self._dropped_since_report = 0
591
+ batch = {"sdk": "%s/%s" % (SDK_NAME, SDK_VERSION), "droppedClient": dropped_client, "events": events}
592
+ try:
593
+ status, raw = self._opener(self._endpoint, json.dumps(batch).encode("utf-8"))
594
+ except Exception:
595
+ with self._lock:
596
+ self._dropped_since_report += dropped_client
597
+ self._back_off("network", "cannot reach the ingest endpoint; buffering and retrying")
598
+ return
599
+ try:
600
+ body = json.loads(raw) if raw else {}
601
+ except Exception:
602
+ body = {}
603
+
604
+ if 200 <= status < 300:
605
+ with self._lock:
606
+ del self._events[: len(events)]
607
+ self._backoff_s = 0.0
608
+ self._next_attempt_at = 0.0
609
+ if body.get("overQuota"):
610
+ self._pause_until((body.get("quota") or {}).get("resetsAt"))
611
+ self._log_once(
612
+ "over-quota",
613
+ "monthly event quota reached; pausing until the window resets. "
614
+ "Dropped events are counted and shown on your realuptime Errors dashboard.",
615
+ )
616
+ return
617
+ continue
618
+
619
+ reason = body.get("reason")
620
+ if status == 403 and reason == "key_revoked":
621
+ with self._lock:
622
+ self._disabled = True
623
+ self._log_once(
624
+ "revoked",
625
+ "this project's ingest key was revoked; error reporting is disabled for this process. "
626
+ "Rotate the key in realuptime Errors settings and redeploy with the new DSN.",
627
+ )
628
+ return
629
+ if status == 429 and reason == "over_quota":
630
+ with self._lock:
631
+ del self._events[: len(events)]
632
+ self._pause_until(body.get("resetsAt"))
633
+ self._log_once(
634
+ "over-quota",
635
+ "monthly event quota reached; pausing until the window resets. "
636
+ "Dropped events are counted and shown on your realuptime Errors dashboard.",
637
+ )
638
+ return
639
+ if status == 400:
640
+ with self._lock:
641
+ del self._events[: len(events)]
642
+ self._dropped_since_report += dropped_client
643
+ self._log_once(
644
+ "malformed",
645
+ "the server refused a batch as malformed: %s. This is an SDK bug worth reporting."
646
+ % (body.get("error") or "no detail",),
647
+ )
648
+ continue
649
+
650
+ with self._lock:
651
+ self._dropped_since_report += dropped_client
652
+ self._back_off("transient", "ingest endpoint answered %d; buffering and retrying" % status)
653
+ return
654
+
655
+ def _pause_until(self, resets_at) -> None:
656
+ parsed = None
657
+ if isinstance(resets_at, str):
658
+ try:
659
+ import datetime
660
+
661
+ parsed = datetime.datetime.fromisoformat(resets_at.replace("Z", "+00:00")).timestamp()
662
+ except Exception:
663
+ parsed = None
664
+ with self._lock:
665
+ self._paused_until = parsed if parsed is not None else self._now() + 3600.0
666
+
667
+ def _back_off(self, kind: str, message: str) -> None:
668
+ with self._lock:
669
+ self._backoff_s = BACKOFF_START_S if self._backoff_s <= 0 else min(self._backoff_s * 2, BACKOFF_MAX_S)
670
+ self._next_attempt_at = self._now() + self._backoff_s
671
+ self._log_once(kind, message + " (backing off).")
672
+
673
+
674
+ class _State:
675
+ def __init__(self, dsn, release, environment, allow_fields, transport, device=None, include_local_variables=False):
676
+ self.dsn = dsn
677
+ self.release = release
678
+ self.environment = environment
679
+ self.allow_fields = allow_fields
680
+ self.transport = transport
681
+ # Bounded breadcrumb ring, newest last (Phase 3), plus the honesty
682
+ # counter: how many entries eviction has dropped since init.
683
+ self.breadcrumb_lock = threading.Lock()
684
+ self.breadcrumbs: "list[dict]" = []
685
+ self.breadcrumbs_evicted = 0
686
+ # v2 scope (REA-182): sticky identity, tags and context applied to
687
+ # every subsequent event. Bounded at WRITE time so a long-lived
688
+ # process's scope cannot grow without limit.
689
+ self.scope_lock = threading.Lock()
690
+ self.user: "dict | None" = None
691
+ self.tags: "dict[str, str]" = {}
692
+ self.context: "dict[str, str]" = {}
693
+ self.device = device
694
+ self.include_local_variables = include_local_variables
695
+ # REA-264: compiled additional rules; inline ones now, the project's
696
+ # fetched ones once the background GET lands.
697
+ self.custom_scrub = dict(_EMPTY_CUSTOM_SCRUB)
698
+ self._inline_scrub_rules: "list" = []
699
+
700
+
701
+ _state: "_State | None" = None
702
+ _hook_installed = False
703
+ _previous_excepthook = None
704
+
705
+
706
+ def init(
707
+ dsn,
708
+ release=None,
709
+ environment=None,
710
+ allow_fields=None,
711
+ capture_unhandled=True,
712
+ send_device_info=True,
713
+ include_local_variables=False,
714
+ scrub_rules=None,
715
+ fetch_scrub_rules=True,
716
+ _opener=None,
717
+ _now=None,
718
+ _log=None,
719
+ _rules_fetcher=None,
720
+ ):
721
+ """Initializes the SDK. Safe to call twice (last call wins); a missing
722
+ DSN logs once and stays inert. Never raises.
723
+
724
+ v2 (REA-182) options:
725
+
726
+ allow_fields adds the dotted identity names "user.email"
727
+ and "user.username" to the existing per-field
728
+ header opt-back-in. Never a global switch.
729
+ send_device_info runtime/platform facts about THIS process
730
+ (never hostname or IP). Default on.
731
+ include_local_variables per-frame locals. Default OFF, hard-capped
732
+ when on; see _local_vars for why.
733
+
734
+ REA-264 options:
735
+
736
+ scrub_rules ADDITIONAL scrub rules for this process, each
737
+ {"kind": "field_name"|"key_glob"|"value_regex",
738
+ "pattern": ...}, applied after the defaults. An
739
+ invalid one is skipped. Nothing relaxes a
740
+ default.
741
+ fetch_scrub_rules fetch the project's additional rules (set in
742
+ realuptime Errors settings) once, in a daemon
743
+ thread, and apply them client-side too. Default
744
+ True. Fails open to the defaults; the server
745
+ applies the same rules at ingest regardless."""
746
+ global _state
747
+ try:
748
+ if not dsn or not isinstance(dsn, str):
749
+ print("[realuptime-errors] init called without a dsn; error reporting is disabled.", file=sys.stderr)
750
+ return
751
+ transport = _Transport(dsn, opener=_opener, now=_now, log=_log)
752
+ _state = _State(
753
+ dsn,
754
+ release,
755
+ environment,
756
+ list(allow_fields or []),
757
+ transport,
758
+ device=_detect_device() if send_device_info else None,
759
+ include_local_variables=bool(include_local_variables),
760
+ )
761
+ _state._inline_scrub_rules = list(scrub_rules or [])
762
+ if scrub_rules:
763
+ _state.custom_scrub = compile_custom_scrub_rules(scrub_rules)
764
+ if capture_unhandled:
765
+ _install_excepthook()
766
+ if fetch_scrub_rules:
767
+ thread = threading.Thread(
768
+ target=_fetch_scrub_rules, args=(_state, _rules_fetcher or _http_get), daemon=True
769
+ )
770
+ thread.start()
771
+ except Exception as failure:
772
+ try:
773
+ print("[realuptime-errors] init failed: %r" % (failure,), file=sys.stderr)
774
+ except Exception:
775
+ pass
776
+
777
+
778
+ def _fetch_scrub_rules(for_state, fetcher):
779
+ """Best-effort, bounded, never raises, never blocks capture: an event
780
+ sent before the response lands gets the defaults plus inline rules here
781
+ and the project's rules at ingest."""
782
+ try:
783
+ url = scrub_rules_url_for(for_state.dsn)
784
+ if not url:
785
+ return
786
+ status, body = fetcher(url)
787
+ if status != 200:
788
+ return
789
+ parsed = json.loads(body.decode("utf-8") if isinstance(body, bytes) else body)
790
+ rules = parsed.get("rules") if isinstance(parsed, dict) else None
791
+ if not isinstance(rules, list):
792
+ return
793
+ # init may have been called again while this was in flight; only the
794
+ # state this fetch was started for is updated.
795
+ if _state is not for_state:
796
+ return
797
+ for_state.custom_scrub = compile_custom_scrub_rules(list(for_state._inline_scrub_rules) + rules)
798
+ except Exception:
799
+ # Fail open to the defaults. Silent by design; the server still
800
+ # applies them.
801
+ pass
802
+
803
+
804
+ def _scrub_for(state, event: dict) -> dict:
805
+ """Defaults first, always; the customer's additional rules on the result."""
806
+ return apply_custom_scrub_rules(scrub_event(event, state.allow_fields), state.custom_scrub)
807
+
808
+
809
+ def _install_excepthook():
810
+ global _hook_installed, _previous_excepthook
811
+ if _hook_installed:
812
+ return
813
+ _previous_excepthook = sys.excepthook
814
+
815
+ def hook(exc_type, exc, tb):
816
+ try:
817
+ capture_exception(exc)
818
+ flush()
819
+ except Exception:
820
+ pass
821
+ if _previous_excepthook:
822
+ _previous_excepthook(exc_type, exc, tb)
823
+
824
+ sys.excepthook = hook
825
+ _hook_installed = True
826
+
827
+
828
+ def add_breadcrumb(message, category=None, data=None):
829
+ """Records one breadcrumb onto the bounded trail (Phase 3). The trail
830
+ rides the NEXT captured event, newest last, at most
831
+ MAX_BREADCRUMBS_PER_EVENT; older entries are evicted and the eviction
832
+ count rides the event as breadcrumbsDropped, so a truncated trail is
833
+ never presented as the whole story. Never raises."""
834
+ try:
835
+ state = _state
836
+ if state is None or not isinstance(message, str):
837
+ return
838
+ crumb_data = None
839
+ if isinstance(data, dict):
840
+ crumb_data = {}
841
+ for name, value in list(data.items())[:MAX_BREADCRUMB_DATA_ENTRIES]:
842
+ if isinstance(value, str):
843
+ crumb_data[_clip(str(name), MAX_STRING_LENGTH)] = _clip(value, MAX_STRING_LENGTH)
844
+ now = state.transport._now()
845
+ crumb = {
846
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(now)) + ".000Z",
847
+ "category": _clip(category, 100) if isinstance(category, str) else None,
848
+ "message": _clip(message, MAX_STRING_LENGTH),
849
+ "data": crumb_data,
850
+ }
851
+ with state.breadcrumb_lock:
852
+ state.breadcrumbs.append(crumb)
853
+ if len(state.breadcrumbs) > MAX_BREADCRUMBS_PER_EVENT:
854
+ state.breadcrumbs.pop(0)
855
+ state.breadcrumbs_evicted += 1
856
+ except Exception:
857
+ pass
858
+
859
+
860
+ def set_user(user):
861
+ """v2 (REA-182): sets the sticky identity applied to every subsequent
862
+ event. None clears it (sign-out).
863
+
864
+ Only id / email / username are carried; anything else on the mapping is
865
+ ignored rather than forwarded, so handing this the whole user record
866
+ does not ship its address book. email and username are "[scrubbed]"
867
+ before serialization unless the matching allow_fields entry is set.
868
+ Never raises."""
869
+ try:
870
+ state = _state
871
+ if state is None:
872
+ return
873
+ if not isinstance(user, dict):
874
+ with state.scope_lock:
875
+ state.user = None
876
+ return
877
+ next_user = {}
878
+ for key in ("id", "email", "username"):
879
+ value = user.get(key)
880
+ if isinstance(value, str):
881
+ next_user[key] = _clip(value, MAX_STRING_LENGTH)
882
+ with state.scope_lock:
883
+ state.user = next_user or None
884
+ except Exception:
885
+ pass
886
+
887
+
888
+ def set_tag(key, value):
889
+ """v2: sets one sticky tag; a None value removes it. Tags past
890
+ MAX_TAGS_PER_EVENT are ignored rather than evicting an existing one --
891
+ silently replacing what the integrator set earlier is the worse
892
+ surprise. Never raises."""
893
+ try:
894
+ state = _state
895
+ if state is None or not isinstance(key, str) or not key:
896
+ return
897
+ name = _clip(key, MAX_CONTEXT_KEY_LENGTH)
898
+ with state.scope_lock:
899
+ if value is None:
900
+ state.tags.pop(name, None)
901
+ return
902
+ if not isinstance(value, str):
903
+ return
904
+ if name not in state.tags and len(state.tags) >= MAX_TAGS_PER_EVENT:
905
+ return
906
+ state.tags[name] = _clip(value, MAX_STRING_LENGTH)
907
+ except Exception:
908
+ pass
909
+
910
+
911
+ def set_tags(tags):
912
+ """v2: merges several sticky tags at once. Never raises."""
913
+ try:
914
+ if not isinstance(tags, dict):
915
+ return
916
+ for name, value in tags.items():
917
+ set_tag(name, value)
918
+ except Exception:
919
+ pass
920
+
921
+
922
+ def set_context(key, value=None):
923
+ """v2: sets one sticky custom-context entry, or merges a whole mapping:
924
+
925
+ set_context("tenant", "acme")
926
+ set_context({"tenant": "acme", "shard": "eu-1"})
927
+
928
+ A None value removes the named entry; set_context(None) clears the whole
929
+ context. Never raises."""
930
+ try:
931
+ state = _state
932
+ if state is None:
933
+ return
934
+ if key is None:
935
+ with state.scope_lock:
936
+ state.context = {}
937
+ return
938
+ if isinstance(key, dict):
939
+ for name, entry in key.items():
940
+ set_context(name, entry)
941
+ return
942
+ if not isinstance(key, str) or not key:
943
+ return
944
+ name = _clip(key, MAX_CONTEXT_KEY_LENGTH)
945
+ with state.scope_lock:
946
+ if value is None:
947
+ state.context.pop(name, None)
948
+ return
949
+ if not isinstance(value, str):
950
+ return
951
+ if name not in state.context and len(state.context) >= MAX_CONTEXT_ENTRIES:
952
+ return
953
+ state.context[name] = _clip(value, MAX_STRING_LENGTH)
954
+ except Exception:
955
+ pass
956
+
957
+
958
+ def _bounded_map(value, limit):
959
+ """Bounds a caller-supplied per-capture override the same way the sticky
960
+ setters bound the scope."""
961
+ out = {}
962
+ if not isinstance(value, dict):
963
+ return out
964
+ for name, entry in value.items():
965
+ if len(out) >= limit:
966
+ break
967
+ if not isinstance(name, str) or not name or not isinstance(entry, str):
968
+ continue
969
+ out[_clip(name, MAX_CONTEXT_KEY_LENGTH)] = _clip(entry, MAX_STRING_LENGTH)
970
+ return out
971
+
972
+
973
+ def _build_event(
974
+ message,
975
+ exception_type,
976
+ frames,
977
+ request=None,
978
+ fingerprint=None,
979
+ release=None,
980
+ environment=None,
981
+ user=None,
982
+ tags=None,
983
+ context=None,
984
+ ):
985
+ state = _state
986
+ now = state.transport._now() if state else time.time()
987
+ breadcrumbs = None
988
+ breadcrumbs_dropped = 0
989
+ scope_user = None
990
+ scope_tags = {}
991
+ scope_context = {}
992
+ device = None
993
+ if state is not None:
994
+ with state.breadcrumb_lock:
995
+ if state.breadcrumbs:
996
+ breadcrumbs = list(state.breadcrumbs)
997
+ breadcrumbs_dropped = state.breadcrumbs_evicted
998
+ with state.scope_lock:
999
+ scope_user = dict(state.user) if state.user else None
1000
+ scope_tags = dict(state.tags)
1001
+ scope_context = dict(state.context)
1002
+ device = dict(state.device) if state.device else None
1003
+ event = {
1004
+ "occurredAt": time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(now)) + ".000Z",
1005
+ "message": _clip(message, MAX_MESSAGE_LENGTH),
1006
+ "exceptionType": _clip(exception_type, MAX_STRING_LENGTH) if exception_type else None,
1007
+ "release": release or (state.release if state else None),
1008
+ "environment": environment or (state.environment if state else None),
1009
+ "frames": frames,
1010
+ "request": request,
1011
+ "fingerprint": fingerprint,
1012
+ "breadcrumbs": breadcrumbs,
1013
+ "breadcrumbsDropped": breadcrumbs_dropped,
1014
+ }
1015
+ # v2 fields are emitted only when NON-EMPTY, so an integration that
1016
+ # never touches the v2 API keeps producing byte-identical v1 payloads.
1017
+ merged_user = scope_user
1018
+ if isinstance(user, dict):
1019
+ merged_user = dict(scope_user or {})
1020
+ merged_user.update({k: v for k, v in user.items() if k in ("id", "email", "username")})
1021
+ if merged_user:
1022
+ event["user"] = merged_user
1023
+ merged_tags = dict(scope_tags)
1024
+ merged_tags.update(_bounded_map(tags, MAX_TAGS_PER_EVENT))
1025
+ if merged_tags:
1026
+ event["tags"] = merged_tags
1027
+ merged_context = dict(scope_context)
1028
+ merged_context.update(_bounded_map(context, MAX_CONTEXT_ENTRIES))
1029
+ if merged_context:
1030
+ event["context"] = merged_context
1031
+ if device:
1032
+ event["device"] = device
1033
+ return event
1034
+
1035
+
1036
+ def capture_exception(exc, request=None, fingerprint=None, release=None, environment=None, user=None, tags=None, context=None):
1037
+ """Reports an exception. Never raises.
1038
+
1039
+ v2: user / tags / context are per-capture overrides merged OVER the
1040
+ sticky scope set by set_user / set_tag / set_context."""
1041
+ try:
1042
+ state = _state
1043
+ if state is None:
1044
+ return
1045
+ extra = {"user": user, "tags": tags, "context": context}
1046
+ if isinstance(exc, BaseException):
1047
+ event = _build_event(
1048
+ str(exc) or type(exc).__name__,
1049
+ type(exc).__name__,
1050
+ _frames_from_exception(exc, include_locals=state.include_local_variables),
1051
+ request=request,
1052
+ fingerprint=fingerprint,
1053
+ release=release,
1054
+ environment=environment,
1055
+ **extra,
1056
+ )
1057
+ else:
1058
+ event = _build_event(
1059
+ str(exc), None, None, request=request, fingerprint=fingerprint, release=release, environment=environment, **extra
1060
+ )
1061
+ state.transport.enqueue(_scrub_for(state, event))
1062
+ except Exception as failure:
1063
+ try:
1064
+ print("[realuptime-errors] capture_exception failed: %r" % (failure,), file=sys.stderr)
1065
+ except Exception:
1066
+ pass
1067
+
1068
+
1069
+ def capture_message(message, request=None, fingerprint=None, release=None, environment=None, user=None, tags=None, context=None):
1070
+ """Reports a plain message. Never raises."""
1071
+ try:
1072
+ state = _state
1073
+ if state is None:
1074
+ return
1075
+ event = _build_event(
1076
+ str(message),
1077
+ None,
1078
+ None,
1079
+ request=request,
1080
+ fingerprint=fingerprint,
1081
+ release=release,
1082
+ environment=environment,
1083
+ user=user,
1084
+ tags=tags,
1085
+ context=context,
1086
+ )
1087
+ state.transport.enqueue(_scrub_for(state, event))
1088
+ except Exception:
1089
+ pass
1090
+
1091
+
1092
+ def flush():
1093
+ """Delivers anything buffered. Never raises."""
1094
+ try:
1095
+ if _state is not None:
1096
+ _state.transport.flush()
1097
+ except Exception:
1098
+ pass
1099
+
1100
+
1101
+ def _close():
1102
+ """Test seam: drops state and restores the excepthook."""
1103
+ global _state, _hook_installed
1104
+ _state = None
1105
+ if _hook_installed and _previous_excepthook is not None:
1106
+ sys.excepthook = _previous_excepthook
1107
+ _hook_installed = False
1108
+
1109
+
1110
+ class WsgiMiddleware:
1111
+ """Captures unhandled exceptions from a WSGI app, with the default
1112
+ minimal request context (method, path; status is unknown mid-flight and
1113
+ omitted rather than guessed). Re-raises: the middleware reports, the
1114
+ server keeps deciding what a 500 looks like."""
1115
+
1116
+ def __init__(self, app):
1117
+ self._app = app
1118
+
1119
+ def __call__(self, environ, start_response):
1120
+ try:
1121
+ return self._app(environ, start_response)
1122
+ except Exception as exc:
1123
+ try:
1124
+ request = {
1125
+ "method": environ.get("REQUEST_METHOD"),
1126
+ "path": environ.get("PATH_INFO"),
1127
+ }
1128
+ capture_exception(exc, request=request)
1129
+ except Exception:
1130
+ pass
1131
+ raise
1132
+
1133
+
1134
+ class AsgiMiddleware:
1135
+ """ASGI counterpart of WsgiMiddleware, http scope only."""
1136
+
1137
+ def __init__(self, app):
1138
+ self._app = app
1139
+
1140
+ async def __call__(self, scope, receive, send):
1141
+ try:
1142
+ await self._app(scope, receive, send)
1143
+ except Exception as exc:
1144
+ try:
1145
+ if scope.get("type") == "http":
1146
+ request = {"method": scope.get("method"), "path": scope.get("path")}
1147
+ capture_exception(exc, request=request)
1148
+ else:
1149
+ capture_exception(exc)
1150
+ except Exception:
1151
+ pass
1152
+ raise