From 5f7d6da53dbefc4959c7337c15154c22816a9b6f Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 10 May 2026 12:48:02 +0200 Subject: [PATCH] feat(PBI-78): cost-analysis data layer (T-902) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New lib/insights/cost-analysis.ts with 5 query functions: getCostKpi, getCostByDay, getCostByModel, getCostByKind, getCacheEfficiency - Period type: 7d | 30d | 90d | mtd - Cost formula reused from token-stats.ts (input + output + cache + thinking) - Cache savings: cache_read_tokens × (input_price - cache_read_price) / 1M - Plan: docs/plans/PBI-78-cost-analysis-widget.md Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/INDEX.md | 1 + docs/plans/PBI-78-cost-analysis-widget.md | 186 ++++++++++++++ lib/insights/cost-analysis.ts | 291 ++++++++++++++++++++++ 3 files changed, 478 insertions(+) create mode 100644 docs/plans/PBI-78-cost-analysis-widget.md create mode 100644 lib/insights/cost-analysis.ts diff --git a/docs/INDEX.md b/docs/INDEX.md index ef5e696..ab4e4d8 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -54,6 +54,7 @@ Auto-generated on 2026-05-10 from front-matter and headings. | [M9 — Actief Product Backlog](./plans/M9-active-product-backlog.md) | active | 2026-05-03 | | [PBI-11 — Mobile-shell met landscape-lock (settings + backlog + solo)](./plans/PBI-11-mobile-shell.md) | — | — | | [PBI-75 — Sprint task-edit client-side via workspace-store](./plans/PBI-75-sprint-task-edit-store.md) | — | — | +| [PBI-78 — Cost-analyse widget op Insights-pagina](./plans/PBI-78-cost-analysis-widget.md) | — | — | | [Queue-loop verplaatsen van Claude naar runner](./plans/queue-loop-extraction.md) | — | — | | [Advies - SprintRun, PR en worktree lifecycle als state machines](./plans/sprint-pr-worktree-state-machines.md) | draft | 2026-05-06 | | [ST-1109 — PBI krijgt een status (Ready / Blocked / Done)](./plans/ST-1109-pbi-status.md) | active | 2026-05-03 | diff --git a/docs/plans/PBI-78-cost-analysis-widget.md b/docs/plans/PBI-78-cost-analysis-widget.md new file mode 100644 index 0000000..4847656 --- /dev/null +++ b/docs/plans/PBI-78-cost-analysis-widget.md @@ -0,0 +1,186 @@ +# PBI-78 — Cost-analyse widget op Insights-pagina + +## Context + +De insights-pagina heeft al een `TokenUsageCard` die KPI's + per-job tabel toont, maar **alleen voor de actieve sprint van een gefilterd product**. Daardoor mis je het globale plaatje: hoeveel geef je deze maand uit, welk model is de grootste kostenpost, hoe goed werkt prompt-caching, en welke job-kinds (IDEA_MAKE_PLAN met Opus vs TASK_IMPLEMENTATION met Sonnet) trekken het budget. + +We voegen een nieuwe sectie **"Cost analyse"** toe (tussen Sprint Health en Plan-quality). Eén shared periode-selector (7d/30d/90d/MTD) stuurt vier visualisaties aan op basis van best practices uit de Anthropic Console + LLM-observability tools (Datadog, Portkey): + +1. Trend-chart over tijd +2. Breakdown per model +3. Breakdown per job-kind +4. Cache efficiency + +De bestaande `TokenUsageCard` blijft staan als sprint-detail (top duurste jobs voor de actieve sprint). + +## Bestaande infrastructuur (hergebruik) + +**Reeds aanwezig in DB:** + +- [prisma/schema.prisma](../../prisma/schema.prisma) — `ClaudeJob` heeft `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `actual_thinking_tokens`, `model_id`, `kind`, `finished_at` +- `ModelPrice` tabel met prijzen per 1M tokens (input/output/cache_read/cache_write) +- Prijzen worden gesynced via [scripts/sync-model-prices.ts](../../scripts/sync-model-prices.ts) + +**Hergebruikbare patronen:** + +- KPI-strip stijl: zie [app/(app)/insights/components/token-usage.tsx](../../app/(app)/insights/components/token-usage.tsx) (regels 43-64) +- URL-param-gestuurde filter met `useTransition` + `router.replace`: zie [app/(app)/insights/components/agent-throughput.tsx](../../app/(app)/insights/components/agent-throughput.tsx) (regels 38-62) +- Recharts BarChart pattern: zie [app/(app)/insights/components/agent-throughput.tsx](../../app/(app)/insights/components/agent-throughput.tsx) (regels 110-130) +- Cost-formule (zelfde overal): zie [lib/insights/token-stats.ts](../../lib/insights/token-stats.ts) (regels 73-79) — input + output + cache_read + cache_write + thinking, allemaal `× price_per_1m / 1_000_000` +- Server component → parallel data-fetch via `Promise.all`: zie [app/(app)/insights/page.tsx](../../app/(app)/insights/page.tsx) (regels 46-80) + +## Te bouwen + +### Taak 1 — Data-laag — `lib/insights/cost-analysis.ts` (nieuw) + +Eén bestand met vijf functies, allemaal `(userId, period)` als parameters. Period wordt naar `WHERE cj.finished_at >= NOW() - INTERVAL ' days'` vertaald (MTD = `>= date_trunc('month', NOW())`). + +```ts +export type Period = '7d' | '30d' | '90d' | 'mtd' + +export interface CostKpi { + totalCostUsd: number + totalTokens: number + jobCount: number + avgPerDayUsd: number + cacheSavingsUsd: number // (input_price - cache_read_price) × cache_read_tokens + topModelId: string | null + topModelCostUsd: number +} + +export interface CostByDayRow { day: string; costUsd: number } +export interface CostByModelRow { modelId: string; costUsd: number; jobCount: number } +export interface CostByKindRow { kind: string; costUsd: number; jobCount: number } +export interface CacheEfficiency { + cacheReadTokens: number + uncachedInputTokens: number + cacheHitRatio: number // cache_read / (cache_read + input) + savingsUsd: number + spentOnCacheWriteUsd: number +} + +export async function getCostKpi(userId: string, period: Period): Promise +export async function getCostByDay(userId: string, period: Period): Promise +export async function getCostByModel(userId: string, period: Period): Promise +export async function getCostByKind(userId: string, period: Period): Promise +export async function getCacheEfficiency(userId: string, period: Period): Promise +``` + +**Belangrijke details:** + +- Alle queries: `WHERE cj.user_id = ${userId} AND cj.status = 'DONE' AND cj.finished_at >= ` +- Geen `productAccessFilter` nodig — `cj.user_id = ${userId}` filtert al op de eigenaar +- `getCostByDay` vult ontbrekende dagen op met `0` (anders breekt de chart-x-as) — vul aan client- of server-side, kies één +- Periode → days mapping inline: `7d`→7, `30d`→30, `90d`→90, `mtd`→huidige dag-van-maand +- Cache savings: `cache_read_tokens × (input_price - cache_read_price) / 1_000_000` — "wat je betaald zou hebben zonder cache, minus wat je betaalde mét cache" + +### Taak 2 — UI — `app/(app)/insights/components/cost-analysis.tsx` (nieuw) + +Eén client-component die de hele sectie rendert. Structuur: + +``` +[Period selector rechtsboven] +[KPI strip: Totaal | Cache savings | Avg/dag | Top model ($X op claude-opus-4-7)] +[grid grid-cols-1 md:grid-cols-2 gap-4] + [Daily cost line/bar chart] [Model breakdown - horizontal bar of donut] + [Job-kind breakdown - bar] [Cache efficiency - donut + label "X% hit, $Y bespaard"] +``` + +**Period selector:** kopieer pattern uit [agent-throughput.tsx](../../app/(app)/insights/components/agent-throughput.tsx) (regels 50-61) — `useTransition` + `router.replace` met `?period=` in URL. Default tonen als "30d". + +**Charts:** Recharts (al gebruikt in `BurndownChart`, `AgentThroughputCard`, `VelocityChart`): + +- Daily: `` met één bar (cost in USD), x-as = dag (`MM-DD`), tooltip toont `$X.XXXX` +- Model: `` met model_id labels — beperkt tot top 5 +- Kind: `` met kind labels — beperkt tot top 5 +- Cache: `` met twee segmenten (cached / uncached input) + tekst "X% cache hit · $Y bespaard" + +**Empty state:** als `kpi.jobCount === 0`: render één regel "Geen jobs in deze periode." + +### Taak 3 — Integratie — `app/(app)/insights/page.tsx` (edit) + +Wijzigingen: + +```diff + interface InsightsPageProps { +- searchParams: Promise<{ product?: string }> ++ searchParams: Promise<{ product?: string; period?: string }> + } +``` + +```diff +- const { product: filterProductId } = await searchParams ++ const { product: filterProductId, period: rawPeriod } = await searchParams ++ const period = (['7d','30d','90d','mtd'].includes(rawPeriod ?? '') ? rawPeriod : '30d') as Period +``` + +In de `Promise.all`, voeg toe: + +```ts +getCostKpi(userId, period), +getCostByDay(userId, period), +getCostByModel(userId, period), +getCostByKind(userId, period), +getCacheEfficiency(userId, period), +``` + +Nieuwe sectie tussen Sprint Health en Plan-quality: + +```tsx +
+

Cost analyse

+ +
+``` + +De bestaande "Token gebruik" sectie blijft staan (sprint-detail tabel). + +## Bestanden + +**Nieuw:** + +- `lib/insights/cost-analysis.ts` — 5 query-functies + types +- `app/(app)/insights/components/cost-analysis.tsx` — client-component met period-selector + 4 charts + +**Edit:** + +- `app/(app)/insights/page.tsx` — period uit searchParams, parallel-fetch, nieuwe sectie + +**Geen wijzigingen aan:** + +- Prisma schema (alle data is er al) +- MCP server (token-data wordt al weggeschreven via `update_job_status`) +- `TokenUsageCard` (blijft als sprint-detail tabel) + +## Verificatie + +```bash +npm run verify && npm run build +``` + +**Handmatig:** + +1. Open `/insights` zonder query — period default `30d`, sectie toont KPI + 4 charts +2. Wissel period via selector → URL updatet `?period=7d`, charts laden nieuwe data via `router.replace` +3. Check empty state: kies periode zonder jobs → "Geen jobs in deze periode." +4. Sanity-check KPI's tegen ruwe DB-query: + ```sql + SELECT SUM(input_tokens * mp.input_price_per_1m / 1e6 + + output_tokens * mp.output_price_per_1m / 1e6 + + cache_read_tokens * mp.cache_read_price_per_1m / 1e6 + + cache_write_tokens * mp.cache_write_price_per_1m / 1e6 + + COALESCE(actual_thinking_tokens, 0) * mp.input_price_per_1m / 1e6) + FROM claude_jobs cj + LEFT JOIN model_prices mp ON mp.model_id = cj.model_id + WHERE cj.user_id = '' AND cj.status = 'DONE' + AND cj.finished_at >= NOW() - INTERVAL '30 days'; + ``` +5. Cache savings sanity: `cacheSavingsUsd ≈ cache_read_tokens × 0.9 × input_price / 1M` + (cache_read prijs = 0.1× input prijs, dus savings is 90%) diff --git a/lib/insights/cost-analysis.ts b/lib/insights/cost-analysis.ts new file mode 100644 index 0000000..f486b4e --- /dev/null +++ b/lib/insights/cost-analysis.ts @@ -0,0 +1,291 @@ +import { prisma } from '@/lib/prisma' + +export type Period = '7d' | '30d' | '90d' | 'mtd' + +export interface CostKpi { + totalCostUsd: number + totalTokens: number + jobCount: number + avgPerDayUsd: number + cacheSavingsUsd: number + topModelId: string | null + topModelCostUsd: number +} + +export interface CostByDayRow { + day: string + costUsd: number +} + +export interface CostByModelRow { + modelId: string + costUsd: number + jobCount: number +} + +export interface CostByKindRow { + kind: string + costUsd: number + jobCount: number +} + +export interface CacheEfficiency { + cacheReadTokens: number + uncachedInputTokens: number + cacheHitRatio: number + savingsUsd: number + spentOnCacheWriteUsd: number +} + +function periodToDays(period: Period, now: Date = new Date()): number { + switch (period) { + case '7d': + return 7 + case '30d': + return 30 + case '90d': + return 90 + case 'mtd': + return now.getUTCDate() + } +} + +function periodStart(period: Period, now: Date = new Date()): Date { + if (period === 'mtd') { + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)) + } + const days = periodToDays(period, now) + return new Date(now.getTime() - days * 86_400_000) +} + +type RawKpiRow = { + total_cost: number | null + total_tokens: bigint + job_count: bigint + cache_savings: number | null +} + +type RawTopModelRow = { + model_id: string | null + cost: number | null +} + +export async function getCostKpi(userId: string, period: Period): Promise { + const start = periodStart(period) + const days = Math.max(periodToDays(period), 1) + + const [kpiRows, topModelRows] = await Promise.all([ + prisma.$queryRaw` + SELECT + SUM( + cj.input_tokens * mp.input_price_per_1m / 1000000.0 + + cj.output_tokens * mp.output_price_per_1m / 1000000.0 + + cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0 + + cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0 + + COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0 + ) FILTER (WHERE cj.input_tokens IS NOT NULL) AS total_cost, + COALESCE(SUM( + cj.input_tokens + cj.output_tokens + cj.cache_read_tokens + cj.cache_write_tokens + + COALESCE(cj.actual_thinking_tokens, 0) + ), 0) AS total_tokens, + COUNT(*) FILTER (WHERE cj.input_tokens IS NOT NULL) AS job_count, + SUM( + cj.cache_read_tokens * (mp.input_price_per_1m - mp.cache_read_price_per_1m) / 1000000.0 + ) FILTER (WHERE cj.cache_read_tokens IS NOT NULL) AS cache_savings + FROM claude_jobs cj + LEFT JOIN model_prices mp ON mp.model_id = cj.model_id + WHERE cj.user_id = ${userId} + AND cj.status = 'DONE' + AND cj.finished_at >= ${start} + `, + prisma.$queryRaw` + SELECT + cj.model_id, + SUM( + cj.input_tokens * mp.input_price_per_1m / 1000000.0 + + cj.output_tokens * mp.output_price_per_1m / 1000000.0 + + cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0 + + cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0 + + COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0 + ) AS cost + FROM claude_jobs cj + LEFT JOIN model_prices mp ON mp.model_id = cj.model_id + WHERE cj.user_id = ${userId} + AND cj.status = 'DONE' + AND cj.finished_at >= ${start} + AND cj.input_tokens IS NOT NULL + AND cj.model_id IS NOT NULL + GROUP BY cj.model_id + ORDER BY cost DESC NULLS LAST + LIMIT 1 + `, + ]) + + const kpi = kpiRows[0] + const totalCost = Number(kpi?.total_cost ?? 0) + const top = topModelRows[0] + + return { + totalCostUsd: totalCost, + totalTokens: Number(kpi?.total_tokens ?? 0), + jobCount: Number(kpi?.job_count ?? 0), + avgPerDayUsd: totalCost / days, + cacheSavingsUsd: Number(kpi?.cache_savings ?? 0), + topModelId: top?.model_id ?? null, + topModelCostUsd: Number(top?.cost ?? 0), + } +} + +type RawDayRow = { + day: Date + cost: number | null +} + +export async function getCostByDay(userId: string, period: Period): Promise { + const start = periodStart(period) + + const rows = await prisma.$queryRaw` + SELECT + DATE(cj.finished_at) AS day, + SUM( + cj.input_tokens * mp.input_price_per_1m / 1000000.0 + + cj.output_tokens * mp.output_price_per_1m / 1000000.0 + + cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0 + + cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0 + + COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0 + ) FILTER (WHERE cj.input_tokens IS NOT NULL) AS cost + FROM claude_jobs cj + LEFT JOIN model_prices mp ON mp.model_id = cj.model_id + WHERE cj.user_id = ${userId} + AND cj.status = 'DONE' + AND cj.finished_at >= ${start} + GROUP BY DATE(cj.finished_at) + ORDER BY day ASC + ` + + return rows.map(r => ({ + day: r.day.toISOString().slice(0, 10), + costUsd: Number(r.cost ?? 0), + })) +} + +type RawModelRow = { + model_id: string + cost: number | null + job_count: bigint +} + +export async function getCostByModel(userId: string, period: Period): Promise { + const start = periodStart(period) + + const rows = await prisma.$queryRaw` + SELECT + cj.model_id, + SUM( + cj.input_tokens * mp.input_price_per_1m / 1000000.0 + + cj.output_tokens * mp.output_price_per_1m / 1000000.0 + + cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0 + + cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0 + + COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0 + ) AS cost, + COUNT(*) AS job_count + FROM claude_jobs cj + LEFT JOIN model_prices mp ON mp.model_id = cj.model_id + WHERE cj.user_id = ${userId} + AND cj.status = 'DONE' + AND cj.finished_at >= ${start} + AND cj.input_tokens IS NOT NULL + AND cj.model_id IS NOT NULL + GROUP BY cj.model_id + ORDER BY cost DESC NULLS LAST + LIMIT 5 + ` + + return rows.map(r => ({ + modelId: r.model_id, + costUsd: Number(r.cost ?? 0), + jobCount: Number(r.job_count), + })) +} + +type RawKindRow = { + kind: string + cost: number | null + job_count: bigint +} + +export async function getCostByKind(userId: string, period: Period): Promise { + const start = periodStart(period) + + const rows = await prisma.$queryRaw` + SELECT + cj.kind::text AS kind, + SUM( + cj.input_tokens * mp.input_price_per_1m / 1000000.0 + + cj.output_tokens * mp.output_price_per_1m / 1000000.0 + + cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0 + + cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0 + + COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0 + ) AS cost, + COUNT(*) AS job_count + FROM claude_jobs cj + LEFT JOIN model_prices mp ON mp.model_id = cj.model_id + WHERE cj.user_id = ${userId} + AND cj.status = 'DONE' + AND cj.finished_at >= ${start} + AND cj.input_tokens IS NOT NULL + GROUP BY cj.kind + ORDER BY cost DESC NULLS LAST + LIMIT 5 + ` + + return rows.map(r => ({ + kind: r.kind, + costUsd: Number(r.cost ?? 0), + jobCount: Number(r.job_count), + })) +} + +type RawCacheRow = { + cache_read_tokens: bigint + uncached_input_tokens: bigint + savings: number | null + cache_write_cost: number | null +} + +export async function getCacheEfficiency( + userId: string, + period: Period, +): Promise { + const start = periodStart(period) + + const rows = await prisma.$queryRaw` + SELECT + COALESCE(SUM(cj.cache_read_tokens), 0) AS cache_read_tokens, + COALESCE(SUM(cj.input_tokens), 0) AS uncached_input_tokens, + SUM( + cj.cache_read_tokens * (mp.input_price_per_1m - mp.cache_read_price_per_1m) / 1000000.0 + ) FILTER (WHERE cj.cache_read_tokens IS NOT NULL) AS savings, + SUM( + cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0 + ) FILTER (WHERE cj.cache_write_tokens IS NOT NULL) AS cache_write_cost + FROM claude_jobs cj + LEFT JOIN model_prices mp ON mp.model_id = cj.model_id + WHERE cj.user_id = ${userId} + AND cj.status = 'DONE' + AND cj.finished_at >= ${start} + ` + + const r = rows[0] + const cacheReadTokens = Number(r?.cache_read_tokens ?? 0) + const uncachedInputTokens = Number(r?.uncached_input_tokens ?? 0) + const totalInputLike = cacheReadTokens + uncachedInputTokens + + return { + cacheReadTokens, + uncachedInputTokens, + cacheHitRatio: totalInputLike > 0 ? cacheReadTokens / totalInputLike : 0, + savingsUsd: Number(r?.savings ?? 0), + spentOnCacheWriteUsd: Number(r?.cache_write_cost ?? 0), + } +}