bonito-store 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bonito_store-0.1.0/PKG-INFO +62 -0
- bonito_store-0.1.0/README.md +47 -0
- bonito_store-0.1.0/pyproject.toml +31 -0
- bonito_store-0.1.0/setup.cfg +4 -0
- bonito_store-0.1.0/src/bonito_store/__init__.py +2 -0
- bonito_store-0.1.0/src/bonito_store/api.py +52 -0
- bonito_store-0.1.0/src/bonito_store/cli.py +46 -0
- bonito_store-0.1.0/src/bonito_store/normalize.py +19 -0
- bonito_store-0.1.0/src/bonito_store/schema.py +80 -0
- bonito_store-0.1.0/src/bonito_store/store.py +239 -0
- bonito_store-0.1.0/src/bonito_store.egg-info/PKG-INFO +62 -0
- bonito_store-0.1.0/src/bonito_store.egg-info/SOURCES.txt +15 -0
- bonito_store-0.1.0/src/bonito_store.egg-info/dependency_links.txt +1 -0
- bonito_store-0.1.0/src/bonito_store.egg-info/entry_points.txt +2 -0
- bonito_store-0.1.0/src/bonito_store.egg-info/requires.txt +7 -0
- bonito_store-0.1.0/src/bonito_store.egg-info/top_level.txt +1 -0
- bonito_store-0.1.0/tests/test_store.py +170 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bonito-store
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Bonito deep DB observability store — SQLite persistence for query/plan/lock/session events (BON-002).
|
|
5
|
+
Author: Carlos Cortez
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: fastapi>=0.110
|
|
10
|
+
Requires-Dist: uvicorn>=0.29
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
13
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
14
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
15
|
+
|
|
16
|
+
# bonito-store
|
|
17
|
+
|
|
18
|
+
The persistence layer of Bonito (BON-002). Stores exactly what Prometheus
|
|
19
|
+
can't: **query texts, execution plans, lock events, session snapshots and
|
|
20
|
+
table stats** in a single SQLite file. Receives JSON events from
|
|
21
|
+
`bonito-collector` via `POST /events`.
|
|
22
|
+
|
|
23
|
+
## Quickstart
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uv pip install -e bonito-store
|
|
27
|
+
bonito-store serve --db bonito.db # FastAPI on :8000
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
curl -X POST localhost:8000/events -H 'Content-Type: application/json' \
|
|
32
|
+
-d @examples/events.sample.json
|
|
33
|
+
curl localhost:8000/baselines # 7-day mean + p95 per fingerprint
|
|
34
|
+
bonito-store prune # apply retention
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Config
|
|
38
|
+
|
|
39
|
+
| Variable | Default | Description |
|
|
40
|
+
|----------|---------|-------------|
|
|
41
|
+
| `BONITO_DB` | `bonito.db` | SQLite file path |
|
|
42
|
+
| `BONITO_STORE_PORT` | `8000` | HTTP port |
|
|
43
|
+
| `BONITO_RETENTION_DAYS` | `7` | Retention window (pruned in background) |
|
|
44
|
+
|
|
45
|
+
## Endpoints
|
|
46
|
+
|
|
47
|
+
- `POST /events` — ingest a collector snapshot (batch capped at 100 per type)
|
|
48
|
+
- `POST /plans` — ingest execution plan records (stored as JSON)
|
|
49
|
+
- `GET /baselines?fingerprint=...&days=7` — rolling mean + p95 per query
|
|
50
|
+
- `GET /health`
|
|
51
|
+
|
|
52
|
+
## Design
|
|
53
|
+
|
|
54
|
+
- **Dedupe**: `query_texts` is keyed by `fingerprint` (sha256 of the minified
|
|
55
|
+
query). Same query updates stats instead of creating a new row.
|
|
56
|
+
- **Baselines**: each observation is appended to `query_samples`; baselines are
|
|
57
|
+
computed on the fly over the last N days (mean + p95).
|
|
58
|
+
- **Retention**: `lock_events`, `session_snapshots`, `execution_plans`,
|
|
59
|
+
`query_samples` and `table_stats` are pruned past `BONITO_RETENTION_DAYS`.
|
|
60
|
+
Identity tables (`query_texts`) keep the latest stats forever.
|
|
61
|
+
- **Plans**: stored as raw JSON (`EXPLAIN` output) — never interpolated into
|
|
62
|
+
prompts without truncation (LLM token governance).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# bonito-store
|
|
2
|
+
|
|
3
|
+
The persistence layer of Bonito (BON-002). Stores exactly what Prometheus
|
|
4
|
+
can't: **query texts, execution plans, lock events, session snapshots and
|
|
5
|
+
table stats** in a single SQLite file. Receives JSON events from
|
|
6
|
+
`bonito-collector` via `POST /events`.
|
|
7
|
+
|
|
8
|
+
## Quickstart
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
uv pip install -e bonito-store
|
|
12
|
+
bonito-store serve --db bonito.db # FastAPI on :8000
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
curl -X POST localhost:8000/events -H 'Content-Type: application/json' \
|
|
17
|
+
-d @examples/events.sample.json
|
|
18
|
+
curl localhost:8000/baselines # 7-day mean + p95 per fingerprint
|
|
19
|
+
bonito-store prune # apply retention
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Config
|
|
23
|
+
|
|
24
|
+
| Variable | Default | Description |
|
|
25
|
+
|----------|---------|-------------|
|
|
26
|
+
| `BONITO_DB` | `bonito.db` | SQLite file path |
|
|
27
|
+
| `BONITO_STORE_PORT` | `8000` | HTTP port |
|
|
28
|
+
| `BONITO_RETENTION_DAYS` | `7` | Retention window (pruned in background) |
|
|
29
|
+
|
|
30
|
+
## Endpoints
|
|
31
|
+
|
|
32
|
+
- `POST /events` — ingest a collector snapshot (batch capped at 100 per type)
|
|
33
|
+
- `POST /plans` — ingest execution plan records (stored as JSON)
|
|
34
|
+
- `GET /baselines?fingerprint=...&days=7` — rolling mean + p95 per query
|
|
35
|
+
- `GET /health`
|
|
36
|
+
|
|
37
|
+
## Design
|
|
38
|
+
|
|
39
|
+
- **Dedupe**: `query_texts` is keyed by `fingerprint` (sha256 of the minified
|
|
40
|
+
query). Same query updates stats instead of creating a new row.
|
|
41
|
+
- **Baselines**: each observation is appended to `query_samples`; baselines are
|
|
42
|
+
computed on the fly over the last N days (mean + p95).
|
|
43
|
+
- **Retention**: `lock_events`, `session_snapshots`, `execution_plans`,
|
|
44
|
+
`query_samples` and `table_stats` are pruned past `BONITO_RETENTION_DAYS`.
|
|
45
|
+
Identity tables (`query_texts`) keep the latest stats forever.
|
|
46
|
+
- **Plans**: stored as raw JSON (`EXPLAIN` output) — never interpolated into
|
|
47
|
+
prompts without truncation (LLM token governance).
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "bonito-store"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Bonito deep DB observability store — SQLite persistence for query/plan/lock/session events (BON-002)."
|
|
5
|
+
authors = [{ name = "Carlos Cortez" }]
|
|
6
|
+
license = { text = "Apache-2.0" }
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"fastapi>=0.110",
|
|
11
|
+
"uvicorn>=0.29",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
dev = ["pytest>=8.0", "ruff>=0.6", "httpx>=0.27"]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
bonito-store = "bonito_store.cli:main"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["setuptools>=68"]
|
|
22
|
+
build-backend = "setuptools.build_meta"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["src"]
|
|
26
|
+
|
|
27
|
+
[tool.ruff]
|
|
28
|
+
target-version = "py311"
|
|
29
|
+
|
|
30
|
+
[tool.ruff.lint]
|
|
31
|
+
ignore = ["BLE001", "S608"]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""FastAPI app — POST /events receiver + baseline reads (BON-002 deliverables 2, 5)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from .store import BATCH_LIMIT, BonitoStore
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EventsIn(BaseModel):
|
|
13
|
+
"""One collector snapshot. Optional fields match the collector payload."""
|
|
14
|
+
|
|
15
|
+
collected_at: str | None = None
|
|
16
|
+
top_queries: list[dict[str, Any]] = Field(default_factory=list)
|
|
17
|
+
locks: list[dict[str, Any]] = Field(default_factory=list)
|
|
18
|
+
sessions: list[dict[str, Any]] = Field(default_factory=list)
|
|
19
|
+
tables: list[dict[str, Any]] = Field(default_factory=list)
|
|
20
|
+
plans: list[dict[str, Any]] = Field(default_factory=list)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PlansIn(BaseModel):
|
|
24
|
+
plans: list[dict[str, Any]] = Field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def create_app(db_path: str, retention_days: int = 7) -> FastAPI:
|
|
28
|
+
store = BonitoStore(db_path, retention_days)
|
|
29
|
+
|
|
30
|
+
app = FastAPI(title="bonito-store", version="0.1.0")
|
|
31
|
+
|
|
32
|
+
@app.get("/health")
|
|
33
|
+
def health() -> dict[str, str]:
|
|
34
|
+
return {"status": "ok"}
|
|
35
|
+
|
|
36
|
+
@app.post("/events")
|
|
37
|
+
def ingest(events: EventsIn) -> dict[str, Any]:
|
|
38
|
+
summary = store.ingest(events.model_dump(exclude_none=True))
|
|
39
|
+
return {"status": "ok", **summary}
|
|
40
|
+
|
|
41
|
+
@app.post("/plans")
|
|
42
|
+
def plans(payload: PlansIn) -> dict[str, Any]:
|
|
43
|
+
summary = store.ingest({"plans": payload.plans})
|
|
44
|
+
return {"status": "ok", **summary}
|
|
45
|
+
|
|
46
|
+
@app.get("/baselines")
|
|
47
|
+
def baselines(fingerprint: str | None = None, days: int | None = None) -> dict[str, Any]:
|
|
48
|
+
return {"baselines": store.baselines(fingerprint, days or retention_days)}
|
|
49
|
+
|
|
50
|
+
app.state.store = store
|
|
51
|
+
app.state.batch_limit = BATCH_LIMIT
|
|
52
|
+
return app
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""bonito-store CLI — serve the API or run retention pruning."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import uvicorn
|
|
8
|
+
|
|
9
|
+
from .api import create_app
|
|
10
|
+
from .store import BonitoStore
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _env_int(name: str, default: int) -> int:
|
|
14
|
+
try:
|
|
15
|
+
return int(os.environ.get(name, str(default)))
|
|
16
|
+
except ValueError:
|
|
17
|
+
return default
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main() -> None:
|
|
21
|
+
ap = argparse.ArgumentParser(prog="bonito-store")
|
|
22
|
+
sub = ap.add_subparsers(dest="command", required=True)
|
|
23
|
+
|
|
24
|
+
serve = sub.add_parser("serve", help="run the FastAPI receiver")
|
|
25
|
+
serve.add_argument("--host", default="0.0.0.0")
|
|
26
|
+
serve.add_argument("--port", type=int, default=_env_int("BONITO_STORE_PORT", 8000))
|
|
27
|
+
serve.add_argument("--db", default=os.environ.get("BONITO_DB", "bonito.db"))
|
|
28
|
+
serve.add_argument(
|
|
29
|
+
"--retention-days", type=int, default=_env_int("BONITO_RETENTION_DAYS", 7)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
prune = sub.add_parser("prune", help="apply retention pruning once")
|
|
33
|
+
prune.add_argument("--db", default=os.environ.get("BONITO_DB", "bonito.db"))
|
|
34
|
+
prune.add_argument("--days", type=int, default=_env_int("BONITO_RETENTION_DAYS", 7))
|
|
35
|
+
|
|
36
|
+
args = ap.parse_args()
|
|
37
|
+
if args.command == "serve":
|
|
38
|
+
app = create_app(args.db, args.retention_days)
|
|
39
|
+
uvicorn.run(app, host=args.host, port=args.port)
|
|
40
|
+
else:
|
|
41
|
+
store = BonitoStore(args.db, args.days)
|
|
42
|
+
print(store.prune(args.days))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
main()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Fingerprint normalization (BON-002 deliverable 3).
|
|
2
|
+
|
|
3
|
+
Same query text normalizes to the same fingerprint regardless of whitespace
|
|
4
|
+
and keyword casing. If the collector already supplies a fingerprint (the
|
|
5
|
+
Postgres ``queryid``), it takes precedence; this module is the fallback.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def minify_sql(query: str) -> str:
|
|
13
|
+
"""Collapse whitespace and lowercase the query text."""
|
|
14
|
+
return " ".join(query.split()).lower()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def fingerprint_of(query: str) -> str:
|
|
18
|
+
"""sha256 hex of the minified query — the dedupe key."""
|
|
19
|
+
return hashlib.sha256(minify_sql(query).encode()).hexdigest()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""SQLite schema for bonito-store (BON-002 deliverable 1).
|
|
2
|
+
|
|
3
|
+
Timestamps are stored as ISO-8601 UTC text so retention pruning and baselines
|
|
4
|
+
are trivially comparable.
|
|
5
|
+
"""
|
|
6
|
+
SCHEMA_SQL = """
|
|
7
|
+
CREATE TABLE IF NOT EXISTS query_texts (
|
|
8
|
+
fingerprint TEXT PRIMARY KEY,
|
|
9
|
+
query_text TEXT NOT NULL,
|
|
10
|
+
first_seen TEXT NOT NULL,
|
|
11
|
+
last_seen TEXT NOT NULL,
|
|
12
|
+
calls INTEGER NOT NULL DEFAULT 0,
|
|
13
|
+
avg_ms REAL,
|
|
14
|
+
max_ms REAL
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
CREATE TABLE IF NOT EXISTS query_samples (
|
|
18
|
+
fingerprint TEXT NOT NULL,
|
|
19
|
+
ts TEXT NOT NULL,
|
|
20
|
+
calls INTEGER,
|
|
21
|
+
mean_ms REAL,
|
|
22
|
+
rows INTEGER,
|
|
23
|
+
PRIMARY KEY (fingerprint, ts)
|
|
24
|
+
);
|
|
25
|
+
CREATE INDEX IF NOT EXISTS idx_samples_ts ON query_samples (ts);
|
|
26
|
+
|
|
27
|
+
CREATE TABLE IF NOT EXISTS execution_plans (
|
|
28
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
29
|
+
fingerprint TEXT NOT NULL,
|
|
30
|
+
ts TEXT NOT NULL,
|
|
31
|
+
plan_json TEXT,
|
|
32
|
+
cost REAL
|
|
33
|
+
);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_plans_fp_ts ON execution_plans (fingerprint, ts);
|
|
35
|
+
|
|
36
|
+
CREATE TABLE IF NOT EXISTS lock_events (
|
|
37
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
38
|
+
ts TEXT NOT NULL,
|
|
39
|
+
db_instance TEXT,
|
|
40
|
+
blocking_pid INTEGER,
|
|
41
|
+
blocked_pids TEXT,
|
|
42
|
+
lock_mode TEXT,
|
|
43
|
+
duration_s REAL
|
|
44
|
+
);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_locks_ts ON lock_events (ts);
|
|
46
|
+
|
|
47
|
+
CREATE TABLE IF NOT EXISTS session_snapshots (
|
|
48
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
49
|
+
ts TEXT NOT NULL,
|
|
50
|
+
pid INTEGER,
|
|
51
|
+
user TEXT,
|
|
52
|
+
database TEXT,
|
|
53
|
+
state TEXT,
|
|
54
|
+
wait_event_type TEXT,
|
|
55
|
+
wait_event TEXT,
|
|
56
|
+
query_age_s INTEGER,
|
|
57
|
+
query TEXT
|
|
58
|
+
);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_ts ON session_snapshots (ts);
|
|
60
|
+
|
|
61
|
+
CREATE TABLE IF NOT EXISTS table_stats (
|
|
62
|
+
fingerprint TEXT NOT NULL,
|
|
63
|
+
ts TEXT NOT NULL,
|
|
64
|
+
seq_scan INTEGER,
|
|
65
|
+
idx_scan INTEGER,
|
|
66
|
+
live_rows INTEGER,
|
|
67
|
+
dead_rows INTEGER,
|
|
68
|
+
bloat_pct REAL,
|
|
69
|
+
last_autovacuum TEXT,
|
|
70
|
+
PRIMARY KEY (fingerprint, ts)
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
CREATE TABLE IF NOT EXISTS change_history (
|
|
74
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
75
|
+
ts TEXT NOT NULL DEFAULT (datetime('now')),
|
|
76
|
+
change_type TEXT NOT NULL,
|
|
77
|
+
entity TEXT,
|
|
78
|
+
detail TEXT
|
|
79
|
+
);
|
|
80
|
+
"""
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Persistence layer for bonito-store (SQLite).
|
|
2
|
+
|
|
3
|
+
BON-002 deliverables 2-6: ingest (POST /events), dedupe by fingerprint,
|
|
4
|
+
retention pruning and 7-day baselines.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import sqlite3
|
|
10
|
+
import time
|
|
11
|
+
from datetime import UTC, datetime, timedelta
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .normalize import fingerprint_of
|
|
16
|
+
from .schema import SCHEMA_SQL
|
|
17
|
+
|
|
18
|
+
BATCH_LIMIT = 100
|
|
19
|
+
PRUNE_EVERY_S = 3600 # throttle automatic retention pruning
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _now() -> str:
|
|
23
|
+
return datetime.now(UTC).isoformat()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def iso_days_ago(days: int) -> str:
|
|
27
|
+
return (datetime.now(UTC) - timedelta(days=days)).isoformat()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BonitoStore:
|
|
31
|
+
"""SQLite-backed event store. All writes go through fixed SQL."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, db_path: str | Path, retention_days: int = 7) -> None:
|
|
34
|
+
self._path = str(db_path)
|
|
35
|
+
self.retention_days = retention_days
|
|
36
|
+
self._last_prune = 0.0
|
|
37
|
+
if self._path != ":memory:":
|
|
38
|
+
Path(self._path).parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
with self._conn() as conn:
|
|
40
|
+
conn.executescript(SCHEMA_SQL)
|
|
41
|
+
|
|
42
|
+
def _conn(self) -> sqlite3.Connection:
|
|
43
|
+
conn = sqlite3.connect(self._path)
|
|
44
|
+
conn.row_factory = sqlite3.Row
|
|
45
|
+
return conn
|
|
46
|
+
|
|
47
|
+
# ── ingest (deliverable 2) ─────────────────────────────────────────
|
|
48
|
+
def ingest(self, events: dict[str, Any]) -> dict[str, int]:
|
|
49
|
+
"""Persist one collector snapshot. Batch capped at BATCH_LIMIT/type."""
|
|
50
|
+
now = events.get("collected_at") or _now()
|
|
51
|
+
summary = {
|
|
52
|
+
"top_queries": 0,
|
|
53
|
+
"locks": 0,
|
|
54
|
+
"sessions": 0,
|
|
55
|
+
"tables": 0,
|
|
56
|
+
"plans": 0,
|
|
57
|
+
"new_fingerprints": 0,
|
|
58
|
+
}
|
|
59
|
+
with self._conn() as conn:
|
|
60
|
+
for q in events.get("top_queries", [])[:BATCH_LIMIT]:
|
|
61
|
+
self._upsert_query(conn, q, now, summary)
|
|
62
|
+
for lock in events.get("locks", [])[:BATCH_LIMIT]:
|
|
63
|
+
self._insert_lock(conn, lock, now)
|
|
64
|
+
summary["locks"] += 1
|
|
65
|
+
for sess in events.get("sessions", [])[:BATCH_LIMIT]:
|
|
66
|
+
self._insert_session(conn, sess, now)
|
|
67
|
+
summary["sessions"] += 1
|
|
68
|
+
for t in events.get("tables", [])[:BATCH_LIMIT]:
|
|
69
|
+
self._insert_table(conn, t, now)
|
|
70
|
+
summary["tables"] += 1
|
|
71
|
+
for p in events.get("plans", [])[:BATCH_LIMIT]:
|
|
72
|
+
self._insert_plan(conn, p, now)
|
|
73
|
+
summary["plans"] += 1
|
|
74
|
+
self._maybe_prune()
|
|
75
|
+
return summary
|
|
76
|
+
|
|
77
|
+
def _upsert_query(
|
|
78
|
+
self,
|
|
79
|
+
conn: sqlite3.Connection,
|
|
80
|
+
q: dict[str, Any],
|
|
81
|
+
now: str,
|
|
82
|
+
summary: dict[str, int],
|
|
83
|
+
) -> None:
|
|
84
|
+
fingerprint = q.get("fingerprint") or fingerprint_of(q.get("query", ""))
|
|
85
|
+
query_text = (q.get("query") or "")[:4000]
|
|
86
|
+
calls = q.get("calls", 0)
|
|
87
|
+
if conn.execute(
|
|
88
|
+
"SELECT 1 FROM query_texts WHERE fingerprint = ?", (fingerprint,)
|
|
89
|
+
).fetchone() is None:
|
|
90
|
+
conn.execute(
|
|
91
|
+
"INSERT INTO query_texts "
|
|
92
|
+
"(fingerprint, query_text, first_seen, last_seen, calls, avg_ms, max_ms) "
|
|
93
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
94
|
+
(fingerprint, query_text, now, now, calls, q.get("mean_ms"), q.get("max_ms")),
|
|
95
|
+
)
|
|
96
|
+
conn.execute(
|
|
97
|
+
"INSERT INTO change_history (change_type, entity, detail) "
|
|
98
|
+
"VALUES ('fingerprint_new', ?, ?)",
|
|
99
|
+
(fingerprint, query_text[:200]),
|
|
100
|
+
)
|
|
101
|
+
summary["new_fingerprints"] += 1
|
|
102
|
+
else:
|
|
103
|
+
conn.execute(
|
|
104
|
+
"UPDATE query_texts SET last_seen = ?, calls = ?, avg_ms = ?, max_ms = ? "
|
|
105
|
+
"WHERE fingerprint = ?",
|
|
106
|
+
(now, calls, q.get("mean_ms"), q.get("max_ms"), fingerprint),
|
|
107
|
+
)
|
|
108
|
+
conn.execute(
|
|
109
|
+
"INSERT OR REPLACE INTO query_samples (fingerprint, ts, calls, mean_ms, rows) "
|
|
110
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
111
|
+
(fingerprint, now, calls, q.get("mean_ms"), q.get("rows")),
|
|
112
|
+
)
|
|
113
|
+
summary["top_queries"] += 1
|
|
114
|
+
|
|
115
|
+
def _insert_lock(self, conn: sqlite3.Connection, lock: dict[str, Any], now: str) -> None:
|
|
116
|
+
conn.execute(
|
|
117
|
+
"INSERT INTO lock_events "
|
|
118
|
+
"(ts, db_instance, blocking_pid, blocked_pids, lock_mode, duration_s) "
|
|
119
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
120
|
+
(
|
|
121
|
+
now,
|
|
122
|
+
lock.get("database"),
|
|
123
|
+
lock.get("blocking_pid"),
|
|
124
|
+
json.dumps([lock["blocked_pid"]]) if lock.get("blocked_pid") else None,
|
|
125
|
+
lock.get("lock_mode") or lock.get("wait_event"),
|
|
126
|
+
lock.get("duration_s"),
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def _insert_session(
|
|
131
|
+
self, conn: sqlite3.Connection, sess: dict[str, Any], now: str
|
|
132
|
+
) -> None:
|
|
133
|
+
conn.execute(
|
|
134
|
+
"INSERT INTO session_snapshots "
|
|
135
|
+
"(ts, pid, user, database, state, wait_event_type, wait_event, query_age_s, query) "
|
|
136
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
137
|
+
(
|
|
138
|
+
now,
|
|
139
|
+
sess.get("pid"),
|
|
140
|
+
sess.get("user"),
|
|
141
|
+
sess.get("database"),
|
|
142
|
+
sess.get("state"),
|
|
143
|
+
sess.get("wait_event_type"),
|
|
144
|
+
sess.get("wait_event"),
|
|
145
|
+
sess.get("query_age_s"),
|
|
146
|
+
(sess.get("query") or "")[:2000],
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def _insert_table(self, conn: sqlite3.Connection, t: dict[str, Any], now: str) -> None:
|
|
151
|
+
conn.execute(
|
|
152
|
+
"INSERT OR REPLACE INTO table_stats "
|
|
153
|
+
"(fingerprint, ts, seq_scan, idx_scan, live_rows, dead_rows, bloat_pct, last_autovacuum) "
|
|
154
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
155
|
+
(
|
|
156
|
+
f"{t.get('schema')}.{t.get('table')}",
|
|
157
|
+
now,
|
|
158
|
+
t.get("seq_scan"),
|
|
159
|
+
t.get("idx_scan"),
|
|
160
|
+
t.get("live_rows"),
|
|
161
|
+
t.get("dead_rows"),
|
|
162
|
+
t.get("bloat_pct"),
|
|
163
|
+
t.get("last_autovacuum"),
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def _insert_plan(self, conn: sqlite3.Connection, p: dict[str, Any], now: str) -> None:
|
|
168
|
+
plan = p.get("plan")
|
|
169
|
+
conn.execute(
|
|
170
|
+
"INSERT INTO execution_plans (fingerprint, ts, plan_json, cost) "
|
|
171
|
+
"VALUES (?, ?, ?, ?)",
|
|
172
|
+
(
|
|
173
|
+
p.get("fingerprint") or fingerprint_of(p.get("query", "")),
|
|
174
|
+
now,
|
|
175
|
+
json.dumps(plan) if plan is not None else None,
|
|
176
|
+
p.get("cost"),
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# ── baselines (deliverable 5) ──────────────────────────────────────
|
|
181
|
+
def baselines(
|
|
182
|
+
self, fingerprint: str | None = None, days: int | None = None
|
|
183
|
+
) -> list[dict[str, Any]]:
|
|
184
|
+
"""7-day rolling mean + p95 per fingerprint, computed from samples."""
|
|
185
|
+
days = days or self.retention_days
|
|
186
|
+
where = "WHERE ts >= ?"
|
|
187
|
+
params: list[Any] = [iso_days_ago(days)]
|
|
188
|
+
if fingerprint:
|
|
189
|
+
where += " AND fingerprint = ?"
|
|
190
|
+
params.append(fingerprint)
|
|
191
|
+
with self._conn() as conn:
|
|
192
|
+
rows = conn.execute(
|
|
193
|
+
f"SELECT fingerprint, mean_ms FROM query_samples {where} ORDER BY fingerprint",
|
|
194
|
+
params,
|
|
195
|
+
).fetchall()
|
|
196
|
+
grouped: dict[str, list[float]] = {}
|
|
197
|
+
for r in rows:
|
|
198
|
+
if r["mean_ms"] is not None:
|
|
199
|
+
grouped.setdefault(r["fingerprint"], []).append(float(r["mean_ms"]))
|
|
200
|
+
out: list[dict[str, Any]] = []
|
|
201
|
+
for fp, vals in grouped.items():
|
|
202
|
+
vals.sort()
|
|
203
|
+
p95 = vals[int(0.95 * (len(vals) - 1))]
|
|
204
|
+
out.append(
|
|
205
|
+
{
|
|
206
|
+
"fingerprint": fp,
|
|
207
|
+
"samples": len(vals),
|
|
208
|
+
"mean_ms": round(sum(vals) / len(vals), 2),
|
|
209
|
+
"p95_ms": round(p95, 2),
|
|
210
|
+
}
|
|
211
|
+
)
|
|
212
|
+
return out
|
|
213
|
+
|
|
214
|
+
# ── retention pruning (deliverable 4) ──────────────────────────────
|
|
215
|
+
def prune(self, days: int | None = None) -> dict[str, int]:
|
|
216
|
+
days = days or self.retention_days
|
|
217
|
+
cutoff = iso_days_ago(days)
|
|
218
|
+
removed: dict[str, int] = {}
|
|
219
|
+
with self._conn() as conn:
|
|
220
|
+
for table in (
|
|
221
|
+
"query_samples",
|
|
222
|
+
"execution_plans",
|
|
223
|
+
"lock_events",
|
|
224
|
+
"session_snapshots",
|
|
225
|
+
"table_stats",
|
|
226
|
+
):
|
|
227
|
+
cur = conn.execute(f"DELETE FROM {table} WHERE ts < ?", (cutoff,))
|
|
228
|
+
removed[table] = cur.rowcount
|
|
229
|
+
conn.execute(
|
|
230
|
+
"INSERT INTO change_history (change_type, entity, detail) "
|
|
231
|
+
"VALUES ('retention_prune', 'all', ?)",
|
|
232
|
+
(f"pruned older than {cutoff}",),
|
|
233
|
+
)
|
|
234
|
+
self._last_prune = time.time()
|
|
235
|
+
return removed
|
|
236
|
+
|
|
237
|
+
def _maybe_prune(self) -> None:
|
|
238
|
+
if time.time() - self._last_prune > PRUNE_EVERY_S:
|
|
239
|
+
self.prune()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bonito-store
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Bonito deep DB observability store — SQLite persistence for query/plan/lock/session events (BON-002).
|
|
5
|
+
Author: Carlos Cortez
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: fastapi>=0.110
|
|
10
|
+
Requires-Dist: uvicorn>=0.29
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
13
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
14
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
15
|
+
|
|
16
|
+
# bonito-store
|
|
17
|
+
|
|
18
|
+
The persistence layer of Bonito (BON-002). Stores exactly what Prometheus
|
|
19
|
+
can't: **query texts, execution plans, lock events, session snapshots and
|
|
20
|
+
table stats** in a single SQLite file. Receives JSON events from
|
|
21
|
+
`bonito-collector` via `POST /events`.
|
|
22
|
+
|
|
23
|
+
## Quickstart
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uv pip install -e bonito-store
|
|
27
|
+
bonito-store serve --db bonito.db # FastAPI on :8000
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
curl -X POST localhost:8000/events -H 'Content-Type: application/json' \
|
|
32
|
+
-d @examples/events.sample.json
|
|
33
|
+
curl localhost:8000/baselines # 7-day mean + p95 per fingerprint
|
|
34
|
+
bonito-store prune # apply retention
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Config
|
|
38
|
+
|
|
39
|
+
| Variable | Default | Description |
|
|
40
|
+
|----------|---------|-------------|
|
|
41
|
+
| `BONITO_DB` | `bonito.db` | SQLite file path |
|
|
42
|
+
| `BONITO_STORE_PORT` | `8000` | HTTP port |
|
|
43
|
+
| `BONITO_RETENTION_DAYS` | `7` | Retention window (pruned in background) |
|
|
44
|
+
|
|
45
|
+
## Endpoints
|
|
46
|
+
|
|
47
|
+
- `POST /events` — ingest a collector snapshot (batch capped at 100 per type)
|
|
48
|
+
- `POST /plans` — ingest execution plan records (stored as JSON)
|
|
49
|
+
- `GET /baselines?fingerprint=...&days=7` — rolling mean + p95 per query
|
|
50
|
+
- `GET /health`
|
|
51
|
+
|
|
52
|
+
## Design
|
|
53
|
+
|
|
54
|
+
- **Dedupe**: `query_texts` is keyed by `fingerprint` (sha256 of the minified
|
|
55
|
+
query). Same query updates stats instead of creating a new row.
|
|
56
|
+
- **Baselines**: each observation is appended to `query_samples`; baselines are
|
|
57
|
+
computed on the fly over the last N days (mean + p95).
|
|
58
|
+
- **Retention**: `lock_events`, `session_snapshots`, `execution_plans`,
|
|
59
|
+
`query_samples` and `table_stats` are pruned past `BONITO_RETENTION_DAYS`.
|
|
60
|
+
Identity tables (`query_texts`) keep the latest stats forever.
|
|
61
|
+
- **Plans**: stored as raw JSON (`EXPLAIN` output) — never interpolated into
|
|
62
|
+
prompts without truncation (LLM token governance).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/bonito_store/__init__.py
|
|
4
|
+
src/bonito_store/api.py
|
|
5
|
+
src/bonito_store/cli.py
|
|
6
|
+
src/bonito_store/normalize.py
|
|
7
|
+
src/bonito_store/schema.py
|
|
8
|
+
src/bonito_store/store.py
|
|
9
|
+
src/bonito_store.egg-info/PKG-INFO
|
|
10
|
+
src/bonito_store.egg-info/SOURCES.txt
|
|
11
|
+
src/bonito_store.egg-info/dependency_links.txt
|
|
12
|
+
src/bonito_store.egg-info/entry_points.txt
|
|
13
|
+
src/bonito_store.egg-info/requires.txt
|
|
14
|
+
src/bonito_store.egg-info/top_level.txt
|
|
15
|
+
tests/test_store.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bonito_store
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Tests for bonito-store — dedupe, baselines, pruning, batch + API."""
|
|
2
|
+
from fastapi.testclient import TestClient
|
|
3
|
+
|
|
4
|
+
from bonito_store.api import create_app
|
|
5
|
+
from bonito_store.normalize import fingerprint_of, minify_sql
|
|
6
|
+
from bonito_store.store import BonitoStore
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def sample_events() -> dict:
|
|
10
|
+
return {
|
|
11
|
+
"collected_at": "2026-09-04T02:10:00+00:00",
|
|
12
|
+
"top_queries": [
|
|
13
|
+
{
|
|
14
|
+
"fingerprint": "-6842865755026457642",
|
|
15
|
+
"calls": 1284,
|
|
16
|
+
"total_ms": 15932.11,
|
|
17
|
+
"mean_ms": 12.43,
|
|
18
|
+
"max_ms": 841.02,
|
|
19
|
+
"rows": 245680,
|
|
20
|
+
"buffer_hit_ratio": 99.2,
|
|
21
|
+
"query": "SELECT * FROM orders WHERE customer_id = $1",
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"locks": [
|
|
25
|
+
{
|
|
26
|
+
"blocked_pid": 1412,
|
|
27
|
+
"blocked_user": "app",
|
|
28
|
+
"blocked_query": "UPDATE orders SET status='paid' WHERE id=$1",
|
|
29
|
+
"blocking_pid": 1408,
|
|
30
|
+
"blocking_user": "app",
|
|
31
|
+
"blocking_query": "SELECT ... FOR UPDATE",
|
|
32
|
+
"wait_event_type": "Lock",
|
|
33
|
+
"wait_event": "transactionid",
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"sessions": [
|
|
37
|
+
{
|
|
38
|
+
"pid": 1412,
|
|
39
|
+
"user": "app",
|
|
40
|
+
"database": "app",
|
|
41
|
+
"state": "active",
|
|
42
|
+
"wait_event_type": "Lock",
|
|
43
|
+
"wait_event": "transactionid",
|
|
44
|
+
"query_age_s": 42,
|
|
45
|
+
"query": "UPDATE orders SET status='paid' WHERE id=$1",
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"tables": [
|
|
49
|
+
{
|
|
50
|
+
"schema": "public",
|
|
51
|
+
"table": "orders",
|
|
52
|
+
"seq_scan": 1200,
|
|
53
|
+
"idx_scan": 45800,
|
|
54
|
+
"live_rows": 245680,
|
|
55
|
+
"dead_rows": 10241,
|
|
56
|
+
"bloat_pct": 4.2,
|
|
57
|
+
"last_autovacuum": "2026-09-04T00:00:00+00:00",
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_minify_and_fingerprint():
|
|
64
|
+
assert (
|
|
65
|
+
minify_sql(" SELECT *\nFROM users WHERE id = 1 ")
|
|
66
|
+
== "select * from users where id = 1"
|
|
67
|
+
)
|
|
68
|
+
assert fingerprint_of("SELECT 1") == fingerprint_of("select 1")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_ingest_dedupe_by_fingerprint(tmp_path):
|
|
72
|
+
store = BonitoStore(tmp_path / "t.db")
|
|
73
|
+
s1 = store.ingest(sample_events())
|
|
74
|
+
s2 = store.ingest(sample_events())
|
|
75
|
+
assert s1["new_fingerprints"] == 1
|
|
76
|
+
assert s2["new_fingerprints"] == 0
|
|
77
|
+
assert s1["top_queries"] == 1 and s1["locks"] == 1
|
|
78
|
+
assert s1["sessions"] == 1 and s1["tables"] == 1
|
|
79
|
+
with store._conn() as conn:
|
|
80
|
+
rows = conn.execute(
|
|
81
|
+
"SELECT calls, avg_ms FROM query_texts WHERE fingerprint = ?",
|
|
82
|
+
("-6842865755026457642",),
|
|
83
|
+
).fetchall()
|
|
84
|
+
assert len(rows) == 1 # dedupe: one row, stats updated
|
|
85
|
+
assert rows[0]["calls"] == 1284
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_fallback_fingerprint_from_query(tmp_path):
|
|
89
|
+
store = BonitoStore(tmp_path / "t.db")
|
|
90
|
+
events = sample_events()
|
|
91
|
+
del events["top_queries"][0]["fingerprint"]
|
|
92
|
+
events["top_queries"][0]["query"] = " SELECT 1 "
|
|
93
|
+
summary = store.ingest(events)
|
|
94
|
+
assert summary["new_fingerprints"] == 1
|
|
95
|
+
assert fingerprint_of("SELECT 1") in [
|
|
96
|
+
b["fingerprint"] for b in store.baselines()
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_baseline_mean_and_p95(tmp_path):
|
|
101
|
+
store = BonitoStore(tmp_path / "t.db")
|
|
102
|
+
for i in range(10):
|
|
103
|
+
events = sample_events()
|
|
104
|
+
events["collected_at"] = f"2026-09-04T02:{i:02d}:00+00:00"
|
|
105
|
+
store.ingest(events)
|
|
106
|
+
bl = store.baselines()
|
|
107
|
+
assert len(bl) == 1
|
|
108
|
+
assert bl[0]["samples"] == 10
|
|
109
|
+
assert bl[0]["mean_ms"] == 12.43
|
|
110
|
+
assert bl[0]["p95_ms"] == 12.43 # all samples equal
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_prune_removes_old_samples(tmp_path):
|
|
114
|
+
store = BonitoStore(tmp_path / "t.db", retention_days=1)
|
|
115
|
+
with store._conn() as conn:
|
|
116
|
+
conn.execute(
|
|
117
|
+
"INSERT INTO query_samples (fingerprint, ts, calls, mean_ms, rows) "
|
|
118
|
+
"VALUES ('old', '2000-01-01T00:00:00+00:00', 1, 5.0, 1)"
|
|
119
|
+
)
|
|
120
|
+
removed = store.prune()
|
|
121
|
+
assert removed["query_samples"] >= 1
|
|
122
|
+
with store._conn() as conn:
|
|
123
|
+
row = conn.execute(
|
|
124
|
+
"SELECT 1 FROM query_samples WHERE fingerprint = 'old'"
|
|
125
|
+
).fetchone()
|
|
126
|
+
assert row is None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_batch_capped_at_100(tmp_path):
|
|
130
|
+
store = BonitoStore(tmp_path / "t.db")
|
|
131
|
+
events = sample_events()
|
|
132
|
+
events["top_queries"] = [sample_events()["top_queries"][0]] * 150
|
|
133
|
+
summary = store.ingest(events)
|
|
134
|
+
assert summary["top_queries"] == 100 # capped by BATCH_LIMIT
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_api_events_and_baselines(tmp_path):
|
|
138
|
+
app = create_app(str(tmp_path / "api.db"))
|
|
139
|
+
client = TestClient(app)
|
|
140
|
+
r = client.post("/events", json=sample_events())
|
|
141
|
+
assert r.status_code == 200
|
|
142
|
+
assert r.json()["new_fingerprints"] == 1
|
|
143
|
+
bl = client.get("/baselines").json()["baselines"]
|
|
144
|
+
assert bl[0]["fingerprint"] == "-6842865755026457642"
|
|
145
|
+
assert client.get("/health").json() == {"status": "ok"}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_api_plans_endpoint(tmp_path):
|
|
149
|
+
app = create_app(str(tmp_path / "plans.db"))
|
|
150
|
+
client = TestClient(app)
|
|
151
|
+
r = client.post(
|
|
152
|
+
"/plans",
|
|
153
|
+
json={
|
|
154
|
+
"plans": [
|
|
155
|
+
{
|
|
156
|
+
"fingerprint": "abc",
|
|
157
|
+
"plan": {"Plan": {"Node Type": "Seq Scan"}},
|
|
158
|
+
"cost": 42.0,
|
|
159
|
+
}
|
|
160
|
+
]
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
assert r.status_code == 200
|
|
164
|
+
assert r.json()["plans"] == 1
|
|
165
|
+
with app.state.store._conn() as conn:
|
|
166
|
+
row = conn.execute(
|
|
167
|
+
"SELECT plan_json, cost FROM execution_plans WHERE fingerprint = 'abc'"
|
|
168
|
+
).fetchone()
|
|
169
|
+
assert row["cost"] == 42.0
|
|
170
|
+
assert "Seq Scan" in row["plan_json"]
|