aperioflow 0.1.0__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.
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: aperioflow
3
+ Version: 0.1.0
4
+ Summary: Report errors from Python to AperioFlow. No dependencies.
5
+ License: MIT
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Requires-Python: >=3.8
9
+ Description-Content-Type: text/markdown
10
+
11
+ # AperioFlow for Python
12
+
13
+ Report errors from Python 3.8 or later to an AperioFlow project. No dependencies: events go to your project's ingest URL with
14
+ `urllib`.
15
+
16
+ Not on PyPI yet. Until it is, copy the `aperioflow` folder into your project.
17
+
18
+ ## Set up
19
+
20
+ Open the project in AperioFlow, choose **Set up reporting** and copy the ingest URL.
21
+
22
+ ```python
23
+ import aperioflow
24
+
25
+ aperioflow.init(
26
+ "https://app.aperioflow.co.uk/ingest/YOUR_KEY",
27
+ release="1.4.0", # optional, groups events by release and finds regressions
28
+ environment="production", # optional
29
+ )
30
+ ```
31
+
32
+ After `init`, uncaught exceptions are reported through `sys.excepthook`; the previous hook still runs, so the traceback is printed and
33
+ the program exits as before (`capture_uncaught=False` turns that off).
34
+
35
+ ## Report errors yourself
36
+
37
+ ```python
38
+ try:
39
+ charge_card(order)
40
+ except Exception as exc:
41
+ aperioflow.capture_exception(exc, fingerprint="card-charge", extra={"order_id": order.id})
42
+
43
+ aperioflow.capture_message("Queue is backing up", level="warning")
44
+ aperioflow.add_breadcrumb("Started nightly import", category="job")
45
+ aperioflow.set_tag("region", "eu-west-2")
46
+ aperioflow.set_user(id=user.id) # only the id is sent
47
+ ```
48
+
49
+ `capture_exception()` with no argument reports the exception being handled. Sends wait for the answer (5 seconds at most); pass
50
+ `background=True` to `init` to send on a thread, and call `aperioflow._client.flush()` before the program exits.
51
+
52
+ ## Options
53
+
54
+ | Option | What it does |
55
+ | --- | --- |
56
+ | `ingest_url` | Required. The project's ingest URL. |
57
+ | `release`, `environment` | Sent with every event. |
58
+ | `sample_rate` | 0 to 1 (default 1). The share of events sent. |
59
+ | `before_send(event)` | Change an event, or return `None` to drop it. |
60
+ | `capture_uncaught` | Default `True`. |
61
+ | `timeout`, `background` | How long one send may take (default 5 seconds), and whether to send on a thread. |
62
+
63
+ ## Privacy
64
+
65
+ - Nothing about the user, the machine or its IP address is collected on its own. A user id is sent only when you call `set_user`, and
66
+ only the id, whatever else you pass.
67
+ - Before an event leaves the process, values of keys that name a secret (password, token, cookie, authorization, api_key and the like),
68
+ Bearer and Basic credentials, email addresses and card numbers are replaced with `[redacted]` markers. The server scrubs again before
69
+ it stores anything.
70
+ - Breadcrumbs are limited to the latest 50, and the event's extra data to the server's 20 KB.
71
+ - A failed send never raises into your app.
72
+
73
+ ## Tests
74
+
75
+ ```bash
76
+ python3 -m unittest
77
+ ```
@@ -0,0 +1,67 @@
1
+ # AperioFlow for Python
2
+
3
+ Report errors from Python 3.8 or later to an AperioFlow project. No dependencies: events go to your project's ingest URL with
4
+ `urllib`.
5
+
6
+ Not on PyPI yet. Until it is, copy the `aperioflow` folder into your project.
7
+
8
+ ## Set up
9
+
10
+ Open the project in AperioFlow, choose **Set up reporting** and copy the ingest URL.
11
+
12
+ ```python
13
+ import aperioflow
14
+
15
+ aperioflow.init(
16
+ "https://app.aperioflow.co.uk/ingest/YOUR_KEY",
17
+ release="1.4.0", # optional, groups events by release and finds regressions
18
+ environment="production", # optional
19
+ )
20
+ ```
21
+
22
+ After `init`, uncaught exceptions are reported through `sys.excepthook`; the previous hook still runs, so the traceback is printed and
23
+ the program exits as before (`capture_uncaught=False` turns that off).
24
+
25
+ ## Report errors yourself
26
+
27
+ ```python
28
+ try:
29
+ charge_card(order)
30
+ except Exception as exc:
31
+ aperioflow.capture_exception(exc, fingerprint="card-charge", extra={"order_id": order.id})
32
+
33
+ aperioflow.capture_message("Queue is backing up", level="warning")
34
+ aperioflow.add_breadcrumb("Started nightly import", category="job")
35
+ aperioflow.set_tag("region", "eu-west-2")
36
+ aperioflow.set_user(id=user.id) # only the id is sent
37
+ ```
38
+
39
+ `capture_exception()` with no argument reports the exception being handled. Sends wait for the answer (5 seconds at most); pass
40
+ `background=True` to `init` to send on a thread, and call `aperioflow._client.flush()` before the program exits.
41
+
42
+ ## Options
43
+
44
+ | Option | What it does |
45
+ | --- | --- |
46
+ | `ingest_url` | Required. The project's ingest URL. |
47
+ | `release`, `environment` | Sent with every event. |
48
+ | `sample_rate` | 0 to 1 (default 1). The share of events sent. |
49
+ | `before_send(event)` | Change an event, or return `None` to drop it. |
50
+ | `capture_uncaught` | Default `True`. |
51
+ | `timeout`, `background` | How long one send may take (default 5 seconds), and whether to send on a thread. |
52
+
53
+ ## Privacy
54
+
55
+ - Nothing about the user, the machine or its IP address is collected on its own. A user id is sent only when you call `set_user`, and
56
+ only the id, whatever else you pass.
57
+ - Before an event leaves the process, values of keys that name a secret (password, token, cookie, authorization, api_key and the like),
58
+ Bearer and Basic credentials, email addresses and card numbers are replaced with `[redacted]` markers. The server scrubs again before
59
+ it stores anything.
60
+ - Breadcrumbs are limited to the latest 50, and the event's extra data to the server's 20 KB.
61
+ - A failed send never raises into your app.
62
+
63
+ ## Tests
64
+
65
+ ```bash
66
+ python3 -m unittest
67
+ ```
@@ -0,0 +1,264 @@
1
+ """AperioFlow error reporting for Python. No dependencies: it posts events to your project's ingest URL with urllib.
2
+
3
+ import aperioflow
4
+ aperioflow.init(ingest_url="https://app.aperioflow.co.uk/ingest/YOUR_KEY", release="1.4.0", environment="production")
5
+ try:
6
+ ...
7
+ except Exception as exc:
8
+ aperioflow.capture_exception(exc)
9
+
10
+ It never collects the IP address or anything about the user on its own: a user id is sent only when you call set_user(id=...).
11
+ Obvious secrets (password, token, cookie, authorization and similar keys, Bearer credentials, email addresses, card numbers) are
12
+ taken out before an event leaves the process; the server scrubs again before it stores anything.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import datetime as _dt
18
+ import json as _json
19
+ import random as _random
20
+ import re as _re
21
+ import sys as _sys
22
+ import threading as _threading
23
+ import traceback as _traceback
24
+ import urllib.request as _request
25
+
26
+ __all__ = [
27
+ "init", "close", "capture_exception", "capture_message", "add_breadcrumb", "set_user", "set_tag", "Client",
28
+ "scrub_text", "scrub_value", "is_secret_key", "MAX_BREADCRUMBS",
29
+ ]
30
+
31
+ SDK_NAME = "aperioflow.python"
32
+ SDK_VERSION = "0.1.0"
33
+ MAX_BREADCRUMBS = 50
34
+ _MAX_MESSAGE = 10000
35
+ _MAX_STACK = 50000
36
+ _MAX_CONTEXT_BYTES = 20000
37
+ _LEVELS = {"fatal", "error", "warning", "info"}
38
+
39
+ # ---------- client-side scrubbing of obvious secrets ----------
40
+
41
+ _SECRET_PARTS = ("password", "passwd", "secret", "token", "authorization", "cookie", "apikey", "privatekey", "sessionid", "csrf")
42
+ _SECRET_EXACT = {"auth", "session", "pin", "otp", "jwt"}
43
+ _PAIR = _re.compile(r"([A-Za-z0-9_.-]{1,100})=([^&\s#;\"',]+)")
44
+ _AUTH = _re.compile(r"\b(Bearer|Basic|Token)(\s+)[A-Za-z0-9._~+/=-]{8,}", _re.IGNORECASE)
45
+ _EMAIL = _re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}")
46
+ _CARD = _re.compile(r"(?<![\d.])[2-6](?:[ -]?\d){12,18}(?![\d.])")
47
+
48
+
49
+ def is_secret_key(key) -> bool:
50
+ k = _re.sub(r"[-_\s]", "", str(key).lower())
51
+ return k in _SECRET_EXACT or any(part in k for part in _SECRET_PARTS)
52
+
53
+
54
+ def _luhn(digits: str) -> bool:
55
+ total = 0
56
+ for i, ch in enumerate(reversed(digits)):
57
+ d = int(ch)
58
+ if i % 2:
59
+ d = d * 2 - 9 if d * 2 > 9 else d * 2
60
+ total += d
61
+ return total % 10 == 0
62
+
63
+
64
+ def scrub_text(text):
65
+ if not isinstance(text, str):
66
+ return text
67
+ text = _PAIR.sub(lambda m: f"{m.group(1)}=[redacted]" if is_secret_key(m.group(1)) else m.group(0), text)
68
+ text = _AUTH.sub(lambda m: f"{m.group(1)}{m.group(2)}[redacted token]", text)
69
+ text = _EMAIL.sub("[redacted email]", text)
70
+ return _CARD.sub(lambda m: "[redacted card]" if _luhn(_re.sub(r"\D", "", m.group(0))) else m.group(0), text)
71
+
72
+
73
+ def scrub_value(value, depth: int = 0):
74
+ if isinstance(value, str):
75
+ return scrub_text(value)
76
+ if depth > 10:
77
+ return "[redacted]"
78
+ if isinstance(value, dict):
79
+ return {
80
+ k: ("[redacted]" if is_secret_key(k) and v not in (None, "") else scrub_value(v, depth + 1))
81
+ for k, v in value.items()
82
+ }
83
+ if isinstance(value, (list, tuple)):
84
+ return [scrub_value(v, depth + 1) for v in value]
85
+ return value
86
+
87
+
88
+ def _clip(value, limit):
89
+ return value[:limit] if isinstance(value, str) and len(value) > limit else value
90
+
91
+
92
+ def _http_transport(timeout: float):
93
+ def send(url: str, event: dict) -> None:
94
+ data = _json.dumps(event, default=str).encode("utf-8")
95
+ req = _request.Request(url, data=data, method="POST", headers={"Content-Type": "application/json"})
96
+ with _request.urlopen(req, timeout=timeout) as res: # noqa: S310 - the URL is the project's own ingest address
97
+ res.read()
98
+
99
+ return send
100
+
101
+
102
+ class Client:
103
+ """A client with its own settings. Most apps use the module functions, which share one client."""
104
+
105
+ def __init__(self, ingest_url=None, release=None, environment=None, sample_rate=1.0, before_send=None,
106
+ platform="python", transport=None, timeout=5.0, rand=None, background=False):
107
+ self.ingest_url = ingest_url
108
+ self.release = release
109
+ self.environment = environment
110
+ self.sample_rate = min(1.0, max(0.0, float(sample_rate)))
111
+ self.before_send = before_send
112
+ self.platform = platform
113
+ self.transport = transport or _http_transport(timeout)
114
+ self.rand = rand or _random.random
115
+ self.background = background
116
+ self._breadcrumbs: list = []
117
+ self._user = None
118
+ self._tags: dict = {}
119
+ self._lock = _threading.Lock()
120
+ self._threads: list = []
121
+
122
+ def add_breadcrumb(self, message=None, category=None, level=None, data=None):
123
+ crumb = scrub_value({"category": category, "message": _clip(message, 500), "level": level, "data": data})
124
+ crumb = {"timestamp": _dt.datetime.now(_dt.timezone.utc).isoformat(), **{k: v for k, v in crumb.items() if v is not None}}
125
+ with self._lock:
126
+ self._breadcrumbs.append(crumb)
127
+ del self._breadcrumbs[:-MAX_BREADCRUMBS]
128
+
129
+ def set_user(self, id=None, **_ignored): # noqa: A002 - "id" is the public name
130
+ """Only an id is kept: no name, email or IP address, whatever else is passed."""
131
+ self._user = {"id": str(id)[:200]} if id is not None else None
132
+
133
+ def set_tag(self, key, value):
134
+ if isinstance(key, str) and key:
135
+ self._tags[key[:100]] = str(value)[:200]
136
+
137
+ def build(self, base: dict, level=None, fingerprint=None, url=None, extra=None) -> dict:
138
+ context = {}
139
+ with self._lock:
140
+ if self._breadcrumbs:
141
+ context["breadcrumbs"] = list(self._breadcrumbs)
142
+ if self._user:
143
+ context["user"] = dict(self._user)
144
+ if self._tags:
145
+ context["tags"] = dict(self._tags)
146
+ if isinstance(extra, dict):
147
+ context["extra"] = extra
148
+ event = dict(base)
149
+ event["level"] = level if level in _LEVELS else base.get("level", "error")
150
+ event["platform"] = self.platform
151
+ if self.release:
152
+ event["release"] = str(self.release)[:200]
153
+ if self.environment:
154
+ event["environment"] = str(self.environment)[:100]
155
+ if fingerprint:
156
+ event["fingerprint"] = str(fingerprint)[:200]
157
+ if url:
158
+ event["url"] = _re.split(r"[?#]", str(url))[0][:2000]
159
+ event["message"] = _clip(scrub_text(event.get("message") or ""), _MAX_MESSAGE) or "(no message)"
160
+ if event.get("stack"):
161
+ event["stack"] = _clip(scrub_text(event["stack"]), _MAX_STACK)
162
+ if context:
163
+ context = scrub_value(context)
164
+ # Keep within the server's 20 KB: the oldest breadcrumbs go first, then the extra data.
165
+ while len(_json.dumps(context, default=str)) > _MAX_CONTEXT_BYTES and context.get("breadcrumbs"):
166
+ context["breadcrumbs"].pop(0)
167
+ if len(_json.dumps(context, default=str)) > _MAX_CONTEXT_BYTES:
168
+ context.pop("extra", None)
169
+ event["context"] = context
170
+ return event
171
+
172
+ def _send(self, event: dict) -> bool:
173
+ if not self.ingest_url or self.rand() >= self.sample_rate:
174
+ return False
175
+ if self.before_send:
176
+ try:
177
+ event = self.before_send(event)
178
+ except Exception: # noqa: BLE001 - reporting must never break the app
179
+ return False
180
+ if not event:
181
+ return False
182
+
183
+ def deliver():
184
+ try:
185
+ self.transport(self.ingest_url, event)
186
+ return True
187
+ except Exception: # noqa: BLE001 - reporting must never break the app
188
+ return False
189
+
190
+ if self.background:
191
+ thread = _threading.Thread(target=deliver, daemon=True)
192
+ thread.start()
193
+ self._threads.append(thread)
194
+ return True
195
+ return deliver()
196
+
197
+ def capture_exception(self, exc=None, level=None, fingerprint=None, url=None, extra=None) -> bool:
198
+ if exc is None:
199
+ exc = _sys.exc_info()[1]
200
+ if exc is None:
201
+ return False
202
+ stack = "".join(_traceback.format_exception(type(exc), exc, exc.__traceback__))
203
+ base = {"type": type(exc).__name__, "message": str(exc) or type(exc).__name__, "stack": stack}
204
+ return self._send(self.build(base, level=level, fingerprint=fingerprint, url=url, extra=extra))
205
+
206
+ def capture_message(self, message, level="info", fingerprint=None, url=None, extra=None) -> bool:
207
+ return self._send(self.build({"message": str(message), "level": "info"}, level=level, fingerprint=fingerprint, url=url, extra=extra))
208
+
209
+ def flush(self, timeout: float = 2.0) -> None:
210
+ for thread in list(self._threads):
211
+ thread.join(timeout)
212
+ self._threads = [t for t in self._threads if t.is_alive()]
213
+
214
+
215
+ _client = Client()
216
+ _previous_hook = None
217
+
218
+
219
+ def init(ingest_url, release=None, environment=None, sample_rate=1.0, before_send=None, capture_uncaught=True, **kwargs) -> Client:
220
+ """Sets up the shared client. capture_uncaught (default True) reports uncaught exceptions through sys.excepthook, then calls the
221
+ previous hook, so the program still prints the traceback and exits as it would have."""
222
+ global _client, _previous_hook
223
+ close()
224
+ _client = Client(ingest_url=ingest_url, release=release, environment=environment, sample_rate=sample_rate, before_send=before_send, **kwargs)
225
+ if capture_uncaught:
226
+ _previous_hook = _sys.excepthook
227
+
228
+ def hook(exc_type, exc, tb):
229
+ try:
230
+ _client.capture_exception(exc, level="fatal")
231
+ _client.flush()
232
+ finally:
233
+ (_previous_hook or _sys.__excepthook__)(exc_type, exc, tb)
234
+
235
+ _sys.excepthook = hook
236
+ return _client
237
+
238
+
239
+ def close() -> None:
240
+ """Puts back the excepthook init replaced."""
241
+ global _previous_hook
242
+ if _previous_hook is not None:
243
+ _sys.excepthook = _previous_hook
244
+ _previous_hook = None
245
+
246
+
247
+ def capture_exception(exc=None, **kwargs) -> bool:
248
+ return _client.capture_exception(exc, **kwargs)
249
+
250
+
251
+ def capture_message(message, level="info", **kwargs) -> bool:
252
+ return _client.capture_message(message, level=level, **kwargs)
253
+
254
+
255
+ def add_breadcrumb(message=None, category=None, level=None, data=None) -> None:
256
+ _client.add_breadcrumb(message=message, category=category, level=level, data=data)
257
+
258
+
259
+ def set_user(id=None, **kwargs) -> None: # noqa: A002
260
+ _client.set_user(id=id, **kwargs)
261
+
262
+
263
+ def set_tag(key, value) -> None:
264
+ _client.set_tag(key, value)
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: aperioflow
3
+ Version: 0.1.0
4
+ Summary: Report errors from Python to AperioFlow. No dependencies.
5
+ License: MIT
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Requires-Python: >=3.8
9
+ Description-Content-Type: text/markdown
10
+
11
+ # AperioFlow for Python
12
+
13
+ Report errors from Python 3.8 or later to an AperioFlow project. No dependencies: events go to your project's ingest URL with
14
+ `urllib`.
15
+
16
+ Not on PyPI yet. Until it is, copy the `aperioflow` folder into your project.
17
+
18
+ ## Set up
19
+
20
+ Open the project in AperioFlow, choose **Set up reporting** and copy the ingest URL.
21
+
22
+ ```python
23
+ import aperioflow
24
+
25
+ aperioflow.init(
26
+ "https://app.aperioflow.co.uk/ingest/YOUR_KEY",
27
+ release="1.4.0", # optional, groups events by release and finds regressions
28
+ environment="production", # optional
29
+ )
30
+ ```
31
+
32
+ After `init`, uncaught exceptions are reported through `sys.excepthook`; the previous hook still runs, so the traceback is printed and
33
+ the program exits as before (`capture_uncaught=False` turns that off).
34
+
35
+ ## Report errors yourself
36
+
37
+ ```python
38
+ try:
39
+ charge_card(order)
40
+ except Exception as exc:
41
+ aperioflow.capture_exception(exc, fingerprint="card-charge", extra={"order_id": order.id})
42
+
43
+ aperioflow.capture_message("Queue is backing up", level="warning")
44
+ aperioflow.add_breadcrumb("Started nightly import", category="job")
45
+ aperioflow.set_tag("region", "eu-west-2")
46
+ aperioflow.set_user(id=user.id) # only the id is sent
47
+ ```
48
+
49
+ `capture_exception()` with no argument reports the exception being handled. Sends wait for the answer (5 seconds at most); pass
50
+ `background=True` to `init` to send on a thread, and call `aperioflow._client.flush()` before the program exits.
51
+
52
+ ## Options
53
+
54
+ | Option | What it does |
55
+ | --- | --- |
56
+ | `ingest_url` | Required. The project's ingest URL. |
57
+ | `release`, `environment` | Sent with every event. |
58
+ | `sample_rate` | 0 to 1 (default 1). The share of events sent. |
59
+ | `before_send(event)` | Change an event, or return `None` to drop it. |
60
+ | `capture_uncaught` | Default `True`. |
61
+ | `timeout`, `background` | How long one send may take (default 5 seconds), and whether to send on a thread. |
62
+
63
+ ## Privacy
64
+
65
+ - Nothing about the user, the machine or its IP address is collected on its own. A user id is sent only when you call `set_user`, and
66
+ only the id, whatever else you pass.
67
+ - Before an event leaves the process, values of keys that name a secret (password, token, cookie, authorization, api_key and the like),
68
+ Bearer and Basic credentials, email addresses and card numbers are replaced with `[redacted]` markers. The server scrubs again before
69
+ it stores anything.
70
+ - Breadcrumbs are limited to the latest 50, and the event's extra data to the server's 20 KB.
71
+ - A failed send never raises into your app.
72
+
73
+ ## Tests
74
+
75
+ ```bash
76
+ python3 -m unittest
77
+ ```
@@ -0,0 +1,8 @@
1
+ README.md
2
+ pyproject.toml
3
+ aperioflow/__init__.py
4
+ aperioflow.egg-info/PKG-INFO
5
+ aperioflow.egg-info/SOURCES.txt
6
+ aperioflow.egg-info/dependency_links.txt
7
+ aperioflow.egg-info/top_level.txt
8
+ tests/test_aperioflow.py
@@ -0,0 +1 @@
1
+ aperioflow
@@ -0,0 +1,15 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "aperioflow"
7
+ version = "0.1.0"
8
+ description = "Report errors from Python to AperioFlow. No dependencies."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ classifiers = ["Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License"]
13
+
14
+ [tool.setuptools]
15
+ packages = ["aperioflow"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,171 @@
1
+ import json
2
+ import sys
3
+ import threading
4
+ import unittest
5
+ from http.server import BaseHTTPRequestHandler, HTTPServer
6
+
7
+ import aperioflow
8
+ from aperioflow import MAX_BREADCRUMBS, Client, is_secret_key, scrub_text, scrub_value
9
+
10
+ URL = "https://app.aperioflow.co.uk/ingest/ik_test"
11
+
12
+
13
+ def collector():
14
+ sent = []
15
+ return sent, lambda url, event: sent.append((url, event))
16
+
17
+
18
+ class CaptureTests(unittest.TestCase):
19
+ def test_exception_shape(self):
20
+ sent, transport = collector()
21
+ client = Client(ingest_url=URL, release="1.4.0", environment="production", transport=transport)
22
+ try:
23
+ raise ValueError("bad value")
24
+ except ValueError as exc:
25
+ self.assertTrue(client.capture_exception(exc))
26
+ url, event = sent[0]
27
+ self.assertEqual(url, URL)
28
+ self.assertEqual(event["type"], "ValueError")
29
+ self.assertEqual(event["message"], "bad value")
30
+ self.assertEqual(event["level"], "error")
31
+ self.assertEqual(event["platform"], "python")
32
+ self.assertEqual(event["release"], "1.4.0")
33
+ self.assertEqual(event["environment"], "production")
34
+ self.assertIn("ValueError: bad value", event["stack"])
35
+ self.assertNotIn("context", event, "nothing about the user or the machine is added on its own")
36
+
37
+ def test_current_exception_and_message(self):
38
+ sent, transport = collector()
39
+ client = Client(ingest_url=URL, transport=transport)
40
+ try:
41
+ 1 / 0
42
+ except ZeroDivisionError:
43
+ client.capture_exception()
44
+ client.capture_message("Disk nearly full", level="warning", fingerprint="disk", url="https://a.example/p?q=1#x")
45
+ self.assertEqual(sent[0][1]["type"], "ZeroDivisionError")
46
+ self.assertEqual((sent[1][1]["level"], sent[1][1]["fingerprint"], sent[1][1]["url"]), ("warning", "disk", "https://a.example/p"))
47
+ self.assertFalse(client.capture_exception(None))
48
+
49
+ def test_breadcrumbs_and_user_id_only(self):
50
+ sent, transport = collector()
51
+ client = Client(ingest_url=URL, transport=transport)
52
+ for i in range(60):
53
+ client.add_breadcrumb(f"step {i}", category="nav")
54
+ client.set_user(id=42, email="jo@example.com", ip_address="203.0.113.1")
55
+ client.capture_message("m")
56
+ context = sent[0][1]["context"]
57
+ self.assertEqual(len(context["breadcrumbs"]), MAX_BREADCRUMBS)
58
+ self.assertEqual(context["breadcrumbs"][0]["message"], "step 10")
59
+ self.assertEqual(context["user"], {"id": "42"})
60
+ client.set_user(None)
61
+ client.capture_message("m2")
62
+ self.assertNotIn("user", sent[1][1]["context"])
63
+
64
+ def test_secrets_are_scrubbed(self):
65
+ self.assertTrue(is_secret_key("Authorization") and is_secret_key("api_key") and is_secret_key("Set-Cookie"))
66
+ self.assertFalse(is_secret_key("user"))
67
+ self.assertEqual(scrub_text("login?user=bo&password=hunter2"), "login?user=bo&password=[redacted]")
68
+ self.assertEqual(scrub_text("Bearer abcdefgh12345678"), "Bearer [redacted token]")
69
+ self.assertEqual(scrub_text("for jo@example.com card 4111 1111 1111 1111"), "for [redacted email] card [redacted card]")
70
+ self.assertEqual(scrub_text("order 4111111111111112"), "order 4111111111111112")
71
+ self.assertEqual(scrub_value({"headers": {"cookie": "a=b", "accept": "x"}, "list": [{"token": "t"}]}),
72
+ {"headers": {"cookie": "[redacted]", "accept": "x"}, "list": [{"token": "[redacted]"}]})
73
+ sent, transport = collector()
74
+ client = Client(ingest_url=URL, transport=transport)
75
+ client.add_breadcrumb("POST /login", data={"password": "p", "email": "a@b.co"})
76
+ client.capture_exception(RuntimeError("Failed for jo@example.com"), extra={"session": "abc", "ok": 1})
77
+ event = sent[0][1]
78
+ self.assertEqual(event["message"], "Failed for [redacted email]")
79
+ self.assertEqual(event["context"]["breadcrumbs"][0]["data"], {"password": "[redacted]", "email": "[redacted email]"})
80
+ self.assertEqual(event["context"]["extra"], {"session": "[redacted]", "ok": 1})
81
+ self.assertNotIn("jo@example.com", json.dumps(event))
82
+
83
+ def test_sample_rate_and_before_send(self):
84
+ sent, transport = collector()
85
+ value = [0.7]
86
+ client = Client(ingest_url=URL, transport=transport, sample_rate=0.5, rand=lambda: value[0])
87
+ self.assertFalse(client.capture_message("dropped"))
88
+ value[0] = 0.2
89
+ self.assertTrue(client.capture_message("kept"))
90
+ edited = Client(ingest_url=URL, transport=transport, before_send=lambda e: None if e["message"] == "drop me" else {**e, "message": e["message"] + "!"})
91
+ self.assertFalse(edited.capture_message("drop me"))
92
+ edited.capture_message("hi")
93
+
94
+ def boom(_event):
95
+ raise RuntimeError("x")
96
+
97
+ self.assertFalse(Client(ingest_url=URL, transport=transport, before_send=boom).capture_message("lost"))
98
+ self.assertEqual([e["message"] for _, e in sent], ["kept", "hi!"])
99
+
100
+ def test_failures_never_raise_and_context_is_limited(self):
101
+ def offline(_url, _event):
102
+ raise OSError("offline")
103
+
104
+ self.assertFalse(Client(ingest_url=URL, transport=offline).capture_message("m"))
105
+ self.assertFalse(Client().capture_message("no url"))
106
+ sent, transport = collector()
107
+ big = Client(ingest_url=URL, transport=transport)
108
+ for _ in range(50):
109
+ big.add_breadcrumb("x" * 490)
110
+ big.capture_message("m")
111
+ context = sent[0][1]["context"]
112
+ self.assertLessEqual(len(json.dumps(context)), 20000)
113
+ self.assertLess(len(context["breadcrumbs"]), 50)
114
+
115
+
116
+ class HookTests(unittest.TestCase):
117
+ def tearDown(self):
118
+ aperioflow.close()
119
+
120
+ def test_init_reports_uncaught_exceptions_and_close_restores_the_hook(self):
121
+ sent, transport = collector()
122
+ previous_calls = []
123
+ original = sys.excepthook
124
+ sys.excepthook = lambda *args: previous_calls.append(args[0])
125
+ try:
126
+ aperioflow.init(URL, release="r1", transport=transport)
127
+ try:
128
+ raise KeyError("missing")
129
+ except KeyError:
130
+ sys.excepthook(*sys.exc_info())
131
+ self.assertEqual(sent[0][1]["type"], "KeyError")
132
+ self.assertEqual(sent[0][1]["level"], "fatal")
133
+ self.assertEqual(previous_calls, [KeyError], "the previous hook still runs")
134
+ aperioflow.close()
135
+ self.assertIsNot(sys.excepthook, None)
136
+ self.assertEqual(sys.excepthook.__name__, "<lambda>")
137
+ finally:
138
+ sys.excepthook = original
139
+
140
+
141
+ class HttpTransportTests(unittest.TestCase):
142
+ def test_posts_json_to_the_ingest_url(self):
143
+ received = []
144
+
145
+ class Handler(BaseHTTPRequestHandler):
146
+ def do_POST(self): # noqa: N802
147
+ body = self.rfile.read(int(self.headers["Content-Length"]))
148
+ received.append((self.path, self.headers["Content-Type"], json.loads(body)))
149
+ self.send_response(202)
150
+ self.end_headers()
151
+ self.wfile.write(b'{"accepted":1}')
152
+
153
+ def log_message(self, *args):
154
+ pass
155
+
156
+ server = HTTPServer(("127.0.0.1", 0), Handler)
157
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
158
+ thread.start()
159
+ try:
160
+ client = Client(ingest_url=f"http://127.0.0.1:{server.server_port}/ingest/ik_test", background=True)
161
+ self.assertTrue(client.capture_message("over http"))
162
+ client.flush(5)
163
+ finally:
164
+ server.shutdown()
165
+ self.assertEqual(received[0][0], "/ingest/ik_test")
166
+ self.assertEqual(received[0][1], "application/json")
167
+ self.assertEqual(received[0][2]["message"], "over http")
168
+
169
+
170
+ if __name__ == "__main__":
171
+ unittest.main()