forge-ops-tracker 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.
- forge_ops_tracker-0.1.0/LICENSE.txt +21 -0
- forge_ops_tracker-0.1.0/PKG-INFO +179 -0
- forge_ops_tracker-0.1.0/README.md +153 -0
- forge_ops_tracker-0.1.0/pyproject.toml +42 -0
- forge_ops_tracker-0.1.0/setup.cfg +4 -0
- forge_ops_tracker-0.1.0/src/forge_ops_tracker/__init__.py +103 -0
- forge_ops_tracker-0.1.0/src/forge_ops_tracker/client.py +41 -0
- forge_ops_tracker-0.1.0/src/forge_ops_tracker/configuration.py +82 -0
- forge_ops_tracker-0.1.0/src/forge_ops_tracker/delivery_queue.py +55 -0
- forge_ops_tracker-0.1.0/src/forge_ops_tracker/event_builder.py +88 -0
- forge_ops_tracker-0.1.0/src/forge_ops_tracker/integrations/__init__.py +0 -0
- forge_ops_tracker-0.1.0/src/forge_ops_tracker/integrations/django.py +37 -0
- forge_ops_tracker-0.1.0/src/forge_ops_tracker/integrations/flask.py +40 -0
- forge_ops_tracker-0.1.0/src/forge_ops_tracker/pii_scrubber.py +67 -0
- forge_ops_tracker-0.1.0/src/forge_ops_tracker/reporter.py +24 -0
- forge_ops_tracker-0.1.0/src/forge_ops_tracker.egg-info/PKG-INFO +179 -0
- forge_ops_tracker-0.1.0/src/forge_ops_tracker.egg-info/SOURCES.txt +27 -0
- forge_ops_tracker-0.1.0/src/forge_ops_tracker.egg-info/dependency_links.txt +1 -0
- forge_ops_tracker-0.1.0/src/forge_ops_tracker.egg-info/requires.txt +12 -0
- forge_ops_tracker-0.1.0/src/forge_ops_tracker.egg-info/top_level.txt +1 -0
- forge_ops_tracker-0.1.0/tests/test_client.py +82 -0
- forge_ops_tracker-0.1.0/tests/test_configuration.py +63 -0
- forge_ops_tracker-0.1.0/tests/test_delivery_queue.py +76 -0
- forge_ops_tracker-0.1.0/tests/test_event_builder.py +117 -0
- forge_ops_tracker-0.1.0/tests/test_init.py +120 -0
- forge_ops_tracker-0.1.0/tests/test_integrations_django.py +43 -0
- forge_ops_tracker-0.1.0/tests/test_integrations_flask.py +51 -0
- forge_ops_tracker-0.1.0/tests/test_pii_scrubber.py +33 -0
- forge_ops_tracker-0.1.0/tests/test_reporter.py +64 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ForgeOps
|
|
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,179 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: forge-ops-tracker
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: ForgeOps error tracking client: captures unhandled exceptions (Django/Flask middleware, plus explicit capture anywhere else) and delivers them to a ForgeOps instance over HTTP.
|
|
5
|
+
Author: ForgeOps
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://getforgeops.net
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Topic :: System :: Logging
|
|
11
|
+
Classifier: Framework :: Django
|
|
12
|
+
Classifier: Framework :: Flask
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE.txt
|
|
16
|
+
Provides-Extra: django
|
|
17
|
+
Requires-Dist: django>=4.2; extra == "django"
|
|
18
|
+
Provides-Extra: flask
|
|
19
|
+
Requires-Dist: flask>=2.3; extra == "flask"
|
|
20
|
+
Provides-Extra: test
|
|
21
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
22
|
+
Requires-Dist: pytest-django>=4.8; extra == "test"
|
|
23
|
+
Requires-Dist: flask>=2.3; extra == "test"
|
|
24
|
+
Requires-Dist: django>=4.2; extra == "test"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# forge-ops-tracker
|
|
28
|
+
|
|
29
|
+
Python error reporting client for a private, self-hosted [ForgeOps](../../) tracker instance.
|
|
30
|
+
Requires Python 3.9+. A from-scratch port of [`gems/forge_ops_tracker`](../../gems/forge_ops_tracker)
|
|
31
|
+
(the Rails client) -- see that gem's README for the shared design rationale; this document only
|
|
32
|
+
covers what's Python-specific.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
Not yet published to PyPI -- install directly from this path (or a local checkout, once split into
|
|
37
|
+
its own repo):
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install -e path/to/forge_ops/sdks/python
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
For Django or Flask integration, install the matching extra:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install -e "path/to/forge_ops/sdks/python[django]"
|
|
47
|
+
pip install -e "path/to/forge_ops/sdks/python[flask]"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Configuration
|
|
51
|
+
|
|
52
|
+
Set a DSN (from a project's settings page in ForgeOps), either via the `FORGE_OPS_DSN` environment
|
|
53
|
+
variable or explicitly:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import forge_ops_tracker
|
|
57
|
+
|
|
58
|
+
forge_ops_tracker.init(
|
|
59
|
+
dsn="https://<api_key>@your-forgeops-host/api/v1/events", # or leave unset to read FORGE_OPS_DSN
|
|
60
|
+
release="...",
|
|
61
|
+
environment="production",
|
|
62
|
+
)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Call `init()` once at startup -- Django's `settings.py`, or right after creating a Flask app. Any
|
|
66
|
+
`Configuration` attribute can be overridden by keyword.
|
|
67
|
+
|
|
68
|
+
### Django
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
# settings.py
|
|
72
|
+
import forge_ops_tracker
|
|
73
|
+
|
|
74
|
+
forge_ops_tracker.init(dsn="https://<api_key>@your-forgeops-host/api/v1/events")
|
|
75
|
+
|
|
76
|
+
MIDDLEWARE = [
|
|
77
|
+
...,
|
|
78
|
+
"forge_ops_tracker.integrations.django.ForgeOpsTrackerMiddleware",
|
|
79
|
+
]
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Flask
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from flask import Flask
|
|
86
|
+
import forge_ops_tracker
|
|
87
|
+
from forge_ops_tracker.integrations.flask import init_flask
|
|
88
|
+
|
|
89
|
+
forge_ops_tracker.init(dsn="https://<api_key>@your-forgeops-host/api/v1/events")
|
|
90
|
+
|
|
91
|
+
app = Flask(__name__)
|
|
92
|
+
init_flask(app)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## What gets reported automatically, and what doesn't
|
|
96
|
+
|
|
97
|
+
**An exception that crashes a request needs no further wiring at all.** The Django middleware's
|
|
98
|
+
`process_exception` hook and Flask's `got_request_exception` signal both fire for anything that
|
|
99
|
+
propagates uncaught out of a view, then let the framework handle it exactly as if this client
|
|
100
|
+
weren't installed.
|
|
101
|
+
|
|
102
|
+
**An exception your own code catches and handles is different -- neither integration ever sees
|
|
103
|
+
it**, since it never propagates far enough to reach either hook:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
try:
|
|
107
|
+
charge_card(order)
|
|
108
|
+
except CardError as e:
|
|
109
|
+
logger.warning("card declined: %s", e)
|
|
110
|
+
# ForgeOps never sees this -- caught locally, never reaches the
|
|
111
|
+
# middleware/signal at all.
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
There's no Django/Flask-wide equivalent to Rails' `Rails.error.handle` here -- report it explicitly
|
|
115
|
+
instead, right at the catch site:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
except CardError as e:
|
|
119
|
+
forge_ops_tracker.capture_exception(e, context={"order_id": order.id})
|
|
120
|
+
logger.warning("card declined: %s", e)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Called with no arguments, `capture_exception()` picks up whichever exception is currently being
|
|
124
|
+
handled (same as a bare `raise` inside an `except:` block), so it usually reads as just
|
|
125
|
+
`forge_ops_tracker.capture_exception()` from inside the block that already caught it.
|
|
126
|
+
|
|
127
|
+
### Outside a web request (scripts, management commands, workers)
|
|
128
|
+
|
|
129
|
+
`init()` also installs a `sys.excepthook` wrapper by default (`Configuration.install_excepthook`,
|
|
130
|
+
`True` unless set otherwise), which reports anything that crashes the whole interpreter -- a plain
|
|
131
|
+
script, a Django management command, a worker's own top-level loop -- with no wiring needed, the
|
|
132
|
+
same "unhandled needs no wiring" case the Django/Flask integrations cover for web requests. It
|
|
133
|
+
still calls whatever `sys.excepthook` was already installed afterward, so it never changes program
|
|
134
|
+
behavior. This does **not** catch a web request's unhandled exception under a real WSGI server
|
|
135
|
+
(Gunicorn/uWSGI catch that themselves per-request, long before it would ever reach the interpreter
|
|
136
|
+
level) -- that's what the Django/Flask integrations are for.
|
|
137
|
+
|
|
138
|
+
Delivery happens on a background thread with a bounded queue and a short per-request HTTP timeout
|
|
139
|
+
(`Configuration.timeout`, 2s default). Every failure mode -- network errors, timeouts, a full queue,
|
|
140
|
+
a malformed DSN -- is caught and dropped rather than raised, so a broken or unreachable tracker can
|
|
141
|
+
never take down the host app. The worker thread starts lazily, on first push, not at import time --
|
|
142
|
+
Gunicorn (prefork) and uWSGI commonly fork worker processes *after* the application has already
|
|
143
|
+
loaded, which would leave an eagerly-started thread dead in every forked child; starting fresh on
|
|
144
|
+
first push means each forked worker gets its own live thread regardless of when it was forked
|
|
145
|
+
relative to import.
|
|
146
|
+
|
|
147
|
+
## `in_app` backtrace frames
|
|
148
|
+
|
|
149
|
+
Unlike the .NET SDK (where a compiled assembly's file path never matches its original source
|
|
150
|
+
location), Python runs interpreted directly from real `.py` files on disk, so file-path matching
|
|
151
|
+
against `Configuration.app_root` works the same way it does in the Ruby gem's `Rails.root`
|
|
152
|
+
comparison. Defaults to the current working directory; set it explicitly if that doesn't match your
|
|
153
|
+
app's actual layout (a WSGI server started from a different directory than your app's root, for
|
|
154
|
+
instance). Standard-library and installed-package (`site-packages`/`dist-packages`) frames are
|
|
155
|
+
never marked `in_app`, regardless of `app_root`.
|
|
156
|
+
|
|
157
|
+
## PII scrubbing
|
|
158
|
+
|
|
159
|
+
Same behavior as the Ruby gem: the message, backtrace, and any context/tags you attach are scanned
|
|
160
|
+
for likely personal data -- email addresses, formatted SSNs/credit cards, known API key/token
|
|
161
|
+
formats, and anything under a suspiciously-named key (`password`, `api_key`, `ssn`, and similar) --
|
|
162
|
+
and redacted before the payload ever leaves this process. ForgeOps itself scrubs again on arrival
|
|
163
|
+
regardless, so this is a second, earlier layer, not the only one.
|
|
164
|
+
|
|
165
|
+
To disable it:
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
forge_ops_tracker.init(dsn="...", scrub_pii=False)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Running the tests
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
cd sdks/python
|
|
175
|
+
python3 -m venv .venv
|
|
176
|
+
./.venv/bin/pip install -e ".[test]"
|
|
177
|
+
./.venv/bin/python -m pytest
|
|
178
|
+
./.venv/bin/ruff check src tests
|
|
179
|
+
```
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# forge-ops-tracker
|
|
2
|
+
|
|
3
|
+
Python error reporting client for a private, self-hosted [ForgeOps](../../) tracker instance.
|
|
4
|
+
Requires Python 3.9+. A from-scratch port of [`gems/forge_ops_tracker`](../../gems/forge_ops_tracker)
|
|
5
|
+
(the Rails client) -- see that gem's README for the shared design rationale; this document only
|
|
6
|
+
covers what's Python-specific.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
Not yet published to PyPI -- install directly from this path (or a local checkout, once split into
|
|
11
|
+
its own repo):
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install -e path/to/forge_ops/sdks/python
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
For Django or Flask integration, install the matching extra:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install -e "path/to/forge_ops/sdks/python[django]"
|
|
21
|
+
pip install -e "path/to/forge_ops/sdks/python[flask]"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Configuration
|
|
25
|
+
|
|
26
|
+
Set a DSN (from a project's settings page in ForgeOps), either via the `FORGE_OPS_DSN` environment
|
|
27
|
+
variable or explicitly:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import forge_ops_tracker
|
|
31
|
+
|
|
32
|
+
forge_ops_tracker.init(
|
|
33
|
+
dsn="https://<api_key>@your-forgeops-host/api/v1/events", # or leave unset to read FORGE_OPS_DSN
|
|
34
|
+
release="...",
|
|
35
|
+
environment="production",
|
|
36
|
+
)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Call `init()` once at startup -- Django's `settings.py`, or right after creating a Flask app. Any
|
|
40
|
+
`Configuration` attribute can be overridden by keyword.
|
|
41
|
+
|
|
42
|
+
### Django
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
# settings.py
|
|
46
|
+
import forge_ops_tracker
|
|
47
|
+
|
|
48
|
+
forge_ops_tracker.init(dsn="https://<api_key>@your-forgeops-host/api/v1/events")
|
|
49
|
+
|
|
50
|
+
MIDDLEWARE = [
|
|
51
|
+
...,
|
|
52
|
+
"forge_ops_tracker.integrations.django.ForgeOpsTrackerMiddleware",
|
|
53
|
+
]
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Flask
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from flask import Flask
|
|
60
|
+
import forge_ops_tracker
|
|
61
|
+
from forge_ops_tracker.integrations.flask import init_flask
|
|
62
|
+
|
|
63
|
+
forge_ops_tracker.init(dsn="https://<api_key>@your-forgeops-host/api/v1/events")
|
|
64
|
+
|
|
65
|
+
app = Flask(__name__)
|
|
66
|
+
init_flask(app)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## What gets reported automatically, and what doesn't
|
|
70
|
+
|
|
71
|
+
**An exception that crashes a request needs no further wiring at all.** The Django middleware's
|
|
72
|
+
`process_exception` hook and Flask's `got_request_exception` signal both fire for anything that
|
|
73
|
+
propagates uncaught out of a view, then let the framework handle it exactly as if this client
|
|
74
|
+
weren't installed.
|
|
75
|
+
|
|
76
|
+
**An exception your own code catches and handles is different -- neither integration ever sees
|
|
77
|
+
it**, since it never propagates far enough to reach either hook:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
try:
|
|
81
|
+
charge_card(order)
|
|
82
|
+
except CardError as e:
|
|
83
|
+
logger.warning("card declined: %s", e)
|
|
84
|
+
# ForgeOps never sees this -- caught locally, never reaches the
|
|
85
|
+
# middleware/signal at all.
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
There's no Django/Flask-wide equivalent to Rails' `Rails.error.handle` here -- report it explicitly
|
|
89
|
+
instead, right at the catch site:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
except CardError as e:
|
|
93
|
+
forge_ops_tracker.capture_exception(e, context={"order_id": order.id})
|
|
94
|
+
logger.warning("card declined: %s", e)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Called with no arguments, `capture_exception()` picks up whichever exception is currently being
|
|
98
|
+
handled (same as a bare `raise` inside an `except:` block), so it usually reads as just
|
|
99
|
+
`forge_ops_tracker.capture_exception()` from inside the block that already caught it.
|
|
100
|
+
|
|
101
|
+
### Outside a web request (scripts, management commands, workers)
|
|
102
|
+
|
|
103
|
+
`init()` also installs a `sys.excepthook` wrapper by default (`Configuration.install_excepthook`,
|
|
104
|
+
`True` unless set otherwise), which reports anything that crashes the whole interpreter -- a plain
|
|
105
|
+
script, a Django management command, a worker's own top-level loop -- with no wiring needed, the
|
|
106
|
+
same "unhandled needs no wiring" case the Django/Flask integrations cover for web requests. It
|
|
107
|
+
still calls whatever `sys.excepthook` was already installed afterward, so it never changes program
|
|
108
|
+
behavior. This does **not** catch a web request's unhandled exception under a real WSGI server
|
|
109
|
+
(Gunicorn/uWSGI catch that themselves per-request, long before it would ever reach the interpreter
|
|
110
|
+
level) -- that's what the Django/Flask integrations are for.
|
|
111
|
+
|
|
112
|
+
Delivery happens on a background thread with a bounded queue and a short per-request HTTP timeout
|
|
113
|
+
(`Configuration.timeout`, 2s default). Every failure mode -- network errors, timeouts, a full queue,
|
|
114
|
+
a malformed DSN -- is caught and dropped rather than raised, so a broken or unreachable tracker can
|
|
115
|
+
never take down the host app. The worker thread starts lazily, on first push, not at import time --
|
|
116
|
+
Gunicorn (prefork) and uWSGI commonly fork worker processes *after* the application has already
|
|
117
|
+
loaded, which would leave an eagerly-started thread dead in every forked child; starting fresh on
|
|
118
|
+
first push means each forked worker gets its own live thread regardless of when it was forked
|
|
119
|
+
relative to import.
|
|
120
|
+
|
|
121
|
+
## `in_app` backtrace frames
|
|
122
|
+
|
|
123
|
+
Unlike the .NET SDK (where a compiled assembly's file path never matches its original source
|
|
124
|
+
location), Python runs interpreted directly from real `.py` files on disk, so file-path matching
|
|
125
|
+
against `Configuration.app_root` works the same way it does in the Ruby gem's `Rails.root`
|
|
126
|
+
comparison. Defaults to the current working directory; set it explicitly if that doesn't match your
|
|
127
|
+
app's actual layout (a WSGI server started from a different directory than your app's root, for
|
|
128
|
+
instance). Standard-library and installed-package (`site-packages`/`dist-packages`) frames are
|
|
129
|
+
never marked `in_app`, regardless of `app_root`.
|
|
130
|
+
|
|
131
|
+
## PII scrubbing
|
|
132
|
+
|
|
133
|
+
Same behavior as the Ruby gem: the message, backtrace, and any context/tags you attach are scanned
|
|
134
|
+
for likely personal data -- email addresses, formatted SSNs/credit cards, known API key/token
|
|
135
|
+
formats, and anything under a suspiciously-named key (`password`, `api_key`, `ssn`, and similar) --
|
|
136
|
+
and redacted before the payload ever leaves this process. ForgeOps itself scrubs again on arrival
|
|
137
|
+
regardless, so this is a second, earlier layer, not the only one.
|
|
138
|
+
|
|
139
|
+
To disable it:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
forge_ops_tracker.init(dsn="...", scrub_pii=False)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Running the tests
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
cd sdks/python
|
|
149
|
+
python3 -m venv .venv
|
|
150
|
+
./.venv/bin/pip install -e ".[test]"
|
|
151
|
+
./.venv/bin/python -m pytest
|
|
152
|
+
./.venv/bin/ruff check src tests
|
|
153
|
+
```
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "forge-ops-tracker"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "ForgeOps error tracking client: captures unhandled exceptions (Django/Flask middleware, plus explicit capture anywhere else) and delivers them to a ForgeOps instance over HTTP."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = [ "LICENSE.txt" ]
|
|
13
|
+
authors = [ { name = "ForgeOps" } ]
|
|
14
|
+
dependencies = []
|
|
15
|
+
# No "License :: OSI Approved :: MIT License" classifier -- superseded by the license = "MIT"
|
|
16
|
+
# SPDX expression above (PEP 639); setuptools now rejects having both.
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Topic :: System :: Logging",
|
|
21
|
+
"Framework :: Django",
|
|
22
|
+
"Framework :: Flask",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
# source_code_uri/repository/changelog deliberately omitted -- the monorepo this currently
|
|
26
|
+
# lives in is private, so a link to it would 404 for anyone outside the team. Add these once
|
|
27
|
+
# this is split into its own public repo (see gems/forge_ops_tracker's gemspec for the same
|
|
28
|
+
# reasoning, applied there first).
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://getforgeops.net"
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
django = ["django>=4.2"]
|
|
34
|
+
flask = ["flask>=2.3"]
|
|
35
|
+
test = ["pytest>=8", "pytest-django>=4.8", "flask>=2.3", "django>=4.2"]
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
where = ["src"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
DJANGO_SETTINGS_MODULE = "tests.django_settings"
|
|
42
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""ForgeOps error tracking client.
|
|
2
|
+
|
|
3
|
+
import forge_ops_tracker
|
|
4
|
+
forge_ops_tracker.init(dsn="https://<api_key>@your-forgeops-host/api/v1/events")
|
|
5
|
+
|
|
6
|
+
See the README for Django/Flask integration and what gets captured
|
|
7
|
+
automatically vs. what needs an explicit capture_exception() call.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
|
|
13
|
+
from .client import Client
|
|
14
|
+
from .configuration import Configuration
|
|
15
|
+
from .delivery_queue import DeliveryQueue
|
|
16
|
+
from .event_builder import EventBuilder
|
|
17
|
+
from .reporter import Reporter
|
|
18
|
+
|
|
19
|
+
__all__ = ["Configuration", "capture_exception", "init"]
|
|
20
|
+
|
|
21
|
+
_configuration = None
|
|
22
|
+
_reporter = None
|
|
23
|
+
_lock = threading.Lock()
|
|
24
|
+
_original_excepthook = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _state():
|
|
28
|
+
global _configuration, _reporter
|
|
29
|
+
if _reporter is None:
|
|
30
|
+
with _lock:
|
|
31
|
+
if _reporter is None:
|
|
32
|
+
_configuration = Configuration()
|
|
33
|
+
client = Client(_configuration)
|
|
34
|
+
delivery_queue = DeliveryQueue(_configuration, client)
|
|
35
|
+
_reporter = Reporter(_configuration, EventBuilder(_configuration), delivery_queue)
|
|
36
|
+
return _configuration, _reporter
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def init(dsn=None, **overrides):
|
|
40
|
+
"""Configure the client. Call once at startup (Django settings.py, or
|
|
41
|
+
right after creating a Flask app). Any Configuration attribute can be
|
|
42
|
+
overridden by keyword, e.g. init(dsn=..., release=..., environment=...).
|
|
43
|
+
"""
|
|
44
|
+
configuration, _ = _state()
|
|
45
|
+
if dsn is not None:
|
|
46
|
+
configuration.dsn = dsn
|
|
47
|
+
for key, value in overrides.items():
|
|
48
|
+
if not hasattr(configuration, key):
|
|
49
|
+
raise TypeError(f"Configuration has no attribute {key!r}")
|
|
50
|
+
setattr(configuration, key, value)
|
|
51
|
+
|
|
52
|
+
if configuration.install_excepthook:
|
|
53
|
+
_install_excepthook()
|
|
54
|
+
|
|
55
|
+
return configuration
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def capture_exception(exc=None, context=None):
|
|
59
|
+
"""Report an exception you've already caught. `exc` defaults to
|
|
60
|
+
whichever exception is currently being handled, so this can usually
|
|
61
|
+
just be called as capture_exception() from inside an `except:` block."""
|
|
62
|
+
if exc is None:
|
|
63
|
+
exc = sys.exc_info()[1]
|
|
64
|
+
if exc is None:
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
_, reporter = _state()
|
|
68
|
+
reporter.report(exc, context=context)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _install_excepthook():
|
|
72
|
+
# Reports anything that crashes the whole interpreter (a plain
|
|
73
|
+
# script, a management command, a worker's own top-level loop) with
|
|
74
|
+
# no further wiring -- the same "unhandled needs no wiring" case
|
|
75
|
+
# Rails.error/ASP.NET Core's middleware cover automatically. This does
|
|
76
|
+
# *not* catch a web request's unhandled exception under a WSGI
|
|
77
|
+
# server -- Django/Flask catch that themselves before it ever reaches
|
|
78
|
+
# here, which is what the integrations in forge_ops_tracker.integrations
|
|
79
|
+
# are for.
|
|
80
|
+
global _original_excepthook
|
|
81
|
+
if _original_excepthook is not None:
|
|
82
|
+
return # already installed
|
|
83
|
+
|
|
84
|
+
_original_excepthook = sys.excepthook
|
|
85
|
+
|
|
86
|
+
def _excepthook(exc_type, exc_value, exc_tb):
|
|
87
|
+
try:
|
|
88
|
+
capture_exception(exc_value)
|
|
89
|
+
finally:
|
|
90
|
+
_original_excepthook(exc_type, exc_value, exc_tb)
|
|
91
|
+
|
|
92
|
+
sys.excepthook = _excepthook
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _reset_for_testing():
|
|
96
|
+
"""Not part of the public API -- resets module-level state between
|
|
97
|
+
test cases."""
|
|
98
|
+
global _configuration, _reporter, _original_excepthook
|
|
99
|
+
if _original_excepthook is not None:
|
|
100
|
+
sys.excepthook = _original_excepthook
|
|
101
|
+
_configuration = None
|
|
102
|
+
_reporter = None
|
|
103
|
+
_original_excepthook = None
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Delivers one payload over HTTP. Every failure mode -- DNS, connection,
|
|
2
|
+
timeout, TLS, a non-2xx response -- is caught here and turned into a
|
|
3
|
+
`False` return rather than a raised exception, since a broken or
|
|
4
|
+
unreachable tracker must never be able to break the host app. Ported
|
|
5
|
+
from gems/forge_ops_tracker/lib/forge_ops_tracker/client.rb.
|
|
6
|
+
|
|
7
|
+
Uses only the standard library (urllib), not `requests` -- same reason
|
|
8
|
+
the Ruby gem uses plain Net::HTTP rather than a gem dependency: this has
|
|
9
|
+
to work in any host app without adding a dependency of its own.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Client:
|
|
18
|
+
def __init__(self, configuration):
|
|
19
|
+
self._configuration = configuration
|
|
20
|
+
|
|
21
|
+
def deliver(self, payload):
|
|
22
|
+
uri = self._configuration.ingestion_uri()
|
|
23
|
+
if not uri:
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
body = json.dumps(payload).encode("utf-8")
|
|
28
|
+
request = urllib.request.Request(
|
|
29
|
+
uri,
|
|
30
|
+
data=body,
|
|
31
|
+
method="POST",
|
|
32
|
+
headers={
|
|
33
|
+
"Authorization": f"Bearer {self._configuration.api_key}",
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
with urllib.request.urlopen(request, timeout=self._configuration.timeout) as response:
|
|
38
|
+
return 200 <= response.status < 300
|
|
39
|
+
except Exception as e: # noqa: BLE001 -- deliberately broad: a broken/unreachable tracker must never break the host app
|
|
40
|
+
self._configuration.logger.debug("[forge_ops_tracker] delivery failed: %s: %s", type(e).__name__, e)
|
|
41
|
+
return False
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Holds a single ForgeOps DSN plus everything else the client needs to
|
|
2
|
+
build and deliver events. Mirrors gems/forge_ops_tracker's Configuration --
|
|
3
|
+
a single Sentry-style DSN string carries both the ingestion URL and the
|
|
4
|
+
project's api_key: "https://<api_key>@host/api/v1/events".
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import socket
|
|
10
|
+
from urllib.parse import unquote, urlsplit, urlunsplit
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Configuration:
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self.dsn = os.environ.get("FORGE_OPS_DSN")
|
|
16
|
+
self.environment = os.environ.get("FORGE_OPS_ENVIRONMENT", "development")
|
|
17
|
+
self.release = os.environ.get("FORGE_OPS_RELEASE")
|
|
18
|
+
self.server_name = _safe_hostname()
|
|
19
|
+
|
|
20
|
+
# Used to decide whether a backtrace frame is "in_app": a frame's
|
|
21
|
+
# file path is compared against this root. Unlike the .NET SDK
|
|
22
|
+
# (where a compiled assembly's file path never matches its
|
|
23
|
+
# original source location), Python runs interpreted directly
|
|
24
|
+
# from real .py files on disk, so file-path matching is the
|
|
25
|
+
# correct approach here too, same as the Ruby gem's Rails.root
|
|
26
|
+
# comparison. Defaults to the current working directory; set
|
|
27
|
+
# explicitly if that doesn't match your app's actual layout (a
|
|
28
|
+
# WSGI server started from a different directory, for instance).
|
|
29
|
+
self.app_root = os.getcwd()
|
|
30
|
+
|
|
31
|
+
self.enabled_environments = {"production", "staging"}
|
|
32
|
+
self.queue_size = 1000
|
|
33
|
+
self.timeout = 2.0 # seconds
|
|
34
|
+
self.scrub_pii = True
|
|
35
|
+
self.logger = logging.getLogger("forge_ops_tracker")
|
|
36
|
+
|
|
37
|
+
# Reports anything that crashes the whole interpreter (a plain
|
|
38
|
+
# script, a management command) with zero extra wiring, the same
|
|
39
|
+
# way an unhandled Rails/ASP.NET Core request is covered
|
|
40
|
+
# automatically elsewhere -- see _install_excepthook in __init__.py.
|
|
41
|
+
# Doesn't change program behavior (the original hook still runs
|
|
42
|
+
# afterward), so on by default is safe; set False to opt out.
|
|
43
|
+
self.install_excepthook = True
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def api_key(self):
|
|
47
|
+
parsed = self._parsed_dsn()
|
|
48
|
+
if parsed is None or not parsed.username:
|
|
49
|
+
return None
|
|
50
|
+
return unquote(parsed.username)
|
|
51
|
+
|
|
52
|
+
def ingestion_uri(self):
|
|
53
|
+
"""The ingestion URL with credentials stripped out (they travel as
|
|
54
|
+
the Authorization header instead, not embedded in the request
|
|
55
|
+
URI)."""
|
|
56
|
+
parsed = self._parsed_dsn()
|
|
57
|
+
if parsed is None:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
netloc = parsed.hostname or ""
|
|
61
|
+
if parsed.port:
|
|
62
|
+
netloc = f"{netloc}:{parsed.port}"
|
|
63
|
+
return urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, ""))
|
|
64
|
+
|
|
65
|
+
def is_enabled(self):
|
|
66
|
+
return bool(self.dsn) and bool(self.api_key) and self.environment in self.enabled_environments
|
|
67
|
+
|
|
68
|
+
def _parsed_dsn(self):
|
|
69
|
+
if not self.dsn:
|
|
70
|
+
return None
|
|
71
|
+
# urlsplit never raises on malformed input (verified directly,
|
|
72
|
+
# not assumed) -- a bad DSN just parses to an empty netloc, which
|
|
73
|
+
# api_key/ingestion_uri already handle by returning None.
|
|
74
|
+
result = urlsplit(self.dsn)
|
|
75
|
+
return result if result.scheme else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _safe_hostname():
|
|
79
|
+
try:
|
|
80
|
+
return socket.gethostname()
|
|
81
|
+
except Exception: # noqa: BLE001 -- hostname lookup must never be able to crash the host app
|
|
82
|
+
return None
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""A small in-process background thread + bounded queue, so delivery
|
|
2
|
+
never blocks the caller that raised the error and never depends on the
|
|
3
|
+
host app having any particular job backend configured. Ported from
|
|
4
|
+
gems/forge_ops_tracker/lib/forge_ops_tracker/delivery_queue.rb.
|
|
5
|
+
|
|
6
|
+
The worker thread is started lazily, on first push, not at import/
|
|
7
|
+
construction time -- deliberately mirroring the Ruby gem rather than the
|
|
8
|
+
.NET SDK's eager start. Gunicorn (prefork) and uWSGI commonly fork worker
|
|
9
|
+
processes *after* the application (and this module) has already loaded,
|
|
10
|
+
which would leave an eagerly-started thread dead in every forked child --
|
|
11
|
+
the exact hazard the Ruby gem's own lazy start avoids for Puma. Starting
|
|
12
|
+
fresh on first push means each forked worker gets its own live thread
|
|
13
|
+
regardless of when it was forked relative to import time.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import queue
|
|
17
|
+
import threading
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DeliveryQueue:
|
|
21
|
+
def __init__(self, configuration, client):
|
|
22
|
+
self._configuration = configuration
|
|
23
|
+
self._client = client
|
|
24
|
+
self._queue = queue.Queue(maxsize=max(1, configuration.queue_size))
|
|
25
|
+
self._thread = None
|
|
26
|
+
self._start_lock = threading.Lock()
|
|
27
|
+
|
|
28
|
+
def push(self, payload):
|
|
29
|
+
self._ensure_worker_started()
|
|
30
|
+
try:
|
|
31
|
+
self._queue.put_nowait(payload)
|
|
32
|
+
return True
|
|
33
|
+
except queue.Full:
|
|
34
|
+
self._configuration.logger.debug("[forge_ops_tracker] delivery queue full, dropping event")
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
def _ensure_worker_started(self):
|
|
38
|
+
if self._thread is not None and self._thread.is_alive():
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
with self._start_lock:
|
|
42
|
+
if self._thread is not None and self._thread.is_alive():
|
|
43
|
+
return
|
|
44
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
45
|
+
self._thread.start()
|
|
46
|
+
|
|
47
|
+
def _run(self):
|
|
48
|
+
while True:
|
|
49
|
+
payload = self._queue.get()
|
|
50
|
+
try:
|
|
51
|
+
self._client.deliver(payload)
|
|
52
|
+
except Exception as e: # noqa: BLE001 -- per-item, so one bad delivery can't kill the worker for every event after it
|
|
53
|
+
# Per-item, not wrapping the whole loop: one bad delivery
|
|
54
|
+
# must not kill the worker for every event after it.
|
|
55
|
+
self._configuration.logger.debug("[forge_ops_tracker] delivery worker error: %s: %s", type(e).__name__, e)
|