@sentry/junior-dashboard 0.247.0 → 0.248.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,12 @@
1
1
  import type {
2
2
  ConversationAuxiliaryCosts,
3
+ ConversationChildAgentTotals,
3
4
  ConversationModelUsage,
4
5
  } from "@sentry/junior/api/schema";
5
6
  import {
6
- formatCostBreakdown,
7
7
  formatCostSummary,
8
8
  formatCompactNumber,
9
+ formatRuntime,
9
10
  formatTime,
10
11
  formatTokenSummary,
11
12
  summarizeCost,
@@ -19,6 +20,13 @@ import {
19
20
  } from "../format";
20
21
  import { MetricValue, type MetricTooltipLine } from "../components/Metric";
21
22
  import { ShimmerText } from "../components/ShimmerText";
23
+ import {
24
+ UsageCard,
25
+ type UsageCardProps,
26
+ type UsageCardRow,
27
+ type UsageCardSection,
28
+ type UsageCardSplit,
29
+ } from "./UsageCard";
22
30
 
23
31
  function plural(label: string, count: number): string {
24
32
  return `${formatCompactNumber(count)} ${label}${count === 1 ? "" : "s"}`;
@@ -30,44 +38,66 @@ function isMetricTooltipLine(
30
38
  return Boolean(line);
31
39
  }
32
40
 
33
- function usageTooltipLines(
34
- summary: TokenUsageSummary,
35
- ): Array<MetricTooltipLine | undefined> {
41
+ /** List the recorded token types, such as input and output. */
42
+ export function tokenTypeRows(summary: TokenUsageSummary): UsageCardRow[] {
43
+ return (
44
+ [
45
+ ["input", summary.inputTokens],
46
+ ["output", summary.outputTokens],
47
+ ["cached", summary.cachedInputTokens],
48
+ ["cache write", summary.cacheCreationTokens],
49
+ ["reasoning", summary.reasoningTokens],
50
+ ] as const
51
+ ).flatMap(([label, value]) =>
52
+ value ? [{ label, value: formatCompactNumber(value) }] : [],
53
+ );
54
+ }
55
+
56
+ /** Add up the tokens and cost of all subagents, including auxiliary cost. */
57
+ export function subagentUsage(
58
+ childAgentTotals: ConversationChildAgentTotals | undefined,
59
+ ): { cost: number; tokens: number } {
60
+ return {
61
+ cost:
62
+ (summarizeCost(childAgentTotals?.cumulativeUsage)?.total ?? 0) +
63
+ (childAgentTotals?.auxiliaryCostUsd ?? 0),
64
+ tokens: summarizeUsage(childAgentTotals?.cumulativeUsage)?.totalTokens ?? 0,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Cap the subagent part of a total to the total. Saved subagent usage can
70
+ * run ahead of a total built from model calls, such as when a child's
71
+ * transcript is gone.
72
+ */
73
+ export function subagentShare(part: number, total: number): number {
74
+ return Math.min(Math.max(0, part), Math.max(0, total));
75
+ }
76
+
77
+ /**
78
+ * Split a total that includes child agents into the part of this agent and the
79
+ * part of its subagents. Return no lines when there are no subagents.
80
+ */
81
+ function agentSplitLines(
82
+ childAgentTotals: ConversationChildAgentTotals | undefined,
83
+ total: number,
84
+ subagentPart: number,
85
+ format: (value: number) => string,
86
+ ): MetricTooltipLine[] {
87
+ if (!childAgentTotals) return [];
88
+ const subagents = subagentShare(subagentPart, total);
36
89
  return [
37
- summary.inputTokens !== undefined
38
- ? { label: "input", value: formatCompactNumber(summary.inputTokens) }
39
- : undefined,
40
- summary.outputTokens !== undefined
41
- ? { label: "output", value: formatCompactNumber(summary.outputTokens) }
42
- : undefined,
43
- summary.cachedInputTokens !== undefined
44
- ? {
45
- label: "cached",
46
- value: formatCompactNumber(summary.cachedInputTokens),
47
- }
48
- : undefined,
49
- summary.cacheCreationTokens !== undefined
50
- ? {
51
- label: "cache write",
52
- value: formatCompactNumber(summary.cacheCreationTokens),
53
- }
54
- : undefined,
55
- summary.reasoningTokens !== undefined
56
- ? {
57
- label: "reasoning",
58
- value: formatCompactNumber(summary.reasoningTokens),
59
- }
60
- : undefined,
61
- summary.providerTotalTokens !== undefined
62
- ? {
63
- label: "provider",
64
- value: formatCompactNumber(summary.providerTotalTokens),
65
- }
66
- : undefined,
90
+ { value: "By agent", valueStyle: "heading" },
91
+ { label: "this agent", value: format(total - subagents) },
92
+ {
93
+ label: plural("subagent", childAgentTotals.count),
94
+ value: format(subagents),
95
+ },
67
96
  ];
68
97
  }
69
98
 
70
- function modelLabel(modelId: string): string {
99
+ /** Drop the provider prefix from a model id, such as `openai/gpt-5`. */
100
+ export function modelLabel(modelId: string): string {
71
101
  return modelId.split("/").at(-1) ?? modelId;
72
102
  }
73
103
 
@@ -82,158 +112,227 @@ function modelHeading(
82
112
  };
83
113
  }
84
114
 
85
- function tokenTooltip(
115
+ /** Rows shown before the rest of a list rolls into one "N more" line. */
116
+ const MODEL_LIMIT = 4;
117
+ const AUXILIARY_LIMIT = 3;
118
+ const DETAILS_FOOTER = "Full breakdown in Details";
119
+
120
+ type RankedRow = UsageCardRow & { amount: number };
121
+
122
+ /**
123
+ * Keep the largest rows and roll the rest into one "N more" line. A single
124
+ * extra row shows as itself, since "1 more" saves no space.
125
+ */
126
+ function capRows(
127
+ rows: RankedRow[],
128
+ limit: number,
129
+ format: (value: number) => string,
130
+ ): Pick<UsageCardSection, "more" | "rows"> {
131
+ if (rows.length <= limit + 1) return { rows };
132
+ const rest = rows.slice(limit);
133
+ return {
134
+ more: {
135
+ count: rest.length,
136
+ value: format(rest.reduce((sum, row) => sum + row.amount, 0)),
137
+ },
138
+ rows: rows.slice(0, limit),
139
+ };
140
+ }
141
+
142
+ /**
143
+ * One row per model, largest first. The live model leads the list before it
144
+ * reports usage.
145
+ */
146
+ function modelRows<Summary>(
147
+ modelUsage: ConversationModelUsage[] | undefined,
148
+ liveModelId: string | undefined,
149
+ summarize: (usage: ConversationModelUsage["usage"]) => Summary | undefined,
150
+ size: (summary: Summary) => number,
151
+ format: (value: number) => string,
152
+ ): RankedRow[] {
153
+ const rows = (modelUsage ?? [])
154
+ .flatMap((item) => {
155
+ const summary = summarize(item.usage);
156
+ return summary ? [{ amount: size(summary), modelId: item.modelId }] : [];
157
+ })
158
+ .sort((a, b) => b.amount - a.amount)
159
+ .map((item) => ({
160
+ amount: item.amount,
161
+ code: true,
162
+ label: modelLabel(item.modelId),
163
+ live: item.modelId === liveModelId,
164
+ value: format(item.amount),
165
+ }));
166
+ if (liveModelId && !rows.some((row) => row.live)) {
167
+ rows.unshift({
168
+ amount: 0,
169
+ code: true,
170
+ label: modelLabel(liveModelId),
171
+ live: true,
172
+ value: "…",
173
+ });
174
+ }
175
+ return rows;
176
+ }
177
+
178
+ /** Split a total that includes subagents into this agent's part and theirs. */
179
+ function agentSplit(
180
+ childAgentTotals: ConversationChildAgentTotals | undefined,
181
+ total: number,
182
+ subagentPart: number,
183
+ format: (value: number) => string,
184
+ ): UsageCardSplit | undefined {
185
+ if (!childAgentTotals) return undefined;
186
+ const subagents = subagentShare(subagentPart, total);
187
+ return {
188
+ count: childAgentTotals.count,
189
+ self: format(total - subagents),
190
+ selfShare: total > 0 ? (total - subagents) / total : 1,
191
+ subagents: format(subagents),
192
+ };
193
+ }
194
+
195
+ function usdFormat(digits: number): Intl.NumberFormat {
196
+ return new Intl.NumberFormat("en-US", {
197
+ currency: "USD",
198
+ maximumFractionDigits: digits,
199
+ minimumFractionDigits: digits,
200
+ style: "currency",
201
+ });
202
+ }
203
+
204
+ /**
205
+ * Format every amount on one cost card with the same precision, so the
206
+ * decimal points line up down the column. An amount too small for that
207
+ * precision keeps 4 digits instead of showing as zero.
208
+ */
209
+ export function costFormatter(total: number): (value: number) => string {
210
+ const digits = total >= 10 ? 2 : total >= 1 ? 3 : 4;
211
+ const format = usdFormat(digits);
212
+ const smallFormat = usdFormat(4);
213
+ return (value) =>
214
+ value > 0 && value < 0.5 * 10 ** -digits
215
+ ? smallFormat.format(value)
216
+ : format.format(value);
217
+ }
218
+
219
+ function tokenCard(
86
220
  summary: TokenUsageSummary,
87
221
  modelUsage: ConversationModelUsage[] | undefined,
88
222
  compactionCount: number | undefined,
89
223
  liveModelId: string | undefined,
90
- ): MetricTooltipLine[] {
91
- const lines: Array<MetricTooltipLine | undefined> = [
92
- compactionCount
93
- ? { label: "compactions", value: formatCompactNumber(compactionCount) }
94
- : undefined,
95
- ];
96
- if (!modelUsage?.length) {
97
- lines.push(...usageTooltipLines(summary));
98
- }
99
- let includesLiveModel = false;
100
- for (const item of modelUsage ?? []) {
101
- const modelSummary = summarizeUsage(item.usage);
102
- if (!modelSummary) continue;
103
- includesLiveModel ||= item.modelId === liveModelId;
104
- lines.push(
105
- modelHeading(item.modelId, liveModelId),
106
- ...usageTooltipLines(modelSummary).map((line) =>
107
- line?.label ? { ...line, label: `• ${line.label}` } : line,
108
- ),
109
- );
110
- }
111
- if (liveModelId && !includesLiveModel) {
112
- lines.push(modelHeading(liveModelId, liveModelId));
113
- }
114
- return lines.filter(isMetricTooltipLine);
224
+ childAgentTotals: ConversationChildAgentTotals | undefined,
225
+ detailsHint: boolean,
226
+ ): UsageCardProps {
227
+ const models = modelRows(
228
+ modelUsage,
229
+ liveModelId,
230
+ summarizeUsage,
231
+ (model) => model.totalTokens,
232
+ formatCompactNumber,
233
+ );
234
+ return {
235
+ footer: detailsHint && models.length ? DETAILS_FOOTER : undefined,
236
+ note: compactionCount ? plural("compaction", compactionCount) : undefined,
237
+ sections: models.length
238
+ ? [
239
+ {
240
+ title: "By model",
241
+ ...capRows(models, MODEL_LIMIT, formatCompactNumber),
242
+ },
243
+ ]
244
+ : [{ rows: tokenTypeRows(summary), title: "By type" }].filter(
245
+ (section) => section.rows.length,
246
+ ),
247
+ split: agentSplit(
248
+ childAgentTotals,
249
+ summary.totalTokens,
250
+ subagentUsage(childAgentTotals).tokens,
251
+ formatCompactNumber,
252
+ ),
253
+ title: "Tokens",
254
+ total: formatCompactNumber(summary.totalTokens),
255
+ };
115
256
  }
116
257
 
117
- function costTooltipLines(summary: CostUsageSummary): MetricTooltipLine[] {
118
- const lines: Array<MetricTooltipLine | undefined> = [
119
- { label: "total", value: formatCostBreakdown(summary) },
120
- summary.input !== undefined
121
- ? {
122
- label: "input",
123
- value: formatCostBreakdown({ total: summary.input }),
124
- }
125
- : undefined,
126
- summary.output !== undefined
127
- ? {
128
- label: "output",
129
- value: formatCostBreakdown({ total: summary.output }),
130
- }
131
- : undefined,
132
- summary.cacheRead !== undefined
133
- ? {
134
- label: "cache read",
135
- value: formatCostBreakdown({ total: summary.cacheRead }),
136
- }
137
- : undefined,
138
- summary.cacheWrite !== undefined
139
- ? {
140
- label: "cache write",
141
- value: formatCostBreakdown({ total: summary.cacheWrite }),
142
- }
143
- : undefined,
144
- ];
145
- return lines.filter(isMetricTooltipLine);
258
+ function costTypeRows(
259
+ summary: CostUsageSummary,
260
+ format: (value: number) => string,
261
+ ): UsageCardRow[] {
262
+ return (
263
+ [
264
+ ["input", summary.input],
265
+ ["output", summary.output],
266
+ ["cache read", summary.cacheRead],
267
+ ["cache write", summary.cacheWrite],
268
+ ] as const
269
+ ).flatMap(([label, value]) =>
270
+ value === undefined ? [] : [{ label, value: format(value) }],
271
+ );
146
272
  }
147
273
 
148
- function costTooltip(
274
+ /** Summarize cost. Return nothing until there is a cost to show. */
275
+ function costCard(
149
276
  summary: CostUsageSummary | undefined,
150
277
  modelUsage: ConversationModelUsage[] | undefined,
151
278
  auxiliaryCosts: ConversationAuxiliaryCosts | undefined,
152
279
  liveModelId: string | undefined,
153
- ): {
154
- tooltip?: MetricTooltipLine[];
155
- tooltipColumns?: MetricTooltipLine[][];
156
- } {
280
+ childAgentTotals: ConversationChildAgentTotals | undefined,
281
+ detailsHint: boolean,
282
+ ): UsageCardProps | undefined {
157
283
  const total = totalConversationCost(summary, auxiliaryCosts);
158
- const modelSummaries = (modelUsage ?? []).flatMap((item) => {
159
- const modelSummary = summarizeCost(item.usage);
160
- return modelSummary
161
- ? [{ modelId: item.modelId, summary: modelSummary }]
162
- : [];
163
- });
164
- const pendingLines: MetricTooltipLine[] =
165
- liveModelId && !modelSummaries.some((item) => item.modelId === liveModelId)
166
- ? [modelHeading(liveModelId, liveModelId)]
167
- : [];
168
- if (!total) return { tooltip: pendingLines };
169
- if (!auxiliaryCosts) {
170
- if (!modelSummaries.length) {
171
- return {
172
- tooltip: [
173
- ...(summary ? costTooltipLines(summary) : []),
174
- ...pendingLines,
175
- ],
176
- };
177
- }
178
- return {
179
- tooltip: [
180
- ...modelSummaries.flatMap((item) => [
181
- modelHeading(item.modelId, liveModelId),
182
- ...costTooltipLines(item.summary).map((line) => ({
183
- ...line,
184
- label: `• ${line.label}`,
185
- })),
186
- ]),
187
- ...pendingLines,
188
- ],
189
- };
190
- }
191
-
192
- const conversationLines: MetricTooltipLine[] = [
193
- { value: "Conversation", valueStyle: "heading" },
194
- { label: "total", value: formatCostBreakdown(total) },
195
- ];
196
- if (summary) {
197
- conversationLines.push({
198
- label: "agent",
199
- value: formatCostBreakdown(summary),
284
+ if (!total) return undefined;
285
+ const format = costFormatter(total.total);
286
+ const models = modelRows(
287
+ modelUsage,
288
+ liveModelId,
289
+ summarizeCost,
290
+ (model) => model.total,
291
+ format,
292
+ );
293
+ const operations: RankedRow[] = [...(auxiliaryCosts?.operations ?? [])]
294
+ .sort((a, b) => b.costUsd - a.costUsd)
295
+ .map((operation) => ({
296
+ amount: operation.costUsd,
297
+ label: auxiliaryOperationLabel(operation),
298
+ value: format(operation.costUsd),
299
+ }));
300
+ const sections: UsageCardSection[] = [];
301
+ if (models.length) {
302
+ sections.push({
303
+ title: "By model",
304
+ ...capRows(models, MODEL_LIMIT, format),
200
305
  });
201
- if (!modelSummaries.length) {
202
- conversationLines.push(
203
- ...costTooltipLines(summary)
204
- .filter((line) => line.label !== "total")
205
- .map((line) => ({ ...line, label: `• ${line.label}` })),
206
- );
207
- } else {
208
- for (const item of modelSummaries) {
209
- conversationLines.push(
210
- modelHeading(item.modelId, liveModelId),
211
- ...costTooltipLines(item.summary).map((line) => ({
212
- ...line,
213
- label: `• ${line.label}`,
214
- })),
215
- );
216
- }
217
- }
306
+ } else if (summary) {
307
+ const rows = costTypeRows(summary, format);
308
+ if (rows.length) sections.push({ rows, title: "By type" });
218
309
  }
219
- conversationLines.push(...pendingLines);
220
- const auxiliaryLines: MetricTooltipLine[] = [
221
- { value: "Auxiliary", valueStyle: "heading" },
222
- {
223
- label: "total",
224
- value: formatCostBreakdown({ total: auxiliaryCosts.costUsd }),
225
- },
226
- ];
227
- for (const operation of auxiliaryCosts.operations) {
228
- auxiliaryLines.push({
229
- label: `${auxiliaryOperationLabel(operation)} (${formatCompactNumber(operation.events)})`,
230
- value: formatCostBreakdown({ total: operation.costUsd }),
310
+ if (auxiliaryCosts && operations.length) {
311
+ sections.push({
312
+ title: "Auxiliary",
313
+ total: format(auxiliaryCosts.costUsd),
314
+ ...capRows(operations, AUXILIARY_LIMIT, format),
231
315
  });
232
316
  }
233
- return { tooltipColumns: [conversationLines, auxiliaryLines] };
317
+ return {
318
+ footer:
319
+ detailsHint && (models.length || operations.length)
320
+ ? DETAILS_FOOTER
321
+ : undefined,
322
+ sections,
323
+ split: agentSplit(
324
+ childAgentTotals,
325
+ total.total,
326
+ subagentUsage(childAgentTotals).cost,
327
+ format,
328
+ ),
329
+ title: "Estimated cost",
330
+ total: format(total.total),
331
+ };
234
332
  }
235
333
 
236
- function auxiliaryOperationLabel(
334
+ /** Name an auxiliary operation, such as memory recall or turn routing. */
335
+ export function auxiliaryOperationLabel(
237
336
  operation: ConversationAuxiliaryCosts["operations"][number],
238
337
  ): string {
239
338
  const knownLabels: Readonly<Record<string, string>> = {
@@ -254,6 +353,9 @@ function auxiliaryOperationLabel(
254
353
  export function CostMetric(props: {
255
354
  align?: "left" | "right";
256
355
  auxiliaryCosts?: ConversationAuxiliaryCosts;
356
+ childAgentTotals?: ConversationChildAgentTotals;
357
+ /** Point to the Details drawer, which has the full breakdown. */
358
+ detailsHint?: boolean;
257
359
  live?: boolean;
258
360
  liveModelId?: string;
259
361
  modelUsage?: ConversationModelUsage[];
@@ -267,16 +369,24 @@ export function CostMetric(props: {
267
369
  const label = total
268
370
  ? `${formatCostSummary(total)}${pending ? "+" : ""}`
269
371
  : "$…";
372
+ const card = costCard(
373
+ summary,
374
+ props.modelUsage,
375
+ props.auxiliaryCosts,
376
+ props.liveModelId,
377
+ props.childAgentTotals,
378
+ Boolean(props.detailsHint),
379
+ );
270
380
  return (
271
381
  <MetricValue
272
382
  align={props.align}
273
383
  tooltipPlacement="above"
274
- {...costTooltip(
275
- summary,
276
- props.modelUsage,
277
- props.auxiliaryCosts,
278
- props.liveModelId,
279
- )}
384
+ tooltip={
385
+ !card && props.liveModelId
386
+ ? [modelHeading(props.liveModelId, props.liveModelId)]
387
+ : undefined
388
+ }
389
+ tooltipCard={card ? <UsageCard {...card} /> : undefined}
280
390
  >
281
391
  <ShimmerText active={pending}>{label}</ShimmerText>
282
392
  </MetricValue>
@@ -286,7 +396,10 @@ export function CostMetric(props: {
286
396
  /** Render total token usage with a hoverable breakdown. */
287
397
  export function TokenMetric(props: {
288
398
  align?: "left" | "right";
399
+ childAgentTotals?: ConversationChildAgentTotals;
289
400
  compactionCount?: number;
401
+ /** Point to the Details drawer, which has the full breakdown. */
402
+ detailsHint?: boolean;
290
403
  live?: boolean;
291
404
  liveModelId?: string;
292
405
  modelUsage?: ConversationModelUsage[];
@@ -296,12 +409,18 @@ export function TokenMetric(props: {
296
409
  return (
297
410
  <MetricValue
298
411
  align={props.align}
299
- tooltip={tokenTooltip(
300
- props.summary,
301
- props.modelUsage,
302
- props.compactionCount,
303
- props.liveModelId,
304
- )}
412
+ tooltipCard={
413
+ <UsageCard
414
+ {...tokenCard(
415
+ props.summary,
416
+ props.modelUsage,
417
+ props.compactionCount,
418
+ props.liveModelId,
419
+ props.childAgentTotals,
420
+ Boolean(props.detailsHint),
421
+ )}
422
+ />
423
+ }
305
424
  >
306
425
  <ShimmerText active={props.live}>
307
426
  {formatTokenSummary(props.summary)}
@@ -313,6 +432,8 @@ export function TokenMetric(props: {
313
432
  /** Render a duration value with start/end timestamps in the tooltip. */
314
433
  export function DurationMetric(props: {
315
434
  align?: "left" | "right";
435
+ childAgentTotals?: ConversationChildAgentTotals;
436
+ durationMs?: number;
316
437
  endedAt?: string;
317
438
  label: string;
318
439
  startedAt?: string;
@@ -325,6 +446,12 @@ export function DurationMetric(props: {
325
446
  props.endedAt
326
447
  ? { label: "ended", value: formatTime(props.endedAt) }
327
448
  : undefined,
449
+ ...agentSplitLines(
450
+ props.childAgentTotals,
451
+ props.durationMs ?? 0,
452
+ props.childAgentTotals?.cumulativeDurationMs ?? 0,
453
+ (value) => formatRuntime(value) || "0s",
454
+ ),
328
455
  ];
329
456
  const tooltip = lines.filter(isMetricTooltipLine);
330
457
  return (
@@ -334,6 +461,26 @@ export function DurationMetric(props: {
334
461
  );
335
462
  }
336
463
 
464
+ /**
465
+ * Say how many subagents the tokens, cost, and runtime of a Conversation
466
+ * include. Render nothing for a Conversation without subagents.
467
+ */
468
+ export function SubagentsMetric(props: {
469
+ childAgentTotals: ConversationChildAgentTotals | undefined;
470
+ }) {
471
+ if (!props.childAgentTotals) return null;
472
+ const label = plural("subagent", props.childAgentTotals.count);
473
+ return (
474
+ <MetricValue
475
+ tooltip={[
476
+ { value: `Tokens, cost, and runtime include ${label} at any depth.` },
477
+ ]}
478
+ >
479
+ {label}
480
+ </MetricValue>
481
+ );
482
+ }
483
+
337
484
  /** Render a tool-call count with top tool names and counts. */
338
485
  export function ToolCallsMetric(props: {
339
486
  align?: "left" | "right";