Metadata-Version: 2.4
Name: runguard-sdk
Version: 1.0.0
Summary: RunGuard SDK – capture runtime errors and send them to RunGuard
Author-email: Robion Systems BV <info@robion-systems.com>
License: MIT
Project-URL: Homepage, https://runguard.ai
Project-URL: Repository, https://github.com/Robion-Systems/runguard-python
Project-URL: Bug Tracker, https://github.com/Robion-Systems/runguard-python/issues
Keywords: runguard,error-tracking,runtime-errors,production-monitoring
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"

# RunGuard Python SDK

Capture runtime errors and send them to RunGuard. Graceful: never raises, never blocks longer than the configured timeout (default 3 s).

> **Read-only mirror.** This repo is a snapshot of the SDK source published
> to [PyPI as `runguard-sdk`](https://pypi.org/project/runguard-sdk/).
> Pull requests cannot be merged here — please open an issue describing
> the problem or change, and we'll work it back into the upstream source.
> The SDK is developed in a private monorepo by [Robion Systems](https://github.com/Robion-Systems).

## Prerequisites

1. A RunGuard account — sign up at [runguard.ai](https://runguard.ai).
2. Install the RunGuard GitHub App on your repos:
   [github.com/apps/runguard-app](https://github.com/apps/runguard-app).
3. Get an API key from the RunGuard dashboard.

## Install

```bash
pip install runguard-sdk
```

Requires Python 3.9+. Zero runtime dependencies — uses only the standard library.

## Usage

```python
from runguard_sdk import init, capture

init(api_key="your-api-key")

try:
    do_work()
except Exception as err:
    capture(err)
    raise
```

In asyncio apps (FastAPI, Starlette, aiohttp, Sanic), use `acapture` so the event loop is not blocked on the HTTP send:

```python
from runguard_sdk import init, acapture

init(api_key="your-api-key")

@app.exception_handler(Exception)
async def on_error(request, exc):
    await acapture(exc, metadata={"path": request.url.path})
    raise
```

In hot paths where you want best-effort reporting without paying the HTTP round-trip cost on the request path, use `capture_sync`:

```python
from runguard_sdk import capture_sync

capture_sync(err)   # returns immediately; HTTP send runs in a daemon thread
```

## API

- **`init(*, api_key, base_url=None, timeout=None)`** — configure the SDK. May be called multiple times; the last call wins. Empty `api_key` is allowed and short-circuits `capture()` without making a network call. `timeout` is in **seconds**.
- **`capture(error, *, service=None, env=None, timestamp=None, metadata=None) -> bool`** — synchronous. Returns `True` on HTTP 2xx, `False` on anything else. Never raises.
- **`await acapture(error, ...) -> bool`** — async variant; same arguments. Runs the blocking HTTP send in a worker thread via `asyncio.to_thread` so the event loop is never blocked.
- **`capture_sync(error, ...) -> None`** — fire-and-forget. Spawns a daemon thread and returns immediately.

## Environment variables

| Name | Purpose |
|---|---|
| `RUNGUARD_API_KEY` | API key (used if `init(api_key=...)` not called) |
| `RUNGUARD_BASE_URL` | Ingest base URL (default `https://api.runguard.ai`) |
| `RUNGUARD_TIMEOUT_MS` | HTTP send timeout in **milliseconds** (default 3000) |
| `RUNGUARD_SERVICE` | Service name override |
| `RUNGUARD_ENV` | Environment override; **wins over `ENVIRONMENT`** |

## Service detection

When `service` is not passed and `RUNGUARD_SERVICE` is unset, the SDK tries, in order:

1. `OTEL_SERVICE_NAME`
2. `K_SERVICE` (Cloud Run)
3. `FLY_APP_NAME` (fly.io)
4. Falls back to `"app"` and prints a one-time warning to stderr.

If none of these env vars are set in your runtime, pass `service` explicitly:

```python
capture(err, service="checkout-api")
```

## Env detection

`RUNGUARD_ENV` → `ENVIRONMENT` (12-factor convention) → `"production"`.

The pre-1.0 `ENV` fallback was removed because it collides with too many other tools (notably Heroku-style config and several shell helpers).

## Auto-collected metadata

Every payload includes the following keys under `metadata` in addition to anything you pass:

| Key | Value |
|---|---|
| `sdk_name` | `"runguard-python"` |
| `sdk_version` | Package version |
| `sdk_runtime` | `python-<version>` (e.g. `python-3.12.7`) |
| `sdk_platform` | `platform.platform()` (`Darwin-23.6.0-arm64`, `Linux-5.15-x86_64`, …) |
| `sdk_pid` | `os.getpid()` |
| `sdk_hostname` | `socket.gethostname()` when available |

User-supplied `metadata` keys override SDK auto-keys on collision. Per-payload limit: 20 keys total; values are server-side stringified and truncated to 1000 chars. Client-side the SDK pre-coerces complex objects to strings so `json.dumps` of the payload never throws; `None` values are dropped.

## Graceful failure

`capture()` never throws and never blocks longer than `timeout`. It returns `False` when:

- the API key is missing,
- the server returns a non-2xx response (401, 5xx, …),
- the network is unreachable, the DNS lookup fails, or the TLS handshake fails,
- the send exceeds `timeout` (default 3 s).

No retries — the SDK is at-most-once delivery. Pair it with a structured logger if you need a local fallback for ingest outages.

## Framework patterns

### Django / Flask (WSGI)

`capture()` is thread-safe; you can call it from any view handler. With gunicorn `--workers N --threads M`, all `N*M` threads share the same module-global config.

```python
# settings.py / app.py — once at boot
from runguard_sdk import init
init(api_key=os.environ["RUNGUARD_API_KEY"])

# views.py / handler — synchronous
from runguard_sdk import capture

def my_view(request):
    try:
        return do_work(request)
    except Exception as err:
        capture(err, metadata={"path": request.path})
        raise
```

### FastAPI / Starlette / aiohttp / Sanic (ASGI)

Use `acapture` to avoid blocking the event loop:

```python
from runguard_sdk import init, acapture

init(api_key=...)

@app.middleware("http")
async def report_errors(request, call_next):
    try:
        return await call_next(request)
    except Exception as err:
        await acapture(err, metadata={"path": str(request.url.path)})
        raise
```

### Celery

For `--pool=threads`, use `capture()` directly inside the task body. For `--pool=prefork`, each worker process must `init()` itself (workers are forked before the user code imports the SDK in most setups; call `init()` at module top of `celery_app.py`).

```python
from celery import Celery
from runguard_sdk import init, capture

init(api_key=os.environ["RUNGUARD_API_KEY"])
celery_app = Celery(...)

@celery_app.task(bind=True)
def send_email(self, to_addr):
    try:
        smtp.send(to_addr)
    except Exception as err:
        capture(err, metadata={"task": self.name, "to": to_addr})
        raise  # let Celery retry
```

### AWS Lambda / Cloud Run

`init()` once at module top so the warm container reuses it. `capture()` from inside the handler.

```python
from runguard_sdk import init, capture
init(api_key=os.environ["RUNGUARD_API_KEY"])

def handler(event, context):
    try:
        return do_work(event)
    except Exception as err:
        capture(err, metadata={"request_id": context.aws_request_id})
        raise
```

## Privacy note

Error tracebacks contain file paths from the throwing process. On developer machines this means paths like `/Users/<you>/...` are sent to RunGuard. In production these typically resolve to container paths (`/app/...`). Pass redacted stacks or extra context via `metadata=` if your build embeds sensitive paths.

## Development

```bash
cd packages/runguard-python
pip install -e ".[dev]"
pytest
```

The test suite spins up a real `http.server` on `127.0.0.1` and points the SDK at it for transport-level tests — no monkey-patching `urllib`. Framework-compatibility tests simulate Django/Flask/FastAPI/Celery via stdlib primitives (`threading.Thread`, `asyncio`, `multiprocessing`) rather than installing the frameworks.

Publish a new version:

```bash
pip install build twine
python -m build
twine upload dist/*
```
