aperioflow 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
aperioflow/__init__.py
ADDED
|
@@ -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,5 @@
|
|
|
1
|
+
aperioflow/__init__.py,sha256=y7zU8YDAHteVLw6L4NTvljIOZtvWpJbjThUnwWnGoYE,10853
|
|
2
|
+
aperioflow-0.1.0.dist-info/METADATA,sha256=is2M9xPDUqJXB4UXLWuu2l636jy8rkFGS50ZfNxscwk,2933
|
|
3
|
+
aperioflow-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
4
|
+
aperioflow-0.1.0.dist-info/top_level.txt,sha256=EFr_iOTZDBwrvwG7S6bcgax4D18_KRPZ-hVj_hb9j2U,11
|
|
5
|
+
aperioflow-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aperioflow
|