Metadata-Version: 2.4
Name: forge-ops-tracker
Version: 0.2.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 [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

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",
    "forge_ops_tracker.integrations.django.ForgeOpsTrackerSessionTrackingMiddleware",
]
```

### 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 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:

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

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.

To disable it:

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

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

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

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