diff --git a/packages/junior-dashboard/e2e/system.spec.ts b/packages/junior-dashboard/e2e/system.spec.ts index 0367c88f2b..5b521f6482 100644 --- a/packages/junior-dashboard/e2e/system.spec.ts +++ b/packages/junior-dashboard/e2e/system.spec.ts @@ -11,7 +11,7 @@ test("shows system usage and plugin details", async ({ page, dashboard }) => { ).toBeVisible(); await expect(page.getByText("Conversation activity")).toBeVisible(); await expect(page.getByLabel("Conversations per day")).toBeVisible(); - await expect(page.getByText("Cache hit rate")).toBeVisible(); + await expect(page.getByText("Cached input share")).toBeVisible(); await expect(page.getByText("Input token cache")).toBeVisible(); await expect( page.getByRole("heading", { name: "Model spend", exact: true }), diff --git a/packages/junior-dashboard/src/client/components/charts/SystemMetricCharts.tsx b/packages/junior-dashboard/src/client/components/charts/SystemMetricCharts.tsx index 63602b54d2..172132dd8f 100644 --- a/packages/junior-dashboard/src/client/components/charts/SystemMetricCharts.tsx +++ b/packages/junior-dashboard/src/client/components/charts/SystemMetricCharts.tsx @@ -29,7 +29,6 @@ type Metric = "costUsd" | "durationMs" | "inputTokens" | "tokens"; type ChartConfig = { axisFormat(value: number): string; color: string; - description: string; format(value: number): string; metric: Metric; title: string; @@ -48,11 +47,10 @@ function compactDuration(value: number): string { return formatDuration(value); } -function tokenChart(bucketUnit: TimeRangeBucketUnit): ChartConfig { +function tokenChart(): ChartConfig { return { axisFormat: formatCompactNumber, color: "#22d3ee", - description: bucketUnit === "hour" ? "Hourly model tokens" : bucketUnit === "6hour" ? "6-hour model tokens" : "Daily model tokens", format: formatCompactNumber, metric: "tokens", title: "Token usage", @@ -60,11 +58,10 @@ function tokenChart(bucketUnit: TimeRangeBucketUnit): ChartConfig { }; } -function inputCacheChart(bucketUnit: TimeRangeBucketUnit): ChartConfig { +function inputCacheChart(): ChartConfig { return { axisFormat: formatCompactNumber, color: "#22d3ee", - description: bucketUnit === "hour" ? "Hourly cache mix" : bucketUnit === "6hour" ? "6-hour cache mix" : "Daily cache mix", format: formatCompactNumber, metric: "inputTokens", title: "Input token cache", @@ -72,13 +69,11 @@ function inputCacheChart(bucketUnit: TimeRangeBucketUnit): ChartConfig { }; } -function supportingCharts(bucketUnit: TimeRangeBucketUnit): ChartConfig[] { +function supportingCharts(): ChartConfig[] { return [ { axisFormat: compactCurrency, color: "#fbbf24", - description: - bucketUnit === "hour" ? "Hourly estimated cost" : bucketUnit === "6hour" ? "6-hour estimated cost" : "Daily estimated cost", format: (value) => formatCostSummary({ total: value }), metric: "costUsd", title: "Model spend", @@ -87,12 +82,6 @@ function supportingCharts(bucketUnit: TimeRangeBucketUnit): ChartConfig[] { { axisFormat: compactDuration, color: "#a78bfa", - description: - bucketUnit === "hour" - ? "Hourly cumulative runtime" - : bucketUnit === "6hour" - ? "6-hour cumulative runtime" - : "Daily cumulative runtime", format: formatDuration, metric: "durationMs", title: "Runtime", @@ -103,7 +92,11 @@ function supportingCharts(bucketUnit: TimeRangeBucketUnit): ChartConfig[] { function metricValue(day: ConversationMetricDay, metric: Metric): number { if (metric === "inputTokens") { - return (day.inputTokens ?? 0) + (day.cachedInputTokens ?? 0); + return ( + (day.inputTokens ?? 0) + + (day.cachedInputTokens ?? 0) + + (day.cacheCreationTokens ?? 0) + ); } return day[metric] ?? 0; } @@ -116,8 +109,8 @@ export function SystemMetricCharts(props: { }) { const bucketUnit = props.bucketUnit ?? "day"; const charts = [ - props.cacheBreakdown ? inputCacheChart(bucketUnit) : tokenChart(bucketUnit), - ...supportingCharts(bucketUnit), + props.cacheBreakdown ? inputCacheChart() : tokenChart(), + ...supportingCharts(), ]; return (
@@ -164,11 +157,7 @@ function MetricChart(props: { return ( - + {chart.metric === "inputTokens" ? (
@@ -235,6 +225,10 @@ function MetricChart(props: { "cached", formatCompactNumber(day.cachedInputTokens ?? 0), ], + [ + "written", + formatCompactNumber(day.cacheCreationTokens ?? 0), + ], ["uncached", formatCompactNumber(day.inputTokens ?? 0)], ]} /> @@ -260,6 +254,22 @@ function MetricChart(props: { x={point.x - barWidth / 2} y={layout.top + layout.plotHeight - renderedBarHeight} /> + ; }) { const kindLabel = - props.sourceTask.kind === "scheduled" ? "Scheduled automation" : "Event automation"; + props.sourceTask.kind === "scheduled" + ? "Scheduled automation" + : "Event automation"; const automationId = props.sourceTask.id?.trim(); const title = props.sourceTask.title?.trim(); const link = automationId ? ( @@ -643,7 +646,8 @@ function conversationStatItems(props: { : props.detail; const usage = props.detail?.cumulativeUsage ?? props.conversation.cumulativeUsage; - const tokenSummary = summarizeUsage(usage); + const tokenSummary = + summarizeModelUsage(props.detail?.modelUsage) ?? summarizeUsage(usage); const costSummary = summarizeCost(usage); const location = slackLocationLabel(props.conversation, { includeId: false, diff --git a/packages/junior-dashboard/src/client/format.ts b/packages/junior-dashboard/src/client/format.ts index c7fa1aab58..70ad6d34c2 100644 --- a/packages/junior-dashboard/src/client/format.ts +++ b/packages/junior-dashboard/src/client/format.ts @@ -2,6 +2,7 @@ import { bundledLanguages, type BundledLanguage } from "shiki/bundle/web"; import type { ActorIdentity, ConversationAuxiliaryCosts, + ConversationModelUsage, ConversationSummaryReport, ConversationUsage, } from "@sentry/junior/api/schema"; @@ -494,6 +495,30 @@ export function summarizeUsage( return summary.totalTokens > 0 ? summary : undefined; } +/** Sum model token components for a conversation detail. */ +export function summarizeModelUsage( + modelUsage: ConversationModelUsage[] | undefined, +): TokenUsageSummary | undefined { + if (!modelUsage?.length) return undefined; + const summary: TokenUsageSummary = { totalTokens: 0 }; + for (const item of modelUsage) { + const model = summarizeUsage(item.usage); + if (!model) continue; + summary.totalTokens += model.totalTokens; + for (const field of [ + "inputTokens", + "outputTokens", + "cachedInputTokens", + "cacheCreationTokens", + "reasoningTokens", + ] as const) { + const value = model[field]; + if (value !== undefined) summary[field] = (summary[field] ?? 0) + value; + } + } + return summary.totalTokens > 0 ? summary : undefined; +} + /** Format total token usage for compact metadata. */ export function formatTokenSummary( summary: TokenUsageSummary | undefined, diff --git a/packages/junior-dashboard/src/client/pages/system/SystemActivity.tsx b/packages/junior-dashboard/src/client/pages/system/SystemActivity.tsx index 5bbb262db1..0a4cfedbaa 100644 --- a/packages/junior-dashboard/src/client/pages/system/SystemActivity.tsx +++ b/packages/junior-dashboard/src/client/pages/system/SystemActivity.tsx @@ -22,6 +22,8 @@ function periodTotals(days: ConversationMetricDay[]) { return days.reduce( (total, day) => ({ cachedInputTokens: total.cachedInputTokens + (day.cachedInputTokens ?? 0), + cacheCreationTokens: + total.cacheCreationTokens + (day.cacheCreationTokens ?? 0), conversations: total.conversations + day.conversations, costUsd: total.costUsd + (day.costUsd ?? 0), inputTokens: total.inputTokens + (day.inputTokens ?? 0), @@ -29,6 +31,7 @@ function periodTotals(days: ConversationMetricDay[]) { }), { cachedInputTokens: 0, + cacheCreationTokens: 0, conversations: 0, costUsd: 0, inputTokens: 0, @@ -37,10 +40,17 @@ function periodTotals(days: ConversationMetricDay[]) { ); } -function formatCacheHitRate(inputTokens: number, cachedInputTokens: number) { - const totalInputTokens = inputTokens + cachedInputTokens; +function formatCachedInputShare( + uncachedInputTokens: number, + cachedInputTokens: number, + cacheCreationTokens: number, +) { + const totalInputTokens = + uncachedInputTokens + cachedInputTokens + cacheCreationTokens; if (!totalInputTokens) return "—"; - return `${((cachedInputTokens / totalInputTokens) * 100).toFixed(1)}%`; + const percentage = (cachedInputTokens / totalInputTokens) * 100; + if (percentage < 100 && percentage >= 99.95) return "<100%"; + return `${percentage.toFixed(1)}%`; } /** Present selectable daily runtime and model-usage trends. */ @@ -113,21 +123,18 @@ export function SystemActivity(props: { value={formatCostSummary({ total: totals.costUsd })} />
- + ); diff --git a/packages/junior-dashboard/tests/activity-chart.test.tsx b/packages/junior-dashboard/tests/activity-chart.test.tsx index 8ba3d703d4..08791577dc 100644 --- a/packages/junior-dashboard/tests/activity-chart.test.tsx +++ b/packages/junior-dashboard/tests/activity-chart.test.tsx @@ -240,6 +240,7 @@ describe("SystemMetricCharts average line", () => { costUsd: 1.5, date: "2026-05-01", cachedInputTokens: 750_000_000, + cacheCreationTokens: 100_000_000, durationMs: 120_000, inputTokens: 250_000_000, tokens: 1_000_000_000, @@ -249,6 +250,7 @@ describe("SystemMetricCharts average line", () => { costUsd: 2.5, date: "2026-05-02", cachedInputTokens: 1_000_000_000, + cacheCreationTokens: 200_000_000, durationMs: 180_000, inputTokens: 400_000_000, tokens: 1_400_000_000, @@ -267,16 +269,18 @@ describe("SystemMetricCharts average line", () => { expect(html).toContain("Runtime"); }); - it("stacks cached and uncached input tokens only for cache breakdown", () => { + it("stacks cached, written, and uncached input tokens only for cache breakdown", () => { const html = renderToStaticMarkup( , ); expect(html).toContain("Input token cache"); expect(html).toContain("Cached"); + expect(html).toContain("Written"); expect(html).toContain("Uncached"); + expect(html).toContain('aria-label="May 1: 1.1b input tokens"'); expect(html).toContain("input tokens"); - expect(html).toContain('aria-label="average 1.2b / day"'); + expect(html).toContain('aria-label="average 1.3b / day"'); expect(html).not.toContain("Token usage"); }); }); diff --git a/packages/junior-dashboard/tests/format.test.ts b/packages/junior-dashboard/tests/format.test.ts index 7479702147..8f75484084 100644 --- a/packages/junior-dashboard/tests/format.test.ts +++ b/packages/junior-dashboard/tests/format.test.ts @@ -30,6 +30,7 @@ import { slackLocationLabel, setDashboardTimeZone, summarizeMessages, + summarizeModelUsage, summarizeToolCalls, summarizeTurns, } from "../src/client/format"; @@ -105,6 +106,28 @@ describe("dashboard conversation formatting", () => { expect(formatCostTotal({ cost: { total: 0.0042 } })).toBe("$0.0042"); }); + it("reconciles model usage totals with their breakdown", () => { + expect( + summarizeModelUsage([ + { + modelId: "anthropic/claude-sonnet-4-5", + usage: { + inputTokens: 18, + outputTokens: 592, + cachedInputTokens: 179_000, + cacheCreationTokens: 36_000, + }, + }, + ]), + ).toEqual({ + inputTokens: 18, + outputTokens: 592, + cachedInputTokens: 179_000, + cacheCreationTokens: 36_000, + totalTokens: 215_610, + }); + }); + it("formats human-readable durations at increasing scales", () => { expect(formatDuration(999)).toBe("999ms"); expect(formatDuration(3_500)).toBe("3.5s"); diff --git a/packages/junior-dashboard/tests/telemetry-components.test.tsx b/packages/junior-dashboard/tests/telemetry-components.test.tsx index dc3532af09..6fb05afa84 100644 --- a/packages/junior-dashboard/tests/telemetry-components.test.tsx +++ b/packages/junior-dashboard/tests/telemetry-components.test.tsx @@ -24,7 +24,10 @@ import { ConversationAnnotations, ConversationStats, } from "../src/client/conversations/ConversationMeta"; -import { conversationFromDetail, setDashboardTimeZone } from "../src/client/format"; +import { + conversationFromDetail, + setDashboardTimeZone, +} from "../src/client/format"; import { TranscriptMarkdown } from "../src/client/conversations/TranscriptMarkdown"; import { TranscriptText } from "../src/client/conversations/TranscriptText"; import { TranscriptToolView } from "../src/client/conversations/TranscriptToolView"; @@ -1540,7 +1543,7 @@ describe("dashboard canonical-event components", () => { expect(systemHtml).not.toContain("Usage over time"); expect(systemHtml).toContain("Conversation activity"); expect(systemHtml).toContain('aria-label="Conversations per day"'); - expect(systemHtml).toContain("Cache hit rate"); + expect(systemHtml).toContain("Cached input share"); expect(systemHtml).toContain("75.0%"); expect(systemHtml).toContain("Input token cache"); expect(systemHtml).toContain("Model spend"); @@ -1571,6 +1574,21 @@ describe("dashboard canonical-event components", () => { expect(systemHtml).not.toContain(">Skills<"); expect(systemHtml).not.toContain(">GitHub<"); expect(systemHtml).not.toContain(">loaded<"); + + data.conversationStats!.metricDays[0] = { + ...data.conversationStats!.metricDays[0], + cachedInputTokens: 9_999, + inputTokens: 1, + }; + const nearCompleteCacheHtml = renderToStaticMarkup( + + + , + ); + expect(nearCompleteCacheHtml).toContain("<100%"); + expect(nearCompleteCacheHtml).toContain( + "9.9k read · 0 written · 1 uncached", + ); expect(systemHtml).not.toContain(">quiet<"); expect(systemHtml).not.toContain(">metrics<"); expect(systemHtml).not.toContain(">datasets<"); diff --git a/packages/junior/src/api/conversations/aggregate.ts b/packages/junior/src/api/conversations/aggregate.ts index 0f596030a9..cb16ea416b 100644 --- a/packages/junior/src/api/conversations/aggregate.ts +++ b/packages/junior/src/api/conversations/aggregate.ts @@ -11,7 +11,7 @@ interface ConversationAggregateSource { function usageTokenValue( source: ConversationAggregateSource, - field: "cachedInputTokens" | "inputTokens", + field: "cachedInputTokens" | "cacheCreationTokens" | "inputTokens", ) { return sql`CASE WHEN ${source.usage}->>${field} IS NOT NULL @@ -83,6 +83,9 @@ export function conversationAggregateColumns(sources?: { cachedInputTokens: sql< number | null >`SUM(${usageTokenValue(metrics, "cachedInputTokens")})::double precision`, + cacheCreationTokens: sql< + number | null + >`SUM(${usageTokenValue(metrics, "cacheCreationTokens")})::double precision`, costUsd: sql`SUM(${costValue(metrics)})::double precision`, durationMs: sql`COALESCE(SUM(${metrics.durationMs}), 0)::double precision`, failed: sql`${conversationCount} FILTER ( diff --git a/packages/junior/src/api/conversations/stats.query.ts b/packages/junior/src/api/conversations/stats.query.ts index 0ee0350bdd..9ca236fad5 100644 --- a/packages/junior/src/api/conversations/stats.query.ts +++ b/packages/junior/src/api/conversations/stats.query.ts @@ -102,6 +102,7 @@ function locationLabel(row: { type AggregateRow = { active: number; cachedInputTokens: number | null; + cacheCreationTokens: number | null; conversations: number; costUsd: number | null; durationMs: number; @@ -159,6 +160,7 @@ const WINDOW_HOURS = WINDOW_SEVEN_DAY_HOURS; type MetricRow = { cachedInputTokens: number | null; + cacheCreationTokens: number | null; conversations: number; costUsd: number | null; date: string; @@ -177,6 +179,7 @@ function applyMetricBuckets( { ...row, cachedInputTokens: null, + cacheCreationTokens: null, costUsd: null, durationMs: 0, inputTokens: null, @@ -187,6 +190,7 @@ function applyMetricBuckets( for (const metric of metrics) { const row = byDate.get(metric.date) ?? { cachedInputTokens: null, + cacheCreationTokens: null, conversations: 0, costUsd: null, date: metric.date, @@ -196,6 +200,8 @@ function applyMetricBuckets( }; if (metric.metric === "cached_input_tokens") { row.cachedInputTokens = metric.value; + } else if (metric.metric === "cache_creation_tokens") { + row.cacheCreationTokens = metric.value; } else if (metric.metric === "cost_usd") { row.costUsd = metric.value; } else if (metric.metric === "duration_ms") { @@ -224,6 +230,7 @@ function metricPoint( row: | { cachedInputTokens?: number | null; + cacheCreationTokens?: number | null; conversations?: number; costUsd?: number | null; durationMs?: number; @@ -239,6 +246,10 @@ function metricPoint( ...(row?.cachedInputTokens !== null && row?.cachedInputTokens !== undefined ? { cachedInputTokens: row.cachedInputTokens } : undefined), + ...(row?.cacheCreationTokens !== null && + row?.cacheCreationTokens !== undefined + ? { cacheCreationTokens: row.cacheCreationTokens } + : undefined), ...(row?.costUsd !== null && row?.costUsd !== undefined ? { costUsd: addUsd(undefined, row.costUsd) } : undefined), @@ -602,6 +613,10 @@ export async function readConversationStatsFromSql(): Promise { durationMs: 1_500, usage: { cachedInputTokens: 300, + cacheCreationTokens: 7, inputTokens: 100, outputTokens: 20, reasoningTokens: 5, @@ -204,6 +205,7 @@ describe("conversation stats API", () => { expect(report).toMatchObject({ active: 1, cachedInputTokens: 300, + cacheCreationTokens: 7, conversations: 3, costUsd: 0.0045, durationMs: 2_004, @@ -216,7 +218,7 @@ describe("conversation stats API", () => { deny: 1, requests: 3, }, - tokens: 457, + tokens: 464, source: "conversation_index", }); expect(report.actors).toEqual( @@ -226,7 +228,7 @@ describe("conversation stats API", () => { costUsd: 0.003, durationMs: 1_504, label: "alice@example.com", - tokens: 427, + tokens: 434, }), expect.objectContaining({ conversations: 1, @@ -283,12 +285,13 @@ describe("conversation stats API", () => { expect(report.metricDays.at(-1)).toEqual( expect.objectContaining({ cachedInputTokens: 300, + cacheCreationTokens: 7, conversations: 3, costUsd: 0.003, date: "2026-06-15", durationMs: 1_504, inputTokens: 100, - tokens: 427, + tokens: 434, }), ); expect(report.metricHours).toHaveLength(7 * 24); @@ -310,12 +313,13 @@ describe("conversation stats API", () => { ).toEqual( expect.objectContaining({ cachedInputTokens: 300, + cacheCreationTokens: 7, conversations: 2, costUsd: 0.003, date: "2026-06-15T11", durationMs: 1_504, inputTokens: 100, - tokens: 427, + tokens: 434, }), ); expect(