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,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
|