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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ aperioflow