townhouse 0.4.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.
- townhouse-0.4.0/PKG-INFO +90 -0
- townhouse-0.4.0/README.md +68 -0
- townhouse-0.4.0/pyproject.toml +33 -0
- townhouse-0.4.0/setup.cfg +4 -0
- townhouse-0.4.0/tests/test_client.py +159 -0
- townhouse-0.4.0/tests/test_frameworks.py +101 -0
- townhouse-0.4.0/townhouse/__init__.py +101 -0
- townhouse-0.4.0/townhouse/client.py +401 -0
- townhouse-0.4.0/townhouse/integrations/__init__.py +1 -0
- townhouse-0.4.0/townhouse/integrations/asgi.py +71 -0
- townhouse-0.4.0/townhouse/integrations/django.py +58 -0
- townhouse-0.4.0/townhouse/integrations/flask.py +52 -0
- townhouse-0.4.0/townhouse/integrations/logging.py +37 -0
- townhouse-0.4.0/townhouse.egg-info/PKG-INFO +90 -0
- townhouse-0.4.0/townhouse.egg-info/SOURCES.txt +16 -0
- townhouse-0.4.0/townhouse.egg-info/dependency_links.txt +1 -0
- townhouse-0.4.0/townhouse.egg-info/requires.txt +7 -0
- townhouse-0.4.0/townhouse.egg-info/top_level.txt +1 -0
townhouse-0.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: townhouse
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Townhouse error tracking for Python: FastAPI, Starlette, Flask, Django, logging and plain scripts
|
|
5
|
+
Author: Townhouse
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://townhouse.dev
|
|
8
|
+
Keywords: townhouse,error tracking,monitoring,fastapi,flask,django
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Framework :: FastAPI
|
|
11
|
+
Classifier: Framework :: Flask
|
|
12
|
+
Classifier: Framework :: Django
|
|
13
|
+
Classifier: Topic :: System :: Monitoring
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Provides-Extra: test
|
|
17
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
18
|
+
Requires-Dist: fastapi; extra == "test"
|
|
19
|
+
Requires-Dist: httpx; extra == "test"
|
|
20
|
+
Requires-Dist: flask; extra == "test"
|
|
21
|
+
Requires-Dist: django; extra == "test"
|
|
22
|
+
|
|
23
|
+
# townhouse (Python)
|
|
24
|
+
|
|
25
|
+
Error tracking for Python apps, sent to Townhouse: errors grouped into problems, linked to the part of your app they
|
|
26
|
+
break, and fixable with your own model.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install townhouse
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import os, townhouse
|
|
34
|
+
townhouse.init(key=os.environ["TOWNHOUSE_KEY"], release=os.environ.get("GIT_SHA"), environment="production")
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`TOWNHOUSE_KEY` is your project's ingest key (`gh_live_...`), from the Townhouse app. A DSN works too:
|
|
38
|
+
`townhouse.init(dsn="https://gh_live_xxx@api.townhouse.dev/<projectId>")`.
|
|
39
|
+
|
|
40
|
+
## Frameworks
|
|
41
|
+
|
|
42
|
+
FastAPI and Starlette:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from townhouse.integrations.asgi import TownhouseMiddleware
|
|
46
|
+
app.add_middleware(TownhouseMiddleware)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Flask:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from townhouse.integrations.flask import init_app
|
|
53
|
+
init_app(app)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Django (`settings.py`):
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
MIDDLEWARE = ["townhouse.integrations.django.TownhouseMiddleware", *MIDDLEWARE]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Logging (log lines become breadcrumbs, `ERROR` and above become events):
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import logging
|
|
66
|
+
from townhouse.integrations.logging import TownhouseHandler
|
|
67
|
+
logging.getLogger().addHandler(TownhouseHandler())
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## What it sends
|
|
71
|
+
|
|
72
|
+
- Uncaught exceptions from the main thread and other threads, with the process exiting as it would have.
|
|
73
|
+
- Unhandled view or endpoint exceptions and 5xx responses, with the route pattern.
|
|
74
|
+
- Frames ordered outermost first, marked in-app for your own files and not for installed packages.
|
|
75
|
+
- Emails and tokens masked, secret-looking fields redacted, query strings dropped, and only four request headers kept.
|
|
76
|
+
- Batches gzipped from a background thread, flushed at exit. No dependencies outside the standard library.
|
|
77
|
+
|
|
78
|
+
Manual capture: `townhouse.capture_exception(exc)`, `townhouse.capture_message("text", "warning")`,
|
|
79
|
+
`townhouse.add_breadcrumb("query", "SELECT orders")`, `townhouse.set_tag("tenant", "acme")`, `townhouse.set_user("u_123")`.
|
|
80
|
+
|
|
81
|
+
## Developing and testing
|
|
82
|
+
|
|
83
|
+
The tests cover the client and the FastAPI, Flask and Django integrations, so they need those frameworks. Install the
|
|
84
|
+
package in editable mode with its `test` extra, then run pytest from this folder:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
python3 -m venv .venv && . .venv/bin/activate
|
|
88
|
+
pip install -e ".[test]"
|
|
89
|
+
python -m pytest -q
|
|
90
|
+
```
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# townhouse (Python)
|
|
2
|
+
|
|
3
|
+
Error tracking for Python apps, sent to Townhouse: errors grouped into problems, linked to the part of your app they
|
|
4
|
+
break, and fixable with your own model.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install townhouse
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
import os, townhouse
|
|
12
|
+
townhouse.init(key=os.environ["TOWNHOUSE_KEY"], release=os.environ.get("GIT_SHA"), environment="production")
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`TOWNHOUSE_KEY` is your project's ingest key (`gh_live_...`), from the Townhouse app. A DSN works too:
|
|
16
|
+
`townhouse.init(dsn="https://gh_live_xxx@api.townhouse.dev/<projectId>")`.
|
|
17
|
+
|
|
18
|
+
## Frameworks
|
|
19
|
+
|
|
20
|
+
FastAPI and Starlette:
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from townhouse.integrations.asgi import TownhouseMiddleware
|
|
24
|
+
app.add_middleware(TownhouseMiddleware)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Flask:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from townhouse.integrations.flask import init_app
|
|
31
|
+
init_app(app)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Django (`settings.py`):
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
MIDDLEWARE = ["townhouse.integrations.django.TownhouseMiddleware", *MIDDLEWARE]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Logging (log lines become breadcrumbs, `ERROR` and above become events):
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import logging
|
|
44
|
+
from townhouse.integrations.logging import TownhouseHandler
|
|
45
|
+
logging.getLogger().addHandler(TownhouseHandler())
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## What it sends
|
|
49
|
+
|
|
50
|
+
- Uncaught exceptions from the main thread and other threads, with the process exiting as it would have.
|
|
51
|
+
- Unhandled view or endpoint exceptions and 5xx responses, with the route pattern.
|
|
52
|
+
- Frames ordered outermost first, marked in-app for your own files and not for installed packages.
|
|
53
|
+
- Emails and tokens masked, secret-looking fields redacted, query strings dropped, and only four request headers kept.
|
|
54
|
+
- Batches gzipped from a background thread, flushed at exit. No dependencies outside the standard library.
|
|
55
|
+
|
|
56
|
+
Manual capture: `townhouse.capture_exception(exc)`, `townhouse.capture_message("text", "warning")`,
|
|
57
|
+
`townhouse.add_breadcrumb("query", "SELECT orders")`, `townhouse.set_tag("tenant", "acme")`, `townhouse.set_user("u_123")`.
|
|
58
|
+
|
|
59
|
+
## Developing and testing
|
|
60
|
+
|
|
61
|
+
The tests cover the client and the FastAPI, Flask and Django integrations, so they need those frameworks. Install the
|
|
62
|
+
package in editable mode with its `test` extra, then run pytest from this folder:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
python3 -m venv .venv && . .venv/bin/activate
|
|
66
|
+
pip install -e ".[test]"
|
|
67
|
+
python -m pytest -q
|
|
68
|
+
```
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "townhouse"
|
|
7
|
+
version = "0.4.0"
|
|
8
|
+
description = "Townhouse error tracking for Python: FastAPI, Starlette, Flask, Django, logging and plain scripts"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Townhouse" }]
|
|
13
|
+
keywords = ["townhouse", "error tracking", "monitoring", "fastapi", "flask", "django"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Framework :: FastAPI",
|
|
17
|
+
"Framework :: Flask",
|
|
18
|
+
"Framework :: Django",
|
|
19
|
+
"Topic :: System :: Monitoring",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
test = ["pytest>=8", "fastapi", "httpx", "flask", "django"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://townhouse.dev"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
include = ["townhouse*"]
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
import threading
|
|
4
|
+
|
|
5
|
+
import townhouse
|
|
6
|
+
from townhouse.client import Client, scrub, scrub_string
|
|
7
|
+
from townhouse.integrations.logging import TownhouseHandler
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def boom():
|
|
11
|
+
raise ValueError("card declined for jane@example.com with sk_live_abcdefghijklmnop")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_envelope_matches_contract(capture):
|
|
15
|
+
cap, client = capture
|
|
16
|
+
try:
|
|
17
|
+
boom()
|
|
18
|
+
except ValueError as exc:
|
|
19
|
+
event_id = townhouse.capture_exception(exc, handled=False)
|
|
20
|
+
assert event_id
|
|
21
|
+
assert townhouse.flush()
|
|
22
|
+
assert len(cap.requests) == 1
|
|
23
|
+
req = cap.requests[0]
|
|
24
|
+
assert req["headers"]["x-townhouse-key"] == "gh_live_testkey1234"
|
|
25
|
+
assert req["headers"]["content-encoding"] == "gzip"
|
|
26
|
+
assert req["endpoint"] == "https://api.townhouse.dev/v1/ingest/events"
|
|
27
|
+
ev = cap.events[0]
|
|
28
|
+
assert ev["eventId"] == event_id
|
|
29
|
+
assert ev["timestamp"].endswith("Z")
|
|
30
|
+
assert ev["platform"] == "python"
|
|
31
|
+
assert ev["sdk"] == {"name": "townhouse.python", "version": townhouse.SDK_VERSION}
|
|
32
|
+
assert ev["release"] == "abc123" and ev["environment"] == "test"
|
|
33
|
+
ex = ev["exception"]
|
|
34
|
+
assert ex["type"] == "ValueError"
|
|
35
|
+
assert "[email]" in ex["value"] and "[redacted]" in ex["value"]
|
|
36
|
+
assert "jane@example.com" not in ex["stack"] and "sk_live_" not in ex["stack"]
|
|
37
|
+
assert ex["mechanism"] == {"handled": False, "type": "python"}
|
|
38
|
+
frames = ex["frames"]
|
|
39
|
+
assert frames[-1]["function"] == "boom", "innermost frame last"
|
|
40
|
+
assert frames[-1]["inApp"] is True
|
|
41
|
+
assert set(frames[-1]) >= {"filename", "function", "lineno", "colno", "inApp"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_scrub_redacts_secret_keys_and_values():
|
|
45
|
+
out = scrub({"password": "hunter2", "nested": {"api_key": "x", "note": "Bearer abcdefghijklmnop"}, "email": "a@b.co"})
|
|
46
|
+
assert out["password"] == "[redacted]"
|
|
47
|
+
assert out["nested"]["api_key"] == "[redacted]"
|
|
48
|
+
assert out["nested"]["note"] == "[redacted]"
|
|
49
|
+
assert out["email"] == "[email]"
|
|
50
|
+
assert scrub_string("ghp_" + "a" * 30) == "[redacted]"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_message_breadcrumbs_tags_user_and_fingerprint(capture):
|
|
54
|
+
cap, client = capture
|
|
55
|
+
townhouse.add_breadcrumb("query", "SELECT * FROM orders WHERE email = 'x@y.com'")
|
|
56
|
+
townhouse.set_tag("tenant", "acme")
|
|
57
|
+
townhouse.set_user("u_1")
|
|
58
|
+
client.capture_message("Queue is backing up", "warning", tags={"queue": "emails"})
|
|
59
|
+
client.flush()
|
|
60
|
+
ev = cap.events[0]
|
|
61
|
+
assert ev["message"] == "Queue is backing up" and ev["level"] == "warning"
|
|
62
|
+
assert "exception" not in ev
|
|
63
|
+
assert ev["breadcrumbs"][0]["category"] == "query" and "[email]" in ev["breadcrumbs"][0]["message"]
|
|
64
|
+
assert ev["tags"] == {"tenant": "acme", "queue": "emails"}
|
|
65
|
+
assert ev["user"] == {"id": "u_1"}
|
|
66
|
+
try:
|
|
67
|
+
boom()
|
|
68
|
+
except ValueError as exc:
|
|
69
|
+
client.capture_exception(exc, fingerprint=["{{ default }}", "checkout"])
|
|
70
|
+
client.flush()
|
|
71
|
+
assert cap.events[-1]["fingerprint"] == ["{{ default }}", "checkout"]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_dedupe_sampling_before_send_and_no_key():
|
|
75
|
+
sent = []
|
|
76
|
+
c = Client(key="gh_live_k", transport=lambda b, h, e: sent.append(b) or 202, start_thread=False)
|
|
77
|
+
for _ in range(3):
|
|
78
|
+
try:
|
|
79
|
+
boom()
|
|
80
|
+
except ValueError as exc:
|
|
81
|
+
c.capture_exception(exc)
|
|
82
|
+
c.flush()
|
|
83
|
+
assert len(sent) == 1, "identical errors within a second are sent once"
|
|
84
|
+
dropped = Client(key="gh_live_k", transport=lambda b, h, e: 202, start_thread=False, before_send=lambda ev, hint: None)
|
|
85
|
+
assert dropped.capture_message("x") is None
|
|
86
|
+
nokey = Client(key=None, transport=lambda b, h, e: 202, start_thread=False)
|
|
87
|
+
import os
|
|
88
|
+
os.environ.pop("TOWNHOUSE_KEY", None)
|
|
89
|
+
assert Client(key=None, transport=lambda b, h, e: 202, start_thread=False).capture_message("x") is None or nokey.key
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_failed_send_is_retried():
|
|
93
|
+
calls = {"n": 0}
|
|
94
|
+
|
|
95
|
+
def flaky(body, headers, endpoint):
|
|
96
|
+
calls["n"] += 1
|
|
97
|
+
return 503 if calls["n"] == 1 else 202
|
|
98
|
+
|
|
99
|
+
c = Client(key="gh_live_k", transport=flaky, start_thread=False)
|
|
100
|
+
c.capture_message("hello")
|
|
101
|
+
assert c.flush(1.0) is False or calls["n"] >= 1
|
|
102
|
+
assert c.flush(1.0) is True
|
|
103
|
+
assert calls["n"] == 2
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_dsn_sets_key_and_endpoint():
|
|
107
|
+
c = Client(dsn="https://gh_live_abc123@api.townhouse.dev/proj-1", start_thread=False, transport=lambda b, h, e: 202)
|
|
108
|
+
assert c.key == "gh_live_abc123"
|
|
109
|
+
assert c.endpoint == "https://api.townhouse.dev/v1/ingest/events"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_excepthook_and_thread_hook_capture_uncaught():
|
|
113
|
+
sent = []
|
|
114
|
+
townhouse.close()
|
|
115
|
+
townhouse.init(key="gh_live_k", transport=lambda b, h, e: sent.append(b) or 202, start_thread=False)
|
|
116
|
+
seen = []
|
|
117
|
+
import townhouse as th
|
|
118
|
+
previous = th._previous_excepthook
|
|
119
|
+
th._previous_excepthook = lambda *a: seen.append(a)
|
|
120
|
+
try:
|
|
121
|
+
try:
|
|
122
|
+
raise RuntimeError("uncaught main")
|
|
123
|
+
except RuntimeError:
|
|
124
|
+
sys.excepthook(*sys.exc_info())
|
|
125
|
+
t = threading.Thread(target=boom)
|
|
126
|
+
t.start()
|
|
127
|
+
t.join()
|
|
128
|
+
th.flush()
|
|
129
|
+
finally:
|
|
130
|
+
th._previous_excepthook = previous
|
|
131
|
+
townhouse.close()
|
|
132
|
+
assert seen, "the previous hook still runs"
|
|
133
|
+
import gzip, json
|
|
134
|
+
events = [e for b in sent for e in json.loads(gzip.decompress(b))["events"]]
|
|
135
|
+
types = {e["exception"]["type"] for e in events}
|
|
136
|
+
assert {"RuntimeError", "ValueError"} <= types
|
|
137
|
+
main = next(e for e in events if e["exception"]["type"] == "RuntimeError")
|
|
138
|
+
assert main["level"] == "fatal" and main["exception"]["mechanism"]["handled"] is False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_logging_handler(capture):
|
|
142
|
+
cap, client = capture
|
|
143
|
+
log = logging.getLogger("shop")
|
|
144
|
+
handler = TownhouseHandler()
|
|
145
|
+
log.addHandler(handler)
|
|
146
|
+
log.setLevel(logging.INFO)
|
|
147
|
+
try:
|
|
148
|
+
log.info("loading cart")
|
|
149
|
+
try:
|
|
150
|
+
boom()
|
|
151
|
+
except ValueError:
|
|
152
|
+
log.exception("checkout failed")
|
|
153
|
+
log.error("plain error line")
|
|
154
|
+
client.flush()
|
|
155
|
+
finally:
|
|
156
|
+
log.removeHandler(handler)
|
|
157
|
+
types = [e.get("exception", {}).get("type") or e.get("message") for e in cap.events]
|
|
158
|
+
assert "ValueError" in types and "plain error line" in types
|
|
159
|
+
assert any(b["message"] == "shop: loading cart" for b in cap.events[0]["breadcrumbs"])
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
import townhouse
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_fastapi_middleware_reports_exception_and_5xx_with_route(capture):
|
|
7
|
+
cap, client = capture
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
from fastapi.responses import JSONResponse
|
|
10
|
+
from fastapi.testclient import TestClient
|
|
11
|
+
from townhouse.integrations.asgi import TownhouseMiddleware
|
|
12
|
+
|
|
13
|
+
app = FastAPI()
|
|
14
|
+
app.add_middleware(TownhouseMiddleware)
|
|
15
|
+
|
|
16
|
+
@app.post("/orders/{order_id}")
|
|
17
|
+
def create(order_id: int):
|
|
18
|
+
raise KeyError("total")
|
|
19
|
+
|
|
20
|
+
@app.get("/health")
|
|
21
|
+
def health():
|
|
22
|
+
return JSONResponse({"ok": False}, status_code=503)
|
|
23
|
+
|
|
24
|
+
tc = TestClient(app, raise_server_exceptions=False)
|
|
25
|
+
assert tc.post("/orders/42?token=secret", headers={"user-agent": "pytest", "authorization": "Bearer abcdefghijkl"}).status_code == 500
|
|
26
|
+
assert tc.get("/health").status_code == 503
|
|
27
|
+
client.flush()
|
|
28
|
+
exc = next(e for e in cap.events if e["exception"]["type"] == "KeyError")
|
|
29
|
+
assert exc["request"]["method"] == "POST"
|
|
30
|
+
assert exc["request"]["url"] == "/orders/42", "query string stripped"
|
|
31
|
+
assert exc["request"]["route"] == "/orders/{order_id}"
|
|
32
|
+
assert exc["request"]["headers"] == {"user-agent": "pytest"}, "headers allowlisted"
|
|
33
|
+
assert exc["exception"]["mechanism"]["handled"] is False
|
|
34
|
+
http = next(e for e in cap.events if "503" in e["exception"]["value"])
|
|
35
|
+
assert http["tags"]["http.status"] == "503"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_flask_integration_reports_exception_and_5xx(capture):
|
|
39
|
+
cap, client = capture
|
|
40
|
+
from flask import Flask
|
|
41
|
+
from townhouse.integrations.flask import init_app
|
|
42
|
+
|
|
43
|
+
app = Flask(__name__)
|
|
44
|
+
init_app(app)
|
|
45
|
+
|
|
46
|
+
@app.route("/orders/<int:order_id>", methods=["POST"])
|
|
47
|
+
def create(order_id):
|
|
48
|
+
raise ZeroDivisionError("division by zero")
|
|
49
|
+
|
|
50
|
+
@app.route("/down")
|
|
51
|
+
def down():
|
|
52
|
+
return "down", 502
|
|
53
|
+
|
|
54
|
+
tc = app.test_client()
|
|
55
|
+
assert tc.post("/orders/7").status_code == 500
|
|
56
|
+
assert tc.get("/down").status_code == 502
|
|
57
|
+
client.flush()
|
|
58
|
+
exc = next(e for e in cap.events if e["exception"]["type"] == "ZeroDivisionError")
|
|
59
|
+
assert exc["request"]["route"] == "/orders/<int:order_id>"
|
|
60
|
+
assert exc["request"]["url"] == "/orders/7"
|
|
61
|
+
assert exc["exception"]["frames"][-1]["function"] == "create"
|
|
62
|
+
assert sum(1 for e in cap.events if e["exception"]["type"] == "ZeroDivisionError") == 1, "not reported twice as a 5xx"
|
|
63
|
+
assert any("502" in e["exception"]["value"] for e in cap.events)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_django_middleware_reports_exception_with_route(capture):
|
|
67
|
+
cap, client = capture
|
|
68
|
+
import django
|
|
69
|
+
from django.conf import settings
|
|
70
|
+
|
|
71
|
+
if not settings.configured:
|
|
72
|
+
settings.configure(
|
|
73
|
+
DEBUG=False,
|
|
74
|
+
SECRET_KEY="test",
|
|
75
|
+
ROOT_URLCONF=__name__,
|
|
76
|
+
ALLOWED_HOSTS=["testserver"],
|
|
77
|
+
MIDDLEWARE=["townhouse.integrations.django.TownhouseMiddleware"],
|
|
78
|
+
INSTALLED_APPS=[],
|
|
79
|
+
)
|
|
80
|
+
django.setup()
|
|
81
|
+
from django.test import Client as DjangoClient
|
|
82
|
+
|
|
83
|
+
resp = DjangoClient(raise_request_exception=False).get("/orders/9/")
|
|
84
|
+
assert resp.status_code == 500
|
|
85
|
+
client.flush()
|
|
86
|
+
exc = next(e for e in cap.events if e["exception"]["type"] == "LookupError")
|
|
87
|
+
assert exc["request"]["route"] == "/orders/<int:order_id>/"
|
|
88
|
+
assert exc["request"]["method"] == "GET"
|
|
89
|
+
assert exc["tags"]["handler"] == "django"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _django_view(request, order_id):
|
|
93
|
+
raise LookupError("order missing")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
from django.urls import path
|
|
98
|
+
|
|
99
|
+
urlpatterns = [path("orders/<int:order_id>/", _django_view)]
|
|
100
|
+
except Exception: # pragma: no cover
|
|
101
|
+
urlpatterns = []
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Townhouse error tracking for Python.
|
|
2
|
+
|
|
3
|
+
import townhouse
|
|
4
|
+
townhouse.init(key="gh_live_...", release="1.4.2", environment="production")
|
|
5
|
+
|
|
6
|
+
Uncaught exceptions (main thread and threads) are reported, with the process behaving as it would have. Framework
|
|
7
|
+
integrations live in townhouse.integrations: FastAPI or Starlette, Flask, Django and logging.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from .client import SDK_VERSION, Client, scrub, scrub_string
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"init", "get_client", "capture_exception", "capture_message", "add_breadcrumb", "set_tag", "set_user", "flush",
|
|
19
|
+
"close", "Client", "SDK_VERSION", "scrub", "scrub_string",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
_client: Optional[Client] = None
|
|
23
|
+
_previous_excepthook = None
|
|
24
|
+
_previous_threading_hook = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def init(key: Optional[str] = None, *, dsn: Optional[str] = None, release: Optional[str] = None, environment: Optional[str] = None,
|
|
28
|
+
capture_uncaught: bool = True, **options: Any) -> Client:
|
|
29
|
+
"""Start the client. Safe to call more than once; later calls return the existing client."""
|
|
30
|
+
global _client, _previous_excepthook, _previous_threading_hook
|
|
31
|
+
if _client is not None:
|
|
32
|
+
return _client
|
|
33
|
+
_client = Client(key=key, dsn=dsn, release=release, environment=environment, **options)
|
|
34
|
+
if capture_uncaught:
|
|
35
|
+
_previous_excepthook = sys.excepthook
|
|
36
|
+
|
|
37
|
+
def _excepthook(exc_type, exc, tb): # type: ignore[no-untyped-def]
|
|
38
|
+
try:
|
|
39
|
+
if _client is not None and not issubclass(exc_type, KeyboardInterrupt):
|
|
40
|
+
_client.capture_exception(exc, handled=False, mechanism="python", level="fatal")
|
|
41
|
+
_client.flush(2.0)
|
|
42
|
+
finally:
|
|
43
|
+
(_previous_excepthook or sys.__excepthook__)(exc_type, exc, tb)
|
|
44
|
+
|
|
45
|
+
sys.excepthook = _excepthook
|
|
46
|
+
if hasattr(threading, "excepthook"):
|
|
47
|
+
_previous_threading_hook = threading.excepthook
|
|
48
|
+
|
|
49
|
+
def _thread_hook(args): # type: ignore[no-untyped-def]
|
|
50
|
+
try:
|
|
51
|
+
if _client is not None and args.exc_value is not None and not isinstance(args.exc_value, SystemExit):
|
|
52
|
+
_client.capture_exception(args.exc_value, handled=False, mechanism="python", tags={"thread": getattr(args.thread, "name", None)})
|
|
53
|
+
finally:
|
|
54
|
+
if _previous_threading_hook is not None:
|
|
55
|
+
_previous_threading_hook(args)
|
|
56
|
+
|
|
57
|
+
threading.excepthook = _thread_hook
|
|
58
|
+
return _client
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_client() -> Optional[Client]:
|
|
62
|
+
return _client
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def capture_exception(exc: Optional[BaseException] = None, **kwargs: Any) -> Optional[str]:
|
|
66
|
+
return _client.capture_exception(exc, **kwargs) if _client else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def capture_message(message: str, level: str = "info", **kwargs: Any) -> Optional[str]:
|
|
70
|
+
return _client.capture_message(message, level, **kwargs) if _client else None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def add_breadcrumb(category: str = "ui", message: Optional[str] = None, level: str = "info", data: Optional[Dict[str, Any]] = None) -> None:
|
|
74
|
+
if _client:
|
|
75
|
+
_client.add_breadcrumb(category, message, level, data)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def set_tag(key: str, value: Any) -> None:
|
|
79
|
+
if _client:
|
|
80
|
+
_client.set_tag(key, value)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def set_user(user_id: Optional[str]) -> None:
|
|
84
|
+
if _client:
|
|
85
|
+
_client.set_user(user_id)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def flush(timeout: float = 5.0) -> bool:
|
|
89
|
+
return _client.flush(timeout) if _client else True
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def close() -> None:
|
|
93
|
+
"""Stop the client and restore the hooks it installed."""
|
|
94
|
+
global _client
|
|
95
|
+
if _client is not None:
|
|
96
|
+
_client.close()
|
|
97
|
+
if _previous_excepthook is not None:
|
|
98
|
+
sys.excepthook = _previous_excepthook
|
|
99
|
+
if _previous_threading_hook is not None and hasattr(threading, "excepthook"):
|
|
100
|
+
threading.excepthook = _previous_threading_hook
|
|
101
|
+
_client = None
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""Townhouse error tracking core for Python.
|
|
2
|
+
|
|
3
|
+
Builds events exactly as the Townhouse errors contract (v1) describes, scrubs secrets and emails, and sends them in
|
|
4
|
+
gzip batches from a background thread to POST /v1/ingest/events with the project ingest key in x-townhouse-key.
|
|
5
|
+
Standard library only. Capture code never raises into the host application.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import atexit
|
|
10
|
+
import datetime as _dt
|
|
11
|
+
import gzip
|
|
12
|
+
import json
|
|
13
|
+
import linecache
|
|
14
|
+
import os
|
|
15
|
+
import platform as _platform
|
|
16
|
+
import queue
|
|
17
|
+
import random
|
|
18
|
+
import re
|
|
19
|
+
import socket
|
|
20
|
+
import sys
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
import traceback
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.parse
|
|
26
|
+
import urllib.request
|
|
27
|
+
import uuid
|
|
28
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
29
|
+
|
|
30
|
+
SDK_NAME = "townhouse.python"
|
|
31
|
+
SDK_VERSION = "0.4.0"
|
|
32
|
+
DEFAULT_ENDPOINT = "https://api.townhouse.dev/v1/ingest/events"
|
|
33
|
+
|
|
34
|
+
LIMITS = {
|
|
35
|
+
"value": 2000,
|
|
36
|
+
"message": 2000,
|
|
37
|
+
"stack": 16000,
|
|
38
|
+
"frames": 100,
|
|
39
|
+
"tags": 50,
|
|
40
|
+
"breadcrumbs": 50,
|
|
41
|
+
"breadcrumb_message": 500,
|
|
42
|
+
"batch": 100,
|
|
43
|
+
"body_bytes": 900 * 1024,
|
|
44
|
+
"queue": 300,
|
|
45
|
+
}
|
|
46
|
+
LEVELS = ("fatal", "error", "warning", "info")
|
|
47
|
+
MECHANISMS = ("onerror", "onunhandledrejection", "express", "nextjs", "python", "manual")
|
|
48
|
+
HEADER_ALLOWLIST = ("user-agent", "referer", "accept-language", "content-type")
|
|
49
|
+
|
|
50
|
+
SECRET_KEY = re.compile(r"pass(word|wd)?|secret|token|auth(orization)?|cookie|session|api[-_]?key|private[-_]?key|credential|signature|jwt|bearer|otp|\bpin\b|ssn|card[-_]?(number|no)|cvc|cvv", re.I)
|
|
51
|
+
EMAIL = re.compile(r"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", re.I)
|
|
52
|
+
SECRET_VALUES = [
|
|
53
|
+
re.compile(r"\bBearer\s+[A-Za-z0-9._~+/=-]{8,}", re.I),
|
|
54
|
+
re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"),
|
|
55
|
+
re.compile(r"\b(sk|rk|pk)_(live|test)_[A-Za-z0-9]{8,}"),
|
|
56
|
+
re.compile(r"\bsk-(ant-|proj-|or-)?[A-Za-z0-9_-]{16,}"),
|
|
57
|
+
re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}"),
|
|
58
|
+
re.compile(r"\bgh_live_[A-Za-z0-9_-]{8,}"),
|
|
59
|
+
re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}"),
|
|
60
|
+
re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
|
|
61
|
+
re.compile(r"\bAIza[0-9A-Za-z_-]{30,}"),
|
|
62
|
+
re.compile(r"\bre_[A-Za-z0-9]{16,}"),
|
|
63
|
+
]
|
|
64
|
+
_NOT_IN_APP = re.compile(r"site-packages|dist-packages|[\\/]lib[\\/]python\d|<frozen |[\\/]townhouse[\\/]|^<")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _now_iso() -> str:
|
|
68
|
+
return _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def scrub_string(value: Any, limit: Optional[int] = None) -> str:
|
|
72
|
+
s = str(value)
|
|
73
|
+
for pattern in SECRET_VALUES:
|
|
74
|
+
s = pattern.sub("[redacted]", s)
|
|
75
|
+
s = EMAIL.sub("[email]", s)
|
|
76
|
+
return s[:limit] if limit else s
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def scrub(value: Any, depth: int = 0) -> Any:
|
|
80
|
+
if value is None or isinstance(value, (bool, int, float)):
|
|
81
|
+
return value
|
|
82
|
+
if isinstance(value, str):
|
|
83
|
+
return scrub_string(value, 2000)
|
|
84
|
+
if depth > 5:
|
|
85
|
+
return "[depth]"
|
|
86
|
+
if isinstance(value, (list, tuple, set)):
|
|
87
|
+
return [scrub(v, depth + 1) for v in list(value)[:50]]
|
|
88
|
+
if isinstance(value, dict):
|
|
89
|
+
out = {}
|
|
90
|
+
for i, (k, v) in enumerate(value.items()):
|
|
91
|
+
if i >= 50:
|
|
92
|
+
break
|
|
93
|
+
out[str(k)] = "[redacted]" if SECRET_KEY.search(str(k)) else scrub(v, depth + 1)
|
|
94
|
+
return out
|
|
95
|
+
return scrub_string(repr(value), 2000)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def path_only(url: Any) -> Optional[str]:
|
|
99
|
+
if url is None:
|
|
100
|
+
return None
|
|
101
|
+
try:
|
|
102
|
+
return urllib.parse.urlsplit(str(url)).path or "/"
|
|
103
|
+
except Exception:
|
|
104
|
+
return str(url).split("?")[0].split("#")[0]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def pick_headers(headers: Any) -> Dict[str, str]:
|
|
108
|
+
out: Dict[str, str] = {}
|
|
109
|
+
if not headers:
|
|
110
|
+
return out
|
|
111
|
+
try:
|
|
112
|
+
items = {str(k).lower(): v for k, v in (headers.items() if hasattr(headers, "items") else headers)}
|
|
113
|
+
except Exception:
|
|
114
|
+
return out
|
|
115
|
+
for key in HEADER_ALLOWLIST:
|
|
116
|
+
v = items.get(key)
|
|
117
|
+
if v:
|
|
118
|
+
out[key] = scrub_string(v, 500)
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def in_app(filename: str, app_roots: List[str]) -> bool:
|
|
123
|
+
if not filename or _NOT_IN_APP.search(filename):
|
|
124
|
+
return False
|
|
125
|
+
if app_roots:
|
|
126
|
+
return any(os.path.abspath(filename).startswith(root) for root in app_roots)
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def frames_from_traceback(tb: Any, app_roots: List[str]) -> List[Dict[str, Any]]:
|
|
131
|
+
frames: List[Dict[str, Any]] = []
|
|
132
|
+
for frame_summary in traceback.extract_tb(tb):
|
|
133
|
+
filename = frame_summary.filename
|
|
134
|
+
rel = filename
|
|
135
|
+
for root in app_roots:
|
|
136
|
+
if os.path.abspath(filename).startswith(root):
|
|
137
|
+
rel = os.path.relpath(os.path.abspath(filename), root)
|
|
138
|
+
break
|
|
139
|
+
frames.append({
|
|
140
|
+
"filename": rel,
|
|
141
|
+
"function": frame_summary.name or "?",
|
|
142
|
+
"lineno": frame_summary.lineno or 0,
|
|
143
|
+
"colno": getattr(frame_summary, "colno", None) or 0,
|
|
144
|
+
"inApp": in_app(filename, app_roots),
|
|
145
|
+
"module": None,
|
|
146
|
+
})
|
|
147
|
+
# traceback.extract_tb is outermost first, innermost last, which is the contract order. Keep the innermost frames.
|
|
148
|
+
return frames[-LIMITS["frames"]:]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class Client:
|
|
152
|
+
def __init__(
|
|
153
|
+
self,
|
|
154
|
+
key: Optional[str] = None,
|
|
155
|
+
dsn: Optional[str] = None,
|
|
156
|
+
endpoint: Optional[str] = None,
|
|
157
|
+
release: Optional[str] = None,
|
|
158
|
+
environment: Optional[str] = None,
|
|
159
|
+
server_name: Optional[str] = None,
|
|
160
|
+
sample_rate: float = 1.0,
|
|
161
|
+
before_send: Optional[Callable[[Dict[str, Any], Dict[str, Any]], Optional[Dict[str, Any]]]] = None,
|
|
162
|
+
fingerprint: Optional[List[str]] = None,
|
|
163
|
+
tags: Optional[Dict[str, str]] = None,
|
|
164
|
+
app_roots: Optional[List[str]] = None,
|
|
165
|
+
flush_interval: float = 2.0,
|
|
166
|
+
transport: Optional[Callable[[bytes, Dict[str, str], str], int]] = None,
|
|
167
|
+
debug: bool = False,
|
|
168
|
+
start_thread: bool = True,
|
|
169
|
+
) -> None:
|
|
170
|
+
env = os.environ
|
|
171
|
+
dsn = dsn or env.get("TOWNHOUSE_DSN")
|
|
172
|
+
dsn_key = dsn_endpoint = None
|
|
173
|
+
if dsn:
|
|
174
|
+
try:
|
|
175
|
+
parts = urllib.parse.urlsplit(dsn)
|
|
176
|
+
dsn_key = urllib.parse.unquote(parts.username or "") or None
|
|
177
|
+
dsn_endpoint = f"{parts.scheme}://{parts.hostname}{(':' + str(parts.port)) if parts.port else ''}/v1/ingest/events"
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
self.key = key or env.get("TOWNHOUSE_KEY") or env.get("TOWNHOUSE_API_KEY") or dsn_key
|
|
181
|
+
self.endpoint = endpoint or dsn_endpoint or env.get("TOWNHOUSE_ENDPOINT") or DEFAULT_ENDPOINT
|
|
182
|
+
rel = release or env.get("TOWNHOUSE_RELEASE") or env.get("GIT_COMMIT") or env.get("RAILWAY_GIT_COMMIT_SHA") or env.get("RENDER_GIT_COMMIT") or env.get("GITHUB_SHA") or env.get("SOURCE_VERSION")
|
|
183
|
+
self.release = rel[:12] if rel and re.fullmatch(r"[0-9a-f]{40}", rel) else rel
|
|
184
|
+
self.environment = environment or env.get("TOWNHOUSE_ENVIRONMENT") or env.get("ENVIRONMENT") or "production"
|
|
185
|
+
self.server_name = server_name or _safe(socket.gethostname)
|
|
186
|
+
self.sample_rate = sample_rate
|
|
187
|
+
self.before_send = before_send
|
|
188
|
+
self.fingerprint = fingerprint
|
|
189
|
+
self.tags: Dict[str, str] = {}
|
|
190
|
+
for k, v in (tags or {}).items():
|
|
191
|
+
self.set_tag(k, v)
|
|
192
|
+
self.app_roots = [os.path.abspath(r) for r in (app_roots or [os.getcwd()])]
|
|
193
|
+
self.flush_interval = flush_interval
|
|
194
|
+
self.transport = transport or self._http_transport
|
|
195
|
+
self.debug = debug
|
|
196
|
+
self.breadcrumbs: List[Dict[str, Any]] = []
|
|
197
|
+
self.user: Optional[Dict[str, str]] = None
|
|
198
|
+
self._lock = threading.Lock()
|
|
199
|
+
self._queue: "queue.Queue[Dict[str, Any]]" = queue.Queue(maxsize=LIMITS["queue"])
|
|
200
|
+
self._recent: Dict[str, float] = {}
|
|
201
|
+
self._wake = threading.Event()
|
|
202
|
+
self._stopped = False
|
|
203
|
+
self._thread: Optional[threading.Thread] = None
|
|
204
|
+
self.contexts = {
|
|
205
|
+
"runtime": {"name": _platform.python_implementation(), "version": _platform.python_version()},
|
|
206
|
+
"os": {"name": _platform.system(), "version": _platform.release()},
|
|
207
|
+
}
|
|
208
|
+
if start_thread:
|
|
209
|
+
self._thread = threading.Thread(target=self._run, name="townhouse-sender", daemon=True)
|
|
210
|
+
self._thread.start()
|
|
211
|
+
atexit.register(self.close)
|
|
212
|
+
|
|
213
|
+
# Public API -------------------------------------------------------------------------------------------------
|
|
214
|
+
def set_tag(self, key: str, value: Any) -> None:
|
|
215
|
+
if len(self.tags) < LIMITS["tags"] or key in self.tags:
|
|
216
|
+
self.tags[str(key)[:200]] = scrub_string(value, 200)
|
|
217
|
+
|
|
218
|
+
def set_user(self, user_id: Optional[str]) -> None:
|
|
219
|
+
self.user = None if user_id is None else {"id": str(user_id)[:128]}
|
|
220
|
+
|
|
221
|
+
def add_breadcrumb(self, category: str = "ui", message: Optional[str] = None, level: str = "info", data: Optional[Dict[str, Any]] = None) -> None:
|
|
222
|
+
crumb = {
|
|
223
|
+
"ts": _now_iso(),
|
|
224
|
+
"category": category if category in ("navigation", "http", "console", "ui", "query") else "ui",
|
|
225
|
+
"message": scrub_string(message, LIMITS["breadcrumb_message"]) if message is not None else None,
|
|
226
|
+
"level": level if level in LEVELS else "info",
|
|
227
|
+
"data": scrub(data) if data else None,
|
|
228
|
+
}
|
|
229
|
+
with self._lock:
|
|
230
|
+
self.breadcrumbs.append(crumb)
|
|
231
|
+
del self.breadcrumbs[: max(0, len(self.breadcrumbs) - LIMITS["breadcrumbs"])]
|
|
232
|
+
|
|
233
|
+
def capture_exception(self, exc: Optional[BaseException] = None, *, handled: bool = True, mechanism: str = "python", level: str = "error", request: Optional[Dict[str, Any]] = None, tags: Optional[Dict[str, Any]] = None, fingerprint: Optional[List[str]] = None) -> Optional[str]:
|
|
234
|
+
try:
|
|
235
|
+
if exc is None:
|
|
236
|
+
exc = sys.exc_info()[1]
|
|
237
|
+
if exc is None:
|
|
238
|
+
return None
|
|
239
|
+
event = self._build(exc=exc, handled=handled, mechanism=mechanism, level=level, request=request, tags=tags, fingerprint=fingerprint)
|
|
240
|
+
return self._enqueue(event, exc)
|
|
241
|
+
except Exception:
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
def capture_message(self, message: str, level: str = "info", *, request: Optional[Dict[str, Any]] = None, tags: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
|
245
|
+
try:
|
|
246
|
+
event = self._build(message=message, level=level, request=request, tags=tags)
|
|
247
|
+
return self._enqueue(event, None)
|
|
248
|
+
except Exception:
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
def flush(self, timeout: float = 5.0) -> bool:
|
|
252
|
+
deadline = time.monotonic() + timeout
|
|
253
|
+
while not self._queue.empty() and time.monotonic() < deadline:
|
|
254
|
+
if not self._send_batch():
|
|
255
|
+
break
|
|
256
|
+
return self._queue.empty()
|
|
257
|
+
|
|
258
|
+
def close(self, timeout: float = 2.0) -> None:
|
|
259
|
+
if self._stopped:
|
|
260
|
+
return
|
|
261
|
+
self._stopped = True
|
|
262
|
+
self._wake.set()
|
|
263
|
+
_safe(lambda: self.flush(timeout))
|
|
264
|
+
|
|
265
|
+
# Internals --------------------------------------------------------------------------------------------------
|
|
266
|
+
def _build(self, exc: Optional[BaseException] = None, message: Optional[str] = None, handled: bool = True, mechanism: str = "python", level: str = "error", request: Optional[Dict[str, Any]] = None, tags: Optional[Dict[str, Any]] = None, fingerprint: Optional[List[str]] = None) -> Dict[str, Any]:
|
|
267
|
+
event: Dict[str, Any] = {
|
|
268
|
+
"eventId": str(uuid.uuid4()),
|
|
269
|
+
"timestamp": _now_iso(),
|
|
270
|
+
"sdk": {"name": SDK_NAME, "version": SDK_VERSION},
|
|
271
|
+
"platform": "python",
|
|
272
|
+
"environment": self.environment,
|
|
273
|
+
"release": self.release,
|
|
274
|
+
"serverName": self.server_name,
|
|
275
|
+
"level": level if level in LEVELS else "error",
|
|
276
|
+
}
|
|
277
|
+
if message is not None:
|
|
278
|
+
event["message"] = scrub_string(message, LIMITS["message"])
|
|
279
|
+
if exc is not None:
|
|
280
|
+
stack = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
|
281
|
+
event["exception"] = {
|
|
282
|
+
"type": type(exc).__name__,
|
|
283
|
+
"value": scrub_string(str(exc), LIMITS["value"]),
|
|
284
|
+
"stack": scrub_string(stack, LIMITS["stack"]),
|
|
285
|
+
"frames": frames_from_traceback(exc.__traceback__, self.app_roots),
|
|
286
|
+
"mechanism": {"handled": bool(handled), "type": mechanism if mechanism in MECHANISMS else "python"},
|
|
287
|
+
}
|
|
288
|
+
if request:
|
|
289
|
+
event["request"] = {
|
|
290
|
+
"method": (request.get("method") or "").upper()[:10] or None,
|
|
291
|
+
"url": path_only(request.get("url")),
|
|
292
|
+
"route": path_only(request.get("route")) if request.get("route") else None,
|
|
293
|
+
"headers": pick_headers(request.get("headers")),
|
|
294
|
+
}
|
|
295
|
+
if self.user:
|
|
296
|
+
event["user"] = dict(self.user)
|
|
297
|
+
with self._lock:
|
|
298
|
+
event["breadcrumbs"] = list(self.breadcrumbs)
|
|
299
|
+
merged = dict(self.tags)
|
|
300
|
+
for k, v in (tags or {}).items():
|
|
301
|
+
if v is not None:
|
|
302
|
+
merged[str(k)[:200]] = scrub_string(v, 200)
|
|
303
|
+
event["tags"] = dict(list(merged.items())[: LIMITS["tags"]])
|
|
304
|
+
event["contexts"] = dict(self.contexts)
|
|
305
|
+
fp = fingerprint or self.fingerprint
|
|
306
|
+
if fp:
|
|
307
|
+
event["fingerprint"] = [str(x)[:200] for x in fp][:10]
|
|
308
|
+
return {k: v for k, v in event.items() if v is not None}
|
|
309
|
+
|
|
310
|
+
def _enqueue(self, event: Dict[str, Any], exc: Optional[BaseException]) -> Optional[str]:
|
|
311
|
+
if not self.key:
|
|
312
|
+
return None
|
|
313
|
+
if self.sample_rate < 1 and random.random() >= self.sample_rate:
|
|
314
|
+
return None
|
|
315
|
+
ex = event.get("exception") or {}
|
|
316
|
+
frames = ex.get("frames") or []
|
|
317
|
+
inner = frames[-1] if frames else {}
|
|
318
|
+
dedupe = f"{ex.get('type')}|{ex.get('value') or event.get('message')}|{inner.get('filename')}:{inner.get('lineno')}"
|
|
319
|
+
now = time.monotonic()
|
|
320
|
+
last = self._recent.get(dedupe)
|
|
321
|
+
if last is not None and now - last < 1.0:
|
|
322
|
+
return None
|
|
323
|
+
self._recent[dedupe] = now
|
|
324
|
+
if len(self._recent) > 300:
|
|
325
|
+
self._recent.pop(next(iter(self._recent)))
|
|
326
|
+
if self.before_send:
|
|
327
|
+
try:
|
|
328
|
+
event = self.before_send(event, {"original_exception": exc})
|
|
329
|
+
except Exception:
|
|
330
|
+
pass
|
|
331
|
+
if not event:
|
|
332
|
+
return None
|
|
333
|
+
try:
|
|
334
|
+
self._queue.put_nowait(event)
|
|
335
|
+
except queue.Full:
|
|
336
|
+
_safe(self._queue.get_nowait)
|
|
337
|
+
_safe(lambda: self._queue.put_nowait(event))
|
|
338
|
+
self._wake.set()
|
|
339
|
+
return event.get("eventId")
|
|
340
|
+
|
|
341
|
+
def _take_batch(self) -> List[Dict[str, Any]]:
|
|
342
|
+
batch: List[Dict[str, Any]] = []
|
|
343
|
+
size = 12
|
|
344
|
+
while len(batch) < LIMITS["batch"]:
|
|
345
|
+
try:
|
|
346
|
+
item = self._queue.get_nowait()
|
|
347
|
+
except queue.Empty:
|
|
348
|
+
break
|
|
349
|
+
item_size = len(json.dumps(item, default=str)) + 1
|
|
350
|
+
if batch and size + item_size > LIMITS["body_bytes"]:
|
|
351
|
+
_safe(lambda: self._queue.put_nowait(item))
|
|
352
|
+
break
|
|
353
|
+
batch.append(item)
|
|
354
|
+
size += item_size
|
|
355
|
+
return batch
|
|
356
|
+
|
|
357
|
+
def _send_batch(self) -> bool:
|
|
358
|
+
batch = self._take_batch()
|
|
359
|
+
if not batch:
|
|
360
|
+
return True
|
|
361
|
+
body = gzip.compress(json.dumps({"events": batch}, default=str).encode("utf-8"))
|
|
362
|
+
headers = {"content-type": "application/json", "content-encoding": "gzip", "x-townhouse-key": self.key or "", "user-agent": f"townhouse-python/{SDK_VERSION}"}
|
|
363
|
+
try:
|
|
364
|
+
status = self.transport(body, headers, self.endpoint)
|
|
365
|
+
except Exception:
|
|
366
|
+
status = 0
|
|
367
|
+
if status == 202 or 200 <= status < 300 or (400 <= status < 500 and status not in (408, 429)):
|
|
368
|
+
return True
|
|
369
|
+
for item in batch:
|
|
370
|
+
_safe(lambda item=item: self._queue.put_nowait(item))
|
|
371
|
+
return False
|
|
372
|
+
|
|
373
|
+
@staticmethod
|
|
374
|
+
def _http_transport(body: bytes, headers: Dict[str, str], endpoint: str) -> int:
|
|
375
|
+
req = urllib.request.Request(endpoint, data=body, headers=headers, method="POST")
|
|
376
|
+
try:
|
|
377
|
+
with urllib.request.urlopen(req, timeout=10) as res:
|
|
378
|
+
return res.status
|
|
379
|
+
except urllib.error.HTTPError as e:
|
|
380
|
+
return e.code
|
|
381
|
+
except Exception:
|
|
382
|
+
return 0
|
|
383
|
+
|
|
384
|
+
def _run(self) -> None:
|
|
385
|
+
while not self._stopped:
|
|
386
|
+
self._wake.wait(self.flush_interval)
|
|
387
|
+
self._wake.clear()
|
|
388
|
+
if self._stopped:
|
|
389
|
+
break
|
|
390
|
+
time.sleep(0.05)
|
|
391
|
+
while not self._queue.empty():
|
|
392
|
+
if not self._send_batch():
|
|
393
|
+
time.sleep(min(30.0, self.flush_interval * 4))
|
|
394
|
+
break
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _safe(fn: Callable[[], Any], default: Any = None) -> Any:
|
|
398
|
+
try:
|
|
399
|
+
return fn()
|
|
400
|
+
except Exception:
|
|
401
|
+
return default
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Framework integrations: townhouse.integrations.asgi (FastAPI, Starlette), .flask, .django and .logging."""
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""ASGI middleware for FastAPI, Starlette and any ASGI app.
|
|
2
|
+
|
|
3
|
+
import townhouse
|
|
4
|
+
from townhouse.integrations.asgi import TownhouseMiddleware
|
|
5
|
+
townhouse.init(key="gh_live_...")
|
|
6
|
+
app.add_middleware(TownhouseMiddleware)
|
|
7
|
+
|
|
8
|
+
Reports unhandled exceptions and 5xx responses with the matched route (for example /orders/{order_id}), then re-raises
|
|
9
|
+
so the framework's own error handling still runs.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any, Dict, Optional
|
|
15
|
+
|
|
16
|
+
import townhouse
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _route_of(scope: Dict[str, Any]) -> Optional[str]:
|
|
20
|
+
route = scope.get("route")
|
|
21
|
+
path = getattr(route, "path", None) or getattr(route, "path_format", None)
|
|
22
|
+
return path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _headers(scope: Dict[str, Any]) -> Dict[str, str]:
|
|
26
|
+
out: Dict[str, str] = {}
|
|
27
|
+
for k, v in scope.get("headers") or []:
|
|
28
|
+
try:
|
|
29
|
+
out[k.decode("latin-1").lower()] = v.decode("latin-1")
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
32
|
+
return out
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TownhouseMiddleware:
|
|
36
|
+
def __init__(self, app: Any, capture_status: int = 500) -> None:
|
|
37
|
+
self.app = app
|
|
38
|
+
self.capture_status = capture_status
|
|
39
|
+
|
|
40
|
+
async def __call__(self, scope: Dict[str, Any], receive: Any, send: Any) -> None:
|
|
41
|
+
if scope.get("type") != "http":
|
|
42
|
+
await self.app(scope, receive, send)
|
|
43
|
+
return
|
|
44
|
+
client = townhouse.get_client()
|
|
45
|
+
status: Dict[str, int] = {"code": 0}
|
|
46
|
+
started = time.monotonic()
|
|
47
|
+
|
|
48
|
+
async def _send(message: Dict[str, Any]) -> None:
|
|
49
|
+
if message.get("type") == "http.response.start":
|
|
50
|
+
status["code"] = int(message.get("status") or 0)
|
|
51
|
+
await send(message)
|
|
52
|
+
|
|
53
|
+
def _request() -> Dict[str, Any]:
|
|
54
|
+
return {"method": scope.get("method"), "url": scope.get("path"), "route": _route_of(scope), "headers": _headers(scope)}
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
await self.app(scope, receive, _send)
|
|
58
|
+
except Exception as exc:
|
|
59
|
+
if client is not None:
|
|
60
|
+
client.capture_exception(exc, handled=False, mechanism="python", request=_request(), tags={"handler": "asgi"})
|
|
61
|
+
raise
|
|
62
|
+
finally:
|
|
63
|
+
if client is not None:
|
|
64
|
+
try:
|
|
65
|
+
route = _route_of(scope) or scope.get("path")
|
|
66
|
+
client.add_breadcrumb("http", f"{scope.get('method')} {route} {status['code']}", "error" if status["code"] >= 500 else "info", {"status": status["code"], "ms": int((time.monotonic() - started) * 1000)})
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
if client is not None and status["code"] >= self.capture_status:
|
|
70
|
+
err = RuntimeError(f"{scope.get('method')} {_route_of(scope) or scope.get('path')} responded {status['code']}")
|
|
71
|
+
client.capture_exception(err, handled=True, mechanism="python", request=_request(), tags={"handler": "asgi", "http.status": str(status["code"])})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Django integration.
|
|
2
|
+
|
|
3
|
+
settings.py:
|
|
4
|
+
MIDDLEWARE = ["townhouse.integrations.django.TownhouseMiddleware", *MIDDLEWARE]
|
|
5
|
+
|
|
6
|
+
and call townhouse.init(key=...) in settings.py or wsgi.py. Reports unhandled view exceptions (through
|
|
7
|
+
process_exception, so Django's own handling still runs) and 5xx responses, with the URL pattern as the route.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Callable, Dict, Optional
|
|
12
|
+
|
|
13
|
+
import townhouse
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _route(request: Any) -> Optional[str]:
|
|
17
|
+
match = getattr(request, "resolver_match", None)
|
|
18
|
+
route = getattr(match, "route", None)
|
|
19
|
+
if route:
|
|
20
|
+
return "/" + route.lstrip("^").rstrip("$").lstrip("/")
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _request_ctx(request: Any) -> Dict[str, Any]:
|
|
25
|
+
headers = {k.lower(): v for k, v in getattr(request, "headers", {}).items()}
|
|
26
|
+
return {"method": request.method, "url": request.path, "route": _route(request), "headers": headers}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TownhouseMiddleware:
|
|
30
|
+
capture_status = 500
|
|
31
|
+
|
|
32
|
+
def __init__(self, get_response: Callable[[Any], Any]) -> None:
|
|
33
|
+
self.get_response = get_response
|
|
34
|
+
|
|
35
|
+
def __call__(self, request: Any) -> Any:
|
|
36
|
+
response = self.get_response(request)
|
|
37
|
+
client = townhouse.get_client()
|
|
38
|
+
try:
|
|
39
|
+
if client is not None:
|
|
40
|
+
ctx = _request_ctx(request)
|
|
41
|
+
client.add_breadcrumb("http", f"{ctx['method']} {ctx['route'] or ctx['url']} {response.status_code}", "error" if response.status_code >= 500 else "info", {"status": response.status_code})
|
|
42
|
+
if response.status_code >= self.capture_status and not getattr(request, "_townhouse_captured", False):
|
|
43
|
+
err = RuntimeError(f"{ctx['method']} {ctx['route'] or ctx['url']} responded {response.status_code}")
|
|
44
|
+
client.capture_exception(err, handled=True, mechanism="python", request=ctx, tags={"handler": "django", "http.status": str(response.status_code)})
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
return response
|
|
48
|
+
|
|
49
|
+
def process_exception(self, request: Any, exception: BaseException) -> None:
|
|
50
|
+
client = townhouse.get_client()
|
|
51
|
+
if client is None:
|
|
52
|
+
return None
|
|
53
|
+
try:
|
|
54
|
+
client.capture_exception(exception, handled=False, mechanism="python", request=_request_ctx(request), tags={"handler": "django"})
|
|
55
|
+
request._townhouse_captured = True
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
return None
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Flask integration.
|
|
2
|
+
|
|
3
|
+
import townhouse
|
|
4
|
+
from townhouse.integrations.flask import init_app
|
|
5
|
+
townhouse.init(key="gh_live_...")
|
|
6
|
+
init_app(app)
|
|
7
|
+
|
|
8
|
+
Uses Flask's got_request_exception signal, so your error handlers keep working, and reports 5xx responses with the URL
|
|
9
|
+
rule (for example /orders/<int:order_id>).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Dict
|
|
14
|
+
|
|
15
|
+
import townhouse
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _request_ctx() -> Dict[str, Any]:
|
|
19
|
+
from flask import request
|
|
20
|
+
|
|
21
|
+
rule = getattr(request, "url_rule", None)
|
|
22
|
+
return {"method": request.method, "url": request.path, "route": getattr(rule, "rule", None), "headers": dict(request.headers)}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def init_app(app: Any, capture_status: int = 500) -> None:
|
|
26
|
+
from flask import got_request_exception, g
|
|
27
|
+
|
|
28
|
+
def _on_exception(sender: Any, exception: BaseException, **extra: Any) -> None:
|
|
29
|
+
client = townhouse.get_client()
|
|
30
|
+
if client is None:
|
|
31
|
+
return
|
|
32
|
+
try:
|
|
33
|
+
client.capture_exception(exception, handled=False, mechanism="python", request=_request_ctx(), tags={"handler": "flask"})
|
|
34
|
+
g._townhouse_captured = True
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
got_request_exception.connect(_on_exception, app, weak=False)
|
|
39
|
+
|
|
40
|
+
@app.after_request
|
|
41
|
+
def _after(response: Any) -> Any:
|
|
42
|
+
client = townhouse.get_client()
|
|
43
|
+
try:
|
|
44
|
+
if client is not None:
|
|
45
|
+
ctx = _request_ctx()
|
|
46
|
+
client.add_breadcrumb("http", f"{ctx['method']} {ctx['route'] or ctx['url']} {response.status_code}", "error" if response.status_code >= 500 else "info", {"status": response.status_code})
|
|
47
|
+
if response.status_code >= capture_status and not getattr(g, "_townhouse_captured", False):
|
|
48
|
+
err = RuntimeError(f"{ctx['method']} {ctx['route'] or ctx['url']} responded {response.status_code}")
|
|
49
|
+
client.capture_exception(err, handled=True, mechanism="python", request=ctx, tags={"handler": "flask", "http.status": str(response.status_code)})
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
return response
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Logging integration: records log lines as breadcrumbs and reports ERROR records (and above) as events.
|
|
2
|
+
|
|
3
|
+
import logging, townhouse
|
|
4
|
+
from townhouse.integrations.logging import TownhouseHandler
|
|
5
|
+
townhouse.init(key="gh_live_...")
|
|
6
|
+
logging.getLogger().addHandler(TownhouseHandler())
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
import townhouse
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TownhouseHandler(logging.Handler):
|
|
16
|
+
def __init__(self, event_level: int = logging.ERROR, breadcrumb_level: int = logging.INFO) -> None:
|
|
17
|
+
super().__init__(level=min(event_level, breadcrumb_level))
|
|
18
|
+
self.event_level = event_level
|
|
19
|
+
self.breadcrumb_level = breadcrumb_level
|
|
20
|
+
|
|
21
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
22
|
+
client = townhouse.get_client()
|
|
23
|
+
if client is None or record.name.startswith("townhouse"):
|
|
24
|
+
return
|
|
25
|
+
try:
|
|
26
|
+
level = "fatal" if record.levelno >= logging.CRITICAL else "error" if record.levelno >= logging.ERROR else "warning" if record.levelno >= logging.WARNING else "info"
|
|
27
|
+
message = record.getMessage()
|
|
28
|
+
if record.levelno >= self.event_level:
|
|
29
|
+
tags = {"logger": record.name}
|
|
30
|
+
if record.exc_info and record.exc_info[1] is not None:
|
|
31
|
+
client.capture_exception(record.exc_info[1], handled=True, mechanism="python", level=level, tags=tags)
|
|
32
|
+
else:
|
|
33
|
+
client.capture_message(message, level, tags=tags)
|
|
34
|
+
elif record.levelno >= self.breadcrumb_level:
|
|
35
|
+
client.add_breadcrumb("console", f"{record.name}: {message}", level)
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: townhouse
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Townhouse error tracking for Python: FastAPI, Starlette, Flask, Django, logging and plain scripts
|
|
5
|
+
Author: Townhouse
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://townhouse.dev
|
|
8
|
+
Keywords: townhouse,error tracking,monitoring,fastapi,flask,django
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Framework :: FastAPI
|
|
11
|
+
Classifier: Framework :: Flask
|
|
12
|
+
Classifier: Framework :: Django
|
|
13
|
+
Classifier: Topic :: System :: Monitoring
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Provides-Extra: test
|
|
17
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
18
|
+
Requires-Dist: fastapi; extra == "test"
|
|
19
|
+
Requires-Dist: httpx; extra == "test"
|
|
20
|
+
Requires-Dist: flask; extra == "test"
|
|
21
|
+
Requires-Dist: django; extra == "test"
|
|
22
|
+
|
|
23
|
+
# townhouse (Python)
|
|
24
|
+
|
|
25
|
+
Error tracking for Python apps, sent to Townhouse: errors grouped into problems, linked to the part of your app they
|
|
26
|
+
break, and fixable with your own model.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install townhouse
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import os, townhouse
|
|
34
|
+
townhouse.init(key=os.environ["TOWNHOUSE_KEY"], release=os.environ.get("GIT_SHA"), environment="production")
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`TOWNHOUSE_KEY` is your project's ingest key (`gh_live_...`), from the Townhouse app. A DSN works too:
|
|
38
|
+
`townhouse.init(dsn="https://gh_live_xxx@api.townhouse.dev/<projectId>")`.
|
|
39
|
+
|
|
40
|
+
## Frameworks
|
|
41
|
+
|
|
42
|
+
FastAPI and Starlette:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from townhouse.integrations.asgi import TownhouseMiddleware
|
|
46
|
+
app.add_middleware(TownhouseMiddleware)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Flask:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from townhouse.integrations.flask import init_app
|
|
53
|
+
init_app(app)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Django (`settings.py`):
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
MIDDLEWARE = ["townhouse.integrations.django.TownhouseMiddleware", *MIDDLEWARE]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Logging (log lines become breadcrumbs, `ERROR` and above become events):
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import logging
|
|
66
|
+
from townhouse.integrations.logging import TownhouseHandler
|
|
67
|
+
logging.getLogger().addHandler(TownhouseHandler())
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## What it sends
|
|
71
|
+
|
|
72
|
+
- Uncaught exceptions from the main thread and other threads, with the process exiting as it would have.
|
|
73
|
+
- Unhandled view or endpoint exceptions and 5xx responses, with the route pattern.
|
|
74
|
+
- Frames ordered outermost first, marked in-app for your own files and not for installed packages.
|
|
75
|
+
- Emails and tokens masked, secret-looking fields redacted, query strings dropped, and only four request headers kept.
|
|
76
|
+
- Batches gzipped from a background thread, flushed at exit. No dependencies outside the standard library.
|
|
77
|
+
|
|
78
|
+
Manual capture: `townhouse.capture_exception(exc)`, `townhouse.capture_message("text", "warning")`,
|
|
79
|
+
`townhouse.add_breadcrumb("query", "SELECT orders")`, `townhouse.set_tag("tenant", "acme")`, `townhouse.set_user("u_123")`.
|
|
80
|
+
|
|
81
|
+
## Developing and testing
|
|
82
|
+
|
|
83
|
+
The tests cover the client and the FastAPI, Flask and Django integrations, so they need those frameworks. Install the
|
|
84
|
+
package in editable mode with its `test` extra, then run pytest from this folder:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
python3 -m venv .venv && . .venv/bin/activate
|
|
88
|
+
pip install -e ".[test]"
|
|
89
|
+
python -m pytest -q
|
|
90
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
tests/test_client.py
|
|
4
|
+
tests/test_frameworks.py
|
|
5
|
+
townhouse/__init__.py
|
|
6
|
+
townhouse/client.py
|
|
7
|
+
townhouse.egg-info/PKG-INFO
|
|
8
|
+
townhouse.egg-info/SOURCES.txt
|
|
9
|
+
townhouse.egg-info/dependency_links.txt
|
|
10
|
+
townhouse.egg-info/requires.txt
|
|
11
|
+
townhouse.egg-info/top_level.txt
|
|
12
|
+
townhouse/integrations/__init__.py
|
|
13
|
+
townhouse/integrations/asgi.py
|
|
14
|
+
townhouse/integrations/django.py
|
|
15
|
+
townhouse/integrations/flask.py
|
|
16
|
+
townhouse/integrations/logging.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
townhouse
|