Skip to main content

Enriches exceptions with plain-English causal chains at throw time

Project description

because

Stack traces show symptoms. because shows causes.

Your error tracker fires. The stack trace points to db/pool.py:142. You open the file, stare at the code, and start the slow walk backwards through logs, traces, and recent deploys — trying to reconstruct what actually happened.

because does that reconstruction for you. It captures a rolling timeline of recent operations in-process, matches known failure patterns at throw time, and — optionally — sends the full context to an LLM for a plain-English explanation. All before you open a single log file.


Before and after

Before because:

sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached,
connection timed out, timeout 30
  File "app/api/checkout.py", line 54, in handle_checkout
    result = db.execute(query)

You have a pool error. You don't know why.

After because:

sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached,
connection timed out, timeout 30
  File "app/api/checkout.py", line 54, in handle_checkout
    result = db.execute(query)

[because context]
  Likely cause: Database connection pool may be exhausted
    • Exception message contains 'QueuePool limit'
    • 12 DB queries in context window (pool was active)
    • 9/12 recent DB queries failed (75% failure rate)
  Caught-and-swallowed (2):
    OperationalError: server closed the connection unexpectedly  (8.3s ago)
    OperationalError: server closed the connection unexpectedly  (3.1s ago)
  Recent operations (12):
    [ok]   db_query       2.1ms  SELECT * FROM orders WHERE user_id = ?
    [ok]   db_query       1.8ms  SELECT * FROM orders WHERE user_id = ?
    [ok]   db_query      18.4ms  UPDATE orders SET status = 'processing' ...
    [FAIL] db_query       0.5ms  error=OperationalError
    [FAIL] db_query       0.5ms  error=OperationalError
    [FAIL] db_query       0.5ms  error=OperationalError
    ...

You see the pattern. Two swallowed errors in the 10 seconds before the crash. The pool draining under load. You know exactly where to look.


Install

pip install because-py

With instrumentation extras:

pip install "because-py[sqlalchemy]"
pip install "because-py[sqlalchemy,requests,httpx,redis]"

With the LLM explainer (Claude or GPT-4o):

pip install "because-py[llm]"         # Anthropic
pip install "because-py[llm,openai]"  # + OpenAI

Zero-config setup

import because
because.install()

That's it. because hooks sys.excepthook and starts recording operations in a per-thread ring buffer. Any uncaught exception automatically gets an enriched context chain appended to stderr — no changes to your existing error handling required.


Instrumenting libraries

Attach instruments to your existing clients — they record timing, success/failure, and metadata on every operation:

from because.instruments.sqlalchemy import instrument as instrument_sa
from because.instruments.requests import instrument as instrument_requests
from because.instruments.httpx import instrument as instrument_httpx
from because.instruments.redis import instrument as instrument_redis
from because.instruments.logging import instrument as instrument_logging

instrument_sa(engine)                   # SQLAlchemy engine
instrument_requests(requests.Session()) # requests Session
instrument_httpx(client)                # httpx Client or AsyncClient
instrument_redis(redis_client)          # redis-py (sync or async)
instrument_logging()                    # root logger, WARNING and above

All instruments write to a bounded ring buffer — zero I/O on the hot path.


Zero-boilerplate enrichment with @because.watch

The easiest way to enrich exceptions — just decorate the function:

@because.watch
def process_order(order_id):
    ...  # any exception that escapes gets auto-enriched

@because.watch
async def fetch_user(user_id):
    ...  # works on async functions too

Use reraise=False for background tasks where you want context captured but don't want the caller to crash:

@because.watch(reraise=False)
async def background_sync():
    ...  # exception is enriched and swallowed

Recording swallowed exceptions

Silently caught exceptions are often the real cause of a downstream crash. because.catch() makes them visible:

def get_user(user_id: int):
    with because.catch(Exception):
        return db.query(User).filter_by(id=user_id).one()
    return None  # only reached when exception was swallowed

When a downstream AttributeError: 'NoneType' object has no attribute 'email' fires, because surfaces the swallowed DB error as the likely cause — not the symptom.


Enriching caught exceptions manually

try:
    process_order(order_id)
except Exception as exc:
    because.enrich_with_swallowed(exc)
    logger.error("Order processing failed", extra={"because": exc.__context_chain__})
    raise

__context_chain__ serializes cleanly into Sentry extra, Datadog span tags, or structured log fields.


LLM-based root cause analysis

Go beyond pattern matching — get a plain-English explanation with a concrete suggested fix:

import because

because.configure_llm(api_key="sk-ant-...")  # Anthropic by default

try:
    risky_operation()
except Exception as exc:
    because.enrich_with_swallowed(exc)
    explanation = await because.explain_async(exc)
    print(explanation)

Output:

Root cause (high confidence): The database lookup silently failed due to a dropped
connection (OperationalError), returning None instead of a user object. The downstream
attribute access on that None value then raised AttributeError.
Contributing factors:
  • An OperationalError was caught and swallowed without re-raising, masking the failure.
  • No None-check exists before accessing .email on the return value.
Suggested fix: Re-raise or propagate the OperationalError in get_user(), and add a
guard — if user is None: raise ValueError('User not found') — before accessing attributes.

Use OpenAI instead:

because.configure_llm(api_key="sk-...", provider="openai", model="gpt-4o")

Bring your own provider by implementing the LLMProvider protocol:

class MyProvider:
    async def complete(self, prompt: str) -> str:
        ...  # call any LLM you like

Sync usage (avoid in async contexts):

explanation = because.explain(exc)

CLI

Analyze any stack trace without touching your code:

# pipe from a log file
cat error.log | because explain

# pass a file directly
because explain error.log

# paste interactively (Ctrl-D to submit)
because explain

# use OpenAI
because explain --provider openai --model gpt-4o error.log

Example output:

Root cause (high confidence): The SQLAlchemy connection pool has been exhausted —
all 15 allowed connections are in use and new requests are timing out after 30s.
Contributing factors:
  • Connections may not be released promptly due to missing session closes or
    long-running transactions.
  • A spike in concurrent checkout requests may be exceeding pool capacity.
Suggested fix: Audit the checkout path to ensure sessions are always closed via
context managers (with db.connect() as conn:) and check for uncommitted transactions
holding connections open.

Reads ANTHROPIC_API_KEY or OPENAI_API_KEY from the environment, or pass --api-key directly.


Framework integrations

# Flask
from because.integrations.flask import instrument as instrument_flask
instrument_flask(app)

# FastAPI
from because.integrations.fastapi import BecauseMiddleware
app.add_middleware(BecauseMiddleware)

# Django — add to MIDDLEWARE in settings.py
MIDDLEWARE = [
    "because.integrations.django.BecauseMiddleware",
    ...
]

Observability integrations

because attaches to your existing observability stack — it doesn't replace it:

# Sentry: attach context chain to every error event
from because.integrations.sentry import before_send
sentry_sdk.init(..., before_send=before_send)

# Datadog: tag the active span with because context
from because.integrations.datadog import tag_current_span
tag_current_span(exc)

# OpenTelemetry: tag the current span and add span events per operation
from because.integrations.otel import tag_current_span
tag_current_span(exc)

# OpenTelemetry: emit each operation as a child span (detailed tracing)
from because.integrations.otel import record_spans
from opentelemetry import trace
record_spans(trace.get_tracer("because"), exc)

# Structured logging: emit because context as a JSON field
from because.integrations.logging import BecauseFormatter
handler.setFormatter(BecauseFormatter())

Install the OTel extra:

pip install "because-py[otel]"

Heuristic patterns

Pattern matching runs at throw time — no API key, no latency:

Pattern Fires when
pool_exhaustion Connection/pool error + recent DB failures or explicit pool message
silent_failure A swallowed exception preceded the current error
retry_storm Timeout + high concentration of repeated HTTP requests to the same host

Each pattern is a small, independently testable unit. Output always uses hedged language — because never claims certainty.


Design principles

  • Honest framing. Output uses "likely cause" and "contributing factor." Wrong-but-confident destroys trust faster than no answer.
  • Zero-config default. import because; because.install() does something useful immediately.
  • No hot-path cost. Instrumentation writes to bounded ring buffers. Enrichment and LLM calls happen only on exception.
  • Composable. Attaches to Sentry, Datadog, and structured logging. Doesn't replace them.
  • Library, not platform. Pure Python, no required backend, ships as a pip package.

Runnable examples

python examples/pool_exhaustion.py   # connection pool saturated under load
python examples/silent_failure.py    # swallowed DB error causes downstream crash
python examples/retry_storm.py       # naive retry loop hammers a degraded API

# LLM explainer (requires ANTHROPIC_API_KEY)
python examples/llm_explainer.py

Roadmap

  • v1.0 — Cross-process / cross-service causal reasoning
  • More instruments: stdlib socket, grpc
  • OpenTelemetry span export

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

because_py-0.2.8.tar.gz (55.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

because_py-0.2.8-py3-none-any.whl (36.7 kB view details)

Uploaded Python 3

File details

Details for the file because_py-0.2.8.tar.gz.

File metadata

  • Download URL: because_py-0.2.8.tar.gz
  • Upload date:
  • Size: 55.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: Hatch/1.16.5 cpython/3.13.3 HTTPX/0.28.1

File hashes

Hashes for because_py-0.2.8.tar.gz
Algorithm Hash digest
SHA256 46b459c743bc747852e05e87b5bd78b328ccfeb91fd707514ab63b0984b969a4
MD5 c8125e7977462931bba5bb9f6a38484b
BLAKE2b-256 f1f70480b669204dea34f83660ef781da50ce26216f3466e07a94a9cdc4cbe4b

See more details on using hashes here.

File details

Details for the file because_py-0.2.8-py3-none-any.whl.

File metadata

  • Download URL: because_py-0.2.8-py3-none-any.whl
  • Upload date:
  • Size: 36.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: Hatch/1.16.5 cpython/3.13.3 HTTPX/0.28.1

File hashes

Hashes for because_py-0.2.8-py3-none-any.whl
Algorithm Hash digest
SHA256 fa923bb12fc7499f5e7b1229158f879f6dfd2c168b034101b4f2f10669e06519
MD5 17bdc3a2bfae8e88b74d200eebc8b8d9
BLAKE2b-256 27ca2efc3a2967d33cab0bfd3d7e75e7ae6899e1a46474e2a75bad2248595955

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page