forge-ops-tracker
Python error reporting client for a ForgeOps instance. Requires Python 3.9+. It captures unhandled and explicitly reported exceptions, builds a backtrace, scrubs likely PII, and delivers events to ForgeOps over HTTP without blocking the request or process that raised them.
Installation
pip install forge-ops-tracker
For Django or Flask integration, install the matching extra:
pip install "forge-ops-tracker[django]"
pip install "forge-ops-tracker[flask]"
Configuration
Set a DSN (from a project's settings page in ForgeOps), either via the FORGE_OPS_DSN environment
variable or explicitly:
import forge_ops_tracker
forge_ops_tracker.init(
dsn="https://<api_key>@your-forgeops-host/api/v1/events", # or leave unset to read FORGE_OPS_DSN
release="...",
environment="production",
)
Call init() once at startup: Django's settings.py, or right after creating a Flask app. Any
Configuration attribute can be overridden by keyword.
Django
# settings.py
import forge_ops_tracker
forge_ops_tracker.init(dsn="https://<api_key>@your-forgeops-host/api/v1/events")
MIDDLEWARE = [
...,
"django.contrib.auth.middleware.AuthenticationMiddleware", # if not already there
"forge_ops_tracker.integrations.django.ForgeOpsTrackerMiddleware",
"forge_ops_tracker.integrations.django.ForgeOpsTrackerSessionTrackingMiddleware",
"forge_ops_tracker.integrations.django.ForgeOpsTrackerPerformanceTrackingMiddleware",
"forge_ops_tracker.integrations.django.ForgeOpsTrackerUserContextMiddleware",
]
Flask
from flask import Flask
import forge_ops_tracker
from forge_ops_tracker.integrations.flask import init_flask
forge_ops_tracker.init(dsn="https://<api_key>@your-forgeops-host/api/v1/events")
app = Flask(__name__)
init_flask(app)
What gets reported automatically, and what doesn't
An exception that crashes a request needs no further wiring at all. The Django middleware's
process_exception hook and Flask's got_request_exception signal both fire for anything that
propagates uncaught out of a view, then let the framework handle it exactly as if this client
weren't installed.
An exception your own code catches and handles is different: neither integration ever sees it, since it never propagates far enough to reach either hook:
try:
charge_card(order)
except CardError as e:
logger.warning("card declined: %s", e)
# ForgeOps never sees this: caught locally, never reaches the
# middleware/signal at all.
There's no application-wide hook that reports an exception while still letting your own except
block handle it: report it explicitly instead, right at the catch site:
except CardError as e:
forge_ops_tracker.capture_exception(e, context={"order_id": order.id})
logger.warning("card declined: %s", e)
Called with no arguments, capture_exception() picks up whichever exception is currently being
handled (same as a bare raise inside an except: block), so it usually reads as just
forge_ops_tracker.capture_exception() from inside the block that already caught it.
Outside a web request (scripts, management commands, workers)
init() also installs a sys.excepthook wrapper by default (Configuration.install_excepthook,
True unless set otherwise), which reports anything that crashes the whole interpreter: a plain
script, a Django management command, a worker's own top-level loop: with no wiring needed, the
same "unhandled needs no wiring" case the Django/Flask integrations cover for web requests. It
still calls whatever sys.excepthook was already installed afterward, so it never changes program
behavior. This does not catch a web request's unhandled exception under a real WSGI server
(Gunicorn/uWSGI catch that themselves per-request, long before it would ever reach the interpreter
level): that's what the Django/Flask integrations are for.
Delivery happens on a background thread with a bounded queue and a short per-request HTTP timeout
(Configuration.timeout, 2s default). Every failure mode: network errors, timeouts, a full queue,
a malformed DSN: is caught and dropped rather than raised, so a broken or unreachable tracker can
never take down the host app. The worker thread starts lazily, on first push, not at import time:
Gunicorn (prefork) and uWSGI commonly fork worker processes after the application has already
loaded, which would leave an eagerly-started thread dead in every forked child; starting fresh on
first push means each forked worker gets its own live thread regardless of when it was forked
relative to import.
Identifying users
forge_ops_tracker.capture_exception(error, user={"id": user.id, "email": user.email})
Or set_user(id=None, email=None, username=None) to attach it for the rest of the current thread
(a WSGI request, a background worker, a console session) rather than passing it to every
capture_exception() call by hand:
forge_ops_tracker.set_user(id=request.user.id, email=request.user.email)
Thread-local, not global: a WSGI server (Django/Flask's own deployment model) runs one request per
thread, so this is the right isolation boundary; not correct under an ASGI/asyncio deployment,
where several requests can share one OS thread, but this SDK has no async integration today for
that gap to matter yet. id/email/username are all independently optional; call set_user()
with none of them to clear whatever was set, e.g. once a request finishes. Shows up on an issue's
own detail page, and as its own affected-users count alongside the regular event count.
Automatic detection, if you're using either integration above:
- Django:
ForgeOpsTrackerUserContextMiddleware(see the Django snippet above; must be listed afterdjango.contrib.auth.middleware.AuthenticationMiddleware, or whatever else setsrequest.user) readsrequest.userwhen it's present andis_authenticated, and callsset_user()for you:pkforid(present on every model instance, regardless of your ownAUTH_USER_MODEL),emailif the attribute exists, andget_username()(correct even with a customUSERNAME_FIELD) if it does. A no-op for an app with no such middleware installed at all. - Flask:
init_flask()detects Flask-Login if it's installed and configured (a genuinely optional dependency this package never requires):idcomes fromcurrent_user.get_id()(the only thing Flask-Login's ownUserMixinactually guarantees);email/usernameare read as plain optional attributes, since Flask-Login itself guarantees neither, the common convention most apps' ownUsermodel follows regardless. A no-op if Flask-Login isn't installed, isn't configured (noLoginManagerattached to this app), or nobody's logged in.
Both compose with the manual API above rather than replacing it: call set_user() yourself
afterward (e.g. for a custom auth setup neither integration can detect, or to override what was
auto-detected) and it wins for the rest of that request.
in_app backtrace frames
Python runs interpreted directly from real .py files on disk, so file-path matching against
Configuration.app_root is a straightforward prefix comparison against those on-disk paths.
Defaults to the current working directory; set it explicitly if that doesn't match your app's
actual layout (a WSGI server started from a different directory than your app's root, for
instance). Standard-library and installed-package (site-packages/dist-packages) frames are
never marked in_app, regardless of app_root.
PII scrubbing
By default, the message, backtrace, and any context/tags you attach are scanned
for likely personal data (email addresses, formatted SSNs/credit cards, known API key/token
formats, and anything under a suspiciously-named key like password, api_key, or ssn)
and redacted before the payload ever leaves this process. ForgeOps itself scrubs again on arrival
regardless, so this is a second, earlier layer, not the only one. The user attached via user=/
set_user() above is a deliberate exception: it's never scrubbed, since redacting it would defeat
the whole point of identifying users in the first place.
To disable it:
forge_ops_tracker.init(dsn="...", scrub_pii=False)
Source context
By default, each in-app backtrace frame (never a standard-library or installed-package frame) is
captured along with the 5 lines of source on either side of the culprit line, read straight off
disk at raise-time, so an issue's detail page can show the actual code that broke, not just a
file:line:method reference. This never applies to a frame outside your configured app_root, and
it fails silently (no context, not an exception) for any file that can't be read for whatever
reason.
This is a real, deliberate exception to "off by default is safer": literal source code is being
transmitted, not just a reference to it, and the real protection here is not this flag. Every
project on ForgeOps has its own setting (on by default, off durably and immediately once an org
owner turns it off, regardless of what any individual app's own capture_source_context is still
set to) that governs whether the server will ever actually store what a client sends. Set this to
False if you'd rather this client never even attempt the disk read in the first place:
forge_ops_tracker.init(dsn="...", capture_source_context=False)
Session tracking (release health)
By default, every request through the Django/Flask integrations is counted as a session: crash-free unless an unhandled exception (or a 5xx response, for Django, where the exception has already been converted to a response by the time this client ever sees the request) actually affects it, giving ForgeOps a crash-free rate per release to show alongside the errors themselves, not just the errors on their own. Counted in-process and flushed as a small periodic aggregate on a background thread (never one network call per request), the same delivery philosophy as everything else in this client: a broken or unreachable tracker never affects the host app either way.
forge_ops_tracker.init(
dsn="...",
track_sessions=False, # opt out entirely
session_flush_interval=30, # seconds; default 60
)
Requires a ForgeOps plan that includes release health; on a plan that doesn't, the periodic flushes are simply rejected server-side and dropped, exactly like any other delivery failure.
Performance monitoring
By default, the Django/Flask integrations also time every request, so a dashboard widget on
ForgeOps can show which parts of your app are actually slow, not just which ones raise. Bucketed
by transaction ("GET /users/<int:id>", the matched URL pattern rather than the literal path, so
a distinct user id doesn't explode into its own separate transaction) and flushed as a small
periodic aggregate per transaction on the same kind of background thread session tracking above
uses.
forge_ops_tracker.init(
dsn="...",
track_performance=False, # opt out entirely
performance_flush_interval=30, # seconds; default 60
)
Requires a ForgeOps plan that includes performance monitoring; on a plan that doesn't, the periodic flushes are simply rejected server-side and dropped, exactly like any other delivery failure.
Database queries and Celery tasks
The same automatic instrumentation, on the same track_performance flag, also covers:
-
Database queries, for Django automatically (no extra step beyond the middleware above) and for any SQLAlchemy engine (Flask-SQLAlchemy included) with one extra call:
from forge_ops_tracker.integrations.sqlalchemy import track_sqlalchemy_queries db = SQLAlchemy(app) track_sqlalchemy_queries(db.engine)
Bucketed by
"<VERB> <table>"("SELECT auth_user","INSERT INTO orders"), not the raw SQL text: a low-cardinality name in the same spirit as the request transaction name above, and never a literal value even where a query's own parameters aren't already placeholder-bound. -
Celery tasks, via a separate integration (Celery is an optional dependency, same as Django/Flask):
from forge_ops_tracker.integrations.celery import init_celery init_celery()
Bucketed by the task's own registered name (
"myapp.tasks.send_email"). This is also this SDK's only error-reporting integration for Celery: a task that raises is reported the same way an unhandled Django/Flask request exception already is, no separate wiring needed.
Each shows up as its own kind ("controller", "job", "query") on the same performance
dashboard dataset, so "slowest queries" and "slowest tasks" are just a filtered version of the
same widget builder "slowest transactions" already uses.
Running the tests
cd sdks/python
python3 -m venv .venv
./.venv/bin/pip install -e ".[test]"
./.venv/bin/python -m pytest
./.venv/bin/ruff check src tests
Release files for forge-ops-tracker 0.6.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| forge_ops_tracker-0.6.0.tar.gz | 41.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| forge_ops_tracker-0.6.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 73.7 kB
Release files / forge_ops_tracker-0.6.0.tar.gz
| Download URL | forge_ops_tracker-0.6.0.tar.gz |
|---|---|
| Size | 41.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
063ae8f2189054383ef1c53093e8a3fd3c6abab85cb6890caad5dba25fec9c2e
|
|
BLAKE2b-256 checksum How to use checksums |
5e293c071eacaa4ef4cf857402cccc901c0d605c81cf2ec8104a9e2e941529e6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.14
|
Release files / forge_ops_tracker-0.6.0-py3-none-any.whl
| Download URL | forge_ops_tracker-0.6.0-py3-none-any.whl |
|---|---|
| Size | 31.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
112e3c7dbda2475b33268328d7a46c47cb6187cd174462c5134bc1bbe79e8462
|
|
BLAKE2b-256 checksum How to use checksums |
7bc112c3e61b2d15e025ff05d80a7c142df3836e94d4e9ed4d23247596fedad6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.14
|