Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/junior-dashboard/e2e/system.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -48,37 +47,33 @@ 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",
type: "bar",
};
}

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",
type: "bar",
};
}

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",
Expand All @@ -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",
Expand All @@ -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;
}
Expand All @@ -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 (
<div className="grid gap-4 lg:grid-cols-3">
Expand Down Expand Up @@ -164,18 +157,15 @@ function MetricChart(props: {

return (
<Card>
<ChartHeader
description={chart.description}
title={chart.title}
total={chart.format(total)}
/>
<ChartHeader title={chart.title} total={chart.format(total)} />
{chart.metric === "inputTokens" ? (
<div className="px-5 pt-3">
<ChartLegend
ariaLabel="Input token cache series"
inline
items={[
{ color: "#22d3ee", key: "cached", label: "Cached" },
{ color: "#fbbf24", key: "written", label: "Written" },
{ color: "#a78bfa", key: "uncached", label: "Uncached" },
]}
/>
Expand Down Expand Up @@ -235,6 +225,10 @@ function MetricChart(props: {
"cached",
formatCompactNumber(day.cachedInputTokens ?? 0),
],
[
"written",
formatCompactNumber(day.cacheCreationTokens ?? 0),
],
["uncached", formatCompactNumber(day.inputTokens ?? 0)],
]}
/>
Expand All @@ -260,6 +254,22 @@ function MetricChart(props: {
x={point.x - barWidth / 2}
y={layout.top + layout.plotHeight - renderedBarHeight}
/>
<rect
fill="#fbbf24"
height={
value
? (((day.cachedInputTokens ?? 0) +
(day.cacheCreationTokens ?? 0)) /
value) *
renderedBarHeight
: 0
}
opacity={0.85}
rx="1.5"
width={barWidth}
x={point.x - barWidth / 2}
y={layout.top + layout.plotHeight - renderedBarHeight}
/>
<rect
fill="#22d3ee"
height={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
peoplePath,
slackLocationLabel,
summarizeCost,
summarizeModelUsage,
summarizeUsage,
automationPath,
} from "../format";
Expand Down Expand Up @@ -541,7 +542,9 @@ function SourceTask(props: {
sourceTask: NonNullable<ConversationDetailReport["sourceTask"]>;
}) {
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 ? (
Expand Down Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions packages/junior-dashboard/src/client/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { bundledLanguages, type BundledLanguage } from "shiki/bundle/web";
import type {
ActorIdentity,
ConversationAuxiliaryCosts,
ConversationModelUsage,
ConversationSummaryReport,
ConversationUsage,
} from "@sentry/junior/api/schema";
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,16 @@ 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),
tokens: total.tokens + (day.tokens ?? 0),
}),
{
cachedInputTokens: 0,
cacheCreationTokens: 0,
conversations: 0,
costUsd: 0,
inputTokens: 0,
Expand All @@ -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. */
Expand Down Expand Up @@ -113,21 +123,18 @@ export function SystemActivity(props: {
value={formatCostSummary({ total: totals.costUsd })}
/>
<StatCard
detail={`${formatCompactNumber(totals.cachedInputTokens)} cached · ${formatCompactNumber(totals.inputTokens)} uncached`}
detail={`${formatCompactNumber(totals.cachedInputTokens)} read · ${formatCompactNumber(totals.cacheCreationTokens)} written · ${formatCompactNumber(totals.inputTokens)} uncached`}
icon={Gauge}
label="Cache hit rate"
value={formatCacheHitRate(
label="Cached input share"
value={formatCachedInputShare(
totals.inputTokens,
totals.cachedInputTokens,
totals.cacheCreationTokens,
)}
/>
</div>
<ConversationActivityChart bucketUnit={bucketUnit} days={days} />
<SystemMetricCharts
bucketUnit={bucketUnit}
cacheBreakdown
days={days}
/>
<SystemMetricCharts bucketUnit={bucketUnit} cacheBreakdown days={days} />
Comment thread
cursor[bot] marked this conversation as resolved.
<GuardianActivity bucketUnit={bucketUnit} days={guardianDays} />
</section>
);
Expand Down
8 changes: 6 additions & 2 deletions packages/junior-dashboard/tests/activity-chart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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(
<SystemMetricCharts cacheBreakdown days={days} />,
);

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");
});
});
23 changes: 23 additions & 0 deletions packages/junior-dashboard/tests/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
slackLocationLabel,
setDashboardTimeZone,
summarizeMessages,
summarizeModelUsage,
summarizeToolCalls,
summarizeTurns,
} from "../src/client/format";
Expand Down Expand Up @@ -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");
Expand Down
22 changes: 20 additions & 2 deletions packages/junior-dashboard/tests/telemetry-components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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(
<MemoryRouter initialEntries={["/system"]}>
<SystemPage data={data} />
</MemoryRouter>,
);
expect(nearCompleteCacheHtml).toContain("&lt;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<");
Expand Down
Loading
Loading