realuptime-errors 0.4.0__tar.gz → 0.4.1__tar.gz
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.
- {realuptime_errors-0.4.0/realuptime_errors.egg-info → realuptime_errors-0.4.1}/PKG-INFO +3 -3
- {realuptime_errors-0.4.0 → realuptime_errors-0.4.1}/README.md +2 -2
- {realuptime_errors-0.4.0 → realuptime_errors-0.4.1}/pyproject.toml +1 -1
- {realuptime_errors-0.4.0 → realuptime_errors-0.4.1/realuptime_errors.egg-info}/PKG-INFO +3 -3
- {realuptime_errors-0.4.0 → realuptime_errors-0.4.1}/realuptime_errors.py +230 -29
- {realuptime_errors-0.4.0 → realuptime_errors-0.4.1}/tests/test_client.py +5 -5
- {realuptime_errors-0.4.0 → realuptime_errors-0.4.1}/tests/test_middleware.py +1 -1
- {realuptime_errors-0.4.0 → realuptime_errors-0.4.1}/tests/test_scrub.py +14 -1
- {realuptime_errors-0.4.0 → realuptime_errors-0.4.1}/tests/test_wire_contract.py +4 -2
- {realuptime_errors-0.4.0 → realuptime_errors-0.4.1}/LICENSE +0 -0
- {realuptime_errors-0.4.0 → realuptime_errors-0.4.1}/realuptime_errors.egg-info/SOURCES.txt +0 -0
- {realuptime_errors-0.4.0 → realuptime_errors-0.4.1}/realuptime_errors.egg-info/dependency_links.txt +0 -0
- {realuptime_errors-0.4.0 → realuptime_errors-0.4.1}/realuptime_errors.egg-info/top_level.txt +0 -0
- {realuptime_errors-0.4.0 → realuptime_errors-0.4.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: realuptime-errors
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Zero-dependency error tracking SDK for Python (WSGI/ASGI), part of RealUptime Errors.
|
|
5
5
|
Author-email: RealUptime <support@realuptime.io>
|
|
6
6
|
License: MIT
|
|
@@ -18,7 +18,7 @@ Zero-dependency error tracking SDK for Python (WSGI/ASGI middleware plus
|
|
|
18
18
|
manual capture): part of
|
|
19
19
|
[RealUptime Errors](https://realuptime.io/error-tracking).
|
|
20
20
|
|
|
21
|
-
This is a **mirror**. It is published from `
|
|
21
|
+
This is a **mirror**. It is published from `errors-py` in the
|
|
22
22
|
[realuptime monorepo](https://github.com/RealUptimeHQ/realuptime) by
|
|
23
23
|
`scripts/publish-sdk-mirrors.mjs` and is not edited directly; open issues
|
|
24
24
|
and PRs against this repo, but expect source changes to land here after
|
|
@@ -88,7 +88,7 @@ ASGI: `app = realuptime_errors.AsgiMiddleware(app)`
|
|
|
88
88
|
|
|
89
89
|
## Version
|
|
90
90
|
|
|
91
|
-
This mirror tracks SDK_VERSION `0.4.
|
|
91
|
+
This mirror tracks SDK_VERSION `0.4.1` in `realuptime_errors.py`, the
|
|
92
92
|
string every event actually carries on the wire.
|
|
93
93
|
|
|
94
94
|
## License
|
|
@@ -4,7 +4,7 @@ Zero-dependency error tracking SDK for Python (WSGI/ASGI middleware plus
|
|
|
4
4
|
manual capture): part of
|
|
5
5
|
[RealUptime Errors](https://realuptime.io/error-tracking).
|
|
6
6
|
|
|
7
|
-
This is a **mirror**. It is published from `
|
|
7
|
+
This is a **mirror**. It is published from `errors-py` in the
|
|
8
8
|
[realuptime monorepo](https://github.com/RealUptimeHQ/realuptime) by
|
|
9
9
|
`scripts/publish-sdk-mirrors.mjs` and is not edited directly; open issues
|
|
10
10
|
and PRs against this repo, but expect source changes to land here after
|
|
@@ -74,7 +74,7 @@ ASGI: `app = realuptime_errors.AsgiMiddleware(app)`
|
|
|
74
74
|
|
|
75
75
|
## Version
|
|
76
76
|
|
|
77
|
-
This mirror tracks SDK_VERSION `0.4.
|
|
77
|
+
This mirror tracks SDK_VERSION `0.4.1` in `realuptime_errors.py`, the
|
|
78
78
|
string every event actually carries on the wire.
|
|
79
79
|
|
|
80
80
|
## License
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "realuptime-errors"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.1"
|
|
8
8
|
description = "Zero-dependency error tracking SDK for Python (WSGI/ASGI), part of RealUptime Errors."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: realuptime-errors
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Zero-dependency error tracking SDK for Python (WSGI/ASGI), part of RealUptime Errors.
|
|
5
5
|
Author-email: RealUptime <support@realuptime.io>
|
|
6
6
|
License: MIT
|
|
@@ -18,7 +18,7 @@ Zero-dependency error tracking SDK for Python (WSGI/ASGI middleware plus
|
|
|
18
18
|
manual capture): part of
|
|
19
19
|
[RealUptime Errors](https://realuptime.io/error-tracking).
|
|
20
20
|
|
|
21
|
-
This is a **mirror**. It is published from `
|
|
21
|
+
This is a **mirror**. It is published from `errors-py` in the
|
|
22
22
|
[realuptime monorepo](https://github.com/RealUptimeHQ/realuptime) by
|
|
23
23
|
`scripts/publish-sdk-mirrors.mjs` and is not edited directly; open issues
|
|
24
24
|
and PRs against this repo, but expect source changes to land here after
|
|
@@ -88,7 +88,7 @@ ASGI: `app = realuptime_errors.AsgiMiddleware(app)`
|
|
|
88
88
|
|
|
89
89
|
## Version
|
|
90
90
|
|
|
91
|
-
This mirror tracks SDK_VERSION `0.4.
|
|
91
|
+
This mirror tracks SDK_VERSION `0.4.1` in `realuptime_errors.py`, the
|
|
92
92
|
string every event actually carries on the wire.
|
|
93
93
|
|
|
94
94
|
## License
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""realuptime-errors: minimal Python error tracking SDK, Phase 1
|
|
2
|
-
(docs
|
|
2
|
+
(the internal-docs repo's errors-plan.md, Linear REA-57).
|
|
3
3
|
|
|
4
|
-
One wire contract with the JS SDK (
|
|
5
|
-
scrub vectors (
|
|
4
|
+
One wire contract with the JS SDK (errors-js/types.ts); the shared
|
|
5
|
+
scrub vectors (errors-js/scrub-vectors.json) pin both SDKs and the
|
|
6
6
|
server's second net to byte-identical scrubbing. Python 3 stdlib only, no
|
|
7
7
|
dependencies, same rule as deploy/vps: this file runs inside customer
|
|
8
8
|
processes and its dependency graph must be auditable at a glance.
|
|
@@ -14,7 +14,7 @@ that crashes the app it watches is worse than none.
|
|
|
14
14
|
Usage:
|
|
15
15
|
|
|
16
16
|
import realuptime_errors
|
|
17
|
-
realuptime_errors.init(dsn="https://realuptime.io/api/errors/v1/ingest/rue_...",
|
|
17
|
+
realuptime_errors.init(dsn="https://ingest.realuptime.io/api/errors/v1/ingest/rue_...",
|
|
18
18
|
release="v2.4.1", environment="production")
|
|
19
19
|
...
|
|
20
20
|
realuptime_errors.capture_exception(exc)
|
|
@@ -25,6 +25,7 @@ ASGI: app = realuptime_errors.AsgiMiddleware(app)
|
|
|
25
25
|
|
|
26
26
|
from __future__ import annotations
|
|
27
27
|
|
|
28
|
+
import atexit
|
|
28
29
|
import json
|
|
29
30
|
import linecache
|
|
30
31
|
import platform
|
|
@@ -33,12 +34,13 @@ import sys
|
|
|
33
34
|
import threading
|
|
34
35
|
import time
|
|
35
36
|
import urllib.error
|
|
37
|
+
import urllib.parse
|
|
36
38
|
import urllib.request
|
|
37
39
|
|
|
38
40
|
SDK_NAME = "realuptime-errors-py"
|
|
39
|
-
SDK_VERSION = "0.4.
|
|
41
|
+
SDK_VERSION = "0.4.1" # x-release-please-version
|
|
40
42
|
|
|
41
|
-
# Mirrored from
|
|
43
|
+
# Mirrored from errors-js/types.ts and pinned by
|
|
42
44
|
# tests/test_wire_contract.py.
|
|
43
45
|
MAX_EVENTS_PER_BATCH = 50
|
|
44
46
|
MAX_MESSAGE_LENGTH = 4000
|
|
@@ -48,7 +50,7 @@ MAX_BREADCRUMBS_PER_EVENT = 20
|
|
|
48
50
|
MAX_BREADCRUMB_DATA_ENTRIES = 10
|
|
49
51
|
BUFFER_MAX = 200
|
|
50
52
|
|
|
51
|
-
# v2 caps (REA-182), mirrored from
|
|
53
|
+
# v2 caps (REA-182), mirrored from errors-js/types.ts.
|
|
52
54
|
MAX_TAGS_PER_EVENT = 20
|
|
53
55
|
MAX_CONTEXT_ENTRIES = 20
|
|
54
56
|
MAX_CONTEXT_KEY_LENGTH = 64
|
|
@@ -74,12 +76,54 @@ _REMOVED_HEADERS = {"authorization", "proxy-authorization", "cookie", "set-cooki
|
|
|
74
76
|
# many users hit this" answerable, and it is not contact data.
|
|
75
77
|
_REMOVED_USER_FIELDS = ("user.email", "user.username")
|
|
76
78
|
|
|
79
|
+
_URL_CREDS_RE = re.compile(r"\b([a-zA-Z][a-zA-Z0-9+.-]{0,31})://[^\s/@:]+:[^\s/@]+@")
|
|
77
80
|
_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,}")
|
|
81
|
+
_PREFIXED_KEY_RE = re.compile(r"\b(?:sk|pk|rk|ghp|gho|ghs|xox[a-z]|rua|rue|ru_live|whsec)_[A-Za-z0-9_-]{8,}")
|
|
82
|
+
_AWS_KEY_RE = re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")
|
|
79
83
|
_CARD_RE = re.compile(r"(?<!\d)(?:\d[ -]?){12,18}\d(?!\d)")
|
|
80
84
|
_HEX_RE = re.compile(r"\b[0-9a-fA-F]{32,}\b")
|
|
81
85
|
_BASE64_RE = re.compile(r"(?<![A-Za-z0-9+_=-])[A-Za-z0-9+_-]{40,}={0,2}")
|
|
82
86
|
|
|
87
|
+
# Key-name denylist (REA-474): see errors-js/scrub.ts's module doc
|
|
88
|
+
# for the full rationale. Matched against whole camelCase/snake_case
|
|
89
|
+
# segments (and contiguous joins of them) so "tokenizer" and "authentic"
|
|
90
|
+
# never match "token" / "auth".
|
|
91
|
+
_KEY_DENYLIST = frozenset(
|
|
92
|
+
{
|
|
93
|
+
"password",
|
|
94
|
+
"passwd",
|
|
95
|
+
"secret",
|
|
96
|
+
"token",
|
|
97
|
+
"apikey",
|
|
98
|
+
"auth",
|
|
99
|
+
"authorization",
|
|
100
|
+
"credential",
|
|
101
|
+
"credentials",
|
|
102
|
+
"privatekey",
|
|
103
|
+
"accesskey",
|
|
104
|
+
"session",
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
_CAMEL_BOUNDARY_RE = re.compile(r"([a-z0-9])([A-Z])")
|
|
109
|
+
_SEPARATOR_RE = re.compile(r"[^a-zA-Z0-9]+")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _key_segments(key: str) -> "list[str]":
|
|
113
|
+
spaced = _CAMEL_BOUNDARY_RE.sub(r"\1_\2", key)
|
|
114
|
+
return [s.lower() for s in _SEPARATOR_RE.split(spaced) if s]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def is_key_denylisted(key: str) -> bool:
|
|
118
|
+
segments = _key_segments(key)
|
|
119
|
+
for i in range(len(segments)):
|
|
120
|
+
joined = ""
|
|
121
|
+
for j in range(i, len(segments)):
|
|
122
|
+
joined += segments[j]
|
|
123
|
+
if joined in _KEY_DENYLIST:
|
|
124
|
+
return True
|
|
125
|
+
return False
|
|
126
|
+
|
|
83
127
|
|
|
84
128
|
def _luhn_valid(digits: str) -> bool:
|
|
85
129
|
total = 0
|
|
@@ -103,10 +147,16 @@ def _scrub_card(match: "re.Match[str]") -> str:
|
|
|
103
147
|
return run
|
|
104
148
|
|
|
105
149
|
|
|
150
|
+
def _scrub_url_creds(match: "re.Match[str]") -> str:
|
|
151
|
+
return "%s://%s@" % (match.group(1), SCRUBBED)
|
|
152
|
+
|
|
153
|
+
|
|
106
154
|
def scrub_string(value: str) -> str:
|
|
107
|
-
"""The
|
|
108
|
-
out =
|
|
155
|
+
"""The pattern rules, in the shared order (see scrub-vectors.json)."""
|
|
156
|
+
out = _URL_CREDS_RE.sub(_scrub_url_creds, value)
|
|
157
|
+
out = _JWT_RE.sub(SCRUBBED, out)
|
|
109
158
|
out = _PREFIXED_KEY_RE.sub(SCRUBBED, out)
|
|
159
|
+
out = _AWS_KEY_RE.sub(SCRUBBED, out)
|
|
110
160
|
out = _CARD_RE.sub(_scrub_card, out)
|
|
111
161
|
out = _HEX_RE.sub(SCRUBBED, out)
|
|
112
162
|
out = _BASE64_RE.sub(SCRUBBED, out)
|
|
@@ -120,19 +170,23 @@ def _scrub_breadcrumb(crumb: dict) -> dict:
|
|
|
120
170
|
data = out.get("data")
|
|
121
171
|
if isinstance(data, dict):
|
|
122
172
|
out["data"] = {
|
|
123
|
-
name: scrub_string(value) if isinstance(value, str) else value
|
|
173
|
+
name: (SCRUBBED if is_key_denylisted(name) else scrub_string(value)) if isinstance(value, str) else value
|
|
174
|
+
for name, value in data.items()
|
|
124
175
|
}
|
|
125
176
|
return out
|
|
126
177
|
|
|
127
178
|
|
|
128
179
|
def _scrub_string_map(value):
|
|
129
180
|
"""v2: a flat string map (tags, custom context, device, frame locals).
|
|
130
|
-
Values pass the pattern rules
|
|
131
|
-
|
|
132
|
-
|
|
181
|
+
Values pass the pattern rules unless the KEY itself is denylisted by
|
|
182
|
+
name (REA-474: password/secret/token/... -- see is_key_denylisted),
|
|
183
|
+
in which case the value is replaced whole regardless of shape."""
|
|
133
184
|
if not isinstance(value, dict):
|
|
134
185
|
return value
|
|
135
|
-
return {
|
|
186
|
+
return {
|
|
187
|
+
name: (SCRUBBED if is_key_denylisted(name) else scrub_string(entry)) if isinstance(entry, str) else entry
|
|
188
|
+
for name, entry in value.items()
|
|
189
|
+
}
|
|
136
190
|
|
|
137
191
|
|
|
138
192
|
def _scrub_user(user, allowed):
|
|
@@ -205,7 +259,7 @@ def scrub_event(event: dict, allow_fields: "list[str] | None" = None) -> dict:
|
|
|
205
259
|
scrubbed_headers = {}
|
|
206
260
|
for name, value in headers.items():
|
|
207
261
|
lower = name.lower()
|
|
208
|
-
if lower in _REMOVED_HEADERS and lower not in allowed:
|
|
262
|
+
if (lower in _REMOVED_HEADERS or is_key_denylisted(lower)) and lower not in allowed:
|
|
209
263
|
scrubbed_headers[name] = SCRUBBED
|
|
210
264
|
else:
|
|
211
265
|
scrubbed_headers[name] = scrub_string(value) if isinstance(value, str) else value
|
|
@@ -216,8 +270,8 @@ def scrub_event(event: dict, allow_fields: "list[str] | None" = None) -> dict:
|
|
|
216
270
|
|
|
217
271
|
# ---------------------------------------------------------------------------
|
|
218
272
|
# REA-264: customer-defined ADDITIONAL scrub rules. Mirrors
|
|
219
|
-
# packages/db/error-scrub-rules.ts and
|
|
220
|
-
#
|
|
273
|
+
# packages/db/error-scrub-rules.ts and errors-js/custom-scrub.ts;
|
|
274
|
+
# errors-js/custom-scrub-vectors.json pins the three to identical
|
|
221
275
|
# output (tests/test_custom_scrub.py). Applied AFTER scrub_event, never
|
|
222
276
|
# instead of it: nothing here can relax a default. Rules arrive from the
|
|
223
277
|
# project's settings via GET /api/errors/v1/scrub-rules/<key> at init, or
|
|
@@ -230,6 +284,15 @@ MAX_CUSTOM_SCRUB_PATTERN_LENGTH = 200
|
|
|
230
284
|
SCRUB_RULES_TIMEOUT_S = 5.0
|
|
231
285
|
_EMPTY_CUSTOM_SCRUB = {"names": frozenset(), "globs": (), "values": (), "count": 0}
|
|
232
286
|
|
|
287
|
+
# The single background thread that fetches the project's rules, and the two
|
|
288
|
+
# bounded waits that retire it. _close() uses the short one (it is called
|
|
289
|
+
# between tests, where a leftover thread compounds); the interpreter's exit
|
|
290
|
+
# hook uses the longer one, capped at a second, because a host process must
|
|
291
|
+
# never pay a visible shutdown delay for a best-effort fetch.
|
|
292
|
+
SCRUB_FETCH_THREAD_NAME = "realuptime-errors-scrub-rules"
|
|
293
|
+
SCRUB_FETCH_JOIN_S = 0.2
|
|
294
|
+
SCRUB_FETCH_EXIT_JOIN_S = min(SCRUB_RULES_TIMEOUT_S, 1.0)
|
|
295
|
+
|
|
233
296
|
|
|
234
297
|
def _glob_to_regex(glob: str) -> "re.Pattern[str]":
|
|
235
298
|
out = "^"
|
|
@@ -524,7 +587,7 @@ def _detect_device() -> "dict | None":
|
|
|
524
587
|
|
|
525
588
|
class _Transport:
|
|
526
589
|
"""Batched, buffered, backed-off delivery. Mirrors
|
|
527
|
-
|
|
590
|
+
errors-js/client.ts: over_quota pauses until the reset instant,
|
|
528
591
|
key_revoked disables for the process lifetime, transient failures back
|
|
529
592
|
off exponentially, and client-side drops ride the next successful batch
|
|
530
593
|
as droppedClient so they reach the product's visible drop counters."""
|
|
@@ -702,6 +765,35 @@ _state: "_State | None" = None
|
|
|
702
765
|
_hook_installed = False
|
|
703
766
|
_previous_excepthook = None
|
|
704
767
|
|
|
768
|
+
# The ingest route's path shape: /api/errors/v1/ingest/<key>, an optional
|
|
769
|
+
# trailing slash, nothing else. Mirrors errors-js/index.ts's
|
|
770
|
+
# INGEST_PATH_RE and the marker checks in scrub_rules_url_for above.
|
|
771
|
+
_INGEST_PATH_RE = re.compile(r"^/api/errors/v1/ingest/[^/?#]+/?$")
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _is_ingest_url(dsn) -> bool:
|
|
775
|
+
"""REA-575: a dsn must be the full ingest URL the server hands out at
|
|
776
|
+
project creation, not the bare rue_... key alone. Before this check,
|
|
777
|
+
init() accepted any non-empty string and handed it straight to the
|
|
778
|
+
transport as the fetch endpoint: a bare key is not a fetchable URL, so
|
|
779
|
+
every send failed silently inside the transport's own never-raise
|
|
780
|
+
handling, forever, with no signal anywhere."""
|
|
781
|
+
try:
|
|
782
|
+
parsed = urllib.parse.urlsplit(dsn)
|
|
783
|
+
except Exception:
|
|
784
|
+
return False
|
|
785
|
+
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
|
786
|
+
return False
|
|
787
|
+
return bool(_INGEST_PATH_RE.match(parsed.path))
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def is_enabled() -> bool:
|
|
791
|
+
"""Whether error reporting is actually on: init() was called with a dsn
|
|
792
|
+
that passed _is_ingest_url. A host process should log this once at
|
|
793
|
+
startup rather than assume that wiring init() into the code means
|
|
794
|
+
events are flowing."""
|
|
795
|
+
return _state is not None
|
|
796
|
+
|
|
705
797
|
|
|
706
798
|
def init(
|
|
707
799
|
dsn,
|
|
@@ -739,14 +831,27 @@ def init(
|
|
|
739
831
|
invalid one is skipped. Nothing relaxes a
|
|
740
832
|
default.
|
|
741
833
|
fetch_scrub_rules fetch the project's additional rules (set in
|
|
742
|
-
realuptime Errors settings) once,
|
|
743
|
-
thread, and apply them
|
|
744
|
-
True. Fails open to
|
|
745
|
-
applies the same rules
|
|
834
|
+
realuptime Errors settings) once, on the SDK's
|
|
835
|
+
single background thread, and apply them
|
|
836
|
+
client-side too. Default True. Fails open to
|
|
837
|
+
the defaults; the server applies the same rules
|
|
838
|
+
at ingest regardless. The thread retires as
|
|
839
|
+
soon as the fetch lands, calling init() again
|
|
840
|
+
re-uses it rather than starting a second one,
|
|
841
|
+
and an interpreter-exit hook stops and joins it
|
|
842
|
+
for at most SCRUB_FETCH_EXIT_JOIN_S so process
|
|
843
|
+
shutdown is never delayed by more than that."""
|
|
746
844
|
global _state
|
|
747
845
|
try:
|
|
846
|
+
log_fn = _log or (lambda message: print(message, file=sys.stderr))
|
|
748
847
|
if not dsn or not isinstance(dsn, str):
|
|
749
|
-
|
|
848
|
+
log_fn("[realuptime-errors] init called without a dsn; error reporting is disabled.")
|
|
849
|
+
return
|
|
850
|
+
if not _is_ingest_url(dsn):
|
|
851
|
+
log_fn(
|
|
852
|
+
"[realuptime-errors] dsn must be the full ingest URL, got a bare key (or a malformed URL); "
|
|
853
|
+
"expected https://<host>/api/errors/v1/ingest/<key>. Error reporting is disabled."
|
|
854
|
+
)
|
|
750
855
|
return
|
|
751
856
|
transport = _Transport(dsn, opener=_opener, now=_now, log=_log)
|
|
752
857
|
_state = _State(
|
|
@@ -764,10 +869,7 @@ def init(
|
|
|
764
869
|
if capture_unhandled:
|
|
765
870
|
_install_excepthook()
|
|
766
871
|
if fetch_scrub_rules:
|
|
767
|
-
|
|
768
|
-
target=_fetch_scrub_rules, args=(_state, _rules_fetcher or _http_get), daemon=True
|
|
769
|
-
)
|
|
770
|
-
thread.start()
|
|
872
|
+
_start_scrub_fetch(_state, _rules_fetcher or _http_get)
|
|
771
873
|
except Exception as failure:
|
|
772
874
|
try:
|
|
773
875
|
print("[realuptime-errors] init failed: %r" % (failure,), file=sys.stderr)
|
|
@@ -775,6 +877,98 @@ def init(
|
|
|
775
877
|
pass
|
|
776
878
|
|
|
777
879
|
|
|
880
|
+
# ---------------------------------------------------------------------------
|
|
881
|
+
# Shutdown discipline: the scrub-rules fetch runs on ONE thread per process,
|
|
882
|
+
# and that thread is stopped and joined on _close() and at interpreter exit.
|
|
883
|
+
#
|
|
884
|
+
# Before this, every init() started its own daemon thread and _close() simply
|
|
885
|
+
# dropped the state, so a process that called init() repeatedly (a test suite
|
|
886
|
+
# is the obvious one, but so is any host that re-inits per worker or per
|
|
887
|
+
# config reload) finalized the interpreter with a pile of daemon threads
|
|
888
|
+
# parked inside urllib/ssl/getaddrinfo. CPython 3.11 kills daemon threads at
|
|
889
|
+
# an arbitrary point during finalization; killing several that are inside a
|
|
890
|
+
# C call segfaults the process AFTER the useful work is done. The RealUptime
|
|
891
|
+
# gate hit exactly that: 83 tests pass, then exit 139.
|
|
892
|
+
#
|
|
893
|
+
# Why the thread stays a DAEMON thread plus an atexit join, rather than
|
|
894
|
+
# becoming non-daemon: a non-daemon thread is joined by threading._shutdown
|
|
895
|
+
# with NO timeout, and this thread's worst case is not bounded by
|
|
896
|
+
# SCRUB_RULES_TIMEOUT_S. urlopen's timeout covers connect and read but not
|
|
897
|
+
# name resolution, and getaddrinfo against a dead resolver can block for tens
|
|
898
|
+
# of seconds. Making it non-daemon would trade a rare crash for a routine
|
|
899
|
+
# multi-second hang on every host process exit, so the thread stays a daemon
|
|
900
|
+
# and the atexit hook joins it for at most SCRUB_FETCH_EXIT_JOIN_S. Worst
|
|
901
|
+
# case that adds one second to process exit; the common case (the fetch has
|
|
902
|
+
# already finished and the thread has retired) adds nothing at all.
|
|
903
|
+
# ---------------------------------------------------------------------------
|
|
904
|
+
|
|
905
|
+
_scrub_fetch_lock = threading.Lock()
|
|
906
|
+
_scrub_fetch_thread: "threading.Thread | None" = None
|
|
907
|
+
_scrub_fetch_stop = threading.Event()
|
|
908
|
+
# At most one job waits here: a second init() replaces the pending fetch
|
|
909
|
+
# rather than queueing behind it, because only the newest state matters.
|
|
910
|
+
_scrub_fetch_job: "list" = []
|
|
911
|
+
_scrub_fetch_atexit_registered = False
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _start_scrub_fetch(for_state, fetcher) -> None:
|
|
915
|
+
"""Hands the fetch to the process's one fetch thread, starting it if it
|
|
916
|
+
is not already running. Never raises."""
|
|
917
|
+
global _scrub_fetch_thread, _scrub_fetch_atexit_registered
|
|
918
|
+
try:
|
|
919
|
+
with _scrub_fetch_lock:
|
|
920
|
+
_scrub_fetch_stop.clear()
|
|
921
|
+
del _scrub_fetch_job[:]
|
|
922
|
+
_scrub_fetch_job.append((for_state, fetcher))
|
|
923
|
+
# A fetch already in flight picks this up when it returns. Never
|
|
924
|
+
# stack a second thread on top of a stuck one.
|
|
925
|
+
if _scrub_fetch_thread is not None and _scrub_fetch_thread.is_alive():
|
|
926
|
+
return
|
|
927
|
+
if not _scrub_fetch_atexit_registered:
|
|
928
|
+
atexit.register(_stop_scrub_fetch, SCRUB_FETCH_EXIT_JOIN_S)
|
|
929
|
+
_scrub_fetch_atexit_registered = True
|
|
930
|
+
_scrub_fetch_thread = threading.Thread(
|
|
931
|
+
target=_scrub_fetch_worker, name=SCRUB_FETCH_THREAD_NAME, daemon=True
|
|
932
|
+
)
|
|
933
|
+
_scrub_fetch_thread.start()
|
|
934
|
+
except Exception:
|
|
935
|
+
# init() never raises, and reporting still works on the defaults.
|
|
936
|
+
pass
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def _scrub_fetch_worker() -> None:
|
|
940
|
+
"""Drains the pending fetch, then retires. Nothing idles: a process that
|
|
941
|
+
inits once ends up with no SDK thread at all once the rules land."""
|
|
942
|
+
global _scrub_fetch_thread
|
|
943
|
+
while True:
|
|
944
|
+
with _scrub_fetch_lock:
|
|
945
|
+
if _scrub_fetch_stop.is_set() or not _scrub_fetch_job:
|
|
946
|
+
if _scrub_fetch_thread is threading.current_thread():
|
|
947
|
+
_scrub_fetch_thread = None
|
|
948
|
+
return
|
|
949
|
+
for_state, fetcher = _scrub_fetch_job.pop()
|
|
950
|
+
del _scrub_fetch_job[:]
|
|
951
|
+
_fetch_scrub_rules(for_state, fetcher)
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def _stop_scrub_fetch(join_timeout_s: float) -> None:
|
|
955
|
+
"""Tells the fetch thread to drop its result and waits a bounded time for
|
|
956
|
+
it to end. A thread still inside a socket call cannot be interrupted, so
|
|
957
|
+
this is a bounded wait, not a guarantee; what it does guarantee is that
|
|
958
|
+
the result is discarded and that no second thread is ever started behind
|
|
959
|
+
this one. Never raises."""
|
|
960
|
+
try:
|
|
961
|
+
with _scrub_fetch_lock:
|
|
962
|
+
del _scrub_fetch_job[:]
|
|
963
|
+
_scrub_fetch_stop.set()
|
|
964
|
+
thread = _scrub_fetch_thread
|
|
965
|
+
# Joined OUTSIDE the lock: the worker needs it to retire.
|
|
966
|
+
if thread is not None and thread is not threading.current_thread():
|
|
967
|
+
thread.join(join_timeout_s)
|
|
968
|
+
except Exception:
|
|
969
|
+
pass
|
|
970
|
+
|
|
971
|
+
|
|
778
972
|
def _fetch_scrub_rules(for_state, fetcher):
|
|
779
973
|
"""Best-effort, bounded, never raises, never blocks capture: an event
|
|
780
974
|
sent before the response lands gets the defaults plus inline rules here
|
|
@@ -790,6 +984,10 @@ def _fetch_scrub_rules(for_state, fetcher):
|
|
|
790
984
|
rules = parsed.get("rules") if isinstance(parsed, dict) else None
|
|
791
985
|
if not isinstance(rules, list):
|
|
792
986
|
return
|
|
987
|
+
# _close() or the exit hook ran while this was in flight; the result
|
|
988
|
+
# belongs to a process that is done with us.
|
|
989
|
+
if _scrub_fetch_stop.is_set():
|
|
990
|
+
return
|
|
793
991
|
# init may have been called again while this was in flight; only the
|
|
794
992
|
# state this fetch was started for is updated.
|
|
795
993
|
if _state is not for_state:
|
|
@@ -1099,8 +1297,11 @@ def flush():
|
|
|
1099
1297
|
|
|
1100
1298
|
|
|
1101
1299
|
def _close():
|
|
1102
|
-
"""Test seam: drops state and restores the
|
|
1300
|
+
"""Test seam: drops state, stops the scrub-rules fetch, and restores the
|
|
1301
|
+
excepthook. The fetch join is what keeps repeated init()/_close() cycles
|
|
1302
|
+
from leaving live threads behind; see the shutdown note above."""
|
|
1103
1303
|
global _state, _hook_installed
|
|
1304
|
+
_stop_scrub_fetch(SCRUB_FETCH_JOIN_S)
|
|
1104
1305
|
_state = None
|
|
1105
1306
|
if _hook_installed and _previous_excepthook is not None:
|
|
1106
1307
|
sys.excepthook = _previous_excepthook
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Transport and public-API behavior, mirroring
|
|
1
|
+
"""Transport and public-API behavior, mirroring errors-js/client.test.ts."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
@@ -114,7 +114,7 @@ class TestPublicApiNeverRaises(unittest.TestCase):
|
|
|
114
114
|
raise RuntimeError("opener exploded")
|
|
115
115
|
|
|
116
116
|
realuptime_errors.init(
|
|
117
|
-
dsn="https://x/
|
|
117
|
+
dsn="https://x/api/errors/v1/ingest/rue_a", capture_unhandled=False, _opener=exploding_opener, _log=lambda m: None
|
|
118
118
|
)
|
|
119
119
|
try:
|
|
120
120
|
raise ValueError("boom")
|
|
@@ -129,7 +129,7 @@ class TestPublicApiNeverRaises(unittest.TestCase):
|
|
|
129
129
|
captured["batch"] = json.loads(body)
|
|
130
130
|
return 200, ok_body()
|
|
131
131
|
|
|
132
|
-
realuptime_errors.init(dsn="https://x/
|
|
132
|
+
realuptime_errors.init(dsn="https://x/api/errors/v1/ingest/rue_a", capture_unhandled=False, _opener=opener, _log=lambda m: None)
|
|
133
133
|
|
|
134
134
|
def inner():
|
|
135
135
|
raise ValueError("deep")
|
|
@@ -157,7 +157,7 @@ class TestBreadcrumbs(unittest.TestCase):
|
|
|
157
157
|
captured["batch"] = json.loads(body)
|
|
158
158
|
return 200, ok_body()
|
|
159
159
|
|
|
160
|
-
realuptime_errors.init(dsn="https://x/
|
|
160
|
+
realuptime_errors.init(dsn="https://x/api/errors/v1/ingest/rue_a", capture_unhandled=False, _opener=opener, _log=lambda m: None)
|
|
161
161
|
return captured
|
|
162
162
|
|
|
163
163
|
def test_trail_rides_next_event_with_eviction_count(self):
|
|
@@ -195,7 +195,7 @@ class TestSourceContext(unittest.TestCase):
|
|
|
195
195
|
captured["batch"] = json.loads(body)
|
|
196
196
|
return 200, ok_body()
|
|
197
197
|
|
|
198
|
-
realuptime_errors.init(dsn="https://x/
|
|
198
|
+
realuptime_errors.init(dsn="https://x/api/errors/v1/ingest/rue_a", capture_unhandled=False, _opener=opener, _log=lambda m: None)
|
|
199
199
|
|
|
200
200
|
def deep():
|
|
201
201
|
raise ValueError("ctx")
|
|
@@ -18,7 +18,7 @@ class TestMiddleware(unittest.TestCase):
|
|
|
18
18
|
self.batches.append(json.loads(body))
|
|
19
19
|
return 200, json.dumps({"accepted": 1, "droppedOverQuota": 0, "overQuota": False, "quota": {"used": 1, "limit": 10000, "resetsAt": "2026-09-01T00:00:00.000Z"}}).encode()
|
|
20
20
|
|
|
21
|
-
realuptime_errors.init(dsn="https://x/
|
|
21
|
+
realuptime_errors.init(dsn="https://x/api/errors/v1/ingest/rue_a", capture_unhandled=False, _opener=opener, _log=lambda m: None)
|
|
22
22
|
|
|
23
23
|
def tearDown(self):
|
|
24
24
|
realuptime_errors._close()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Drives the SHARED scrub vectors through the Python SDK.
|
|
2
2
|
|
|
3
|
-
The vectors file (
|
|
3
|
+
The vectors file (errors-js/scrub-vectors.json) is the single
|
|
4
4
|
source of truth for scrub semantics across the JS SDK, this SDK, and the
|
|
5
5
|
server's second net; this suite must never grow expectations of its own.
|
|
6
6
|
"""
|
|
@@ -63,3 +63,16 @@ class TestScrubVectors(unittest.TestCase):
|
|
|
63
63
|
|
|
64
64
|
if __name__ == "__main__":
|
|
65
65
|
unittest.main()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TestScrubLinearTime(unittest.TestCase):
|
|
69
|
+
"""Error messages often carry user input, and this runs in the
|
|
70
|
+
customer's own process: a pattern that backtracks on it stalls their app."""
|
|
71
|
+
|
|
72
|
+
def test_hostile_scheme_and_colon_run_is_fast(self):
|
|
73
|
+
import time
|
|
74
|
+
|
|
75
|
+
hostile = "a." * 300 + "://" + ":" * 2000
|
|
76
|
+
started = time.perf_counter()
|
|
77
|
+
realuptime_errors.scrub_string(hostile)
|
|
78
|
+
self.assertLess(time.perf_counter() - started, 0.25)
|
|
@@ -16,12 +16,14 @@ SOURCE = os.path.join(os.path.dirname(__file__), "..", "realuptime_errors.py")
|
|
|
16
16
|
# processes; its dependency graph must be auditable at a glance.
|
|
17
17
|
ALLOWED_IMPORTS = {
|
|
18
18
|
"__future__",
|
|
19
|
+
"atexit",
|
|
19
20
|
"json",
|
|
20
21
|
"re",
|
|
21
22
|
"sys",
|
|
22
23
|
"threading",
|
|
23
24
|
"time",
|
|
24
25
|
"urllib.error",
|
|
26
|
+
"urllib.parse",
|
|
25
27
|
"urllib.request",
|
|
26
28
|
"datetime",
|
|
27
29
|
"linecache",
|
|
@@ -45,7 +47,7 @@ class TestDependencyHygiene(unittest.TestCase):
|
|
|
45
47
|
|
|
46
48
|
class TestWireContractMirror(unittest.TestCase):
|
|
47
49
|
def test_limits_match_the_js_sdk(self):
|
|
48
|
-
# Mirrored from
|
|
50
|
+
# Mirrored from errors-js/types.ts, pinned on both sides.
|
|
49
51
|
self.assertEqual(realuptime_errors.MAX_EVENTS_PER_BATCH, 50)
|
|
50
52
|
self.assertEqual(realuptime_errors.MAX_MESSAGE_LENGTH, 4000)
|
|
51
53
|
self.assertEqual(realuptime_errors.MAX_FRAMES_PER_EVENT, 50)
|
|
@@ -55,7 +57,7 @@ class TestWireContractMirror(unittest.TestCase):
|
|
|
55
57
|
self.assertEqual(realuptime_errors.MAX_BREADCRUMB_DATA_ENTRIES, 10)
|
|
56
58
|
|
|
57
59
|
def test_v2_caps_match_the_js_sdk(self):
|
|
58
|
-
# REA-182, mirrored from
|
|
60
|
+
# REA-182, mirrored from errors-js/types.ts and pinned
|
|
59
61
|
# there too. The server's copies live in
|
|
60
62
|
# packages/db/error-ingest.ts.
|
|
61
63
|
self.assertEqual(realuptime_errors.MAX_TAGS_PER_EVENT, 20)
|
|
File without changes
|
|
File without changes
|
{realuptime_errors-0.4.0 → realuptime_errors-0.4.1}/realuptime_errors.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{realuptime_errors-0.4.0 → realuptime_errors-0.4.1}/realuptime_errors.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|