getfaultline 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- getfaultline-0.1.0/.gitignore +13 -0
- getfaultline-0.1.0/ARCHITECTURE.md +194 -0
- getfaultline-0.1.0/CHANGELOG.md +19 -0
- getfaultline-0.1.0/LICENSE +21 -0
- getfaultline-0.1.0/PKG-INFO +383 -0
- getfaultline-0.1.0/README.md +329 -0
- getfaultline-0.1.0/examples/__init__.py +0 -0
- getfaultline-0.1.0/examples/fastapi_app.py +62 -0
- getfaultline-0.1.0/examples/flask_app.py +64 -0
- getfaultline-0.1.0/pyproject.toml +142 -0
- getfaultline-0.1.0/src/faultline/__init__.py +405 -0
- getfaultline-0.1.0/src/faultline/_version.py +1 -0
- getfaultline-0.1.0/src/faultline/application/__init__.py +33 -0
- getfaultline-0.1.0/src/faultline/application/client.py +377 -0
- getfaultline-0.1.0/src/faultline/application/hub.py +210 -0
- getfaultline-0.1.0/src/faultline/application/ports.py +81 -0
- getfaultline-0.1.0/src/faultline/domain/__init__.py +107 -0
- getfaultline-0.1.0/src/faultline/domain/event.py +109 -0
- getfaultline-0.1.0/src/faultline/domain/limits.py +41 -0
- getfaultline-0.1.0/src/faultline/domain/normalize.py +117 -0
- getfaultline-0.1.0/src/faultline/domain/options.py +192 -0
- getfaultline-0.1.0/src/faultline/domain/processing.py +403 -0
- getfaultline-0.1.0/src/faultline/domain/scope.py +158 -0
- getfaultline-0.1.0/src/faultline/domain/scrub.py +85 -0
- getfaultline-0.1.0/src/faultline/infrastructure/__init__.py +21 -0
- getfaultline-0.1.0/src/faultline/infrastructure/runtime.py +98 -0
- getfaultline-0.1.0/src/faultline/infrastructure/source.py +48 -0
- getfaultline-0.1.0/src/faultline/infrastructure/stack.py +142 -0
- getfaultline-0.1.0/src/faultline/infrastructure/transport.py +414 -0
- getfaultline-0.1.0/src/faultline/integrations/__init__.py +18 -0
- getfaultline-0.1.0/src/faultline/integrations/_common.py +138 -0
- getfaultline-0.1.0/src/faultline/integrations/asgi.py +53 -0
- getfaultline-0.1.0/src/faultline/integrations/django.py +101 -0
- getfaultline-0.1.0/src/faultline/integrations/excepthook.py +86 -0
- getfaultline-0.1.0/src/faultline/integrations/fastapi.py +28 -0
- getfaultline-0.1.0/src/faultline/integrations/flask.py +43 -0
- getfaultline-0.1.0/src/faultline/integrations/logging.py +118 -0
- getfaultline-0.1.0/src/faultline/integrations/stdlib_http.py +137 -0
- getfaultline-0.1.0/src/faultline/integrations/wsgi.py +81 -0
- getfaultline-0.1.0/src/faultline/py.typed +0 -0
- getfaultline-0.1.0/tests/__init__.py +0 -0
- getfaultline-0.1.0/tests/conftest.py +227 -0
- getfaultline-0.1.0/tests/contract.py +155 -0
- getfaultline-0.1.0/tests/test_architecture.py +103 -0
- getfaultline-0.1.0/tests/test_client.py +172 -0
- getfaultline-0.1.0/tests/test_django.py +68 -0
- getfaultline-0.1.0/tests/test_exceptions.py +139 -0
- getfaultline-0.1.0/tests/test_fastapi.py +109 -0
- getfaultline-0.1.0/tests/test_flask.py +93 -0
- getfaultline-0.1.0/tests/test_integrations.py +205 -0
- getfaultline-0.1.0/tests/test_never_throws.py +142 -0
- getfaultline-0.1.0/tests/test_process.py +87 -0
- getfaultline-0.1.0/tests/test_scope.py +153 -0
- getfaultline-0.1.0/tests/test_scrubbing.py +161 -0
- getfaultline-0.1.0/tests/test_stack.py +163 -0
- getfaultline-0.1.0/tests/test_transport.py +250 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# Architecture decision log: `getfaultline` (Python)
|
|
2
|
+
|
|
3
|
+
## Layers
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
src/faultline/
|
|
7
|
+
domain/ pure Python: event contract (TypedDicts), Scope, options validation, limits,
|
|
8
|
+
scrubbing, JSON normalization, event sanitization, dedupe key
|
|
9
|
+
application/ FaultlineClient, Hub (contextvars scopes), Integration protocol
|
|
10
|
+
ports.py Transport, StackExtractor, SourceContextReader, Clock, IdGenerator,
|
|
11
|
+
RandomSource, Logger, RuntimeInfo
|
|
12
|
+
infrastructure/ HttpTransport (worker thread), TracebackStackExtractor,
|
|
13
|
+
LinecacheSourceReader, runtime adapters (clock, uuid, stderr logger, os)
|
|
14
|
+
integrations/ excepthook, threading, logging, http.client, wsgi, asgi, flask, fastapi,
|
|
15
|
+
django
|
|
16
|
+
__init__.py public API + composition root
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Dependency rule: `domain <- application <- infrastructure | integrations <- faultline`.
|
|
20
|
+
`infrastructure` and `integrations` are siblings and may not import each other. They meet only
|
|
21
|
+
in `faultline/__init__.py`. This mirrors the Node SDK one to one.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
### ADR-001: Enforce boundaries with import-linter plus an AST test
|
|
26
|
+
|
|
27
|
+
- **Decision:** `pyproject.toml` declares three import-linter contracts: the layer order
|
|
28
|
+
(`integrations | infrastructure > application > domain`), a pure domain (no `os`, `sys`,
|
|
29
|
+
`socket`, `http`, `threading`, `asyncio`, `logging`, `contextvars`, ...), and "frameworks
|
|
30
|
+
only in their integration module". `tests/test_architecture.py` checks the same rules with
|
|
31
|
+
`ast`, so the rule also holds where import-linter is not run.
|
|
32
|
+
- **Verification:** We added deliberate violations (`import os` and an infrastructure import
|
|
33
|
+
in `domain/limits.py`) and confirmed both contracts break.
|
|
34
|
+
- **Note:** `urllib.parse` is allowed in the domain (it is pure and needed to decode query
|
|
35
|
+
keys while scrubbing). import-linter cannot tell `urllib.parse` from `urllib.request`, so the
|
|
36
|
+
AST test forbids `urllib.request` explicitly.
|
|
37
|
+
|
|
38
|
+
### ADR-002: Zero runtime dependencies; hand-written options validator
|
|
39
|
+
|
|
40
|
+
- **Decision:** `resolve_options()` validates `init()` options by hand and never raises. An
|
|
41
|
+
invalid optional value falls back to its default with a warning; only a missing `api_key`
|
|
42
|
+
or an unusable `endpoint` disables the SDK. Unknown keyword arguments are ignored with a
|
|
43
|
+
warning instead of raising `TypeError`.
|
|
44
|
+
- **Why:** Same reasoning as the Node SDK's ADR-002. pydantic is a test-only dependency: every
|
|
45
|
+
produced event is validated against strict pydantic models that mirror CONTRACTS.md
|
|
46
|
+
(`tests/contract.py`).
|
|
47
|
+
|
|
48
|
+
### ADR-003: `contextvars` used directly in the application layer
|
|
49
|
+
|
|
50
|
+
- **Decision:** The `Hub` owns a `ContextVar[Scope]`. `with_scope()` forks the current scope
|
|
51
|
+
with `clone()` and sets it for the block; framework middleware does the same per request.
|
|
52
|
+
- **Why not a port (as Node did with AsyncLocalStorage)?** `contextvars` is a pure language
|
|
53
|
+
primitive with no I/O and identical behavior on every supported Python, so a port adds
|
|
54
|
+
indirection without a second implementation. The domain still never touches it.
|
|
55
|
+
- **Semantics:** asyncio tasks copy the context when created, so tasks spawned inside a scope
|
|
56
|
+
inherit it and concurrent tasks are isolated. New threads start from the root scope unless
|
|
57
|
+
started via `contextvars.copy_context().run`, matching the stdlib's own semantics.
|
|
58
|
+
`ContextVar.reset` failures (a block exited in another context) fall back to restoring the
|
|
59
|
+
previous value instead of raising.
|
|
60
|
+
|
|
61
|
+
### ADR-004: Copy-on-write Scope
|
|
62
|
+
|
|
63
|
+
- **Decision:** Every `Scope` mutation replaces its dict/tuple instead of changing it in place.
|
|
64
|
+
`clone()` and `get_data()` are O(1) reference copies.
|
|
65
|
+
- **Why:** The root scope can be written from many threads at once. With copy-on-write a
|
|
66
|
+
snapshot taken by the client can never change underneath it, and no lock is needed in the
|
|
67
|
+
(pure) domain. Concurrent writers to the same scope may lose an update, but never crash.
|
|
68
|
+
|
|
69
|
+
### ADR-005: Synchronous event processing, asynchronous delivery
|
|
70
|
+
|
|
71
|
+
- **Node:** splits capture into a sync phase and an async phase (source reads use
|
|
72
|
+
`fs.promises`).
|
|
73
|
+
- **Python:** everything up to `transport.send()` runs in the caller's thread: traceback
|
|
74
|
+
extraction, sampling, `ignore_errors`, dedupe, source context, sanitization, scrubbing and
|
|
75
|
+
`before_send`. Delivery (batching, gzip, HTTP, retries) runs on a worker thread.
|
|
76
|
+
- **Why:** Traceback objects reference live frames and must be read before the caller moves
|
|
77
|
+
on; `linecache` is cached and in-memory after the first read. Doing it synchronously also
|
|
78
|
+
means `capture_exception` returns `None` when `before_send` drops the event (Node returns
|
|
79
|
+
the id anyway). Serialization happens in `send()` so the queued event is a frozen snapshot
|
|
80
|
+
and its size is known up front.
|
|
81
|
+
|
|
82
|
+
### ADR-006: `before_send` receives scrubbed events; a raising `before_send` drops the event
|
|
83
|
+
|
|
84
|
+
Identical to the Node SDK (ADR-005 there). The result is re-sanitized, so a buggy hook cannot
|
|
85
|
+
produce a payload that violates the contract.
|
|
86
|
+
|
|
87
|
+
### ADR-007: `send_default_pii` semantics
|
|
88
|
+
|
|
89
|
+
- **`False` (default):** `user.ipAddress` is removed and the `cookie`, `set-cookie`,
|
|
90
|
+
`authorization`, `proxy-authorization`, `x-forwarded-for`, `x-real-ip` and `forwarded`
|
|
91
|
+
headers are removed entirely. Framework integrations do not collect the client IP.
|
|
92
|
+
- **`True`:** the IP (`REMOTE_ADDR` / ASGI `client`) is collected and those headers are kept.
|
|
93
|
+
Values under sensitive keys are still replaced with `"[Filtered]"`.
|
|
94
|
+
|
|
95
|
+
### ADR-008: Cause chain and exception groups
|
|
96
|
+
|
|
97
|
+
- **Chain:** `__cause__`, else `__context__` unless `__suppress_context__`. At most 5
|
|
98
|
+
exceptions in total (root plus 4), as in Node's ADR-007. Cycles are broken with an
|
|
99
|
+
identity set.
|
|
100
|
+
- **Exception groups:** the contract has a single `cause` slot, so a group is reported as one
|
|
101
|
+
exception whose `value` lists up to 10 sub-exceptions (`ValueError('x'), ...`) and whose
|
|
102
|
+
`cause` is its explicit cause/context or, failing that, its first sub-exception. Detection
|
|
103
|
+
is duck-typed, so the `exceptiongroup` backport on 3.9/3.10 works too.
|
|
104
|
+
- **Non-exception values** (`capture_exception("text")`, a dict, ...) become a synthetic
|
|
105
|
+
`Error` with the caller's stack (SDK frames removed).
|
|
106
|
+
|
|
107
|
+
### ADR-009: In-app rules and frame data
|
|
108
|
+
|
|
109
|
+
- Not in-app: paths containing `/site-packages/` or `/dist-packages/`, paths under the
|
|
110
|
+
interpreter's stdlib (`sysconfig` `stdlib`/`platstdlib` and `base_prefix/lib/pythonX.Y`),
|
|
111
|
+
synthetic filenames starting with `<`, and modules named `faultline` / `faultline.*`.
|
|
112
|
+
Windows paths are normalized (`\` to `/`, drive paths lowercased).
|
|
113
|
+
- `function` is `co_qualname` on 3.11+ (`Class.method`), `co_name` before. `colno` (1-based)
|
|
114
|
+
comes from `co_positions()` on 3.11+.
|
|
115
|
+
- `filename` is the absolute path, as in the Node SDK.
|
|
116
|
+
|
|
117
|
+
### ADR-010: Dedupe
|
|
118
|
+
|
|
119
|
+
Same rule as Node's ADR-009 (consecutive events only, 1 s window from the last sent event;
|
|
120
|
+
key = fingerprint, else type + value + newest 5 frames, else level + message), guarded by a
|
|
121
|
+
lock because captures can come from many threads.
|
|
122
|
+
|
|
123
|
+
### ADR-011: Transport
|
|
124
|
+
|
|
125
|
+
- **Queue:** `deque`, max 100 events, drop oldest. Guarded by a `Condition`.
|
|
126
|
+
- **Worker:** one daemon thread (`faultline-transport`), started lazily on the first event.
|
|
127
|
+
It sends when 20 events are queued, every 2 s, or when `flush()` asks. A send lock ensures a
|
|
128
|
+
single sender.
|
|
129
|
+
- **Batches:** at most 50 events and 1 MB uncompressed, gzip-compressed. Oversized events are
|
|
130
|
+
trimmed (breadcrumbs, then source context, then extra) or dropped.
|
|
131
|
+
- **Retries:** network errors, timeouts (5 s), `5xx` and `408` are retried inline with equal
|
|
132
|
+
jitter `min(30, 2^n)/2 + rand * min(30, 2^n)/2` seconds, up to 3 retries. Backoff sleeps use
|
|
133
|
+
an `Event` so `close()` interrupts them. `429` pauses the transport until `Retry-After`
|
|
134
|
+
(seconds or HTTP date, capped at 10 min) and requeues the batch at the front; an event is
|
|
135
|
+
dropped after 3 rate-limited attempts. `401`, `413` and other `4xx` drop the batch.
|
|
136
|
+
- **HTTP client:** `urllib.request` (honors proxy environment variables). The SDK's own
|
|
137
|
+
requests are recognized by the http integration via the ingest URL and the worker thread
|
|
138
|
+
name and are never recorded as breadcrumbs.
|
|
139
|
+
- **Shutdown:** `atexit` calls `close(2.0)`: flush with a deadline, then stop. Because the worker
|
|
140
|
+
is a daemon thread and every wait is bounded, exit never hangs (tested with a server that
|
|
141
|
+
never answers and with no server at all). If the worker cannot run (for example a thread
|
|
142
|
+
cannot be started during interpreter shutdown), `flush()` sends from the calling thread
|
|
143
|
+
with the same deadline.
|
|
144
|
+
- **Fork safety:** `os.register_at_fork(after_in_child=...)` resets locks and the worker in
|
|
145
|
+
the child; queued events are kept and sent by a new worker.
|
|
146
|
+
|
|
147
|
+
### ADR-012: Integrations patch politely and restore exactly
|
|
148
|
+
|
|
149
|
+
- `sys.excepthook` / `threading.excepthook`: capture, then always call the previous hook.
|
|
150
|
+
`KeyboardInterrupt` and thread `SystemExit` are not captured.
|
|
151
|
+
- `logging`: wraps `Logger.callHandlers` (sees every record that passes level checks, needs no
|
|
152
|
+
handler). A thread-local reentrancy guard and the ignored `faultline` logger prevent loops.
|
|
153
|
+
- `http.client`: wraps `putrequest` / `getresponse` and never changes arguments, return values
|
|
154
|
+
or exceptions. The scope is captured at `putrequest`, so a response read in another context
|
|
155
|
+
still lands in the right scope.
|
|
156
|
+
- `teardown()` restores the original only if our wrapper is still installed, so we never undo
|
|
157
|
+
somebody else's later patch.
|
|
158
|
+
|
|
159
|
+
### ADR-013: Framework integrations
|
|
160
|
+
|
|
161
|
+
- Each lives in its own module and imports its framework lazily (enforced by import-linter
|
|
162
|
+
and the AST test), so `import faultline` never imports Flask, Django or Starlette.
|
|
163
|
+
- **WSGI:** each request runs in `contextvars.copy_context()`; the response iterable is
|
|
164
|
+
wrapped so streaming and `close()` run in the request context and streaming errors are
|
|
165
|
+
captured.
|
|
166
|
+
- **ASGI:** the request runs inside `with_scope()`; `lifespan` passes through untouched.
|
|
167
|
+
- **Flask:** WSGI middleware for scope and request data, plus `got_request_exception` for
|
|
168
|
+
capture (Flask turns exceptions into 500 responses before WSGI middleware can see them).
|
|
169
|
+
- **Django:** sync and async capable middleware for the scope, plus Django's
|
|
170
|
+
`got_request_exception` signal for capture (fires only for real 500s).
|
|
171
|
+
- An exception is marked once captured, so stacked integrations (Flask signal plus WSGI with
|
|
172
|
+
`PROPAGATE_EXCEPTIONS`) never report it twice.
|
|
173
|
+
|
|
174
|
+
### ADR-014: Server-side caps enforced client-side
|
|
175
|
+
|
|
176
|
+
The server's ingest schema also caps fingerprint parts (20), header/query keys (256) and values
|
|
177
|
+
(8192), and `user.ipAddress` (64). These are not pinned in CONTRACTS.md, but sending more
|
|
178
|
+
would get the event rejected, so the SDK truncates to them.
|
|
179
|
+
|
|
180
|
+
### ADR-015: Toolchain
|
|
181
|
+
|
|
182
|
+
- hatchling, src layout, version in `src/faultline/_version.py`.
|
|
183
|
+
- ruff (lint + format), mypy `--strict` (tests and examples relax a few error codes for
|
|
184
|
+
deliberately malformed fixtures and untyped framework decorators; the package does not).
|
|
185
|
+
- CI runs lint/typecheck/import contracts once and tests on 3.9 to 3.14 (plus Windows and
|
|
186
|
+
macOS on 3.13), then builds, runs `twine check --strict`, and smoke-tests the wheel in an
|
|
187
|
+
empty venv. Locally the suite was also run on 3.9.6 (Django 4.2) and 3.14.
|
|
188
|
+
|
|
189
|
+
### Suggestions (not implemented, outside spec)
|
|
190
|
+
|
|
191
|
+
- Offline persistence of the queue across restarts.
|
|
192
|
+
- A `transport` option for custom transports (serverless, tests).
|
|
193
|
+
- Celery / RQ integrations (per-task scope and capture).
|
|
194
|
+
- Capturing local variables per frame (opt-in, PII-sensitive).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `getfaultline` are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - unreleased
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `init`, `capture_exception`, `capture_message`, `set_user`, `set_tag(s)`, `set_context`,
|
|
12
|
+
`set_extra`, `add_breadcrumb`, `with_scope`, `flush`, `close`, `is_enabled`.
|
|
13
|
+
- Tracebacks ordered oldest to newest with in-app detection, source context and columns
|
|
14
|
+
(3.11+); `__cause__` / `__context__` chains (max 5); exception group support.
|
|
15
|
+
- Background batched transport: gzip, retries with backoff and jitter, `429 Retry-After`,
|
|
16
|
+
drops on `401` / `413`, `atexit` flush, fork safety.
|
|
17
|
+
- Client-side scrubbing identical to the server and the Node SDK; dedupe within 1 s.
|
|
18
|
+
- Integrations: `sys.excepthook`, `threading.excepthook`, `logging`, `http.client`, WSGI,
|
|
19
|
+
ASGI, Flask, FastAPI / Starlette, Django.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Faultline
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: getfaultline
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Faultline error tracking
|
|
5
|
+
Project-URL: Homepage, https://www.faultsline.dev
|
|
6
|
+
Project-URL: Source, https://github.com/getfaultline/faultline-python
|
|
7
|
+
Project-URL: Issues, https://github.com/getfaultline/faultline-python/issues
|
|
8
|
+
Author: Faultline
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: django,error-tracking,fastapi,faultline,flask,monitoring,sdk
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: Django
|
|
14
|
+
Classifier: Framework :: FastAPI
|
|
15
|
+
Classifier: Framework :: Flask
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
26
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
27
|
+
Classifier: Topic :: System :: Monitoring
|
|
28
|
+
Classifier: Typing :: Typed
|
|
29
|
+
Requires-Python: >=3.9
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
32
|
+
Requires-Dist: fastapi>=0.100; extra == 'dev'
|
|
33
|
+
Requires-Dist: flask>=2.2; extra == 'dev'
|
|
34
|
+
Requires-Dist: httpx>=0.24; extra == 'dev'
|
|
35
|
+
Requires-Dist: import-linter>=2.0; extra == 'dev'
|
|
36
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
37
|
+
Requires-Dist: pydantic>=2.5; extra == 'dev'
|
|
38
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
39
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
40
|
+
Requires-Dist: twine>=5; extra == 'dev'
|
|
41
|
+
Provides-Extra: django
|
|
42
|
+
Requires-Dist: django>=4.2; extra == 'django'
|
|
43
|
+
Provides-Extra: fastapi
|
|
44
|
+
Requires-Dist: fastapi>=0.100; extra == 'fastapi'
|
|
45
|
+
Provides-Extra: flask
|
|
46
|
+
Requires-Dist: flask>=2.2; extra == 'flask'
|
|
47
|
+
Provides-Extra: test
|
|
48
|
+
Requires-Dist: fastapi>=0.100; extra == 'test'
|
|
49
|
+
Requires-Dist: flask>=2.2; extra == 'test'
|
|
50
|
+
Requires-Dist: httpx>=0.24; extra == 'test'
|
|
51
|
+
Requires-Dist: pydantic>=2.5; extra == 'test'
|
|
52
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
53
|
+
Description-Content-Type: text/markdown
|
|
54
|
+
|
|
55
|
+
# getfaultline
|
|
56
|
+
|
|
57
|
+
The Python SDK for [Faultline](https://www.faultsline.dev), a self-hostable error-tracking
|
|
58
|
+
platform. It captures exceptions and messages, adds context (tracebacks with source,
|
|
59
|
+
breadcrumbs, request data, user, tags), scrubs secrets, and sends batched, gzip-compressed
|
|
60
|
+
events to the Faultline ingest API.
|
|
61
|
+
|
|
62
|
+
- **No runtime dependencies.** Uses only the standard library (`urllib`, `gzip`, `threading`,
|
|
63
|
+
`contextvars`, `linecache`).
|
|
64
|
+
- **Never crashes your app.** Every public function is exception-safe, and internal errors are
|
|
65
|
+
logged only when `debug=True`.
|
|
66
|
+
- **Typed** (`py.typed`, `mypy --strict` clean). Runs on Python **3.9 to 3.14**.
|
|
67
|
+
- **Async-safe and thread-safe scopes** built on `contextvars`.
|
|
68
|
+
|
|
69
|
+
## Quickstart
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install getfaultline
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
import faultline
|
|
77
|
+
|
|
78
|
+
faultline.init(
|
|
79
|
+
api_key="flt_ingest_...", # or os.environ["FAULTLINE_API_KEY"]
|
|
80
|
+
environment="production",
|
|
81
|
+
release="api@1.4.2",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
do_risky_thing()
|
|
86
|
+
except Exception:
|
|
87
|
+
faultline.capture_exception(tags={"feature": "checkout"}) # defaults to sys.exc_info()
|
|
88
|
+
|
|
89
|
+
faultline.capture_message("Payment webhook retried", "warning")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Uncaught exceptions (main thread and other threads) are captured automatically, and queued
|
|
93
|
+
events are flushed when the interpreter exits.
|
|
94
|
+
|
|
95
|
+
## API
|
|
96
|
+
|
|
97
|
+
| Function | Description |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| `init(api_key=..., **options)` | Configure the SDK. Calling it again replaces the previous client. |
|
|
100
|
+
| `capture_exception(exc=None, *, tags, extra, user, level, fingerprint, contexts)` | Capture an exception. `exc` defaults to the exception being handled (`sys.exc_info()`); an `exc_info` tuple or any other value also works. Returns the `event_id`, or `None` when dropped or disabled. |
|
|
101
|
+
| `capture_message(message, level="info", *, tags, extra, user, fingerprint, contexts)` | Capture a message. |
|
|
102
|
+
| `set_user({"id", "email", "username", "ipAddress"})` / `set_user(None)` | Set or clear the user on the current scope. |
|
|
103
|
+
| `set_tag(key, value)` / `set_tags({...})` | Searchable string tags. |
|
|
104
|
+
| `set_context(name, {...})` / `set_extra(key, value)` | Structured context or arbitrary extra data. |
|
|
105
|
+
| `add_breadcrumb(type=, category=, message=, level=, data=)` | Manual breadcrumb (also accepts a dict). `type` is one of `default`, `http`, `log`, `query`, `navigation`. |
|
|
106
|
+
| `with with_scope() as scope:` | Fork the current scope. Changes apply only inside the block, including asyncio tasks created inside it. |
|
|
107
|
+
| `flush(timeout=2.0)` | Block until queued events are sent. Returns `False` on timeout and never raises. |
|
|
108
|
+
| `close(timeout=2.0)` | Flush, remove the integrations, and disable the SDK. |
|
|
109
|
+
| `is_enabled()` / `get_current_scope()` | Introspection helpers. |
|
|
110
|
+
|
|
111
|
+
`set_user`, `set_tag`, `set_context`, `set_extra` and `add_breadcrumb` write to the **current**
|
|
112
|
+
scope. Outside a request or `with_scope()` that is the global scope. Inside one, it is the
|
|
113
|
+
forked scope.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
with faultline.with_scope() as scope:
|
|
117
|
+
scope.set_tag("job", "nightly-export")
|
|
118
|
+
faultline.set_user({"id": "42"})
|
|
119
|
+
run_job() # anything captured here carries the tag and user
|
|
120
|
+
# back to the previous scope here
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Options
|
|
124
|
+
|
|
125
|
+
| Option | Type | Default | Description |
|
|
126
|
+
| --- | --- | --- | --- |
|
|
127
|
+
| `api_key` | `str` | **required** | Public ingest key (`flt_ingest_...`). Sent as `X-Faultline-Key`. |
|
|
128
|
+
| `endpoint` | `str` | `https://www.faultsline.dev` | Base URL of the Faultline server. Override it when self-hosting. |
|
|
129
|
+
| `environment` | `str` | `None` | e.g. `production`, `staging`. |
|
|
130
|
+
| `release` | `str` | `None` | e.g. `api@1.4.2` or a git sha. |
|
|
131
|
+
| `server_name` | `str` | `socket.gethostname()` | Reported as `serverName`. |
|
|
132
|
+
| `sample_rate` | `float` 0..1 | `1.0` | Fraction of events to send. |
|
|
133
|
+
| `max_breadcrumbs` | `int` | `50` | Breadcrumbs kept per scope (capped at 100). |
|
|
134
|
+
| `send_default_pii` | `bool` | `False` | When `False`, the user IP and the `cookie`, `set-cookie`, `authorization`, `proxy-authorization`, `x-forwarded-for`, `x-real-ip` and `forwarded` headers are removed. |
|
|
135
|
+
| `before_send` | `(event) -> event \| None` | `None` | Modify or drop (`None`) an event. Runs after scrubbing. If it raises, the event is dropped. |
|
|
136
|
+
| `ignore_errors` | `list[str \| re.Pattern \| type[BaseException]]` | `[]` | Strings match as substrings and patterns are searched, both against the exception type, the message and `"Type: message"`. Exception classes match with `isinstance`. |
|
|
137
|
+
| `integrations` | `dict` | all `True` | `{"excepthook", "threading", "logging", "http"}` mapped to `True`, `False` or a configured instance (see below). |
|
|
138
|
+
| `enabled` | `bool` | `True` | `False` turns the SDK into a no-op (`api_key` is then not required). |
|
|
139
|
+
| `debug` | `bool` | `False` | Print SDK internals (dropped events, retries, invalid options) to stderr. |
|
|
140
|
+
|
|
141
|
+
Invalid options never raise:
|
|
142
|
+
|
|
143
|
+
- An invalid optional value falls back to its default.
|
|
144
|
+
- A missing `api_key` or an unusable `endpoint` disables the SDK.
|
|
145
|
+
- Run with `debug=True` to see why.
|
|
146
|
+
|
|
147
|
+
### Built-in integrations
|
|
148
|
+
|
|
149
|
+
- **excepthook**: `sys.excepthook` captures uncaught exceptions at level `fatal`, flushes for
|
|
150
|
+
up to 2 s, then calls the previous hook, so the traceback is still printed and the exit code
|
|
151
|
+
is unchanged. `KeyboardInterrupt` is ignored.
|
|
152
|
+
- **threading**: `threading.excepthook` captures exceptions that escape a thread (level
|
|
153
|
+
`error`), then calls the previous hook.
|
|
154
|
+
- **logging**: log records at `INFO` and above become `log` breadcrumbs. Records with
|
|
155
|
+
`exc_info` at `ERROR` and above can also be captured as events, which is off by default:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
from faultline import LoggingIntegration
|
|
159
|
+
|
|
160
|
+
faultline.init(
|
|
161
|
+
api_key=...,
|
|
162
|
+
integrations={
|
|
163
|
+
"logging": LoggingIntegration(capture_errors=True, breadcrumb_level=logging.DEBUG)
|
|
164
|
+
},
|
|
165
|
+
)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The integration wraps `logging.Logger.callHandlers`, so it needs no handler and never
|
|
169
|
+
changes your logging configuration. The SDK's own `faultline` logger is ignored.
|
|
170
|
+
- **http**: outgoing `http.client` requests, which covers `urllib.request`, `urllib3` and
|
|
171
|
+
`requests`, become `http` breadcrumbs (method, URL, status). The instrumentation is passive,
|
|
172
|
+
and the SDK's own ingest requests are never recorded.
|
|
173
|
+
|
|
174
|
+
## Framework guides
|
|
175
|
+
|
|
176
|
+
Every framework integration forks a scope per request, attaches the request (method, URL,
|
|
177
|
+
headers, query; scrubbed as described below), adds a `request` breadcrumb, and captures
|
|
178
|
+
unhandled exceptions with `tags: {mechanism, handled: "no"}`. Framework packages are imported
|
|
179
|
+
lazily, only when you import the integration module.
|
|
180
|
+
|
|
181
|
+
### Flask
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
from flask import Flask
|
|
185
|
+
import faultline
|
|
186
|
+
from faultline.integrations.flask import init_app
|
|
187
|
+
|
|
188
|
+
faultline.init(api_key=os.environ["FAULTLINE_API_KEY"])
|
|
189
|
+
app = init_app(Flask(__name__))
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Unhandled exceptions that become a 500 are captured through Flask's `got_request_exception`
|
|
193
|
+
signal. `HTTPException` (`abort(404)`, 405, ...) is not captured. Runnable demo:
|
|
194
|
+
[`examples/flask_app.py`](examples/flask_app.py).
|
|
195
|
+
|
|
196
|
+
### FastAPI / Starlette
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
from fastapi import FastAPI
|
|
200
|
+
import faultline
|
|
201
|
+
from faultline.integrations.fastapi import init_app
|
|
202
|
+
|
|
203
|
+
faultline.init(api_key=os.environ["FAULTLINE_API_KEY"])
|
|
204
|
+
app = init_app(FastAPI()) # same as app.add_middleware(FaultlineAsgiMiddleware)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`HTTPException` and exceptions handled by your exception handlers are not captured. Sync
|
|
208
|
+
endpoints that run in the threadpool keep the request scope. Runnable demo:
|
|
209
|
+
[`examples/fastapi_app.py`](examples/fastapi_app.py).
|
|
210
|
+
|
|
211
|
+
### Django
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
# settings.py
|
|
215
|
+
import faultline
|
|
216
|
+
|
|
217
|
+
faultline.init(api_key=os.environ["FAULTLINE_API_KEY"], environment="production")
|
|
218
|
+
|
|
219
|
+
MIDDLEWARE = [
|
|
220
|
+
"faultline.integrations.django.FaultlineMiddleware", # first
|
|
221
|
+
# ...
|
|
222
|
+
]
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Works under WSGI and ASGI. Exceptions are captured through Django's `got_request_exception`
|
|
226
|
+
signal, so `Http404`, `PermissionDenied`, `BadRequest` and `SuspiciousOperation` are not
|
|
227
|
+
reported.
|
|
228
|
+
|
|
229
|
+
### Any ASGI or WSGI app
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
from faultline.integrations.asgi import FaultlineAsgiMiddleware
|
|
233
|
+
|
|
234
|
+
app = FaultlineAsgiMiddleware(app) # Quart, Litestar, raw ASGI, ...
|
|
235
|
+
|
|
236
|
+
from faultline.integrations.wsgi import FaultlineWsgiMiddleware
|
|
237
|
+
|
|
238
|
+
application = FaultlineWsgiMiddleware(application) # Bottle, Pyramid, raw WSGI, ...
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Celery, scripts and serverless
|
|
242
|
+
|
|
243
|
+
The SDK delivers from a background daemon thread and flushes at interpreter exit. Workers
|
|
244
|
+
that are killed or frozen (serverless) should call `faultline.flush()` at the end of each
|
|
245
|
+
invocation.
|
|
246
|
+
|
|
247
|
+
## Self-hosting
|
|
248
|
+
|
|
249
|
+
Set `endpoint` to your server's base URL. The SDK posts to `{endpoint}/api/v1/ingest/events`:
|
|
250
|
+
|
|
251
|
+
```python
|
|
252
|
+
faultline.init(api_key="flt_ingest_...", endpoint="http://localhost:4000") # local faultline-server
|
|
253
|
+
faultline.init(api_key="flt_ingest_...", endpoint="https://faultline.internal.example.com")
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
The examples read `FAULTLINE_API_KEY` and `FAULTLINE_ENDPOINT` (default
|
|
257
|
+
`http://localhost:4000`). Proxies configured with `HTTPS_PROXY` / `HTTP_PROXY` are honored.
|
|
258
|
+
|
|
259
|
+
## What gets sent
|
|
260
|
+
|
|
261
|
+
Each event follows the shared Faultline event contract: `eventId` (uuid v4), `timestamp`,
|
|
262
|
+
`level`, `platform: "python"`, `exception` (type, value, frames ordered oldest to newest,
|
|
263
|
+
`cause` chain), `message`, `tags`, `user`, `request`, `contexts` (`runtime`, `os`, plus your
|
|
264
|
+
own), `breadcrumbs`, `extra`, `fingerprint`, `environment`, `release`, `serverName`, and
|
|
265
|
+
`sdk: { name: "getfaultline", version }`.
|
|
266
|
+
|
|
267
|
+
- **Frames** come from the traceback, oldest to newest. A frame is `inApp` unless it lives
|
|
268
|
+
under `site-packages` / `dist-packages`, the standard library, a synthetic file
|
|
269
|
+
(`<frozen ...>`, `<string>`), or the SDK itself. In-app frames get +-5 lines of source
|
|
270
|
+
context (`linecache`, so zip apps work too) and, on Python 3.11+, a column number.
|
|
271
|
+
- **Cause chain**: `__cause__`, otherwise `__context__` unless suppressed with
|
|
272
|
+
`raise ... from None`. At most 5 exceptions in total; cycles are broken.
|
|
273
|
+
- **Exception groups** are reported as the group itself (its message lists the
|
|
274
|
+
sub-exceptions) with the first sub-exception as its cause.
|
|
275
|
+
- **Non-exception values** passed to `capture_exception` become a synthetic `Error` with the
|
|
276
|
+
caller's stack.
|
|
277
|
+
|
|
278
|
+
The SDK enforces the pinned limits before sending:
|
|
279
|
+
|
|
280
|
+
| Field | Limit |
|
|
281
|
+
| --- | --- |
|
|
282
|
+
| Frames | 100 (newest kept) |
|
|
283
|
+
| Breadcrumbs | 100 |
|
|
284
|
+
| Tags | 50 (key <= 32 chars, value <= 200 chars) |
|
|
285
|
+
| `message` / `exception.value` | 8192 chars |
|
|
286
|
+
| Source context | +-5 lines, each <= 300 chars |
|
|
287
|
+
| `exception.cause` chain | 5 exceptions in total |
|
|
288
|
+
| Batch | <= 50 events and <= 1 MB |
|
|
289
|
+
|
|
290
|
+
**Scrubbing (client side, same rules as the server and the Node SDK):**
|
|
291
|
+
|
|
292
|
+
- Values under keys matching
|
|
293
|
+
`(authorization|cookie|set-cookie|password|passwd|secret|token|api[_-]?key|x-faultline-key)`
|
|
294
|
+
(case-insensitive) become `"[Filtered]"`. This applies to request headers and query,
|
|
295
|
+
`extra`, `contexts`, tags and breadcrumb data.
|
|
296
|
+
- Credit-card-like strings (`\b(?:\d[ -]*?){13,16}\b`) in request, `extra` and breadcrumb data
|
|
297
|
+
are also filtered, as are sensitive URL query parameters.
|
|
298
|
+
|
|
299
|
+
**Delivery:**
|
|
300
|
+
|
|
301
|
+
- Events wait in an in-memory queue of at most 100 events; the oldest is dropped when full.
|
|
302
|
+
- A daemon worker thread sends a batch every 2 s, or as soon as 20 events are queued, gzip
|
|
303
|
+
compressed. Each request times out after 5 s.
|
|
304
|
+
- Network errors, timeouts, `5xx` and `408` are retried with exponential backoff and jitter
|
|
305
|
+
(up to 3 retries). A `429` pauses delivery for its `Retry-After`.
|
|
306
|
+
- A `401` or `413` response drops the batch. With `debug=True` the SDK logs the reason.
|
|
307
|
+
- At interpreter exit an `atexit` hook flushes for up to 2 s, then gives up. The SDK never
|
|
308
|
+
keeps your process alive. After `os.fork()` the child starts a fresh worker.
|
|
309
|
+
- Identical consecutive errors within 1 s are sent once.
|
|
310
|
+
|
|
311
|
+
## Architecture
|
|
312
|
+
|
|
313
|
+
The SDK follows Clean Architecture. Dependencies point inward only, and
|
|
314
|
+
[import-linter](https://import-linter.readthedocs.io) enforces this in CI (plus an AST-based
|
|
315
|
+
test in `tests/test_architecture.py`).
|
|
316
|
+
|
|
317
|
+
```mermaid
|
|
318
|
+
flowchart LR
|
|
319
|
+
subgraph root["faultline/__init__.py (public API, composition root)"]
|
|
320
|
+
end
|
|
321
|
+
subgraph integrations["integrations/"]
|
|
322
|
+
X[excepthook, threading] --- L[logging] --- H[http.client] --- F[wsgi, asgi, flask, fastapi, django]
|
|
323
|
+
end
|
|
324
|
+
subgraph infrastructure["infrastructure/"]
|
|
325
|
+
T[HttpTransport] --- S[TracebackStackExtractor] --- C[LinecacheSourceReader] --- R[runtime adapters]
|
|
326
|
+
end
|
|
327
|
+
subgraph application["application/"]
|
|
328
|
+
CL[FaultlineClient] --- HB["Hub (contextvars scopes)"] --- PO[ports]
|
|
329
|
+
end
|
|
330
|
+
subgraph domain["domain/ (pure Python)"]
|
|
331
|
+
EV[event contract] --- SC[Scope] --- OP[options] --- SR[scrubbing, limits, normalization]
|
|
332
|
+
end
|
|
333
|
+
root --> integrations
|
|
334
|
+
root --> infrastructure
|
|
335
|
+
integrations --> application
|
|
336
|
+
infrastructure --> application
|
|
337
|
+
application --> domain
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
```mermaid
|
|
341
|
+
sequenceDiagram
|
|
342
|
+
participant App
|
|
343
|
+
participant Hub
|
|
344
|
+
participant Client as FaultlineClient
|
|
345
|
+
participant Worker as HttpTransport (thread)
|
|
346
|
+
participant Server as Faultline ingest
|
|
347
|
+
App->>Hub: capture_exception(exc)
|
|
348
|
+
Hub->>Client: exc + current scope snapshot (contextvars)
|
|
349
|
+
Client->>Client: traceback, cause chain, sample, ignore_errors, dedupe
|
|
350
|
+
Client->>Client: source context, limits, scrub, before_send
|
|
351
|
+
Client->>Worker: send(event) (non-blocking)
|
|
352
|
+
Client-->>App: event_id
|
|
353
|
+
Worker->>Server: POST /api/v1/ingest/events (gzip batch <= 50)
|
|
354
|
+
Server-->>Worker: 202 / 429 Retry-After / 401 / 413
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
See [ARCHITECTURE.md](ARCHITECTURE.md) for the decision log.
|
|
358
|
+
|
|
359
|
+
## Development
|
|
360
|
+
|
|
361
|
+
```bash
|
|
362
|
+
python3 -m venv .venv
|
|
363
|
+
.venv/bin/pip install -e ".[dev]" django
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
| Command | What it does |
|
|
367
|
+
| --- | --- |
|
|
368
|
+
| `ruff check . && ruff format --check .` | Lint and formatting. |
|
|
369
|
+
| `mypy` | Strict type checking of `src`, `tests` and `examples`. |
|
|
370
|
+
| `lint-imports` | Clean Architecture layer contracts. |
|
|
371
|
+
| `pytest` | Unit, contract-conformance (pydantic), local `http.server` transport, framework and subprocess tests. |
|
|
372
|
+
| `python -m build && twine check dist/*` | Build the sdist and wheel. |
|
|
373
|
+
|
|
374
|
+
### Releasing
|
|
375
|
+
|
|
376
|
+
1. Bump `src/faultline/_version.py` and add a `CHANGELOG.md` entry.
|
|
377
|
+
2. Tag `vX.Y.Z` and push the tag.
|
|
378
|
+
3. `.github/workflows/release.yml` runs the checks, builds, and publishes to PyPI with
|
|
379
|
+
trusted publishing (no API token; configure the trusted publisher on PyPI once).
|
|
380
|
+
|
|
381
|
+
## License
|
|
382
|
+
|
|
383
|
+
MIT
|