# Python

Report errors from Python servers, jobs and Streamlit apps. Frame locals are never read, and messages are redacted by pattern before they leave your process.

## Install

```bash
pip install bugtape
```

The package has no dependencies and supports Python 3.9 and later. If your network cannot reach PyPI, install the same reviewed wheel from BugTape: `pip install https://app.bugtape.ai/sdk/python/bugtape-0.1.1-py3-none-any.whl`.

## Start

```python
import bugtape

bugtape.init(service="reports", release="2026.09.28")
```

The capture key is read from the `BUGTAPE_KEY` environment variable. Use a key from **Setup → Python** in the console (`bt_live_…` or `bt_test_…`). Without a key, `init` logs one warning and reporting stays off.

From then on BugTape reports:

- uncaught exceptions in the main thread and in threads,
- log records at `ERROR` and above, from any logger,
- anything you pass to `bugtape.capture_exception()` or `bugtape.capture_message()`,
- uncaught exceptions in Streamlit pages, when Streamlit is imported.

```python
try:
    run_export()
except Exception:
    bugtape.capture_exception()  # reports the exception being handled
    raise
```

## Streamlit

```python
import streamlit as st
import bugtape

bugtape.init(service="analytics")

st.title("Revenue")
```

Streamlit runs the page again on every interaction. Calling `init` at the top of the page is fine: a call with the same settings keeps the running client.

Streamlit catches page errors itself and shows them in the app, so they never reach Python's normal error hook. BugTape wraps the function Streamlit calls for those errors. The app still shows the error exactly as before, and BugTape files one report with the page name and the Streamlit session.

Streamlit also logs the same error. BugTape recognises it and does not report it twice.

If a future Streamlit release changes that function, `init(streamlit=True)` logs a warning. You can then wrap page code yourself:

```python
from bugtape.integrations.streamlit import capture

with capture():
    render_page()
```

## What leaves your server

| Sent | Never sent |
|---|---|
| Exception type and a redacted message | Frame locals |
| File, function and line of each frame | Source code lines |
| Your `service`, `release` and `environment` | Arguments passed to a log call |
| The log call's template, e.g. `export failed for %s` | Other environment variables (only the settings below, `DD_SERVICE`, `DD_ENV`, `DD_VERSION` and, to fill `release`, the commit variables under Release are read) |
| Datadog trace and span ids, when `ddtrace` is running | Request bodies |

Redaction replaces:

- quoted values: `KeyError: 'Acme Pty Ltd'` becomes `KeyError: '[redacted]'`
- numbers, amounts and percentages: `margin 4.25% for acct 18233` becomes `margin [num]% for acct [num]`
- SQL statements, email addresses, and URLs with credentials
- cloud storage paths, data file names, and ids
- values of `…_id=`, `name=`, `token=`, `password=` and similar pairs

Python type names such as `'int'` and `'DataFrame'` are kept because they carry no data.

Values you pass in `extra` are redacted the same way. A value under a key such as `client`, `customer`, `name`, `account` or `amount` is replaced whole, and numbers become `[num]`.

### What redaction does not catch

Redaction works by pattern. A value written into a message without quotes or digits is not recognised: `ValueError(f"no rate for client {name}")` is sent as `no rate for client Acme Pty Ltd`. The same goes for a log call written as an f-string, because BugTape only sees the finished text. For code like that, pick one:

- pass values as log arguments (`log.error("no rate for client %s", name)`), which are never sent,
- add a pattern with `extra_redactions`,
- check or drop reports with `before_send`,
- or set `send_messages=False`, which sends the exception type and location without the message.

Add your own patterns, or check every report before it is sent:

```python
bugtape.init(
    service="reports",
    extra_redactions=[(r"\bACME-\d+\b", "[account]")],
    before_send=lambda report: None if "healthcheck" in report["title"] else report,
)
```

`before_send` receives the full JSON payload. Return it (changed or not) to send it, or `None` to drop it.

## Grouping

Repeats of the same error group into one issue. The key is the exception type, the redacted message and the code location. The code location is the innermost frame in your own code, not in a library, and it is sent as a stable address such as `python://reports/jobs/export/run_export`. Redaction removes the changing values, so a timeout for account 18233 and one for account 20411 are the same issue.

## Datadog

If your app already runs `ddtrace`, each report carries the active trace and span ids. Set your Datadog site in **Settings → Integrations → Datadog**, and the issue in BugTape links straight to the trace. BugTape never imports or starts Datadog itself. See [Use BugTape with Datadog](/docs/datadog/).

## Options

| Option | Default | Meaning |
|---|---|---|
| `api_key` | `BUGTAPE_KEY` | Capture key |
| `service` | `BUGTAPE_SERVICE`, `DD_SERVICE`, `app` | Name of this app or job |
| `release` | `BUGTAPE_RELEASE`, `DD_VERSION`, then your CI or host's commit | Version or commit (see Release) |
| `environment` | `BUGTAPE_ENVIRONMENT`, `DD_ENV` | e.g. `production` |
| `endpoint` | `https://app.bugtape.ai/v1/ingest` | Ingest URL |
| `redact` | `True` | Set `False` only for data you know is safe |
| `capture_uncaught` | `True` | Hook `sys.excepthook` and `threading.excepthook` |
| `capture_logging` | `True` | Report `ERROR` log records |
| `logging_level` | `logging.ERROR` | Lowest level reported |
| `streamlit` | auto | `True` to require the hook, `False` to skip it |
| `send_messages` | `True` | `False` sends the exception type without its message, and log records without their text unless they use arguments |
| `debug` | `False` | Log BugTape's own delivery problems |

## Delivery

Reports go out from a background thread with a 5 second timeout. BugTape retries once on a network error, a 5xx or a 429, using the same capture id so the server does not count it twice. At most 30 reports a minute are sent. BugTape never raises into your app.

Call `bugtape.flush()` at the end of a short script so queued reports are sent before the process exits. Uncaught exceptions flush on their own. It returns `True` when every report since the last flush was accepted, and `False` on timeout or when one was rejected or could not be sent (`debug=True` logs why).

### Release

Every report should carry a release, so BugTape can tell a fixed issue from a regression. Without one it records `release_missing`. When you pass no `release` and set neither `BUGTAPE_RELEASE` nor `DD_VERSION`, the SDK uses the first of these that is set: `GITHUB_SHA`, `VERCEL_GIT_COMMIT_SHA`, `CF_PAGES_COMMIT_SHA`, `COMMIT_REF`, `CI_COMMIT_SHA`, `CIRCLE_SHA1`, `GIT_COMMIT`, `TRAVIS_COMMIT`, `BUILD_VCS_NUMBER`, `RENDER_GIT_COMMIT`, `RAILWAY_GIT_COMMIT_SHA`, `HEROKU_SLUG_COMMIT`. The same CI list is used by the browser SDK; the last three are hosts that set the commit at runtime. The list is `bugtape.COMMIT_ENV_KEYS`.

## Check it

Raise a test error, then open **Issues** in the console:

```bash
BUGTAPE_KEY=bt_test_… python -c "import bugtape; bugtape.init(service='check'); bugtape.capture_message('BugTape Python check'); bugtape.flush()"
```
