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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: realuptime-errors
3
- Version: 0.4.0
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 `packages/errors-py` in the
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.0` in `realuptime_errors.py`, the
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 `packages/errors-py` in the
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.0` in `realuptime_errors.py`, the
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.0"
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.0
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 `packages/errors-py` in the
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.0` in `realuptime_errors.py`, the
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/errors-plan.md, Linear REA-57).
2
+ (the internal-docs repo's errors-plan.md, Linear REA-57).
3
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
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.0"
41
+ SDK_VERSION = "0.4.1" # x-release-please-version
40
42
 
41
- # Mirrored from packages/errors-js/types.ts and pinned by
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 packages/errors-js/types.ts.
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 five pattern rules, in the shared order (see scrub-vectors.json)."""
108
- out = _JWT_RE.sub(SCRUBBED, value)
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 for name, value in data.items()
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; 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."""
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 {name: scrub_string(entry) if isinstance(entry, str) else entry for name, entry in value.items()}
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 packages/errors-js/custom-scrub.ts;
220
- # packages/errors-js/custom-scrub-vectors.json pins the three to identical
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
- packages/errors-js/client.ts: over_quota pauses until the reset instant,
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, 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."""
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
- print("[realuptime-errors] init called without a dsn; error reporting is disabled.", file=sys.stderr)
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
- thread = threading.Thread(
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 excepthook."""
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 packages/errors-js/client.test.ts."""
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/i/rue_a", capture_unhandled=False, _opener=exploding_opener, _log=lambda m: None
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/i/rue_a", capture_unhandled=False, _opener=opener, _log=lambda m: None)
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/i/rue_a", capture_unhandled=False, _opener=opener, _log=lambda m: None)
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/i/rue_a", capture_unhandled=False, _opener=opener, _log=lambda m: None)
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/i/rue_a", capture_unhandled=False, _opener=opener, _log=lambda m: None)
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 (packages/errors-js/scrub-vectors.json) is the single
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 packages/errors-js/types.ts, pinned on both sides.
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 packages/errors-js/types.ts and pinned
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)