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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """bonito-store v0.1.0 — query/plan/lock/session event storage (BON-002)."""
2
+ __version__ = "0.1.0"
@@ -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,2 @@
1
+ [console_scripts]
2
+ bonito-store = bonito_store.cli:main
@@ -0,0 +1,7 @@
1
+ fastapi>=0.110
2
+ uvicorn>=0.29
3
+
4
+ [dev]
5
+ pytest>=8.0
6
+ ruff>=0.6
7
+ httpx>=0.27
@@ -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"]