Metadata-Version: 2.4
Name: because-py
Version: 0.2.0
Summary: Enriches exceptions with plain-English causal chains at throw time
Project-URL: Homepage, https://github.com/jacobthomasmichael/because
Project-URL: Repository, https://github.com/jacobthomasmichael/because
Project-URL: Bug Tracker, https://github.com/jacobthomasmichael/because/issues
Author-email: Jacob Wagner <jacobthomasmichael@gmail.com>
License: MIT
Keywords: debugging,error-context,exceptions,observability,root-cause
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Debuggers
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Logging
Requires-Python: >=3.10
Provides-Extra: all
Requires-Dist: fastapi>=0.95; extra == 'all'
Requires-Dist: flask>=3.0; extra == 'all'
Requires-Dist: httpx>=0.23; extra == 'all'
Requires-Dist: redis>=4.0; extra == 'all'
Requires-Dist: requests>=2.0; extra == 'all'
Requires-Dist: sqlalchemy>=1.4; extra == 'all'
Requires-Dist: starlette>=0.27; extra == 'all'
Provides-Extra: dev
Requires-Dist: fakeredis>=2.0; extra == 'dev'
Requires-Dist: fastapi>=0.95; extra == 'dev'
Requires-Dist: flask>=3.0; extra == 'dev'
Requires-Dist: httpx>=0.23; extra == 'dev'
Requires-Dist: hypothesis>=6.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: requests>=2.0; extra == 'dev'
Requires-Dist: sqlalchemy>=1.4; extra == 'dev'
Requires-Dist: starlette>=0.27; extra == 'dev'
Provides-Extra: fastapi
Requires-Dist: fastapi>=0.95; extra == 'fastapi'
Requires-Dist: starlette>=0.27; extra == 'fastapi'
Provides-Extra: flask
Requires-Dist: flask>=3.0; extra == 'flask'
Provides-Extra: httpx
Requires-Dist: httpx>=0.23; extra == 'httpx'
Provides-Extra: llm
Requires-Dist: anthropic>=0.25; extra == 'llm'
Provides-Extra: openai
Requires-Dist: openai>=1.0; extra == 'openai'
Provides-Extra: redis
Requires-Dist: redis>=4.0; extra == 'redis'
Provides-Extra: requests
Requires-Dist: requests>=2.0; extra == 'requests'
Provides-Extra: sqlalchemy
Requires-Dist: sqlalchemy>=1.4; extra == 'sqlalchemy'
Description-Content-Type: text/markdown

# because

Stack traces show symptoms. `because` shows causes.

---

## The problem

Your monitoring dashboard lights up with `ConnectionError: connection refused on localhost:5432`. The stack trace points to `db/pool.py:142`. You have no idea why.

The real story: a recent deploy added a synchronous DB call to a hot path. Under load, 48 of 50 connections filled up. Three `TimeoutError`s were silently swallowed over the prior 10 seconds. By the time the `ConnectionError` fired, all the context was gone.

This is the ABS warning light telling you the battery is dead. Today's error tooling stops at the symptom.

`because` captures the context *before* the crash and attaches it to the exception at throw time.

---

## What it looks like

**Without because:**
```
ConnectionError: connection refused on localhost:5432
  File "db/pool.py", line 142, in execute
```

**With because:**
```
ConnectionError: connection refused on localhost:5432
  File "db/pool.py", line 142, in execute

[because context]
  Likely cause: Database connection pool may be exhausted
    • Exception message contains 'QueuePool limit'
    • 8 DB queries in context window (pool was active)
    • 5/8 recent DB queries failed (63% failure rate)
  Recent operations (8):
    [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          2.4ms  SELECT * FROM orders WHERE user_id = ?
    [FAIL] db_query        0.5ms  SELECT * FROM orders WHERE user_id = ?  error=OperationalError
    [FAIL] db_query        0.5ms  SELECT * FROM orders WHERE user_id = ?  error=OperationalError
    [FAIL] db_query        0.5ms  SELECT * FROM orders WHERE user_id = ?  error=OperationalError
    [FAIL] db_query        0.5ms  SELECT * FROM orders WHERE user_id = ?  error=OperationalError
    [FAIL] db_query        0.5ms  SELECT * FROM orders WHERE user_id = ?  error=OperationalError
```

---

## Install

```bash
pip install because
```

With instrumentation extras:

```bash
pip install "because[sqlalchemy]"
pip install "because[sqlalchemy,requests]"
```

---

## Zero-config setup

```python
import because
because.install()
```

That's it. `because` hooks `sys.excepthook` and starts recording operations in the background. Any uncaught exception automatically gets enriched context appended to stderr — no changes to your exception handlers required.

---

## Instrumenting libraries

`because` ships with instruments for common libraries. Attach them to your existing clients:

```python
from because.instruments.sqlalchemy import instrument as instrument_sa
from because.instruments.requests import instrument as instrument_requests
import requests

instrument_sa(engine)          # your SQLAlchemy engine
instrument_requests(requests.Session())
```

Each instrument records operation timing, success/failure, and relevant metadata into a per-thread/per-task ring buffer. Zero I/O on the hot path.

---

## Recording swallowed exceptions

Caught-and-not-reraised exceptions are often the real cause of a downstream crash. Wrap risky calls with `because.catch()` to make them visible:

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

When a downstream `AttributeError: 'NoneType' object has no attribute 'email'` fires, `because` will surface the swallowed DB error as the likely cause.

---

## Enriching caught exceptions manually

For exceptions you handle yourself:

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

`__context_chain__` serializes cleanly into Sentry `extra`, Datadog error attributes, or structured log fields.

---

## Heuristic patterns (v0)

`because` ships with a starter set of deterministic cascade patterns:

| Pattern | Fires when |
|---|---|
| `pool_exhaustion` | Connection/pool error + recent DB activity or explicit pool message |
| `silent_failure` | Swallowed exception preceded the current error |

Each pattern is a small, independently testable unit. Output always uses hedged language ("likely cause", "contributing factor") — `because` never claims certainty.

---

## Design principles

- **Honest framing.** Output uses "likely cause" and "contributing factor." Wrong-but-confident destroys trust.
- **Zero-config default.** `import because; because.install()` does something useful immediately.
- **No hot-path cost.** Instrumentation is bounded ring buffers. Enrichment runs only on exception.
- **Composable with existing tools.** Attaches to Sentry, Datadog, and structured logging — doesn't replace them.
- **Library, not platform.** Pure Python, no required backend, ships as a pip package.

---

## Examples

Runnable demos in `examples/`:

```bash
python examples/pool_exhaustion.py   # connection pool saturated under load
python examples/silent_failure.py    # swallowed DB error causes downstream crash
```

Each demo shows the cascade being triggered and `because` surfacing the cause.

---

## Roadmap

- **v0.2** — Optional LLM-based explanation (async, deferred, BYO API key)
- **v1.0** — Cross-process / cross-service causal reasoning
- More instruments: `httpx`, `redis-py`, `logging`, stdlib `socket`
- Framework middleware: Flask, FastAPI, Django
- Sentry / Datadog / OpenTelemetry integration helpers
