# `bucker-sdk` (Python)

First-party Python SDK: errors with source context, breadcrumbs, request context, session
(release-health) tracking, trace linkage and opt-in GenAI call records.

- **Install:** `pip install bucker-sdk` · **Import:** `import bucker_sdk` · **Python 3.10+**
- **Zero runtime dependencies.** Only `urllib`, `threading` and `contextvars`. The SDK runs
  inside your process, so every dependency it added would be one you inherit.

> **Distribution note.** `bucker-sdk` is built and tested but not yet on PyPI. Its licence is
> decided (Apache-2.0); publishing is the remaining human step (see
> [`sdks/PUBLISHING.md`](../sdks/PUBLISHING.md)). Until then `pip install bucker-sdk` will not
> resolve, and the supported way to send Python errors to Bucker today is an unmodified Sentry
> SDK pointed at your DSN ([switch from Sentry](switch-from-sentry.md)) or an OpenTelemetry
> exporter ([OTLP](otlp.md)). This page says "install" in the present tense because the
> package exists; it does not mean the registry has it.

## Init

```python
import bucker_sdk

bucker_sdk.init(
    dsn="https://<publicKey>@us.ingest.bucker.io/<projectId>",
    environment="production",
    release="api@1.4.0",
)
```

Call it once, early, in the process that serves requests. With no DSN at all the SDK is a
silent no-op, so `init()` is safe to leave in a test suite or a local entrypoint.

**A DSN that cannot be used disables the SDK; it never raises.** An observability library must
never be the reason an application fails to start. The client logs one warning on the
`bucker_sdk` logger and sends nothing. There are three distinct cases, kept apart because the
fix differs:

| Case | The warning says | Fix |
| --- | --- | --- |
| No DSN | (silent) | Set `dsn=` or `BUCKER_DSN` |
| Does not parse | `the configured DSN could not be parsed (<reason>)` | Re-copy it whole; see [DSNs and regions](dsn-and-regions.md) |
| Still the documentation placeholder | `the DSN still contains the documentation placeholder for its public key` | Paste your project's DSN from Settings → Projects |

The placeholder case matters because `https://<publicKey>@us.ingest.bucker.io/<projectId>`
*parses*: accepted, it would enable the SDK and send every event to a project id of
`<projectId>`, where nothing resolves. This is the same guard `@bucker/browser` and
`@bucker/node` have.

### Options

| Option | Default | Meaning |
| --- | --- | --- |
| `dsn` | `$BUCKER_DSN` | The project DSN. A `?query` or `#hash` on a copied URL is accepted and never stored |
| `environment` | `$BUCKER_ENVIRONMENT`, then `production` | |
| `release` | `$BUCKER_RELEASE` | Your build's version or git SHA. **Without it, no sessions are sent** and release health is blank |
| `server_name` | `$BUCKER_SERVER_NAME` | Defaults to the hostname unless `attach_server_name=False` |
| `distinct_id` | none | Stable per-install id for crash-free *users*. Never a real user identifier |
| `auto_session_tracking` | `True` | Emit release-health `session` items |
| `sample_rate` | `1.0` | Fraction of error events to send; the rest are counted as dropped |
| `max_breadcrumbs` | SDK default | Breadcrumb ring size |
| `max_value_length` | 8192 | Longest string kept in an event (bounded, not pruned) |
| `before_send` | none | `fn(event, hint) -> event or None`; `None` drops the event |
| `ignore_errors` | `()` | Drop events whose message or exception (`Type: value`) matches a substring or a compiled `re.Pattern`; before sampling, counted as `event_processor` |
| `before_breadcrumb` | none | Same for breadcrumbs; `None` is the PII escape hatch |
| `in_app_include` / `in_app_exclude` | `()` | Package prefixes that are, or are not, "your code" |
| `include_source_context` | `True` | ±`lines_of_context` lines around each in-app frame, read from disk |
| `lines_of_context` | 5 | |
| `include_local_variables` | `False` | Opt in. Captured as `repr()` strings and masked server-side |
| `attach_server_name` | `True` | |
| `shutdown_timeout` | `2.0` | Seconds the exit flush may take |
| `http_timeout` | `10.0` | Seconds one request may take |
| `install_excepthook` / `install_atexit` | `True` | Process-wide hooks; turn off in tests |
| `debug` | `False` | Log the SDK's own internal failures at debug level |
| `transport` | built-in | Supply your own wire call (a proxy); see Transport |

## Capturing

| Function | Notes |
| --- | --- |
| `capture_exception(error=None)` | `None` means the exception currently being handled. Accepts an exception or an `exc_info` triple. Returns the event id |
| `capture_message(message, level="info")` | |
| `capture_event(event)` | A fully formed event dict |
| `add_breadcrumb(message, category=…, level=…, data=…)` | |
| `set_user(dict)`, `set_tag(k, v)`, `set_extra(k, v)`, `set_context(k, v)` | Write to the **current** scope |
| `flush(timeout)`, `close(timeout)` | `flush` returns whether everything drained |

```python
try:
    risky()
except Exception:
    bucker_sdk.capture_exception()
```

### Scopes

The current scope lives in a `ContextVar`, and the module-level setters are copy-on-write: they
fork the scope they inherited. Two concurrent asyncio tasks or threads cannot see each other's
tags without any `push_scope()` at the call site. `isolation_scope()` starts empty;
`push_scope()` inherits; `get_global_scope()` is process-wide and merges into every event from
every thread (put process-wide configuration there).

One caveat: mutating a `Scope` object you took out of `get_current_scope()` *directly* bypasses
the fork. Use the setters, or `push_scope()` when you want a handle.

## Web frameworks

```python
# WSGI: Flask, Django (sync), Pyramid, Bottle
from bucker_sdk.wsgi import BuckerWsgiMiddleware
app = BuckerWsgiMiddleware(app)

# ASGI: FastAPI, Starlette, Django (async), Quart
from bucker_sdk.asgi import BuckerAsgiMiddleware
app = BuckerAsgiMiddleware(app)
```

Both run each request in an **empty** scope, attach the request (URL, method, query string and
an allowlist of headers) and a transaction name, adopt an inbound `traceparent`, capture
unhandled exceptions and re-raise them untouched. The WSGI middleware also wraps the response
iterable, so a generator view that raises mid-stream is still captured.

- **Headers are allowlisted, not denylisted.** `Cookie` and `Authorization` never leave the
  process.
- **The client IP is opt-in:** `BuckerWsgiMiddleware(app, include_ip=True)`.
- **URLs are redacted.** In breadcrumbs every query and fragment value is replaced; the request's query string keeps ordinary parameters (`page=2`) and blanks only credential-named ones.

The two middlewares are the standards-level layer. Each major framework also has a one-line
integration on top of them (below) that adds route-template names, a server span per request and
the framework's own error path. Every framework is imported lazily by its own module, so
`import bucker_sdk` never pulls one in, and none is a dependency of the SDK.

### Django

```python
INSTALLED_APPS = [..., "bucker_sdk.django"]

BUCKER_DSN = "https://<publicKey>@us.ingest.bucker.io/<projectId>"   # or $BUCKER_DSN
BUCKER = {"release": "web@1.4.0", "environment": "production"}       # optional client options
```

That is the whole install. The app config calls `bucker_sdk.init(...)` from those settings (unless
your project already called it) and puts `BuckerMiddleware` first in `MIDDLEWARE`; set
`BUCKER = {"middleware": False}` to place it yourself. The middleware is sync and async capable.
A view's exception arrives through Django's `process_exception` hook (Django turns it into a 500
response inside each layer, so a `try/except` never sees it) and is captured once; `Http404`,
`PermissionDenied` and `SuspiciousOperation` are control flow and are not captured. Spans and the
transaction are named after the URL pattern (`GET /items/<int:item_id>/`), not the concrete path.

### Flask

```python
from bucker_sdk.flask import BuckerFlask
BuckerFlask(app)                 # or BuckerFlask().init_app(app)
```

Wraps `wsgi_app` in the WSGI middleware, captures through Flask's `got_request_exception` signal
(Flask answers an unhandled error with a 500 itself, so the signal is the only place the
exception is visible), and opens a server span named after the URL rule. `HTTPException`
(`abort(404)`) is not captured.

### FastAPI and Starlette

```python
from bucker_sdk.fastapi import install
install(app)
```

Adds `BuckerAsgiMiddleware` with `trace_requests=True`: an unhandled exception is captured and
re-raised (Starlette still answers 500), `HTTPException` and validation errors are handled further
in and never reach it, and the transaction is the route template (`GET /items/{item_id}`).

### Celery

```python
from bucker_sdk.celery import install
install(app, monitor_beat_tasks=True)      # in the module that creates the Celery app
```

`task_prerun` / `task_postrun` give every task run its own scope and a `queue.task.celery` span;
`task_failure` captures the exception (a `Retry` is a breadcrumb, never an event); a `traceparent`
header is stamped on publish and adopted by the worker, so the request that enqueued a task and
the task are one trace. With `monitor_beat_tasks=True`, each `beat_schedule` entry's task is
bracketed by cron check-ins (`in_progress`, then `ok` or `error`) and the monitor is created from
the schedule on first sight, so a beat task that stops arriving is a missed check-in rather than
silence. Crontab and interval (>= 1 minute) schedules are translated; anything else (solar, custom
schedule classes) is left unmonitored rather than guessed.

## Cron check-ins and feedback

```python
from bucker_sdk import with_monitor, capture_feedback

@with_monitor("nightly-export", monitor_config={
    "schedule": {"type": "crontab", "value": "0 2 * * *"},
    "checkin_margin": 5, "max_runtime": 30, "timezone": "UTC",
})
def nightly_export(): ...

with with_monitor("one-off"):   # also a context manager
    run()

capture_feedback("the page hung", email="a@b.test", associated_event_id=event_id)
```

`with_monitor` sends `in_progress`, then `ok` (or `error` if the block raises; the exception still
propagates), sharing one `check_in_id` and reporting the duration. `capture_check_in(slug, status, ...)`
is the lower-level call. Both ride the Sentry-shaped `check_in` / `feedback` envelope items the server
ingests, on the bulk lane, so `flush()` before a job exits straight after its last check-in.

## Logging

```python
import logging
from bucker_sdk.logging_integration import install_logging

install_logging(event_level=logging.ERROR, breadcrumb_level=logging.INFO)
```

Records at or above `event_level` become events; records at or above `breadcrumb_level` become
breadcrumbs, so the INFO lines before an ERROR travel with it. Records carry the active
trace's `trace_id` and `span_id`. `logging.exception(...)` produces a full exception event. The
SDK's own `bucker_sdk` logger is never forwarded.

## Tracing and the span API

The SDK propagates W3C trace context: `get_trace_context()` returns the active
`TraceContext`, `use_trace_context(ctx)` activates one, and the middlewares adopt an inbound
`traceparent`. Errors and log records raised inside a context link to its trace.

Spans for your own code:

```python
import bucker_sdk

with bucker_sdk.start_span("db.query", op="db", attributes={"db.system": "postgresql"}) as span:
    rows = run_query()
    span.set_attribute("db.rows", len(rows))

@bucker_sdk.traced(op="task")          # sync functions and coroutines
def reindex(): ...

async with bucker_sdk.start_span("fetch"):
    ...
```

Spans nest through the same `ContextVar` as the trace context (so they survive `await`), and are
sent as the `span` envelope item the Node SDK and the GenAI hook already use. An exception inside
a span marks it `error` with the exception TYPE only (messages carry user data) and propagates.
`finish()` is idempotent. A span whose parent is unsampled is non-recording but still carries real
ids, so a downstream `traceparent` keeps saying `sampled=0`. If you already run OpenTelemetry,
keep using it and export over OTLP ([OTLP](otlp.md)) instead; the two are not meant to be mixed in
one process.

## GenAI call records (opt-in)

```python
from bucker_sdk import genai

with genai.llm_call(provider="anthropic", model="claude-opus-4") as call:
    response = client.messages.create(...)
    call.set_usage(input_tokens=response.usage.input_tokens,
                   output_tokens=response.usage.output_tokens)
```

or `genai.record_llm_call(provider=…, input_tokens=…, …)`. It imports no provider SDK and
patches nothing; it emits a `span` item with `gen_ai.*` attributes. See
[agentic observability](agentic-observability.md) for what Bucker does with them.

### GenAI auto-instrumentation

```python
from bucker_sdk.integrations import genai
genai.install()                              # everything importable
genai.install(["openai", "langchain"])       # or a chosen subset
```

| Name | Covers |
| --- | --- |
| `openai` | `chat.completions`, `responses`, `embeddings`; sync, async and streaming |
| `anthropic` | `messages.create`; sync, async and streaming |
| `langchain` | LangChain **and LangGraph** (they share the callback system), installed globally with `register_configure_hook`; one `invoke_agent` turn per top-level run |
| `litellm` | `completion` / `acompletion` / `embedding`, through LiteLLM's callback lists |
| `openai_agents` | the OpenAI Agents SDK, through `add_trace_processor` |
| `claude_agent` | the Claude Agent SDK `query()` stream |

A library that is not installed reports `False`; an unknown name raises `ValueError`. Libraries
are imported lazily and none is a dependency. Streams are observed in place (the SDK's own stream
object is returned), the host's exceptions are recorded by type and re-raised untouched, and a
recording failure is a debug line, never an exception in your request. Prompts and completions
are not recorded; usage, model, finish reason and the first tool call are. Pydantic AI is not
patched because it emits OpenTelemetry GenAI spans itself (`Agent.instrument_all()`): point its
OTLP exporter at Bucker ([OTLP](otlp.md)).

## Transport

Two priority lanes on a daemon worker thread: **critical** (errors, always drained first,
including at shutdown) and **bulk** (spans, logs, client reports).

- A `429` or `X-Sentry-Rate-Limits` **drops** the payload and counts it; it is never queued
  behind the limit. The counts come back as a `client_report`.
- The retry buffer is bounded in bytes and in items; overflow evicts bulk first.
- The exit flush is bounded by `shutdown_timeout`; a hung ingest endpoint costs a fixed delay,
  never a hung interpreter.
- Bodies over 1 KiB are gzipped.
- **Fork-safe.** gunicorn `--preload`, uWSGI and Celery prefork fork after `init()`; an
  `os.register_at_fork` hook starts a fresh worker thread in each child.
- `SIGTERM` is handled so a Fly/Kubernetes shutdown ends the session as `exited` rather than
  leaving it `ok`.

```python
from bucker_sdk.transport import HttpTransport
from bucker_sdk.clientreport import DropAccumulator

drops = DropAccumulator()
bucker_sdk.init(dsn=DSN, transport=HttpTransport(my_sender, drops), drops=drops)
```

Pass the *same* `DropAccumulator` to both so client-side and transport-side drops land in one
client report.

## What an event contains

`exception.values[]` with `stacktrace.frames[]` (oldest first, raising frame last, each with
`abs_path`, `filename`, `function`, `lineno`, `module`, `in_app`), `breadcrumbs.values[]`,
`tags`, `user`, `request`, `contexts`, `release`, `environment` and `platform: "python"`.
Exception chains (`raise … from`, implicit `__context__`) become additional values, root cause
first. `in_app` is decided from real `sysconfig` stdlib and site-packages paths first, with
`in_app_include` winning over every exclusion. The ≤500-token [essence](error-essence.md) is
built server-side, so the SDK sends everything bounded in size and the projection improves
without an SDK release.

## Not in this SDK

- No automatic span creation or database/HTTP client instrumentation. Use OpenTelemetry.
- No source-map or symbol upload (not needed: Python frames carry their own source context).
- Frameworks other than Django, Flask, FastAPI/Starlette and Celery (Pyramid, Tornado, RQ, Dramatiq,
  ...) use the WSGI/ASGI middleware, or `capture_exception()` where you catch a background failure.
- Replay, profiling and attachments are not produced.

## Compatibility and versioning

The Python SDK follows the same release policy as every Bucker SDK
([`sdks/PUBLISHING.md`](../sdks/PUBLISHING.md), "Release discipline"): a deprecation warns one
minor version before removal, and no default widens data collection without a major version and
an explicit opt-in. Its envelope serialization is byte-compatible with the TypeScript
implementation, enforced by a fixture generated from the real serializer.
