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.


Benchmarks

Generated 2026-04-28 on Python 3.13.3. Source.

1 — Ring buffer overhead

The hot-path instrumentation cost per recorded operation:

Metric Value
Baseline (no-op) 0.044 µs
because record() 0.589 µs
Net overhead 0.545 µs per op
Snapshot (128 ops) 0.559 µs

Overhead is measured as the difference between recording an Op and a bare time.monotonic() call. At 1,000 ops/sec that's 544.9 ms/s of total overhead — effectively zero.


2 — Pattern detection accuracy

Each pattern was tested against 5 positive and 5 negative scenarios:

Pattern Precision Recall F1 TP FP FN TN
pool_exhaustion 100% 100% 1.00 5 0 0 5
retry_storm 100% 100% 1.00 5 0 0 5
silent_failure 100% 100% 1.00 5 0 0 5

3 — Time-to-diagnosis: multi-layer cascade

A realistic production incident is simulated:

  • Payment service degrades → retry loop fires against payment.internal (retry_storm)
  • DB connection pool saturates → 3 TimeoutErrors caught and swallowed (silent_failure)
  • Inventory lookup returns None silently
  • Final crash: AttributeError: 'NoneType' has no attribute 'quantity' (the symptom)

Patterns detected: silent_failure Context captured: 16 operations + 3 swallowed exceptions

Step Time
Snapshot context chain 0.4 µs
Run all pattern matchers 9.8 µs
Format human-readable output 15.3 µs
Total diagnosis time 25.6 µs

Without because — engineer sees:

AttributeError: 'NoneType' object has no attribute 'quantity'
  File "app/api/checkout.py", line 84, in process_order
    total = result.quantity * item.price

[No further context available. Manual log correlation required.]

With because — attached to the same exception in 26 µs:

AttributeError: 'NoneType' object has no attribute 'quantity'
  File "app/api/checkout.py", line 84, in process_order
    total = result.quantity * item.price

[because context]
  Likely cause: A prior exception was caught and not re-raised. The current error may be a downstream consequence.
    • Caught-and-swallowed: TimeoutError: pool timeout after 30s (db/pool.py:142) attempt 1
    • Caught-and-swallowed: TimeoutError: pool timeout after 30s (db/pool.py:142) attempt 2
    • Caught-and-swallowed: TimeoutError: pool timeout after 30s (db/pool.py:142) attempt 3
    • Swallowed exception content suggests upstream failure: TimeoutError
  Caught-and-swallowed (3):
    TimeoutError: pool timeout after 30s (db/pool.py:142) attempt 1
    TimeoutError: pool timeout after 30s (db/pool.py:142) attempt 2
    TimeoutError: pool timeout after 30s (db/pool.py:142) attempt 3
  Recent operations (16):
    [ok] http_request      5.0ms  GET https://auth.internal/verify  
    [ok] db_query          5.0ms  SELECT * FROM users WHERE id=$1
    [ok] http_request      5.0ms  GET https://catalog.internal/items  
    [ok] db_query          5.0ms  SELECT stock FROM inventory WHERE sku=$1
    [ok] http_request      5.0ms  POST https://payment.internal/charge  
    [FAIL] http_request      5.0ms  POST https://payment.internal/charge  error=ReadTimeout: timed out
    [FAIL] http_request      5.0ms  POST https://payment.internal/charge  error=ReadTimeout: timed out
    [FAIL] http_request      5.0ms  POST https://payment.internal/charge  error=ReadTimeout: timed out
    [FAIL] http_request      5.0ms  POST https://payment.internal/charge  error=ReadTimeout: timed out
    [FAIL] http_request      5.0ms  POST https://payment.internal/charge  error=ReadTimeout: timed out
    [FAIL] db_query          5.0ms  BEGIN  error=TimeoutError: pool timeout after 30s
    [FAIL] db_query          5.0ms  BEGIN  error=TimeoutError: pool timeout after 30s
    [FAIL] db_query          5.0ms  BEGIN  error=TimeoutError: pool timeout after 30s
    [FAIL] db_query          5.0ms  SELECT quantity FROM inventory WHERE sku=$1  error=OperationalError: server closed the connection unexpectedly
    [FAIL] db_query          5.0ms  UPDATE orders SET status='pending' WHERE id=$1  error=OperationalError: connection refused
    [ok] http_request      5.0ms  GET https://catalog.internal/items/qty  200

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]"
pip install "because-py[grpc]"

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

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

With OpenTelemetry export:

pip install "because-py[otel]"

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
from because.instruments.socket import instrument as instrument_socket
from because.instruments.grpc import instrument as instrument_grpc

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
instrument_socket()                     # raw TCP connect / connect_ex
instrument_grpc(channel)                # gRPC channel (sync or async)

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

Async context propagation

By default, asyncio tasks get isolated ring buffers — ops recorded in subtasks don't roll up to the parent. Use because.gather() and because.create_task() as drop-in replacements to merge child buffers back automatically:

# Drop-in for asyncio.gather()
results = await because.gather(
    fetch_user(user_id),
    fetch_inventory(item_id),
    query_db(),
)

# Drop-in for asyncio.create_task()
task = because.create_task(fetch_user(user_id))
result = await task

Both merge child ops back into the parent buffer on completion, sorted by timestamp. return_exceptions=True and task naming are fully supported.


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.

Filter the output to a time window to cut noise on long-running requests:

print(because.format_context_chain(exc, within_seconds=30))

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.

Supported providers

Provider provider= Default model Extra Env var
Anthropic "anthropic" claude-sonnet-4-6 because-py[llm] ANTHROPIC_API_KEY
OpenAI "openai" gpt-4o because-py[openai] OPENAI_API_KEY
xAI (Grok) "xai" grok-3 because-py[xai] XAI_API_KEY
Google Gemini "gemini" gemini-2.0-flash because-py[gemini] GEMINI_API_KEY
# Anthropic (default)
because.configure_llm(api_key="sk-ant-...", provider="anthropic")

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

# xAI Grok
because.configure_llm(api_key="xai-...", provider="xai")

# Google Gemini
because.configure_llm(api_key="AIza...", provider="gemini", model="gemini-2.0-flash")

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 a specific provider
because explain --provider openai --model gpt-4o error.log
because explain --provider xai error.log
because explain --provider gemini error.log

# print the most recent explanation stored by any because explain call
because last

because last works across both the CLI and in-process explain_async() calls — every explanation is persisted automatically so you can review it any time without re-running the LLM.

because dashboard

because dashboard

Start a local web dashboard that shows the most recent explanation and context chain, auto-refreshing every 3 seconds:

because dashboard            # opens browser at http://127.0.0.1:7331
because dashboard --port 8080
because dashboard --no-open  # start server without opening browser

The dashboard displays:

  • Root cause with confidence badge
  • Contributing factors and suggested fix
  • Pattern matches with evidence
  • Swallowed exceptions (caught-and-suppressed errors that contributed to the failure)
  • Operations timeline — last 50 DB queries, HTTP requests, cache calls, etc. with pass/fail status and duration

The dashboard reads from the same temp files written by explain_async() and because explain, so it works whether you triggered analysis from the CLI or from inside your app. No extra dependencies — stdlib only.

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.


pytest plugin

Install because-py and your failing tests automatically get a because context section — no config required:

FAILED tests/test_checkout.py::test_checkout_under_load

─────────────────────────────── because ───────────────────────────────
[because context]
  Likely cause: Database connection pool may be exhausted
    • 8/10 recent DB queries failed (80% failure rate)
  Recent operations (10):
    [ok]   db_query   2.1ms  SELECT * FROM orders WHERE user_id = ?
    [FAIL] db_query   0.5ms  error=OperationalError
    ...

Disable per-test with @pytest.mark.because_off or globally with --no-because.


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())

Heuristic patterns

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

Pattern Fires when
pool_exhaustion DB or HTTP connection pool error + recent 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
  • Node.js / TypeScript port

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.16.tar.gz (218.3 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.16-py3-none-any.whl (44.0 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: because_py-0.2.16.tar.gz
  • Upload date:
  • Size: 218.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.3

File hashes

Hashes for because_py-0.2.16.tar.gz
Algorithm Hash digest
SHA256 9bbc4dd9cd0f96daaa5db388dffc8e1389fb906c338d852f9e7fb2675d029024
MD5 adbf75a9921950134c1fb9cd1bf5111f
BLAKE2b-256 ca4e9bf4f7b43c03ab7670eb0a2b854e46d049cc84637771821bc6127ba8c24d

See more details on using hashes here.

File details

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

File metadata

  • Download URL: because_py-0.2.16-py3-none-any.whl
  • Upload date:
  • Size: 44.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.3

File hashes

Hashes for because_py-0.2.16-py3-none-any.whl
Algorithm Hash digest
SHA256 5c2798ecf241b69b478794e50e396430911a10e485483372d5102b4dda74d751
MD5 ec60a8114f6370cf1ce2cace46a7d7f7
BLAKE2b-256 315b254e410fee172f31244207364cc3fa59d03867198a789e8f15fe851eb2e7

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