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(