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.
Files changed (56) hide show
  1. getfaultline-0.1.0/.gitignore +13 -0
  2. getfaultline-0.1.0/ARCHITECTURE.md +194 -0
  3. getfaultline-0.1.0/CHANGELOG.md +19 -0
  4. getfaultline-0.1.0/LICENSE +21 -0
  5. getfaultline-0.1.0/PKG-INFO +383 -0
  6. getfaultline-0.1.0/README.md +329 -0
  7. getfaultline-0.1.0/examples/__init__.py +0 -0
  8. getfaultline-0.1.0/examples/fastapi_app.py +62 -0
  9. getfaultline-0.1.0/examples/flask_app.py +64 -0
  10. getfaultline-0.1.0/pyproject.toml +142 -0
  11. getfaultline-0.1.0/src/faultline/__init__.py +405 -0
  12. getfaultline-0.1.0/src/faultline/_version.py +1 -0
  13. getfaultline-0.1.0/src/faultline/application/__init__.py +33 -0
  14. getfaultline-0.1.0/src/faultline/application/client.py +377 -0
  15. getfaultline-0.1.0/src/faultline/application/hub.py +210 -0
  16. getfaultline-0.1.0/src/faultline/application/ports.py +81 -0
  17. getfaultline-0.1.0/src/faultline/domain/__init__.py +107 -0
  18. getfaultline-0.1.0/src/faultline/domain/event.py +109 -0
  19. getfaultline-0.1.0/src/faultline/domain/limits.py +41 -0
  20. getfaultline-0.1.0/src/faultline/domain/normalize.py +117 -0
  21. getfaultline-0.1.0/src/faultline/domain/options.py +192 -0
  22. getfaultline-0.1.0/src/faultline/domain/processing.py +403 -0
  23. getfaultline-0.1.0/src/faultline/domain/scope.py +158 -0
  24. getfaultline-0.1.0/src/faultline/domain/scrub.py +85 -0
  25. getfaultline-0.1.0/src/faultline/infrastructure/__init__.py +21 -0
  26. getfaultline-0.1.0/src/faultline/infrastructure/runtime.py +98 -0
  27. getfaultline-0.1.0/src/faultline/infrastructure/source.py +48 -0
  28. getfaultline-0.1.0/src/faultline/infrastructure/stack.py +142 -0
  29. getfaultline-0.1.0/src/faultline/infrastructure/transport.py +414 -0
  30. getfaultline-0.1.0/src/faultline/integrations/__init__.py +18 -0
  31. getfaultline-0.1.0/src/faultline/integrations/_common.py +138 -0
  32. getfaultline-0.1.0/src/faultline/integrations/asgi.py +53 -0
  33. getfaultline-0.1.0/src/faultline/integrations/django.py +101 -0
  34. getfaultline-0.1.0/src/faultline/integrations/excepthook.py +86 -0
  35. getfaultline-0.1.0/src/faultline/integrations/fastapi.py +28 -0
  36. getfaultline-0.1.0/src/faultline/integrations/flask.py +43 -0
  37. getfaultline-0.1.0/src/faultline/integrations/logging.py +118 -0
  38. getfaultline-0.1.0/src/faultline/integrations/stdlib_http.py +137 -0
  39. getfaultline-0.1.0/src/faultline/integrations/wsgi.py +81 -0
  40. getfaultline-0.1.0/src/faultline/py.typed +0 -0
  41. getfaultline-0.1.0/tests/__init__.py +0 -0
  42. getfaultline-0.1.0/tests/conftest.py +227 -0
  43. getfaultline-0.1.0/tests/contract.py +155 -0
  44. getfaultline-0.1.0/tests/test_architecture.py +103 -0
  45. getfaultline-0.1.0/tests/test_client.py +172 -0
  46. getfaultline-0.1.0/tests/test_django.py +68 -0
  47. getfaultline-0.1.0/tests/test_exceptions.py +139 -0
  48. getfaultline-0.1.0/tests/test_fastapi.py +109 -0
  49. getfaultline-0.1.0/tests/test_flask.py +93 -0
  50. getfaultline-0.1.0/tests/test_integrations.py +205 -0
  51. getfaultline-0.1.0/tests/test_never_throws.py +142 -0
  52. getfaultline-0.1.0/tests/test_process.py +87 -0
  53. getfaultline-0.1.0/tests/test_scope.py +153 -0
  54. getfaultline-0.1.0/tests/test_scrubbing.py +161 -0
  55. getfaultline-0.1.0/tests/test_stack.py +163 -0
  56. getfaultline-0.1.0/tests/test_transport.py +250 -0
@@ -0,0 +1,13 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ .import_linter_cache/
11
+ .coverage
12
+ htmlcov/
13
+ .DS_Store
@@ -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