ADR-029: Usage Analytics Dashboard
- Status
-
Accepted
- Date
-
2026-06-01
- Authors
-
Netresearch DTT GmbH
Context
tx_ has recorded request counts and token totals
per service type and provider since day one, and the per-request cost
column (estimated_) existed from the start. The plumbing to fill
it never did: Usage always passed a null cost,
Model:: had zero callers, and so every row carried
estimated_. The downstream effect was visible — the
AI cost this month dashboard widget (see ADR-024: Dashboard Widgets) summed a column
that was structurally always zero and showed $0 regardless of real
spend.
The table also had no model dimension. Usage could be sliced by provider
and service type, but not by the specific model that produced it, so a
gpt- call and a gpt- call against the same provider were
indistinguishable in the data — even though their pricing differs by an
order of magnitude.
Reporting itself was thin. The only at-a-glance surfaces were the two global dashboard widgets from ADR-024: Dashboard Widgets; there was no dedicated view that combined cost trends, model-level breakdowns, and per-user consumption. With usage now flowing through the middleware pipeline (ADR-026: Provider Middleware Pipeline), there is a single, well-defined place to compute cost as a side effect of every productive provider call.
Decision
Ship a read-only usage analytics module backed by a richer usage table and real cost computation:
- Schema. Add
model_,uid model_,id prompt_, andtokens completion_totokens tx_. Daily granularity is kept — rows still aggregate per day — andnrllm_ service_ usage model_joins the aggregation key (alongsideuid service_,type service_, andprovider request_) so model-level usage rolls up without a second write per request.date - Cost computation.
Usagenow derivesMiddleware estimated_from the configuration'scost Modelpricing viaModel::, using the prompt/completion token split recorded on the usage object. Pricing is stored as cents-per-1M tokens; the estimate is the per-side token count times its rate. When a caller already supplies a cost it is preserved; otherwise the model-derived value is recorded. This fixes the long-standing always-zero-cost defect.estimate Cost () - Read layer. Add
Usage, a read-only reporting service over the usage table. It exposes KPI totals (Analytics Service get), a daily cost/requests trend with filled gaps (Kpi Totals get), breakdowns by provider, model, and service (Daily Trend get/Breakdown By Provider get/Breakdown By Model get), and per-user usage with this-month budget consumption (Breakdown By Service get). A smallPer User Usage Analyticsvalue object normalizes the date-range presetsPeriod 7d/30d/90d/monthand defaults unknown values to30d. - Backend submodule. Register
nrllm_as an admin-only child of the main LLM module (Admin Tools > LLM > Analytics), driven byanalytics Analyticsand a Fluid template: KPI tiles, a cost-plus-requests trend line, provider / model / service breakdown bar charts, and a per-user table with monthly-budget bars. The active range is a plainController ?range=GET parameter — the page is a full reload with no AJAX. Charts render with Chart.js (vendored underResources/).Public/ Java Script/ Vendor/ - Demo data. Ship a dev-only
ddev seed-generator that populates roughly 90 days of realistic historic usage so the module and widgets have something to show during local development.usage
Consequences
Positive:
- ●● Real cost reporting.
estimated_reflects actual model pricing, so the AI cost this month widget (ADR-024: Dashboard Widgets) and the new module both show real figures instead ofcost $0. - ● Model-level breakdowns. The added
model_/uid model_columns let usage and cost be sliced per model, not just per provider.id - ◐ A single dedicated reporting surface combines trend, breakdowns, and per-user consumption that previously had no home.
Negative:
- ◑ One extra write column-set per request (
model_,uid model_,id prompt_,tokens completion_). Negligible — the row was already being written; this widens it, it does not add a second write.tokens - ✕ Specialized-service cost and streaming usage are out of scope for v1
and documented as such. DALL·E / TTS / Whisper / DeepL still record
requests and units but their cost stays
0(no token-based pricing model yet), and streaming responses are skipped by the usage middleware because chunked output has no single terminal token count to price. - ◑ No backfill of pre-migration rows. Rows written before the schema
change keep
model_anduid = 0 estimated_; analytics only reflect cost from the migration forward.cost = 0
Net Score: +3 (Positive)
Alternatives considered
- Per-request (non-aggregated) rows to enable arbitrary slicing.
Rejected — daily aggregation keyed on
service_keeps the table small and the existing widget queries fast; the model dimension is the only slice that was actually missing.type / service_ provider / request_ date / model_ uid - Compute cost lazily in the read layer from stored token counts and
current model pricing. Rejected — pricing drifts over time, so cost must
be captured at call time against the pricing in effect then. Storing
estimated_at write time is the durable record.cost - A third dashboard widget instead of a dedicated module. Rejected — the dashboard widget shapes (ADR-024: Dashboard Widgets) cannot host a trend line, multiple breakdown charts, and a per-user table together; those belong in a full module view.