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.
- aperioflow-0.1.0/PKG-INFO +77 -0
- aperioflow-0.1.0/README.md +67 -0
- aperioflow-0.1.0/aperioflow/__init__.py +264 -0
- aperioflow-0.1.0/aperioflow.egg-info/PKG-INFO +77 -0
- aperioflow-0.1.0/aperioflow.egg-info/SOURCES.txt +8 -0
- aperioflow-0.1.0/aperioflow.egg-info/dependency_links.txt +1 -0
- aperioflow-0.1.0/aperioflow.egg-info/top_level.txt +1 -0
- aperioflow-0.1.0/pyproject.toml +15 -0
- aperioflow-0.1.0/setup.cfg +4 -0
- aperioflow-0.1.0/tests/test_aperioflow.py +171 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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,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()
|