Repository navigation
feat(status): llm_24h reads the hourly rollup plus the edge hour (W06-5) - #75
Conversation
The status page's llm_24h rows - the server view (queryLLM24h) and the
per-tenant generation (buildTenantRollupInto) - no longer scan 24 hours of
context_llm_log on every tick. They read through two new readers of
internal/llmusage, Groups and TenantGroups: the hourly rollup
context_llm_usage_hourly from the first full UTC hour after now - 24 h,
plus the raw rows of the partial hour before it (llmusage.Bounds), each
side aggregated by backend and pipeline (the tenant view by scope as
well) and added in NUMERIC. The edge applies the rollup trigger's
normalisation, so edge and rollup groups meet in one row.
llm_24h_complete is false exactly when unattributed_ok > 0. The tenant
view keeps the join through context_api_keys and context_tenant_scopes,
so a consumer row (api_key_id NULL) counts for no tenant. The error
behaviour is the same, except that a mid-stream server error now returns
no rows instead of the rows read so far.
What changes on the wire (hence feat, docs updated):
- Rows with as many calls are now ordered by backend, then pipeline. The
raw query ordered by calls alone and left ties to the plan; the base
code returned the golden seed's ties in another order than the
tie-broken reference.
- A backend or pipeline name longer than 100 characters shows cut to
100, the rollup's dimension width. The longest live name has 24.
- The window anchor is the Go clock instead of the database's now().
Both run on the same host.
For names of up to 100 characters the figures are byte-equal.
Gates (red first, then green):
- (a) Existing suites before and after:
- -run 'Status|Events|LLMLog' ./internal/handler/: 52 -> 58 top-level
passes, 0 fail.
- ./internal/llmusage/: 9 passes, 0 fail.
- (b) Golden: the raw-row SQL of 14b9963 (now() -> $1, plus the
tie-break) over a mixed seed at 3 anchors wrote
testdata/llm24h_golden.json (16027 bytes) on the base tree. The base
functions match it apart from tie order. The rollup readers are
byte-equal to it (sha256 6ed75ed3, empty diff).
- Parity property: 300 random seeds, 0 differ.
- Red against the base: compile error (llm24hNow, llm24hSpan,
llmusage.Groups undefined).
- Red against overlays: no edge (golden + 253/300 seeds), truncating
avg_ms (262/300), no tie-break (298/300).
- (c) complete probe: red against a rollup read without unattributed_ok
(oc1, rollup-hour case) and an edge without it (oc2, edge case). A
failed row without backend_name keeps complete true.
- (d) EXPLAIN, custom and generic plan: the raw edge is bounded on both
sides and touches 23 rows (edge 23, 24 h 1079); the rollup is read via
idx_llm_usage_bucket. The base SQL fails the same check (1079 rows,
lower bound only).
- (e) Tenant view without consumer rows: pinned. Red against a
consumer-union overlay.
Suites: build, vet -tags=integration, go test -short -race ./... (all
ok), golangci-lint in both forms (0 issues), deadcode.sh and deadcode.sh
testonly (ok).
Also:
- The promptguard census lists llm24hRowOf's Pipeline: g.Pipeline as a
passthrough.
- llmusage edgeRows uses an exact set (Copilot nit on #74).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. Walkthrough
Merge Risk:
|
There was a problem hiding this comment.
Note
Copilot was unable to complete this review at the selected tier due to model policy or plan restrictions.
🟢 Approval recommended
The change replaces the 24h status aggregation with rollup-plus-edge-hour readers and includes extensive parity/golden/plan integration tests and documentation updates, with no unresolved issues noted beyond a minor performance nit.
No open issues 🎉
What changed in this PR
This pull request updates the /api/status llm_24h aggregation (server-wide and per-tenant) to compute results from the hourly rollup table (context_llm_usage_hourly) plus the raw “edge hour” of context_llm_log, preserving output parity with the legacy 24-hour raw scan while significantly reducing per-tick query cost and making tie ordering deterministic.
Changes:
- Add new rollup-backed readers in
internal/llmusage(Groups,TenantGroups) to aggregate(backend, pipeline)over a 24h window using rollup + bounded raw edge-hour rows. - Switch status collection paths (
queryLLM24hand per-tenant rollup generation) to use the new readers, including anchored time handling for tests. - Add/refresh integration tests (golden/parity/plan/reference) and update docs (
operations,architecture) to reflect the new read path and rollback coupling.
| File | Summary |
|---|---|
go/internal/promptguard/promptguard_callsite_test.go |
Update promptguard callsite census coverage for the status pipeline passthrough. |
go/internal/llmusage/window.go |
Package-level documentation updates for the window/reader functionality. |
go/internal/llmusage/window_integration_test.go |
Adjust integration test helpers for edge-hour row membership/counting. |
go/internal/llmusage/groups.go |
Implement rollup + edge-hour readers for server and tenant grouped usage (note: one nit raised about avoiding per-row allocation in scan destination construction in readGroups). |
go/internal/handler/testdata/llm24h_golden.json |
Add/refresh golden fixture for llm_24h outputs. |
go/internal/handler/status.go |
Switch server llm_24h to llmusage.Groups and add mapping/anchoring support. |
go/internal/handler/status_tenant.go |
Switch per-tenant llm_24h generation to llmusage.TenantGroups. |
go/internal/handler/status_llm24h_ref_integration_test.go |
Add raw SQL reference helpers for parity/golden comparisons. |
go/internal/handler/status_llm24h_plan_integration_test.go |
Add plan assertions to enforce bounded raw-edge reads and rollup index access paths. |
go/internal/handler/status_llm24h_parity_integration_test.go |
Add randomized parity test comparing new readers vs reference queries. |
go/internal/handler/status_llm24h_golden_integration_test.go |
Add golden/completeness/consumer-exclusion/long-name-cut integration tests. |
docs/operations.md |
Document operational implications of status reading rollup + edge hour (including rollback coupling). |
docs/architecture.md |
Update architecture documentation to reflect the new rollup-backed status reader behavior. |
Review effort: Balanced
…r and channel (Migration 160) (#76) Wave W06-6 of the OpenAI-compatible API plan (RC-A, telemetry foundation; design/06 §3.3 M-06-3, masterplan K16). Migration **160**, no new table, no `/v1` surface. The read surface `GET /api/audit` follows in W06-7. ## What this changes - `go/migrations/160_audit_keys_quota.sql`: audit rows in `context_settings_audit` for `context_api_keys` (`create`, `update`, `revoke`, `reactivate`, `delete`) and `context_tenant_quota` (`create`, `update`, `delete`). State is projected through **whitelist** functions `audit_state_api_key` / `audit_state_tenant_quota` (never `to_jsonb(NEW) - …`): `key_hash`, `last_used_at`, `created_at`, `updated_at`, `id` never land in `old_value`/`new_value`. The key UPDATE trigger fires only on the ten audited columns and only when one of them changed, so the per-request `last_used_at` write of authentication never audits (B-06-1). `audit_actor_meta()` reads the transaction-local stamps (`ctx.api_key_id`, `ctx.request_id`, `ctx.audit_via`) and resolves the actor's label, principal, tenant and server-admin flag into `metadata` — the inputs of the W06-7 redaction. Backfill: one `snapshot` row per existing key and quota row with `metadata.tenant_id` (12 rows on the live install), idempotent. Index `idx_settings_audit_entity (entity_type, created_at DESC)`. - `internal/store/audit_via.go`: the `via` vocabulary as Go constants (`api`, `bootstrap`, `provision`, `offboarding`, `migration`; `sql` = no stamp), `AuditStamp`, `stampAudit` (one `set_config(…, true)` statement inside the write transaction). Every API-driven writer of the two tables now stamps actor + request id + via: `MintKeyWithQuota`, `MintOwnerKey` (tenant create, project provision), `DeleteApiKey`, `UpdateApiKey`, `UpsertTenantQuota`, the bootstrap admin key (now in a transaction, `via='bootstrap'`, no actor), tenant offboarding (`via='offboarding'`). A row written by psql reads `via='sql'` with no actor — the signal of a direct intervention. - Classification pin: `audit_classification_test.go` compares the live column list of each audited table against audited ∪ explicitly excluded (as data); a new unclassified column turns it red (B-06-2), per table and per column. - Docs: `docs/security.md` "Audit trail" (what is audited, what never is, `via` values, actor fields), `docs/operations.md` "Migration 160". - Hardening after review (`258e0953`): `stampAudit` refuses a non-empty stamp on a handle that is not a `pgx.Tx` (`store: audit stamp outside a transaction`) before anything is written — on a pool the `set_config(…, true)` would be its own transaction and the write would silently read `via='sql'` without an actor (red-proof: `MintOwnerKey(pool, APIStamp)` created the key with a `via='sql'` row on `7d4adf3d`; now an error and 0 rows). Every production caller passes a transaction. - The private plan submodule pointer is bumped in a separate commit (plan corpus of this build). ## Behaviour changes (intended) - Key and quota writes now leave one audit row each; the audit table grows with the lifecycle of keys and quotas (rare), never with requests. - The bootstrap admin key is inserted inside a transaction (was a single statement). - `UpsertTenantQuota` is stamped as well (otherwise the quota row would carry no `request_id`). ## Deviations from design/06 §3.3, with reasons - `via` falls back to `api` only when an actor was stamped, else `sql` (the design's `COALESCE` would read a psql quota update as `api`). - Quota trigger: the `updated_by` fallback actor applies on INSERT or when `updated_by` changed; an UPDATE that changes no audited state writes no row (no empty rows from `ON DELETE SET NULL`). - Quota `entity_key` is the `scope` (the row's natural key). - The index is created before the triggers (shorter lock window, reviewer finding). - Two existing tenant-create tests used `"non-admin-key-id"` as an actor id; the stamp now casts it to `uuid`, so the fixtures use a UUID (production actor ids are always UUIDs). ## Consumers of the changed contracts | Changed symbol / object | Callers / readers | Covered by | |---|---|---| | `MintKeyWithQuota`, `MintOwnerKey`, `DeleteApiKey`, `UpdateApiKey`, `UpsertTenantQuota` (new `AuditStamp` parameter) | `handler/context_manage.go`, `tenant_manage.go`, `quota_manage.go`, `project_provision.go`, `store/provision.go`, `store/tenant.go`, `bootstrap_admin_key.go` | `api_keys_audit_integration_test.go` (store, 8 gates; handler, 4 gates), `TestProvisionAuditVia`, bootstrap/prune suites | | `context_settings_audit` rows for `api_key` / `tenant_quota` | W06-7 reader (next wave), `/api/settings/{key}` audit (unchanged: filters `entity_type='setting'`) | settings suites unchanged | | schema-contract manifest (+ functions, triggers, index) | `/api/contract`, `TestCheck_FreshDB_NoDrift` | red without the 160 entries | ## Overlap search Open PRs #55/#57/#63/#67 on 2026-11-03: no shared files. Based on #75 (`b4d430cb`); root unchanged since. ## Cost Boot: catalog work plus the backfill (12 rows live); `CREATE TRIGGER` takes SHARE ROW EXCLUSIVE on `context_api_keys` briefly (lock_timeout 3 s). Per key/quota write: one `audit_actor_meta` lookup (one indexed `SELECT` on `context_api_keys`) and one audit INSERT. Per authentication: nothing — the `last_used_at` UPDATE is outside the trigger's column list (gate: 6 authentications + 2 no-op updates ⇒ 0 rows). ## Verification Gates, each red on `b4d430cb` first: store gates 8 FAIL → 8 PASS, handler gates 4 FAIL → 4 PASS, `via` pin compile error → PASS. Negative 1 (trigger without the column list): 8 audit rows for 6 authentications + 2 no-op updates → 0. Negative 2 (blacklist `to_jsonb(k) - 'key_hash'`): `last_used_at`, `created_at`, `updated_at`, `id` leak into old/new → only the ten whitelisted columns. Negative 3 (probe column `secret_probe`): classification pin red → green. Negative 4 (`MintOwnerKey` without stamp): `via='sql'`, no actor → `via='api'` with actor and `request_id`; with all stamps disabled every writer gate was red, bootstrap, provision and offboarding included. Backfill: N keys ⇒ N `snapshot` rows with `tenant_id`, second run 0 rows and byte-identical catalog. Suites on the head: `go build ./...`, `go vet -tags=integration ./...`, `golangci-lint run` with and without `--build-tags=integration` (0), `go test -short -race ./...` (66 ok), `deadcode.sh` + `deadcode.sh testonly`, integration `-p 1`: `internal/store` (330), `internal/handler` (138 in the manage/tenant/key/audit/settings/provision selection), `internal/auth` (14), `cmd/ctxd` (51), `internal/schemacontract` (44). Independent reviews: one during the build (0 blocking, 2 should-fix applied — docs downgrade sentence, per-column pin — 6 notes) and one briefed from the main context after it (findings folded in below where they changed the diff). ## Not verified - The settings trigger writes shadow rows with `via='api'` during offboarding and `PruneTenant` leaves quota rows in place — both pre-existing, not touched here. - `metadata.tenant_id` has no index yet; the W06-7 tenant filter runs on `idx_settings_audit_entity` plus a JSON predicate (fine at today's row counts, to be measured at 1 M+ rows). 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **New Features** * Added audit trails for API key and tenant quota creation, updates, and deletion, with relevant actor and request details. * Provisioning and offboarding actions are also attributed in audit records. Unchanged updates and authentication-only activity are excluded. * Existing keys and quotas without audit history receive an initial snapshot. * **Documentation** * Added guidance on audit coverage, recorded details, attribution, and inspecting audit records. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
Wave W06-5 of the OpenAI-compatible API plan (RC-A, telemetry foundation; design/06 §4.7). No migration, no
/v1surface.What this changes
/api/statusllm_24h(server view,queryLLM24h) and the per-tenantllm_24hgeneration (buildTenantRollupInto) read the hourly rollupcontext_llm_usage_hourlyfrom the first full UTC hour afternow − 24 hplus the rawcontext_llm_logrows of that first, partial hour, through two new readers ininternal/llmusage(Groups,TenantGroups), instead of scanning 24 h of raw rows per tick. The tenant reader joins both parts throughcontext_api_keys→context_tenant_scopesexactly as the raw query did; a consumer's row (api_key_idNULL) counts for no tenant.llm_24h_completecomes from the rollup'sunattributed_ok(successful rows without a backend) plus the same count over the edge rows: a failed row without a backend keepscompletetrue, as before.docs/operations.md(rollup readers now quota + status; rollback coupling),docs/architecture.md.Behaviour changes (intended)
callsare now ordered bybackend, thenpipeline(the raw query ordered bycallsalone and left ties to the plan; live has 16/16 ties).now()(same host in production; tests pin the anchor on the boundaries).complete=falseinstead of the rows read so far plusfalse.Consumers of the changed contracts
queryLLM24h,buildTenantRollupInto(same signatures, new source)assemble),SnapshotForTenant, SSE/api/eventsTestStatusLLM24hGolden(byte-equal to the base),TestStatusLLM24hParity(300 random seeds vs the raw SQL),TestStatusLLM24hPlan,TestStatusPerTenantView,TestStatusGoldenKeys,TestStatusEventCarriesEveryDualPathField, 58 handler top-level testsllmusage.Groups,TenantGroups(new)internal/llmusagesuite (9/9), the handler suitespromptguardcall-site censusinternal/handler:g.Pipelinepassthrough (a copied string, no prompt)promptguard_callsite_test.goOverlap search
Open PRs #55/#57/#63/#67 on 2026-11-03: no shared files. Based on #74 (
14b99639); root unchanged since.Cost
Per status tick: the edge is at most one hour of raw rows bounded on both sides (23 rows in the plan test vs 1 079 for the raw 24 h scan), the rest through
idx_llm_usage_bucket; pinned under the custom and the forced-generic plan. Tenant view: the same two parts joined to the key and scope tables.Verification
Gates, each red on
14b99639first: (a) existing suites green before and after (handler 52 → 58, llmusage 9/9); (b) golden: a 16 027-byte JSON of both views generated on the base tree from the base SQL (onlynow()→$1and the tie-break added; the base functions match it up to tie order), new code byte-equal (same sha256, empty diff); red on base by compile error, red against overlays: no edge hour 253/300 seeds, truncated mean 262/300, no tie-break 298/300; (c)completeprobe: red when the rollup read omitsunattributed_okand when the edge does not count it; an error row without a backend keepscompletetrue; (d)EXPLAINcustom + generic: 23 raw rows (exactly the edge) vs 1 079, rollup viaidx_llm_usage_bucket; the base SQL fails the same check; (e) consumer rows absent from the tenant view — pinned, red against an overlay that credits them to a tenant.Suites on the head:
go build ./...,go vet -tags=integration ./...,golangci-lint runwith and without--build-tags=integration(0),go test -short -race ./...,deadcode.sh+deadcode.sh testonly, integration-p 1forinternal/llmusageand-run 'Status|Events|LLMLog|Quota'forinternal/handler+internal/backends. Independent review by a fresh agent during the build: 1 minor (the 100-character cut — documented and pinned), 4 nits (3 fixed, the error-path change documented above).Not verified
🤖 Generated with Claude Code
Summary by CodeRabbit