Metadata-Version: 2.4
Name: api-monitor-sdk
Version: 0.1.4
Summary: Automatic HTTP request monitoring for Python applications
Home-page: https://github.com/riken-khadela/api-monitor-sdk
Author: riken-khadela
Author-email: rikenkhadela777@gmail.com
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Requires-Python: >=3.8
Description-Content-Type: text/markdown
Requires-Dist: requests>=2.20.0
Provides-Extra: dev
Requires-Dist: pytest>=7.0.0; extra == "dev"
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
Requires-Dist: black>=23.0.0; extra == "dev"
Requires-Dist: flake8>=6.0.0; extra == "dev"
Dynamic: author
Dynamic: author-email
Dynamic: classifier
Dynamic: description
Dynamic: description-content-type
Dynamic: home-page
Dynamic: provides-extra
Dynamic: requires-dist
Dynamic: requires-python
Dynamic: summary

# API Monitor SDK — Python

[![PyPI version](https://img.shields.io/pypi/v/api-monitor-sdk.svg)](https://pypi.org/project/api-monitor-sdk/)
[![Python Versions](https://img.shields.io/pypi/pyversions/api-monitor-sdk.svg)](https://pypi.org/project/api-monitor-sdk/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Status: Beta](https://img.shields.io/badge/Status-Beta-blue.svg)]()

> **Zero-code HTTP monitoring and real-time API validation for Python web applications.**  
> Works with Django, Flask, FastAPI, and any code that uses `requests`, `httpx`, or `urllib3` — no code changes required.

---

## Table of Contents

- [Overview](#overview)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [How It Works — Two Flows](#how-it-works--two-flows)
- [Framework Integration](#framework-integration)
  - [Django](#django)
  - [Flask](#flask)
  - [FastAPI](#fastapi)
  - [Generic (requests / httpx)](#generic-requests--httpx)
- [Configuration Reference](#configuration-reference)
  - [Environment Variables](#environment-variables)
- [Features In Depth](#features-in-depth)
  - [Request Validation Gate (Flow 1)](#request-validation-gate-flow-1)
  - [Async Metric Ingest (Flow 2)](#async-metric-ingest-flow-2)
  - [Offline Buffering](#offline-buffering)
  - [PII Sanitisation](#pii-sanitisation)
  - [Identity Resolution](#identity-resolution)
  - [Sampling](#sampling)
  - [Path Exclusions](#path-exclusions)
  - [Custom Events](#custom-events)
  - [Diagnostics](#diagnostics)
- [Graceful Shutdown](#graceful-shutdown)
- [Security Notes](#security-notes)
- [Troubleshooting](#troubleshooting)
- [Changelog](#changelog)

---

## Overview

The **API Monitor SDK** automatically intercepts every inbound and outbound HTTP request made by your application and sends rich telemetry — latency, status codes, headers, request/response bodies, caller identity — to the API Monitor platform.

| Capability | Details |
|---|---|
| **Zero-code instrumentation** | One `init()` call at startup, no decorators or middleware to add |
| **Framework auto-detection** | Django, Flask, FastAPI / Starlette patched automatically |
| **Library interception** | `requests`, `httpx`, `urllib3` patched transparently |
| **Validation gate** | Block / rate-limit requests based on platform rules, <2 ms overhead |
| **Async batching** | Metrics sent in background — zero latency impact on your API |
| **Offline mode** | Metrics cached in local SQLite when the platform is unreachable |
| **PII scrubbing** | Emails, credit cards, phone numbers, API keys redacted before send |
| **Identity resolution** | Caller identified via JWT, headers, body fields, or IP hash |
| **Live config reload** | Sampling rate, batch size, exclusions updated from the platform without restart |
| **Fork safe** | Re-initialises cleanly after `os.fork()` (Gunicorn, uWSGI) |

---

## Installation

```bash
pip install api-monitor-sdk
```

**Python 3.8+ required.** No other mandatory dependencies — `requests` is already installed in almost every Python project.

---

## Quick Start

### Ingest-only (default — metrics, no request blocking)

```python
import apimonitor

apimonitor.init(api_key="your-api-key")
```

That is the entire integration. Every HTTP request your server handles is now captured and streamed to the platform.

### With request validation (rate-limiting / blocking)

```python
import apimonitor

apimonitor.init(
    api_key="your-api-key",
    enable_validation=True,   # enable the sync validation gate
)
```

When validation is enabled, a single `/validate` call to the platform acts as both the gate decision **and** the metric record — no extra network round-trip.

### Both disabled (dry-run / testing)

```python
apimonitor.init(
    api_key="your-api-key",
    enable_ingest=False,
    enable_validation=False,
)
```

---

## How It Works — Two Flows

```
Incoming request
       │
       ▼
 ┌─────────────────────────────────────────┐
 │  Flow 1 — Validation Gate (optional)   │
 │                                         │
 │  1. Check in-process LRU cache         │  ← sub-ms, no network
 │  2. Check circuit breaker state        │  ← sub-ms, no network
 │  3. POST /validate   ──────────────────┼──► Platform decides allow/deny
 │     (with metric piggybacked)          │    AND saves metric in one call
 │                                         │
 │  If denied → return 403/429 immediately │
 └─────────────────────────────────────────┘
       │  (allowed)
       ▼
 Your application handles the request
       │
       ▼
 ┌─────────────────────────────────────────┐
 │  Flow 2 — Async Metric Ingest          │
 │  (only when enable_validation=False)   │
 │                                         │
 │  Metric queued in-memory               │
 │  Background thread batches & sends     │
 │       → POST /ingest (batch)           │
 │  On failure → saved to SQLite offline  │
 └─────────────────────────────────────────┘
```

> **Key efficiency principle:** When `enable_validation=True`, the metric is embedded inside the `/validate` payload. The platform processes both the allow/deny decision and the metric storage **in a single HTTP call**. `/ingest` is never called separately.

---

## Framework Integration

### Django

Add the `init()` call at the **top** of your `settings.py` or in your `AppConfig.ready()` method.

**`settings.py`**
```python
import apimonitor

apimonitor.init(
    api_key="your-api-key",
    framework="django",
    environment="production",
    exclude=["/health/", "/metrics/"],
)
```

**`apps.py` (recommended for Django apps)**
```python
from django.apps import AppConfig

class MyAppConfig(AppConfig):
    name = "myapp"

    def ready(self):
        import apimonitor
        apimonitor.init(
            api_key="your-api-key",
            framework="django",
            environment="production",
        )
```

The SDK patches `django.core.handlers.base.BaseHandler.get_response` — every request/response cycle is captured with zero changes to your views.

---

### Flask

Call `init()` before `app.run()` or before WSGI server startup.

```python
from flask import Flask
import apimonitor

app = Flask(__name__)

apimonitor.init(
    api_key="your-api-key",
    framework="flask",
    environment="production",
    sampling_rate=0.5,          # capture 50% of requests
    exclude=["/healthz", "/ping"],
)

@app.route("/api/users")
def users():
    return {"users": []}

if __name__ == "__main__":
    app.run()
```

The SDK patches `Flask.full_dispatch_request` — works with Flask blueprints, extensions, and all route patterns automatically.

---

### FastAPI

Call `init()` before creating the `FastAPI()` application, or in a startup event.

```python
from fastapi import FastAPI
import apimonitor

# Option A: Before app creation (recommended)
apimonitor.init(
    api_key="your-api-key",
    framework="fastapi",
    environment="production",
)

app = FastAPI()

@app.get("/api/items")
def get_items():
    return []
```

```python
# Option B: Startup event
from contextlib import asynccontextmanager

@asynccontextmanager
async def lifespan(app: FastAPI):
    apimonitor.init(api_key="your-api-key", framework="fastapi")
    yield
    apimonitor.stop()

app = FastAPI(lifespan=lifespan)
```

The SDK patches `starlette.applications.Starlette.__call__` and correctly reads the ASGI request body, then replays it to the actual handler — your endpoint sees the full body normally.

---

### Generic (requests / httpx)

No web framework? The SDK still intercepts all outgoing HTTP calls made by your code.

```python
import apimonitor
import requests

apimonitor.init(api_key="your-api-key")

# This call is automatically monitored
resp = requests.get("https://api.example.com/data")
```

`httpx` and direct `urllib3` usage are intercepted in the same way.

---

## Configuration Reference

All parameters can be passed to `init()` or set via environment variables (env vars take lower precedence than explicit kwargs).

### init() Parameters

| Parameter | Type | Default | Description |
|---|---|---|---|
| `api_key` | `str` | **required** | Your API Monitor platform key |
| `base_url` | `str` | `https://api.yourmonitor.com` | Platform base URL |
| `enable_validation` | `bool` | `False` | Enable Flow 1 — synchronous request gate |
| `enable_ingest` | `bool` | `True` | Enable Flow 2 — async metric capture |
| `framework` | `str` | `None` | e.g. `"django"`, `"flask"`, `"fastapi"` — shown in platform agents list |
| `environment` | `str` | `None` | e.g. `"production"`, `"staging"` — shown in platform |
| `sampling_rate` | `float` | `1.0` | Fraction of requests to capture (0.0–1.0). `0.1` = 10% |
| `batch_size` | `int` | `100` | Flush metrics after this many are queued |
| `batch_timeout` | `int` | `10` | Flush metrics after this many seconds even if batch is not full |
| `queue_maxsize` | `int` | `10000` | Max in-memory queue depth (metrics dropped if exceeded) |
| `exclude` | `list[str]` | `[]` | Paths to skip. Supports exact (`/health`) and wildcard prefix (`/internal/*`) |
| `capture_request_body` | `bool` | `True` | Capture request bodies |
| `capture_response_body` | `bool` | `True` | Capture response bodies |
| `max_body_bytes` | `int` | `10240` | Max bytes captured per body (10 KB default) |
| `sanitize_fields` | `list[str]` | `[]` | Extra field names to redact from bodies and headers |
| `extra_identity_headers` | `list[str]` | `[]` | Extra headers to use for caller identity (e.g. `["x-workspace-id"]`) |
| `extra_identity_fields` | `list[str]` | `[]` | Extra JSON body fields for caller identity |
| `offline_mode` | `bool` | `True` | Buffer metrics in local SQLite when platform is unreachable |
| `local_db_path` | `str` | `~/.apimonitor_queue.db` | Path to the offline SQLite database |
| `max_offline_rows` | `int` | `50000` | Max rows in the offline buffer (oldest are dropped when full) |
| `fail_behavior` | `str` | `"open"` | Validation failure behavior: `"open"` (allow) or `"closed"` (block) |
| `validate_timeout_ms` | `int` | `2000` | Max time (ms) for a /validate call before falling back to `fail_behavior` |
| `check_cache_ttl_ms` | `int` | `500` | How long to cache a validation allow/deny decision (ms) |
| `cb_failure_threshold` | `int` | `5` | Consecutive failures before circuit breaker opens |
| `cb_window_seconds` | `int` | `10` | Rolling window for measuring failures |
| `cb_recovery_seconds` | `int` | `30` | Time before circuit breaker moves to HALF-OPEN and retries |
| `ingest_mode` | `str` | `"background"` | `"background"` (default) or `"inline"` (synchronous, for testing) |
| `heartbeat_interval` | `int` | `30` | Seconds between liveness pings to the platform |
| `retry_initial_delay` | `float` | `1.0` | Initial retry delay after a failed batch send (seconds) |
| `retry_max_delay` | `float` | `60.0` | Maximum retry delay (exponential backoff cap) |
| `retry_multiplier` | `float` | `2.0` | Backoff multiplier |
| `debug` | `bool` | `False` | Enable verbose SDK logging |
| `log_level` | `str` | `"ERROR"` | Python log level for SDK internals |

---

### Environment Variables

Every parameter above has a corresponding environment variable. Use these for zero-change deployments in containerised environments.

| Environment Variable | Corresponding Parameter |
|---|---|
| `APIMONITOR_API_KEY` | `api_key` |
| `APIMONITOR_BASE_URL` | `base_url` |
| `APIMONITOR_ENABLE_VALIDATION` | `enable_validation` |
| `APIMONITOR_ENABLE_INGEST` | `enable_ingest` |
| `APIMONITOR_FRAMEWORK` | `framework` |
| `APIMONITOR_ENVIRONMENT` | `environment` |
| `APIMONITOR_SAMPLING_RATE` | `sampling_rate` |
| `APIMONITOR_BATCH_SIZE` | `batch_size` |
| `APIMONITOR_BATCH_TIMEOUT` | `batch_timeout` |
| `APIMONITOR_QUEUE_MAXSIZE` | `queue_maxsize` |
| `APIMONITOR_EXCLUDE` | `exclude` (comma-separated) |
| `APIMONITOR_CAPTURE_REQ_BODY` | `capture_request_body` |
| `APIMONITOR_CAPTURE_RESP_BODY` | `capture_response_body` |
| `APIMONITOR_MAX_BODY_BYTES` | `max_body_bytes` |
| `APIMONITOR_SANITIZE_FIELDS` | `sanitize_fields` (comma-separated) |
| `APIMONITOR_IDENTITY_HEADERS` | `extra_identity_headers` (comma-separated) |
| `APIMONITOR_IDENTITY_FIELDS` | `extra_identity_fields` (comma-separated) |
| `APIMONITOR_OFFLINE_MODE` | `offline_mode` |
| `APIMONITOR_LOCAL_DB_PATH` | `local_db_path` |
| `APIMONITOR_MAX_OFFLINE_ROWS` | `max_offline_rows` |
| `APIMONITOR_FAIL_BEHAVIOR` | `fail_behavior` |
| `APIMONITOR_VALIDATE_TIMEOUT_MS` | `validate_timeout_ms` |
| `APIMONITOR_CHECK_CACHE_TTL_MS` | `check_cache_ttl_ms` |
| `APIMONITOR_CB_FAILURE_THRESHOLD` | `cb_failure_threshold` |
| `APIMONITOR_CB_WINDOW_SECONDS` | `cb_window_seconds` |
| `APIMONITOR_CB_RECOVERY_SECONDS` | `cb_recovery_seconds` |
| `APIMONITOR_INGEST_MODE` | `ingest_mode` |
| `APIMONITOR_HEARTBEAT_INTERVAL` | `heartbeat_interval` |
| `APIMONITOR_DEBUG` | `debug` |
| `APIMONITOR_LOG_LEVEL` | `log_level` |

**Docker / docker-compose example:**
```yaml
environment:
  - APIMONITOR_API_KEY=your-api-key
  - APIMONITOR_ENVIRONMENT=production
  - APIMONITOR_SAMPLING_RATE=0.25
  - APIMONITOR_EXCLUDE=/health,/metrics,/readyz
  - APIMONITOR_SANITIZE_FIELDS=password,ssn,credit_card
```

---

## Features In Depth

### Request Validation Gate (Flow 1)

When `enable_validation=True`, the SDK makes a synchronous call to the platform **before** forwarding the request to your application. The platform evaluates your rate-limit rules and returns an allow/deny decision.

**Performance protections built in:**

1. **In-process LRU cache** — repeated requests to the same endpoint by the same caller are cached for `check_cache_ttl_ms` ms (default 500 ms). Cache hit = sub-millisecond, no network.
2. **Circuit breaker** — if the platform becomes unreachable, the breaker opens after `cb_failure_threshold` consecutive failures and applies `fail_behavior` without making network calls. Recovers automatically after `cb_recovery_seconds`.
3. **Timeout** — every validate call is bounded by `validate_timeout_ms` (default 2 s). If the platform doesn't respond in time, `fail_behavior` is applied.

**`fail_behavior` options:**

| Value | Behavior |
|---|---|
| `"open"` (default) | Allow all requests when the platform is unreachable. Safe for production — your API keeps working. |
| `"closed"` | Block all requests when unreachable. Use for high-security scenarios. |

**Blocked response format:**
```json
{
  "error": "Rate limit exceeded",
  "blocked_by": "apimonitor",
  "retry_after": 30
}
```

---

### Async Metric Ingest (Flow 2)

When `enable_validation=False` (or for custom events), metrics are captured asynchronously:

1. Metric placed on an in-memory queue (non-blocking, < 1 µs).
2. Background thread (`apimonitor-sender`) drains the queue and accumulates a batch.
3. When batch reaches `batch_size` **or** `batch_timeout` seconds elapses, the batch is POSTed to `/ingest`.
4. On failure, the batch is sent to exponential-backoff retry, and metrics are saved to [offline storage](#offline-buffering).

**Tuning for high-traffic apps:**
```python
apimonitor.init(
    api_key="...",
    batch_size=500,         # larger batches, fewer HTTP calls
    batch_timeout=5,        # flush more frequently
    sampling_rate=0.1,      # capture only 10% of requests
    queue_maxsize=50_000,   # allow larger in-memory queue
)
```

---

### Offline Buffering

When the platform is unreachable, metrics are persisted in a local SQLite database (WAL mode, safe for multi-process servers like Gunicorn).

- **Default path:** `~/.apimonitor_queue.db`
- **Custom path:** `local_db_path="/var/data/apimonitor.db"`
- **Max rows:** `max_offline_rows=50_000` — when full, the oldest 10% of rows are dropped to make room
- **Auto-drain:** When the platform becomes reachable again, the offline buffer is automatically drained in the background

**Disable offline mode** (e.g. ephemeral containers with no persistent disk):
```python
apimonitor.init(api_key="...", offline_mode=False)
```

---

### PII Sanitisation

The SDK automatically redacts the following patterns from all request/response bodies and headers **before** any data leaves your server:

| Pattern | Example | Redacted to |
|---|---|---|
| Email addresses | `user@example.com` | `[REDACTED]` |
| Credit card numbers | `4111 1111 1111 1111` | `[REDACTED]` |
| Phone numbers | `+1 (555) 123-4567` | `[REDACTED]` |
| Authorization header | `Bearer eyJ...` | `[REDACTED]` |
| `x-api-key` header | any value | `[REDACTED]` |
| `cookie` header | any value | `[REDACTED]` |

**Add your own fields to redact:**
```python
apimonitor.init(
    api_key="...",
    sanitize_fields=["password", "ssn", "secret_token", "credit_card_number"],
)
```

Fields are matched case-insensitively in both headers and JSON body (including nested objects).

---

### Identity Resolution

The SDK resolves a stable, anonymised identity for every request caller using this priority chain:

1. **JWT** — extracts `sub`, `user_id`, `userId`, `email`, or `uid` claim from `Authorization: Bearer` header or `token` / `access_token` cookie
2. **Identity headers** — checks `x-user-id`, `x-account-id`, `x-client-id`, `x-tenant-id`, `x-customer-id`, `x-actor-id`
3. **Request body** — scans top-level and one-level-deep JSON for `user_id`, `userId`, `account_id`, `email`, `sub`, `uid`, etc.
4. **IP + User-Agent hash** — fallback when no other signal is available

The resolved identity is always a SHA-256 hash (truncated to 16 hex chars) — **raw PII is never stored**.

**Extend identity detection:**
```python
apimonitor.init(
    api_key="...",
    # Additional headers to check before the IP fallback
    extra_identity_headers=["x-workspace-id", "x-org-id"],
    # Additional body fields to check
    extra_identity_fields=["workspace_id", "org_id"],
)
```

---

### Sampling

Reduce data volume by capturing a fraction of requests:

```python
apimonitor.init(
    api_key="...",
    sampling_rate=0.25,   # capture 25% of traffic
)
```

- `1.0` = 100% (default) — capture everything
- `0.0` = 0% — capture nothing (effectively disabled)
- Sampling applies **only to metric capture** — validation decisions are never sampled
- Sampling rate can be updated live from the platform dashboard without restarting your server

---

### Path Exclusions

Skip monitoring for health checks, metrics endpoints, static files, etc.:

```python
apimonitor.init(
    api_key="...",
    exclude=[
        "/health",          # exact match
        "/healthz",
        "/readyz",
        "/metrics",
        "/static/*",        # wildcard prefix — all paths starting with /static/
        "/internal/*",
    ],
)
```

Exclusion rules support:
- **Exact match:** `/health` matches only `/health`
- **Wildcard prefix:** `/static/*` matches `/static/js/app.js`, `/static/css/main.css`, etc.

---

### Custom Events

Track business events that aren't HTTP requests:

```python
monitor = apimonitor.init(api_key="...")

# Track a custom event with arbitrary metadata
monitor.track("user_signup", plan="pro", source="organic", country="US")
monitor.track("payment_processed", amount=99.99, currency="USD", user_id="u_123")
monitor.track("export_completed", format="csv", rows=15_420)
```

Custom events are batched and sent through the same async pipeline as HTTP metrics.

---

### Diagnostics

Inspect the SDK's internal state at runtime:

```python
monitor = apimonitor.get_monitor()
import pprint
pprint.pprint(monitor.diagnose())
```

**Output example:**
```python
{
    "started": True,
    "pid": 12345,
    "uptime_seconds": 3600.0,
    "api_key_set": True,
    "enable_validation": True,
    "enable_ingest": True,
    "fail_behavior": "open",
    "sampling_rate": 1.0,
    "batch_size": 100,
    "batch_timeout": 10,
    "ingest_mode": "background",
    "circuit_breaker_state": "closed",
    "offline_queue_size": 0,
    "platform_base_url": "https://api.yourmonitor.com",
    "patched_frameworks": ["django"],
    "patched_libraries": ["requests", "httpx"],
    "heartbeat_interval": 30,
}
```

---

## Graceful Shutdown

The SDK registers an `atexit` handler automatically — when your process exits normally (SIGTERM, Ctrl+C, `sys.exit()`), any queued metrics are flushed before shutdown.

For explicit control (e.g. in tests or when using `lifespan` context managers):

```python
# Stop the SDK and flush all remaining metrics
apimonitor.stop()
```

```python
# Check if SDK is running
monitor = apimonitor.get_monitor()
if monitor:
    print(monitor.diagnose())
```

---

## Security Notes

- **API key** — keep your API key in environment variables, never hard-code it in source files.
- **PII** — the SDK scrubs known PII patterns before transmission, but you should also configure `sanitize_fields` for any application-specific sensitive fields.
- **Body capture** — if your API handles highly sensitive data and body capture is not needed for your use case, disable it:
  ```python
  apimonitor.init(
      api_key="...",
      capture_request_body=False,
      capture_response_body=False,
  )
  ```
- **TLS** — all communication with the platform uses HTTPS. Ensure `base_url` is always `https://`.
- **Offline database** — the SQLite queue file may contain request/response data. Protect it with appropriate filesystem permissions.

---

## Troubleshooting

### SDK appears to not send any data

1. Verify `api_key` is correct.
2. Enable debug logging:
   ```python
   apimonitor.init(api_key="...", debug=True, log_level="DEBUG")
   ```
3. Check `diagnose()` — look at `circuit_breaker_state` and `offline_queue_size`.
4. Confirm `base_url` is reachable from your server.

### Validation always allows requests even when rules are set

- Ensure `enable_validation=True` is passed to `init()`.
- Check `circuit_breaker_state` in `diagnose()` — if `"open"`, the platform was unreachable and `fail_behavior="open"` is being applied.

### High memory usage

Reduce the in-memory queue size and use a lower sampling rate:
```python
apimonitor.init(
    api_key="...",
    queue_maxsize=1_000,
    sampling_rate=0.1,
)
```

### Request body is `None` in the platform (FastAPI)

Body capture requires that the request body has not already been consumed before the SDK reads it. The SDK handles this automatically by buffering and replaying the ASGI body stream. If you are using a custom ASGI middleware that reads the body first, ensure it also replays it.

### Gunicorn / uWSGI workers not sending data

The SDK is fork-safe. However, ensure `init()` is called **after** the worker fork (e.g. in a `post_fork` hook or in `AppConfig.ready()`), not only in the main process.

**Gunicorn `post_fork` hook:**
```python
# gunicorn.conf.py
def post_fork(server, worker):
    import apimonitor
    apimonitor.init(api_key="your-api-key", framework="django")
```

---

## Changelog

### v0.1.4
- Combined Flow 1 + Flow 2 into a single `/validate` round-trip — no separate `/ingest` call when validation is enabled
- Added `Heartbeat` class replacing separate `config_sync` thread — liveness ping and config change detection in one thread
- Live circuit breaker threshold updates from the platform control panel
- ASGI body read fixed for FastAPI — request body no longer null
- Added `framework` and `environment` metadata to heartbeat payloads
- `max_offline_rows` cap added to prevent unbounded SQLite growth
- `diagnose()` now reports `patched_frameworks` and `patched_libraries`

---

## License

MIT License — see [LICENSE](LICENSE) for details.

© 2024 riken-khadela
