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.
Files changed (43) hide show
  1. {forge_ops_tracker-0.2.0/src/forge_ops_tracker.egg-info → forge_ops_tracker-0.6.0}/PKG-INFO +114 -8
  2. {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/README.md +106 -7
  3. {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/pyproject.toml +4 -2
  4. forge_ops_tracker-0.6.0/src/forge_ops_tracker/__init__.py +193 -0
  5. {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/src/forge_ops_tracker/client.py +3 -0
  6. {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/src/forge_ops_tracker/configuration.py +17 -0
  7. {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/src/forge_ops_tracker/event_builder.py +7 -3
  8. forge_ops_tracker-0.6.0/src/forge_ops_tracker/integrations/celery.py +57 -0
  9. forge_ops_tracker-0.6.0/src/forge_ops_tracker/integrations/django.py +190 -0
  10. forge_ops_tracker-0.6.0/src/forge_ops_tracker/integrations/flask.py +118 -0
  11. forge_ops_tracker-0.6.0/src/forge_ops_tracker/integrations/sqlalchemy.py +49 -0
  12. forge_ops_tracker-0.6.0/src/forge_ops_tracker/performance_flusher.py +92 -0
  13. {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/src/forge_ops_tracker/reporter.py +2 -2
  14. {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0/src/forge_ops_tracker.egg-info}/PKG-INFO +114 -8
  15. {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/src/forge_ops_tracker.egg-info/SOURCES.txt +7 -0
  16. {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/src/forge_ops_tracker.egg-info/requires.txt +9 -0
  17. {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/tests/test_event_builder.py +18 -0
  18. {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/tests/test_init.py +60 -0
  19. forge_ops_tracker-0.6.0/tests/test_integrations_celery.py +117 -0
  20. forge_ops_tracker-0.6.0/tests/test_integrations_django.py +286 -0
  21. forge_ops_tracker-0.6.0/tests/test_integrations_flask.py +297 -0
  22. forge_ops_tracker-0.6.0/tests/test_integrations_sqlalchemy.py +75 -0
  23. forge_ops_tracker-0.6.0/tests/test_performance_flusher.py +142 -0
  24. forge_ops_tracker-0.6.0/tests/test_query_naming.py +44 -0
  25. {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/tests/test_reporter.py +16 -1
  26. forge_ops_tracker-0.2.0/src/forge_ops_tracker/__init__.py +0 -126
  27. forge_ops_tracker-0.2.0/src/forge_ops_tracker/integrations/django.py +0 -72
  28. forge_ops_tracker-0.2.0/src/forge_ops_tracker/integrations/flask.py +0 -50
  29. forge_ops_tracker-0.2.0/tests/test_integrations_django.py +0 -109
  30. forge_ops_tracker-0.2.0/tests/test_integrations_flask.py +0 -131
  31. {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/LICENSE.txt +0 -0
  32. {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/setup.cfg +0 -0
  33. {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/src/forge_ops_tracker/delivery_queue.py +0 -0
  34. {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/src/forge_ops_tracker/integrations/__init__.py +0 -0
  35. {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/src/forge_ops_tracker/pii_scrubber.py +0 -0
  36. {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/src/forge_ops_tracker/session_flusher.py +0 -0
  37. {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/src/forge_ops_tracker.egg-info/dependency_links.txt +0 -0
  38. {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/src/forge_ops_tracker.egg-info/top_level.txt +0 -0
  39. {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/tests/test_client.py +0 -0
  40. {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/tests/test_configuration.py +0 -0
  41. {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/tests/test_delivery_queue.py +0 -0
  42. {forge_ops_tracker-0.2.0 → forge_ops_tracker-0.6.0}/tests/test_pii_scrubber.py +0 -0
  43. {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.2.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 -e path/to/forge_ops/sdks/python
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 -e "path/to/forge_ops/sdks/python[django]"
47
- pip install -e "path/to/forge_ops/sdks/python[flask]"
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 -e path/to/forge_ops/sdks/python
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 -e "path/to/forge_ops/sdks/python[django]"
21
- pip install -e "path/to/forge_ops/sdks/python[flask]"
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.2.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
- test = ["pytest>=8", "pytest-django>=4.8", "flask>=2.3", "django>=4.2"]
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"])