Metadata-Version: 2.4
Name: forge-ops-tracker
Version: 0.1.0
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.
Author: ForgeOps
License-Expression: MIT
Project-URL: Homepage, https://getforgeops.net
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Classifier: Topic :: System :: Logging
Classifier: Framework :: Django
Classifier: Framework :: Flask
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE.txt
Provides-Extra: django
Requires-Dist: django>=4.2; extra == "django"
Provides-Extra: flask
Requires-Dist: flask>=2.3; extra == "flask"
Provides-Extra: test
Requires-Dist: pytest>=8; extra == "test"
Requires-Dist: pytest-django>=4.8; extra == "test"
Requires-Dist: flask>=2.3; extra == "test"
Requires-Dist: django>=4.2; extra == "test"
Dynamic: license-file

# forge-ops-tracker

Python error reporting client for a private, self-hosted [ForgeOps](../../) tracker instance.
Requires Python 3.9+. A from-scratch port of [`gems/forge_ops_tracker`](../../gems/forge_ops_tracker)
(the Rails client) -- see that gem's README for the shared design rationale; this document only
covers what's Python-specific.

## Installation

Not yet published to PyPI -- install directly from this path (or a local checkout, once split into
its own repo):

```bash
pip install -e path/to/forge_ops/sdks/python
```

For Django or Flask integration, install the matching extra:

```bash
pip install -e "path/to/forge_ops/sdks/python[django]"
pip install -e "path/to/forge_ops/sdks/python[flask]"
```

## Configuration

Set a DSN (from a project's settings page in ForgeOps), either via the `FORGE_OPS_DSN` environment
variable or explicitly:

```python
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

```python
# settings.py
import forge_ops_tracker

forge_ops_tracker.init(dsn="https://<api_key>@your-forgeops-host/api/v1/events")

MIDDLEWARE = [
    ...,
    "forge_ops_tracker.integrations.django.ForgeOpsTrackerMiddleware",
]
```

### Flask

```python
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:

```python
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 Django/Flask-wide equivalent to Rails' `Rails.error.handle` here -- report it explicitly
instead, right at the catch site:

```python
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.

## `in_app` backtrace frames

Unlike the .NET SDK (where a compiled assembly's file path never matches its original source
location), Python runs interpreted directly from real `.py` files on disk, so file-path matching
against `Configuration.app_root` works the same way it does in the Ruby gem's `Rails.root`
comparison. 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

Same behavior as the Ruby gem: 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 (`password`, `api_key`, `ssn`, and similar) --
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.

To disable it:

```python
forge_ops_tracker.init(dsn="...", scrub_pii=False)
```

## Running the tests

```bash
cd sdks/python
python3 -m venv .venv
./.venv/bin/pip install -e ".[test]"
./.venv/bin/python -m pytest
./.venv/bin/ruff check src tests
```
