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
Nonesilently - 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
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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9bbc4dd9cd0f96daaa5db388dffc8e1389fb906c338d852f9e7fb2675d029024
|
|
| MD5 |
adbf75a9921950134c1fb9cd1bf5111f
|
|
| BLAKE2b-256 |
ca4e9bf4f7b43c03ab7670eb0a2b854e46d049cc84637771821bc6127ba8c24d
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5c2798ecf241b69b478794e50e396430911a10e485483372d5102b4dda74d751
|
|
| MD5 |
ec60a8114f6370cf1ce2cace46a7d7f7
|
|
| BLAKE2b-256 |
315b254e410fee172f31244207364cc3fa59d03867198a789e8f15fe851eb2e7
|