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
+10
View File
@@ -244,6 +244,11 @@ _DEFAULTS: dict[str, Any] = {
# this before a real ACL backfill would silently drop legacy results.
# verify-on-read remains the correctness backstop regardless.
"acl_prefilter_enabled": False,
# Usage metering (Deck #67, control-plane usage-metering.md). OFF by
# default so OSS self-hosters don't accrue a metering table or write
# overhead; Astrolabe Cloud provisioning sets it true. When on, billable
# ops record rows into the app-DB usage_events table (best-effort).
"usage_metering_enabled": False,
}
@@ -829,6 +834,10 @@ class Settings:
embedding_gateway_scope: str | None = None
tenant_id: str | None = None # per-tenant identity (UUID form)
acl_prefilter_enabled: bool = False # query-side ACL pre-filter (§11); OFF
# Usage metering (Deck #67); OFF by default. When true, billable ops
# record best-effort rows into the app-DB usage_events table for the
# control plane to pull. See nextcloud_mcp_server/usage/store.py.
usage_metering_enabled: bool = False
def __post_init__(self):
"""Validate configuration and set defaults."""
@@ -1441,6 +1450,7 @@ def get_settings() -> Settings:
"embedding_gateway_scope": "EMBEDDING_GATEWAY_SCOPE",
"tenant_id": "TENANT_ID",
"acl_prefilter_enabled": "ACL_PREFILTER_ENABLED",
"usage_metering_enabled": "USAGE_METERING_ENABLED",
}
# Only pass values that dynaconf actually has; omit unset keys so