feat(usage): record per-tenant usage events into the app DB

Deck #67 data-plane slice: tenant Pods record billable operations
(embedding queries, pages/chunks embedded) into an app-DB usage_events
table that the control plane later pulls read-only into the billing
ledger and syncs to Stripe Meter Events.

- migration 007: usage_events table (Postgres TIMESTAMPTZ/JSONB/UUID
  with portable SQLite fallbacks), indexed (occurred_at, metric) for the
  CP rollup's per-day range scan + GROUP BY metric.
- UsageEventStore: best-effort, flag-gated writer reusing the shared
  RefreshTokenStorage engine; ON CONFLICT (event_id) DO NOTHING for
  idempotent retries; dialect-branched occurred_at bind. All work
  (incl. metadata JSON encode) is swallowed so a metering failure never
  surfaces to the user op.
- USAGE_METERING_ENABLED flag (default off) wired through Settings +
  env map; off-path touches no storage, so OSS self-hosters get an empty
  table and zero write overhead.
- two recording hooks: embeddings_queries (per nc_semantic_search, which
  nc_semantic_search_answer reuses) and pages_chunks (after dense
  embedding succeeds, covering both in-process and procrastinate paths).
- storage.acquire()/.dialect public seams so the sibling store doesn't
  reach into the underscored internal.
- tests parametrized over SQLite + Postgres: flag-off no-op, roundtrip,
  ON CONFLICT dedup, JSON/NULL metadata, and the best-effort swallow of
  both DB errors and unserializable metadata.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-07 15:13:14 +02:00
co-authored by Claude Opus 4.8
parent 42505f8f87
commit 1c6b1a84ea
9 changed files with 491 additions and 1 deletions
+16 -1
View File
@@ -39,7 +39,7 @@ import os
import socket
import sqlite3
import time
from contextlib import asynccontextmanager
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from pathlib import Path
from typing import Any
@@ -666,6 +666,21 @@ class RefreshTokenStorage:
async with self.engine.connect() as conn:
yield _DBConn(conn)
def acquire(self) -> AbstractAsyncContextManager["_DBConn"]:
"""Public alias for :meth:`_db`: a backend-agnostic connection cm.
Lets sibling stores (e.g. :class:`UsageEventStore`) reuse this
instance's engine, NullPool, and ``_DBConn`` shim without reaching
into the underscored internal. Use as ``async with storage.acquire()
as db:``.
"""
return self._db()
@property
def dialect(self) -> str:
"""Backend dialect name ("sqlite" / "postgresql"), or "unknown" pre-init."""
return self._dialect
async def store_refresh_token(
self,
user_id: str,