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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,7 @@
1
+
2
+ [test]
3
+ pytest>=8
4
+ fastapi
5
+ httpx
6
+ flask
7
+ django
@@ -0,0 +1 @@
1
+ townhouse