forge-ops-tracker 0.2.0__tar.gz → 0.6.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.2.0/src/forge_ops_tracker.egg-info → forge_ops_tracker-0.6.0}/PKG-INFO +114 -8
- {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/README.md +106 -7
- {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/pyproject.toml +4 -2
- forge_ops_tracker-0.6.0/src/forge_ops_tracker/__init__.py +193 -0
- {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/src/forge_ops_tracker/client.py +3 -0
- {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/src/forge_ops_tracker/configuration.py +17 -0
- {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/src/forge_ops_tracker/event_builder.py +7 -3
- forge_ops_tracker-0.6.0/src/forge_ops_tracker/integrations/celery.py +57 -0
- forge_ops_tracker-0.6.0/src/forge_ops_tracker/integrations/django.py +190 -0
- forge_ops_tracker-0.6.0/src/forge_ops_tracker/integrations/flask.py +118 -0
- forge_ops_tracker-0.6.0/src/forge_ops_tracker/integrations/sqlalchemy.py +49 -0
- forge_ops_tracker-0.6.0/src/forge_ops_tracker/performance_flusher.py +92 -0
- {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/src/forge_ops_tracker/reporter.py +2 -2
- {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0/src/forge_ops_tracker.egg-info}/PKG-INFO +114 -8
- {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/src/forge_ops_tracker.egg-info/SOURCES.txt +7 -0
- {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/src/forge_ops_tracker.egg-info/requires.txt +9 -0
- {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/tests/test_event_builder.py +18 -0
- {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/tests/test_init.py +60 -0
- forge_ops_tracker-0.6.0/tests/test_integrations_celery.py +117 -0
- forge_ops_tracker-0.6.0/tests/test_integrations_django.py +286 -0
- forge_ops_tracker-0.6.0/tests/test_integrations_flask.py +297 -0
- forge_ops_tracker-0.6.0/tests/test_integrations_sqlalchemy.py +75 -0
- forge_ops_tracker-0.6.0/tests/test_performance_flusher.py +142 -0
- forge_ops_tracker-0.6.0/tests/test_query_naming.py +44 -0
- {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/tests/test_reporter.py +16 -1
- forge_ops_tracker-0.2.0/src/forge_ops_tracker/__init__.py +0 -126
- forge_ops_tracker-0.2.0/src/forge_ops_tracker/integrations/django.py +0 -72
- forge_ops_tracker-0.2.0/src/forge_ops_tracker/integrations/flask.py +0 -50
- forge_ops_tracker-0.2.0/tests/test_integrations_django.py +0 -109
- forge_ops_tracker-0.2.0/tests/test_integrations_flask.py +0 -131
- {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/LICENSE.txt +0 -0
- {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/setup.cfg +0 -0
- {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/src/forge_ops_tracker/delivery_queue.py +0 -0
- {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/src/forge_ops_tracker/integrations/__init__.py +0 -0
- {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/src/forge_ops_tracker/pii_scrubber.py +0 -0
- {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/src/forge_ops_tracker/session_flusher.py +0 -0
- {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/src/forge_ops_tracker.egg-info/dependency_links.txt +0 -0
- {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/src/forge_ops_tracker.egg-info/top_level.txt +0 -0
- {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/tests/test_client.py +0 -0
- {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/tests/test_configuration.py +0 -0
- {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/tests/test_delivery_queue.py +0 -0
- {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/tests/test_pii_scrubber.py +0 -0
- {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/tests/test_session_flusher.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: forge-ops-tracker
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
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
5
|
Author: ForgeOps
|
|
6
6
|
License-Expression: MIT
|
|
@@ -17,11 +17,18 @@ Provides-Extra: django
|
|
|
17
17
|
Requires-Dist: django>=4.2; extra == "django"
|
|
18
18
|
Provides-Extra: flask
|
|
19
19
|
Requires-Dist: flask>=2.3; extra == "flask"
|
|
20
|
+
Provides-Extra: celery
|
|
21
|
+
Requires-Dist: celery>=5.3; extra == "celery"
|
|
22
|
+
Provides-Extra: sqlalchemy
|
|
23
|
+
Requires-Dist: sqlalchemy>=2.0; extra == "sqlalchemy"
|
|
20
24
|
Provides-Extra: test
|
|
21
25
|
Requires-Dist: pytest>=8; extra == "test"
|
|
22
26
|
Requires-Dist: pytest-django>=4.8; extra == "test"
|
|
23
27
|
Requires-Dist: flask>=2.3; extra == "test"
|
|
28
|
+
Requires-Dist: flask-login>=0.6; extra == "test"
|
|
24
29
|
Requires-Dist: django>=4.2; extra == "test"
|
|
30
|
+
Requires-Dist: celery>=5.3; extra == "test"
|
|
31
|
+
Requires-Dist: sqlalchemy>=2.0; extra == "test"
|
|
25
32
|
Dynamic: license-file
|
|
26
33
|
|
|
27
34
|
# forge-ops-tracker
|
|
@@ -33,18 +40,15 @@ process that raised them.
|
|
|
33
40
|
|
|
34
41
|
## Installation
|
|
35
42
|
|
|
36
|
-
Not yet published to PyPI: install directly from this path (or a local checkout, once split into
|
|
37
|
-
its own repo):
|
|
38
|
-
|
|
39
43
|
```bash
|
|
40
|
-
pip install -
|
|
44
|
+
pip install forge-ops-tracker
|
|
41
45
|
```
|
|
42
46
|
|
|
43
47
|
For Django or Flask integration, install the matching extra:
|
|
44
48
|
|
|
45
49
|
```bash
|
|
46
|
-
pip install -
|
|
47
|
-
pip install -
|
|
50
|
+
pip install "forge-ops-tracker[django]"
|
|
51
|
+
pip install "forge-ops-tracker[flask]"
|
|
48
52
|
```
|
|
49
53
|
|
|
50
54
|
## Configuration
|
|
@@ -75,8 +79,11 @@ forge_ops_tracker.init(dsn="https://<api_key>@your-forgeops-host/api/v1/events")
|
|
|
75
79
|
|
|
76
80
|
MIDDLEWARE = [
|
|
77
81
|
...,
|
|
82
|
+
"django.contrib.auth.middleware.AuthenticationMiddleware", # if not already there
|
|
78
83
|
"forge_ops_tracker.integrations.django.ForgeOpsTrackerMiddleware",
|
|
79
84
|
"forge_ops_tracker.integrations.django.ForgeOpsTrackerSessionTrackingMiddleware",
|
|
85
|
+
"forge_ops_tracker.integrations.django.ForgeOpsTrackerPerformanceTrackingMiddleware",
|
|
86
|
+
"forge_ops_tracker.integrations.django.ForgeOpsTrackerUserContextMiddleware",
|
|
80
87
|
]
|
|
81
88
|
```
|
|
82
89
|
|
|
@@ -145,6 +152,47 @@ loaded, which would leave an eagerly-started thread dead in every forked child;
|
|
|
145
152
|
first push means each forked worker gets its own live thread regardless of when it was forked
|
|
146
153
|
relative to import.
|
|
147
154
|
|
|
155
|
+
## Identifying users
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
forge_ops_tracker.capture_exception(error, user={"id": user.id, "email": user.email})
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Or `set_user(id=None, email=None, username=None)` to attach it for the rest of the current thread
|
|
162
|
+
(a WSGI request, a background worker, a console session) rather than passing it to every
|
|
163
|
+
`capture_exception()` call by hand:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
forge_ops_tracker.set_user(id=request.user.id, email=request.user.email)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Thread-local, not global: a WSGI server (Django/Flask's own deployment model) runs one request per
|
|
170
|
+
thread, so this is the right isolation boundary; not correct under an ASGI/asyncio deployment,
|
|
171
|
+
where several requests can share one OS thread, but this SDK has no async integration today for
|
|
172
|
+
that gap to matter yet. `id`/`email`/`username` are all independently optional; call `set_user()`
|
|
173
|
+
with none of them to clear whatever was set, e.g. once a request finishes. Shows up on an issue's
|
|
174
|
+
own detail page, and as its own affected-users count alongside the regular event count.
|
|
175
|
+
|
|
176
|
+
**Automatic detection, if you're using either integration above:**
|
|
177
|
+
|
|
178
|
+
- **Django**: `ForgeOpsTrackerUserContextMiddleware` (see the Django snippet above; must be listed
|
|
179
|
+
after `django.contrib.auth.middleware.AuthenticationMiddleware`, or whatever else sets
|
|
180
|
+
`request.user`) reads `request.user` when it's present and `is_authenticated`, and calls
|
|
181
|
+
`set_user()` for you: `pk` for `id` (present on every model instance, regardless of your own
|
|
182
|
+
`AUTH_USER_MODEL`), `email` if the attribute exists, and `get_username()` (correct even with a
|
|
183
|
+
custom `USERNAME_FIELD`) if it does. A no-op for an app with no such middleware installed at all.
|
|
184
|
+
- **Flask**: `init_flask()` detects [Flask-Login](https://flask-login.readthedocs.io/) if it's
|
|
185
|
+
installed and configured (a genuinely optional dependency this package never requires): `id`
|
|
186
|
+
comes from `current_user.get_id()` (the only thing Flask-Login's own `UserMixin` actually
|
|
187
|
+
guarantees); `email`/`username` are read as plain optional attributes, since Flask-Login itself
|
|
188
|
+
guarantees neither, the common convention most apps' own `User` model follows regardless. A
|
|
189
|
+
no-op if Flask-Login isn't installed, isn't configured (no `LoginManager` attached to this app),
|
|
190
|
+
or nobody's logged in.
|
|
191
|
+
|
|
192
|
+
Both compose with the manual API above rather than replacing it: call `set_user()` yourself
|
|
193
|
+
afterward (e.g. for a custom auth setup neither integration can detect, or to override what was
|
|
194
|
+
auto-detected) and it wins for the rest of that request.
|
|
195
|
+
|
|
148
196
|
## `in_app` backtrace frames
|
|
149
197
|
|
|
150
198
|
Python runs interpreted directly from real `.py` files on disk, so file-path matching against
|
|
@@ -160,7 +208,9 @@ By default, the message, backtrace, and any context/tags you attach are scanned
|
|
|
160
208
|
for likely personal data (email addresses, formatted SSNs/credit cards, known API key/token
|
|
161
209
|
formats, and anything under a suspiciously-named key like `password`, `api_key`, or `ssn`)
|
|
162
210
|
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.
|
|
211
|
+
regardless, so this is a second, earlier layer, not the only one. The user attached via `user=`/
|
|
212
|
+
`set_user()` above is a deliberate exception: it's never scrubbed, since redacting it would defeat
|
|
213
|
+
the whole point of identifying users in the first place.
|
|
164
214
|
|
|
165
215
|
To disable it:
|
|
166
216
|
|
|
@@ -210,6 +260,62 @@ forge_ops_tracker.init(
|
|
|
210
260
|
Requires a ForgeOps plan that includes release health; on a plan that doesn't, the periodic
|
|
211
261
|
flushes are simply rejected server-side and dropped, exactly like any other delivery failure.
|
|
212
262
|
|
|
263
|
+
## Performance monitoring
|
|
264
|
+
|
|
265
|
+
By default, the Django/Flask integrations also time every request, so a dashboard widget on
|
|
266
|
+
ForgeOps can show which parts of your app are actually slow, not just which ones raise. Bucketed
|
|
267
|
+
by transaction (`"GET /users/<int:id>"`, the matched URL pattern rather than the literal path, so
|
|
268
|
+
a distinct user id doesn't explode into its own separate transaction) and flushed as a small
|
|
269
|
+
periodic aggregate per transaction on the same kind of background thread session tracking above
|
|
270
|
+
uses.
|
|
271
|
+
|
|
272
|
+
```python
|
|
273
|
+
forge_ops_tracker.init(
|
|
274
|
+
dsn="...",
|
|
275
|
+
track_performance=False, # opt out entirely
|
|
276
|
+
performance_flush_interval=30, # seconds; default 60
|
|
277
|
+
)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Requires a ForgeOps plan that includes performance monitoring; on a plan that doesn't, the
|
|
281
|
+
periodic flushes are simply rejected server-side and dropped, exactly like any other delivery
|
|
282
|
+
failure.
|
|
283
|
+
|
|
284
|
+
### Database queries and Celery tasks
|
|
285
|
+
|
|
286
|
+
The same automatic instrumentation, on the same `track_performance` flag, also covers:
|
|
287
|
+
|
|
288
|
+
- **Database queries**, for Django automatically (no extra step beyond the middleware above) and
|
|
289
|
+
for any SQLAlchemy engine (Flask-SQLAlchemy included) with one extra call:
|
|
290
|
+
|
|
291
|
+
```python
|
|
292
|
+
from forge_ops_tracker.integrations.sqlalchemy import track_sqlalchemy_queries
|
|
293
|
+
|
|
294
|
+
db = SQLAlchemy(app)
|
|
295
|
+
track_sqlalchemy_queries(db.engine)
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Bucketed by `"<VERB> <table>"` (`"SELECT auth_user"`, `"INSERT INTO orders"`), not the raw SQL
|
|
299
|
+
text: a low-cardinality name in the same spirit as the request transaction name above, and never
|
|
300
|
+
a literal value even where a query's own parameters aren't already placeholder-bound.
|
|
301
|
+
|
|
302
|
+
- **Celery tasks**, via a separate integration (Celery is an optional dependency, same as
|
|
303
|
+
Django/Flask):
|
|
304
|
+
|
|
305
|
+
```python
|
|
306
|
+
from forge_ops_tracker.integrations.celery import init_celery
|
|
307
|
+
|
|
308
|
+
init_celery()
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Bucketed by the task's own registered name (`"myapp.tasks.send_email"`). This is also this
|
|
312
|
+
SDK's only error-reporting integration for Celery: a task that raises is reported the same way
|
|
313
|
+
an unhandled Django/Flask request exception already is, no separate wiring needed.
|
|
314
|
+
|
|
315
|
+
Each shows up as its own `kind` (`"controller"`, `"job"`, `"query"`) on the same `performance`
|
|
316
|
+
dashboard dataset, so "slowest queries" and "slowest tasks" are just a filtered version of the
|
|
317
|
+
same widget builder "slowest transactions" already uses.
|
|
318
|
+
|
|
213
319
|
## Running the tests
|
|
214
320
|
|
|
215
321
|
```bash
|
|
@@ -7,18 +7,15 @@ process that raised them.
|
|
|
7
7
|
|
|
8
8
|
## Installation
|
|
9
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
10
|
```bash
|
|
14
|
-
pip install -
|
|
11
|
+
pip install forge-ops-tracker
|
|
15
12
|
```
|
|
16
13
|
|
|
17
14
|
For Django or Flask integration, install the matching extra:
|
|
18
15
|
|
|
19
16
|
```bash
|
|
20
|
-
pip install -
|
|
21
|
-
pip install -
|
|
17
|
+
pip install "forge-ops-tracker[django]"
|
|
18
|
+
pip install "forge-ops-tracker[flask]"
|
|
22
19
|
```
|
|
23
20
|
|
|
24
21
|
## Configuration
|
|
@@ -49,8 +46,11 @@ forge_ops_tracker.init(dsn="https://<api_key>@your-forgeops-host/api/v1/events")
|
|
|
49
46
|
|
|
50
47
|
MIDDLEWARE = [
|
|
51
48
|
...,
|
|
49
|
+
"django.contrib.auth.middleware.AuthenticationMiddleware", # if not already there
|
|
52
50
|
"forge_ops_tracker.integrations.django.ForgeOpsTrackerMiddleware",
|
|
53
51
|
"forge_ops_tracker.integrations.django.ForgeOpsTrackerSessionTrackingMiddleware",
|
|
52
|
+
"forge_ops_tracker.integrations.django.ForgeOpsTrackerPerformanceTrackingMiddleware",
|
|
53
|
+
"forge_ops_tracker.integrations.django.ForgeOpsTrackerUserContextMiddleware",
|
|
54
54
|
]
|
|
55
55
|
```
|
|
56
56
|
|
|
@@ -119,6 +119,47 @@ loaded, which would leave an eagerly-started thread dead in every forked child;
|
|
|
119
119
|
first push means each forked worker gets its own live thread regardless of when it was forked
|
|
120
120
|
relative to import.
|
|
121
121
|
|
|
122
|
+
## Identifying users
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
forge_ops_tracker.capture_exception(error, user={"id": user.id, "email": user.email})
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Or `set_user(id=None, email=None, username=None)` to attach it for the rest of the current thread
|
|
129
|
+
(a WSGI request, a background worker, a console session) rather than passing it to every
|
|
130
|
+
`capture_exception()` call by hand:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
forge_ops_tracker.set_user(id=request.user.id, email=request.user.email)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Thread-local, not global: a WSGI server (Django/Flask's own deployment model) runs one request per
|
|
137
|
+
thread, so this is the right isolation boundary; not correct under an ASGI/asyncio deployment,
|
|
138
|
+
where several requests can share one OS thread, but this SDK has no async integration today for
|
|
139
|
+
that gap to matter yet. `id`/`email`/`username` are all independently optional; call `set_user()`
|
|
140
|
+
with none of them to clear whatever was set, e.g. once a request finishes. Shows up on an issue's
|
|
141
|
+
own detail page, and as its own affected-users count alongside the regular event count.
|
|
142
|
+
|
|
143
|
+
**Automatic detection, if you're using either integration above:**
|
|
144
|
+
|
|
145
|
+
- **Django**: `ForgeOpsTrackerUserContextMiddleware` (see the Django snippet above; must be listed
|
|
146
|
+
after `django.contrib.auth.middleware.AuthenticationMiddleware`, or whatever else sets
|
|
147
|
+
`request.user`) reads `request.user` when it's present and `is_authenticated`, and calls
|
|
148
|
+
`set_user()` for you: `pk` for `id` (present on every model instance, regardless of your own
|
|
149
|
+
`AUTH_USER_MODEL`), `email` if the attribute exists, and `get_username()` (correct even with a
|
|
150
|
+
custom `USERNAME_FIELD`) if it does. A no-op for an app with no such middleware installed at all.
|
|
151
|
+
- **Flask**: `init_flask()` detects [Flask-Login](https://flask-login.readthedocs.io/) if it's
|
|
152
|
+
installed and configured (a genuinely optional dependency this package never requires): `id`
|
|
153
|
+
comes from `current_user.get_id()` (the only thing Flask-Login's own `UserMixin` actually
|
|
154
|
+
guarantees); `email`/`username` are read as plain optional attributes, since Flask-Login itself
|
|
155
|
+
guarantees neither, the common convention most apps' own `User` model follows regardless. A
|
|
156
|
+
no-op if Flask-Login isn't installed, isn't configured (no `LoginManager` attached to this app),
|
|
157
|
+
or nobody's logged in.
|
|
158
|
+
|
|
159
|
+
Both compose with the manual API above rather than replacing it: call `set_user()` yourself
|
|
160
|
+
afterward (e.g. for a custom auth setup neither integration can detect, or to override what was
|
|
161
|
+
auto-detected) and it wins for the rest of that request.
|
|
162
|
+
|
|
122
163
|
## `in_app` backtrace frames
|
|
123
164
|
|
|
124
165
|
Python runs interpreted directly from real `.py` files on disk, so file-path matching against
|
|
@@ -134,7 +175,9 @@ By default, the message, backtrace, and any context/tags you attach are scanned
|
|
|
134
175
|
for likely personal data (email addresses, formatted SSNs/credit cards, known API key/token
|
|
135
176
|
formats, and anything under a suspiciously-named key like `password`, `api_key`, or `ssn`)
|
|
136
177
|
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.
|
|
178
|
+
regardless, so this is a second, earlier layer, not the only one. The user attached via `user=`/
|
|
179
|
+
`set_user()` above is a deliberate exception: it's never scrubbed, since redacting it would defeat
|
|
180
|
+
the whole point of identifying users in the first place.
|
|
138
181
|
|
|
139
182
|
To disable it:
|
|
140
183
|
|
|
@@ -184,6 +227,62 @@ forge_ops_tracker.init(
|
|
|
184
227
|
Requires a ForgeOps plan that includes release health; on a plan that doesn't, the periodic
|
|
185
228
|
flushes are simply rejected server-side and dropped, exactly like any other delivery failure.
|
|
186
229
|
|
|
230
|
+
## Performance monitoring
|
|
231
|
+
|
|
232
|
+
By default, the Django/Flask integrations also time every request, so a dashboard widget on
|
|
233
|
+
ForgeOps can show which parts of your app are actually slow, not just which ones raise. Bucketed
|
|
234
|
+
by transaction (`"GET /users/<int:id>"`, the matched URL pattern rather than the literal path, so
|
|
235
|
+
a distinct user id doesn't explode into its own separate transaction) and flushed as a small
|
|
236
|
+
periodic aggregate per transaction on the same kind of background thread session tracking above
|
|
237
|
+
uses.
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
forge_ops_tracker.init(
|
|
241
|
+
dsn="...",
|
|
242
|
+
track_performance=False, # opt out entirely
|
|
243
|
+
performance_flush_interval=30, # seconds; default 60
|
|
244
|
+
)
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Requires a ForgeOps plan that includes performance monitoring; on a plan that doesn't, the
|
|
248
|
+
periodic flushes are simply rejected server-side and dropped, exactly like any other delivery
|
|
249
|
+
failure.
|
|
250
|
+
|
|
251
|
+
### Database queries and Celery tasks
|
|
252
|
+
|
|
253
|
+
The same automatic instrumentation, on the same `track_performance` flag, also covers:
|
|
254
|
+
|
|
255
|
+
- **Database queries**, for Django automatically (no extra step beyond the middleware above) and
|
|
256
|
+
for any SQLAlchemy engine (Flask-SQLAlchemy included) with one extra call:
|
|
257
|
+
|
|
258
|
+
```python
|
|
259
|
+
from forge_ops_tracker.integrations.sqlalchemy import track_sqlalchemy_queries
|
|
260
|
+
|
|
261
|
+
db = SQLAlchemy(app)
|
|
262
|
+
track_sqlalchemy_queries(db.engine)
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Bucketed by `"<VERB> <table>"` (`"SELECT auth_user"`, `"INSERT INTO orders"`), not the raw SQL
|
|
266
|
+
text: a low-cardinality name in the same spirit as the request transaction name above, and never
|
|
267
|
+
a literal value even where a query's own parameters aren't already placeholder-bound.
|
|
268
|
+
|
|
269
|
+
- **Celery tasks**, via a separate integration (Celery is an optional dependency, same as
|
|
270
|
+
Django/Flask):
|
|
271
|
+
|
|
272
|
+
```python
|
|
273
|
+
from forge_ops_tracker.integrations.celery import init_celery
|
|
274
|
+
|
|
275
|
+
init_celery()
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Bucketed by the task's own registered name (`"myapp.tasks.send_email"`). This is also this
|
|
279
|
+
SDK's only error-reporting integration for Celery: a task that raises is reported the same way
|
|
280
|
+
an unhandled Django/Flask request exception already is, no separate wiring needed.
|
|
281
|
+
|
|
282
|
+
Each shows up as its own `kind` (`"controller"`, `"job"`, `"query"`) on the same `performance`
|
|
283
|
+
dashboard dataset, so "slowest queries" and "slowest tasks" are just a filtered version of the
|
|
284
|
+
same widget builder "slowest transactions" already uses.
|
|
285
|
+
|
|
187
286
|
## Running the tests
|
|
188
287
|
|
|
189
288
|
```bash
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "forge-ops-tracker"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.0"
|
|
8
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
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -32,7 +32,9 @@ Homepage = "https://getforgeops.net"
|
|
|
32
32
|
[project.optional-dependencies]
|
|
33
33
|
django = ["django>=4.2"]
|
|
34
34
|
flask = ["flask>=2.3"]
|
|
35
|
-
|
|
35
|
+
celery = ["celery>=5.3"]
|
|
36
|
+
sqlalchemy = ["sqlalchemy>=2.0"]
|
|
37
|
+
test = ["pytest>=8", "pytest-django>=4.8", "flask>=2.3", "flask-login>=0.6", "django>=4.2", "celery>=5.3", "sqlalchemy>=2.0"]
|
|
36
38
|
|
|
37
39
|
[tool.setuptools.packages.find]
|
|
38
40
|
where = ["src"]
|
|
@@ -0,0 +1,193 @@
|
|
|
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 re
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
|
|
14
|
+
from .client import Client
|
|
15
|
+
from .configuration import Configuration
|
|
16
|
+
from .delivery_queue import DeliveryQueue
|
|
17
|
+
from .event_builder import EventBuilder
|
|
18
|
+
from .performance_flusher import PerformanceFlusher
|
|
19
|
+
from .reporter import Reporter
|
|
20
|
+
from .session_flusher import SessionFlusher
|
|
21
|
+
|
|
22
|
+
__all__ = ["Configuration", "capture_exception", "init", "set_user"]
|
|
23
|
+
|
|
24
|
+
_configuration = None
|
|
25
|
+
_reporter = None
|
|
26
|
+
_session_flusher = None
|
|
27
|
+
_performance_flusher = None
|
|
28
|
+
_lock = threading.Lock()
|
|
29
|
+
_original_excepthook = None
|
|
30
|
+
# Request-scoped, not global: a WSGI server (Django/Flask's own deployment model, what the
|
|
31
|
+
# integrations in this SDK actually target) runs one request per thread, so thread-local storage
|
|
32
|
+
# is the right isolation boundary, the same reasoning gems/forge_ops_tracker's own
|
|
33
|
+
# Thread.current[:forge_ops_tracker_current_user] already documents. Not correct under an ASGI/
|
|
34
|
+
# asyncio deployment, where several requests can share one OS thread; this SDK has no async
|
|
35
|
+
# integration today for that gap to matter yet.
|
|
36
|
+
_local = threading.local()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _state():
|
|
40
|
+
global _configuration, _reporter
|
|
41
|
+
if _reporter is None:
|
|
42
|
+
with _lock:
|
|
43
|
+
if _reporter is None:
|
|
44
|
+
_configuration = Configuration()
|
|
45
|
+
client = Client(_configuration)
|
|
46
|
+
delivery_queue = DeliveryQueue(_configuration, client)
|
|
47
|
+
_reporter = Reporter(_configuration, EventBuilder(_configuration), delivery_queue)
|
|
48
|
+
return _configuration, _reporter
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _session_state():
|
|
52
|
+
global _session_flusher
|
|
53
|
+
configuration, _ = _state()
|
|
54
|
+
if _session_flusher is None:
|
|
55
|
+
with _lock:
|
|
56
|
+
if _session_flusher is None:
|
|
57
|
+
_session_flusher = SessionFlusher(configuration, Client(configuration))
|
|
58
|
+
return configuration, _session_flusher
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _record_session(crashed):
|
|
62
|
+
"""Internal; called by the Django/Flask session-tracking integrations, never by host app
|
|
63
|
+
code directly (there's nothing for a caller to decide here beyond what the middleware/
|
|
64
|
+
hook itself already observed)."""
|
|
65
|
+
configuration, flusher = _session_state()
|
|
66
|
+
if not configuration.track_sessions or not configuration.is_enabled():
|
|
67
|
+
return
|
|
68
|
+
flusher.record_session(crashed=crashed)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _performance_state():
|
|
72
|
+
global _performance_flusher
|
|
73
|
+
configuration, _ = _state()
|
|
74
|
+
if _performance_flusher is None:
|
|
75
|
+
with _lock:
|
|
76
|
+
if _performance_flusher is None:
|
|
77
|
+
_performance_flusher = PerformanceFlusher(configuration, Client(configuration))
|
|
78
|
+
return configuration, _performance_flusher
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _record_performance(transaction_name, duration_ms, kind="controller"):
|
|
82
|
+
"""Internal; called by the Django/Flask/Celery/SQLAlchemy performance-tracking integrations,
|
|
83
|
+
never by host app code directly, same reasoning as _record_session above. kind
|
|
84
|
+
("controller"/"job"/"query") distinguishes what was actually timed; see each integration's
|
|
85
|
+
own call site for which kind it passes."""
|
|
86
|
+
configuration, flusher = _performance_state()
|
|
87
|
+
if not configuration.track_performance or not configuration.is_enabled():
|
|
88
|
+
return
|
|
89
|
+
flusher.record(transaction_name, duration_ms, kind=kind)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# Neither Django's connection.execute_wrapper nor SQLAlchemy's before/after_cursor_execute events
|
|
93
|
+
# (see integrations/django.py and integrations/sqlalchemy.py) hand over a friendly auto-generated
|
|
94
|
+
# label the way Rails' own sql.active_record payload does; only the raw SQL string. Raw SQL is
|
|
95
|
+
# both too high-cardinality for a transaction_name (every slightly different query shape would
|
|
96
|
+
# become its own bucket) and a real, if usually parameterized, risk of leaking a literal value.
|
|
97
|
+
# Regexes out "<VERB> <table>" ("SELECT auth_user", "INSERT INTO orders") for the two shapes that
|
|
98
|
+
# actually carry a table name in a predictable place; falls back to just the first SQL keyword
|
|
99
|
+
# ("PRAGMA", "BEGIN") for anything else, or the literal string "SQL" if even that fails. Not a
|
|
100
|
+
# real SQL parser, deliberately: the same "close enough, low-cardinality" trade-off Rails' own
|
|
101
|
+
# SCHEMA/cached-query skip already accepts for the Ruby SDK.
|
|
102
|
+
_SELECT_DELETE_RE = re.compile(r"^\s*(SELECT|DELETE)\b.*?\bFROM\s+[\"'`]?(\w+)", re.IGNORECASE | re.DOTALL)
|
|
103
|
+
_INSERT_UPDATE_RE = re.compile(r"^\s*(INSERT INTO|UPDATE)\s+[\"'`]?(\w+)", re.IGNORECASE)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _query_transaction_name(sql):
|
|
107
|
+
match = _SELECT_DELETE_RE.match(sql) or _INSERT_UPDATE_RE.match(sql)
|
|
108
|
+
if match:
|
|
109
|
+
return f"{match.group(1).upper()} {match.group(2)}"
|
|
110
|
+
|
|
111
|
+
words = sql.strip().split(None, 1)
|
|
112
|
+
return words[0].upper() if words else "SQL"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def init(dsn=None, **overrides):
|
|
116
|
+
"""Configure the client. Call once at startup (Django settings.py, or
|
|
117
|
+
right after creating a Flask app). Any Configuration attribute can be
|
|
118
|
+
overridden by keyword, e.g. init(dsn=..., release=..., environment=...).
|
|
119
|
+
"""
|
|
120
|
+
configuration, _ = _state()
|
|
121
|
+
if dsn is not None:
|
|
122
|
+
configuration.dsn = dsn
|
|
123
|
+
for key, value in overrides.items():
|
|
124
|
+
if not hasattr(configuration, key):
|
|
125
|
+
raise TypeError(f"Configuration has no attribute {key!r}")
|
|
126
|
+
setattr(configuration, key, value)
|
|
127
|
+
|
|
128
|
+
if configuration.install_excepthook:
|
|
129
|
+
_install_excepthook()
|
|
130
|
+
|
|
131
|
+
return configuration
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def capture_exception(exc=None, context=None, user=None):
|
|
135
|
+
"""Report an exception you've already caught. `exc` defaults to
|
|
136
|
+
whichever exception is currently being handled, so this can usually
|
|
137
|
+
just be called as capture_exception() from inside an `except:` block.
|
|
138
|
+
`user` defaults to whatever set_user() last set for this thread, if
|
|
139
|
+
anything; pass one explicitly to override that for this one report."""
|
|
140
|
+
if exc is None:
|
|
141
|
+
exc = sys.exc_info()[1]
|
|
142
|
+
if exc is None:
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
_, reporter = _state()
|
|
146
|
+
reporter.report(exc, context=context, user=user if user is not None else getattr(_local, "user", None))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def set_user(id=None, email=None, username=None):
|
|
150
|
+
"""Manually attaches an affected user to whatever gets reported for the rest of this thread
|
|
151
|
+
(a WSGI request, a background worker, a console session): see capture_exception()'s own doc
|
|
152
|
+
for why thread-local, not global. id/email/username are all independently optional; call with
|
|
153
|
+
none of them to clear whatever was set, e.g. once a request finishes."""
|
|
154
|
+
user = {k: v for k, v in {"id": id, "email": email, "username": username}.items() if v is not None}
|
|
155
|
+
_local.user = user or None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _install_excepthook():
|
|
159
|
+
# Reports anything that crashes the whole interpreter (a plain
|
|
160
|
+
# script, a management command, a worker's own top-level loop) with
|
|
161
|
+
# no further wiring: the same "unhandled needs no wiring" case
|
|
162
|
+
# Rails.error/ASP.NET Core's middleware cover automatically. This does
|
|
163
|
+
# *not* catch a web request's unhandled exception under a WSGI
|
|
164
|
+
# server: Django/Flask catch that themselves before it ever reaches
|
|
165
|
+
# here, which is what the integrations in forge_ops_tracker.integrations
|
|
166
|
+
# are for.
|
|
167
|
+
global _original_excepthook
|
|
168
|
+
if _original_excepthook is not None:
|
|
169
|
+
return # already installed
|
|
170
|
+
|
|
171
|
+
_original_excepthook = sys.excepthook
|
|
172
|
+
|
|
173
|
+
def _excepthook(exc_type, exc_value, exc_tb):
|
|
174
|
+
try:
|
|
175
|
+
capture_exception(exc_value)
|
|
176
|
+
finally:
|
|
177
|
+
_original_excepthook(exc_type, exc_value, exc_tb)
|
|
178
|
+
|
|
179
|
+
sys.excepthook = _excepthook
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _reset_for_testing():
|
|
183
|
+
"""Not part of the public API: resets module-level state between
|
|
184
|
+
test cases."""
|
|
185
|
+
global _configuration, _reporter, _session_flusher, _performance_flusher, _original_excepthook
|
|
186
|
+
if _original_excepthook is not None:
|
|
187
|
+
sys.excepthook = _original_excepthook
|
|
188
|
+
_configuration = None
|
|
189
|
+
_reporter = None
|
|
190
|
+
_session_flusher = None
|
|
191
|
+
_performance_flusher = None
|
|
192
|
+
_original_excepthook = None
|
|
193
|
+
_local.user = None
|
|
@@ -24,6 +24,9 @@ class Client:
|
|
|
24
24
|
def deliver_session_checkin(self, payload):
|
|
25
25
|
return self._post(self._configuration.session_checkins_uri(), payload)
|
|
26
26
|
|
|
27
|
+
def deliver_performance_samples(self, samples):
|
|
28
|
+
return self._post(self._configuration.performance_samples_uri(), {"samples": samples})
|
|
29
|
+
|
|
27
30
|
def _post(self, uri, payload):
|
|
28
31
|
if not uri:
|
|
29
32
|
return False
|
|
@@ -64,6 +64,14 @@ class Configuration:
|
|
|
64
64
|
# flushed as one small report on this interval, not one network call per request.
|
|
65
65
|
self.session_flush_interval = 60
|
|
66
66
|
|
|
67
|
+
# Whether the Django/Flask integrations time every request and periodically report an
|
|
68
|
+
# aggregate per transaction: see performance_flusher.py. On by default, same posture as
|
|
69
|
+
# track_sessions above.
|
|
70
|
+
self.track_performance = True
|
|
71
|
+
# Seconds between aggregate performance reports; same "counted in-process, flushed as
|
|
72
|
+
# one small batch on this interval" reasoning as session_flush_interval above.
|
|
73
|
+
self.performance_flush_interval = 60
|
|
74
|
+
|
|
67
75
|
@property
|
|
68
76
|
def api_key(self):
|
|
69
77
|
parsed = self._parsed_dsn()
|
|
@@ -95,6 +103,15 @@ class Configuration:
|
|
|
95
103
|
return uri[: -len("/events")] + "/session_checkins"
|
|
96
104
|
return uri
|
|
97
105
|
|
|
106
|
+
def performance_samples_uri(self):
|
|
107
|
+
"""Same derivation again, swapping the trailing /events for /performance_samples."""
|
|
108
|
+
uri = self.ingestion_uri()
|
|
109
|
+
if not uri:
|
|
110
|
+
return None
|
|
111
|
+
if uri.endswith("/events"):
|
|
112
|
+
return uri[: -len("/events")] + "/performance_samples"
|
|
113
|
+
return uri
|
|
114
|
+
|
|
98
115
|
def is_enabled(self):
|
|
99
116
|
return bool(self.dsn) and bool(self.api_key) and self.environment in self.enabled_environments
|
|
100
117
|
|
|
@@ -35,7 +35,7 @@ class EventBuilder:
|
|
|
35
35
|
def __init__(self, configuration):
|
|
36
36
|
self._configuration = configuration
|
|
37
37
|
|
|
38
|
-
def build(self, exc, context=None):
|
|
38
|
+
def build(self, exc, context=None, user=None):
|
|
39
39
|
payload = {
|
|
40
40
|
"exception_class": _exception_class_name(exc),
|
|
41
41
|
"message": str(exc),
|
|
@@ -48,16 +48,20 @@ class EventBuilder:
|
|
|
48
48
|
"tags": {},
|
|
49
49
|
"sdk_name": SDK_NAME,
|
|
50
50
|
}
|
|
51
|
+
if user:
|
|
52
|
+
payload["user"] = dict(user)
|
|
51
53
|
|
|
52
54
|
if self._configuration.scrub_pii:
|
|
53
55
|
payload = self._scrub(payload)
|
|
54
56
|
|
|
55
57
|
return payload
|
|
56
58
|
|
|
57
|
-
# exception_class/occurred_at/environment/release/server_name/sdk_name are left
|
|
59
|
+
# exception_class/occurred_at/environment/release/server_name/sdk_name/user are left
|
|
58
60
|
# alone: structured fields this client or the host app sets
|
|
59
61
|
# deliberately, not free text an exception or its context could
|
|
60
|
-
# accidentally spill sensitive data into.
|
|
62
|
+
# accidentally spill sensitive data into. user specifically is a deliberate exemption, not an
|
|
63
|
+
# oversight: the scrubber's own email pattern would otherwise redact the exact thing this
|
|
64
|
+
# field exists to carry.
|
|
61
65
|
def _scrub(self, payload):
|
|
62
66
|
payload = dict(payload)
|
|
63
67
|
payload["message"] = pii_scrubber.scrub_string(payload["message"])
|