@sentry/junior-dashboard 0.246.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.
@@ -110,10 +110,16 @@ function ChildAgentRow(props: {
110
110
  // A title is often a short form of the task. Skip the task when it adds nothing.
111
111
  const detail = agent.title && task && task !== agent.title ? task : undefined;
112
112
  const runtime = formatRuntime(agent.cumulativeDurationMs);
113
+ // Totals include this subagent's own subagents. Say so, so the row does not
114
+ // read as the work of this subagent alone.
115
+ const nested = agent.childAgentTotals?.count;
113
116
  const stats = [
114
117
  formatUsageTotal(agent.cumulativeUsage),
115
118
  formatConversationCostTotal(agent.cumulativeUsage, agent.auxiliaryCosts),
116
119
  runtime ? `${runtime} runtime` : "",
120
+ nested
121
+ ? `incl. ${nested} nested ${nested === 1 ? "subagent" : "subagents"}`
122
+ : "",
117
123
  ].filter(Boolean);
118
124
 
119
125
  return (
@@ -25,7 +25,12 @@ import {
25
25
  import { Tooltip } from "../components/Tooltip";
26
26
  import { MetricList, type MetricListItem } from "../components/Metric";
27
27
  import { cn } from "../styles";
28
- import { CostMetric, DurationMetric, TokenMetric } from "./TelemetryMetrics";
28
+ import {
29
+ CostMetric,
30
+ DurationMetric,
31
+ SubagentsMetric,
32
+ TokenMetric,
33
+ } from "./TelemetryMetrics";
29
34
  import type { Conversation } from "../types";
30
35
  import {
31
36
  projectSidebarAnnotationBadges,
@@ -598,6 +603,8 @@ function conversationStatItems(props: {
598
603
  const live = (props.detail?.status ?? props.conversation.status) === "active";
599
604
  const activeModelId = liveModelId(props.detail);
600
605
  const sourceTask = props.detail?.sourceTask;
606
+ const childAgentTotals =
607
+ props.detail?.childAgentTotals ?? props.conversation.childAgentTotals;
601
608
  const rawStats: Array<MetricListItem | undefined> = [
602
609
  location
603
610
  ? {
@@ -615,6 +622,8 @@ function conversationStatItems(props: {
615
622
  ? {
616
623
  content: (
617
624
  <DurationMetric
625
+ childAgentTotals={childAgentTotals}
626
+ durationMs={props.conversation.cumulativeDurationMs}
618
627
  endedAt={props.conversation.lastSeenAt}
619
628
  label={durationLabel}
620
629
  startedAt={props.conversation.startedAt}
@@ -627,6 +636,8 @@ function conversationStatItems(props: {
627
636
  ? {
628
637
  content: (
629
638
  <TokenMetric
639
+ detailsHint={variant === "compact"}
640
+ childAgentTotals={childAgentTotals}
630
641
  compactionCount={
631
642
  completeDetail
632
643
  ? completeDetail.events.filter(
@@ -650,10 +661,12 @@ function conversationStatItems(props: {
650
661
  ? {
651
662
  content: (
652
663
  <CostMetric
664
+ detailsHint={variant === "compact"}
653
665
  auxiliaryCosts={
654
666
  props.detail?.auxiliaryCosts ??
655
667
  props.conversation.auxiliaryCosts
656
668
  }
669
+ childAgentTotals={childAgentTotals}
657
670
  live={live}
658
671
  liveModelId={activeModelId}
659
672
  modelUsage={props.detail?.modelUsage}
@@ -663,6 +676,12 @@ function conversationStatItems(props: {
663
676
  key: "cost",
664
677
  }
665
678
  : undefined,
679
+ childAgentTotals
680
+ ? {
681
+ content: <SubagentsMetric childAgentTotals={childAgentTotals} />,
682
+ key: "subagents",
683
+ }
684
+ : undefined,
666
685
  ];
667
686
  return rawStats.filter((item): item is MetricListItem => item !== undefined);
668
687
  }
@@ -37,6 +37,7 @@ import {
37
37
  hasConversationStats,
38
38
  PendingAuthorization,
39
39
  } from "./ConversationMeta";
40
+ import { ConversationUsageBreakdown } from "./ConversationUsageBreakdown";
40
41
  import { PendingMailboxStack } from "./PendingMailboxStack";
41
42
  import {
42
43
  buildConversations,
@@ -300,10 +301,16 @@ export function ConversationPage(props: {
300
301
  conversation,
301
302
  detail: detail.data,
302
303
  }) ? (
303
- <ConversationStats
304
- conversation={conversation}
305
- detail={detail.data}
306
- />
304
+ <div className="grid min-w-0 gap-5">
305
+ <ConversationStats
306
+ conversation={conversation}
307
+ detail={detail.data}
308
+ />
309
+ <ConversationUsageBreakdown
310
+ conversation={conversation}
311
+ detail={detail.data}
312
+ />
313
+ </div>
307
314
  ) : null
308
315
  }
309
316
  title={title}
@@ -0,0 +1,278 @@
1
+ import type {
2
+ ConversationDetailReport,
3
+ ConversationModelUsage,
4
+ } from "@sentry/junior/api/schema";
5
+ import type { ReactNode } from "react";
6
+
7
+ import {
8
+ formatCompactNumber,
9
+ summarizeCost,
10
+ summarizeModelCost,
11
+ summarizeModelUsage,
12
+ summarizeUsage,
13
+ totalConversationCost,
14
+ } from "../format";
15
+ import { ShimmerText } from "../components/ShimmerText";
16
+ import type { Conversation } from "../types";
17
+ import { liveModelId } from "./ConversationMeta";
18
+ import {
19
+ auxiliaryOperationLabel,
20
+ costFormatter,
21
+ modelLabel,
22
+ subagentShare,
23
+ subagentUsage,
24
+ tokenTypeRows,
25
+ } from "./TelemetryMetrics";
26
+
27
+ type BreakdownRow = {
28
+ cost: string;
29
+ count: string;
30
+ /** Muted second line, such as the token types of one model. */
31
+ detail?: string;
32
+ key: string;
33
+ label: ReactNode;
34
+ };
35
+
36
+ const NOT_RECORDED = "—";
37
+
38
+ /** Name each recorded token type, such as `42k input · 4.6k output`. */
39
+ function tokenTypes(
40
+ usage: ConversationModelUsage["usage"],
41
+ ): string | undefined {
42
+ const summary = summarizeUsage(usage);
43
+ const text = summary
44
+ ? tokenTypeRows(summary)
45
+ .map((row) => `${row.value} ${row.label}`)
46
+ .join(" · ")
47
+ : "";
48
+ return text || undefined;
49
+ }
50
+
51
+ /**
52
+ * One usage table. Every table in the breakdown shares column widths, so the
53
+ * cost column lines up from table to table.
54
+ */
55
+ function BreakdownTable(props: {
56
+ /** Header of the count column, such as Tokens or Calls. */
57
+ countLabel: string;
58
+ label: string;
59
+ rows: BreakdownRow[];
60
+ total?: { cost: string; count: string; label: string };
61
+ }) {
62
+ return (
63
+ <div className="min-w-0 overflow-x-auto">
64
+ <table
65
+ aria-label={props.label}
66
+ className="w-full min-w-72 table-fixed border-collapse text-xs leading-relaxed"
67
+ >
68
+ <colgroup>
69
+ <col />
70
+ <col className="w-20" />
71
+ <col className="w-20" />
72
+ </colgroup>
73
+ <thead>
74
+ <tr className="border-b border-dashboard-border text-dashboard-text-muted">
75
+ <th scope="col" className="py-2 text-left font-normal">
76
+ {props.label}
77
+ </th>
78
+ <th scope="col" className="py-2 text-right font-normal">
79
+ {props.countLabel}
80
+ </th>
81
+ <th scope="col" className="py-2 text-right font-normal">
82
+ Cost
83
+ </th>
84
+ </tr>
85
+ </thead>
86
+ <tbody>
87
+ {props.rows.map((row) => (
88
+ <tr
89
+ className="border-b border-dashboard-border-subtle last:border-b-0"
90
+ key={row.key}
91
+ >
92
+ <th scope="row" className="py-2 pr-3 text-left font-normal">
93
+ <span className="block truncate text-dashboard-text">
94
+ {row.label}
95
+ </span>
96
+ {row.detail ? (
97
+ <span className="mt-0.5 block text-dashboard-text-faint">
98
+ {row.detail}
99
+ </span>
100
+ ) : null}
101
+ </th>
102
+ <td className="py-2 text-right align-top font-mono tabular-nums text-dashboard-text">
103
+ {row.count}
104
+ </td>
105
+ <td className="py-2 text-right align-top font-mono tabular-nums text-dashboard-text">
106
+ {row.cost}
107
+ </td>
108
+ </tr>
109
+ ))}
110
+ {props.total ? (
111
+ <tr className="border-t border-dashboard-border font-semibold text-dashboard-text">
112
+ <th scope="row" className="py-2 text-left font-semibold">
113
+ {props.total.label}
114
+ </th>
115
+ <td className="py-2 text-right font-mono tabular-nums">
116
+ {props.total.count}
117
+ </td>
118
+ <td className="py-2 text-right font-mono tabular-nums">
119
+ {props.total.cost}
120
+ </td>
121
+ </tr>
122
+ ) : null}
123
+ </tbody>
124
+ </table>
125
+ </div>
126
+ );
127
+ }
128
+
129
+ /**
130
+ * Break a Conversation's usage down by agent, model, and auxiliary
131
+ * operation. The header tooltips show only the largest items; this lists
132
+ * every one with its token types. Render nothing without model or auxiliary
133
+ * usage.
134
+ */
135
+ export function ConversationUsageBreakdown(props: {
136
+ conversation: Conversation | undefined;
137
+ detail?: ConversationDetailReport;
138
+ }) {
139
+ if (!props.conversation) return null;
140
+ const modelUsage = props.detail?.modelUsage ?? [];
141
+ const auxiliaryCosts =
142
+ props.detail?.auxiliaryCosts ?? props.conversation.auxiliaryCosts;
143
+ const usage =
144
+ props.detail?.cumulativeUsage ?? props.conversation.cumulativeUsage;
145
+ const childAgentTotals =
146
+ props.detail?.childAgentTotals ?? props.conversation.childAgentTotals;
147
+ const tokens =
148
+ summarizeModelUsage(modelUsage)?.totalTokens ??
149
+ summarizeUsage(usage)?.totalTokens;
150
+ const modelCost = summarizeModelCost(modelUsage) ?? summarizeCost(usage);
151
+ const cost = totalConversationCost(modelCost, auxiliaryCosts)?.total;
152
+ const operations = [...(auxiliaryCosts?.operations ?? [])].sort(
153
+ (a, b) => b.costUsd - a.costUsd,
154
+ );
155
+ if (!modelUsage.length && !operations.length) return null;
156
+
157
+ const formatCost = costFormatter(cost ?? 0);
158
+ const formatTokens = (value: number | undefined) =>
159
+ value === undefined ? NOT_RECORDED : formatCompactNumber(value);
160
+ const formatMaybeCost = (value: number | undefined) =>
161
+ value === undefined ? NOT_RECORDED : formatCost(value);
162
+ const activeModelId = liveModelId(props.detail);
163
+
164
+ const models = modelUsage
165
+ .map((item) => ({
166
+ cost: summarizeCost(item.usage)?.total,
167
+ item,
168
+ tokens: summarizeUsage(item.usage)?.totalTokens,
169
+ }))
170
+ .sort(
171
+ (a, b) =>
172
+ (b.cost ?? 0) - (a.cost ?? 0) || (b.tokens ?? 0) - (a.tokens ?? 0),
173
+ );
174
+
175
+ const subagents = subagentUsage(childAgentTotals);
176
+ const subagentCost = subagentShare(subagents.cost, cost ?? subagents.cost);
177
+ const subagentTokens = subagentShare(
178
+ subagents.tokens,
179
+ tokens ?? subagents.tokens,
180
+ );
181
+
182
+ return (
183
+ <div className="grid min-w-0 gap-5">
184
+ {childAgentTotals ? (
185
+ <BreakdownTable
186
+ countLabel="Tokens"
187
+ label="Agent"
188
+ rows={[
189
+ {
190
+ cost: formatMaybeCost(
191
+ cost === undefined ? undefined : cost - subagentCost,
192
+ ),
193
+ count: formatTokens(
194
+ tokens === undefined ? undefined : tokens - subagentTokens,
195
+ ),
196
+ key: "self",
197
+ label: "This agent",
198
+ },
199
+ {
200
+ cost: formatCost(subagentCost),
201
+ count: formatTokens(subagentTokens),
202
+ key: "subagents",
203
+ label: `${formatCompactNumber(childAgentTotals.count)} subagent${childAgentTotals.count === 1 ? "" : "s"}`,
204
+ },
205
+ ]}
206
+ total={{
207
+ cost: formatMaybeCost(cost),
208
+ count: formatTokens(tokens),
209
+ label: "Total",
210
+ }}
211
+ />
212
+ ) : null}
213
+ {models.length ? (
214
+ <BreakdownTable
215
+ countLabel="Tokens"
216
+ label="Model"
217
+ rows={models.map(({ cost: itemCost, item, tokens: itemTokens }) => ({
218
+ cost: formatMaybeCost(itemCost),
219
+ count: formatTokens(itemTokens),
220
+ detail: tokenTypes(item.usage),
221
+ key: item.modelId,
222
+ label: (
223
+ <span className="flex min-w-0 items-baseline gap-2">
224
+ <span
225
+ className="min-w-0 truncate font-mono"
226
+ title={item.modelId}
227
+ >
228
+ {modelLabel(item.modelId)}
229
+ </span>
230
+ {item.modelId === activeModelId ? (
231
+ <span className="shrink-0 text-dashboard-text-muted">
232
+ <ShimmerText active>in progress</ShimmerText>
233
+ </span>
234
+ ) : null}
235
+ </span>
236
+ ),
237
+ }))}
238
+ total={
239
+ models.length > 1
240
+ ? {
241
+ cost: formatMaybeCost(modelCost?.total),
242
+ count: formatTokens(tokens),
243
+ label: "All models",
244
+ }
245
+ : undefined
246
+ }
247
+ />
248
+ ) : null}
249
+ {auxiliaryCosts && operations.length ? (
250
+ <BreakdownTable
251
+ countLabel="Calls"
252
+ label="Auxiliary"
253
+ rows={operations.map((operation) => ({
254
+ cost: formatCost(operation.costUsd),
255
+ count: formatCompactNumber(operation.events),
256
+ key: `${operation.namespace}/${operation.name}`,
257
+ label: auxiliaryOperationLabel(operation),
258
+ }))}
259
+ total={
260
+ operations.length > 1
261
+ ? {
262
+ cost: formatCost(auxiliaryCosts.costUsd),
263
+ count: formatCompactNumber(
264
+ operations.reduce((sum, item) => sum + item.events, 0),
265
+ ),
266
+ label: "All auxiliary",
267
+ }
268
+ : undefined
269
+ }
270
+ />
271
+ ) : null}
272
+ <p className="m-0 text-xs leading-relaxed text-dashboard-text-muted">
273
+ Estimated costs.
274
+ {childAgentTotals ? " Totals include subagents at any depth." : null}
275
+ </p>
276
+ </div>
277
+ );
278
+ }