@sentry/junior-dashboard 0.232.0 → 0.233.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.
@@ -5,7 +5,12 @@ import { createPortal } from "react-dom";
5
5
  import { Drawer } from "../components/Drawer";
6
6
  import { cn } from "../styles";
7
7
  import type { ConversationTranscript } from "../types";
8
- import { eventLogSummary, eventLogTone } from "./eventLog";
8
+ import {
9
+ eventLogModel,
10
+ eventLogSummary,
11
+ eventLogTone,
12
+ eventLogUsage,
13
+ } from "./eventLog";
9
14
  import { EventDetails } from "./EventDetails";
10
15
  import { HighlightText, useTranscriptSearch } from "./transcriptSearch";
11
16
 
@@ -25,6 +30,8 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
25
30
  () =>
26
31
  events.map((event) => ({
27
32
  event,
33
+ model: eventLogModel(event),
34
+ usage: eventLogUsage(event),
28
35
  summary: eventLogSummary(event.data).replace(/\s+/g, " ").slice(0, 300),
29
36
  })),
30
37
  [events],
@@ -33,8 +40,8 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
33
40
  const visibleRows = useMemo(
34
41
  () =>
35
42
  search.active
36
- ? rows.filter(({ event, summary }) =>
37
- `${summary} ${JSON.stringify(event)}`
43
+ ? rows.filter(({ event, summary, usage }) =>
44
+ `${summary} ${usage} ${JSON.stringify(event)}`
38
45
  .toLowerCase()
39
46
  .includes(search.normalizedQuery),
40
47
  )
@@ -79,7 +86,7 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
79
86
  aria-label="Events"
80
87
  className="m-0 list-none divide-y divide-dashboard-border-subtle p-0"
81
88
  >
82
- {visibleRows.map(({ event, summary }) => (
89
+ {visibleRows.map(({ event, summary, model, usage }) => (
83
90
  <li key={event.seq}>
84
91
  <button
85
92
  aria-haspopup="dialog"
@@ -105,11 +112,44 @@ export const ConversationEventLog = memo(function ConversationEventLog(props: {
105
112
  >
106
113
  <HighlightText text={event.data.type} />
107
114
  </span>
108
- <span
109
- className="col-span-2 row-start-2 truncate text-dashboard-text @min-[48rem]:col-span-1 @min-[48rem]:row-auto"
110
- title={summary}
111
- >
112
- <HighlightText text={summary} />
115
+ <span className="col-span-2 row-start-2 grid min-w-0 gap-1 text-dashboard-text @min-[48rem]:col-span-1 @min-[48rem]:row-auto">
116
+ <span className="flex min-w-0 flex-wrap items-baseline gap-x-2">
117
+ <span
118
+ className={cn(
119
+ "break-all",
120
+ model
121
+ ? "font-semibold text-violet-300"
122
+ : "text-dashboard-text-muted",
123
+ )}
124
+ >
125
+ <HighlightText
126
+ text={model?.modelId ?? "Model not recorded"}
127
+ />
128
+ </span>
129
+ {model?.modelProfile ? (
130
+ <span className="text-dashboard-text-muted">
131
+ <HighlightText text={model.modelProfile} />
132
+ </span>
133
+ ) : null}
134
+ {model?.reasoningLevel ? (
135
+ <span className="text-dashboard-text-muted">
136
+ <HighlightText
137
+ text={`${model.reasoningLevel} reasoning`}
138
+ />
139
+ </span>
140
+ ) : null}
141
+ </span>
142
+ <span className="truncate" title={summary}>
143
+ <HighlightText text={summary} />
144
+ </span>
145
+ {usage ? (
146
+ <span
147
+ className="text-dashboard-text-muted"
148
+ title="Usage for this event only; costs are estimated USD"
149
+ >
150
+ <HighlightText text={usage} />
151
+ </span>
152
+ ) : null}
113
153
  </span>
114
154
  <ChevronRight
115
155
  aria-hidden="true"
@@ -3,13 +3,64 @@ import type { ReactNode } from "react";
3
3
 
4
4
  import { RedactedMarker } from "./TranscriptRedacted";
5
5
  import { TranscriptText } from "./TranscriptText";
6
+ import { eventLogModel } from "./eventLog";
7
+ import { formatCostBreakdown } from "../format";
8
+ import { EventUsage } from "./EventUsage";
6
9
 
7
10
  /** Present event content as readable sections, with the exact report available on demand. */
8
11
  export function EventDetails({ event }: { event: ConversationReportEvent }) {
9
12
  const data = event.data;
13
+ const model = eventLogModel(event);
14
+ const { usage, ...callAttributes } = event.modelCall ?? {};
10
15
  let content: ReactNode;
11
16
 
12
17
  switch (data.type) {
18
+ case "turn_routed": {
19
+ const {
20
+ type: _,
21
+ modelId: _modelId,
22
+ modelProfile: _profile,
23
+ reasoningLevel: _reasoning,
24
+ costUsd,
25
+ ...metadata
26
+ } = data;
27
+ content = (
28
+ <DetailSection title="Route selection">
29
+ <DetailValue
30
+ value={{
31
+ ...metadata,
32
+ ...(costUsd !== undefined
33
+ ? { routerCostUsd: formatCostBreakdown({ total: costUsd }) }
34
+ : undefined),
35
+ }}
36
+ />
37
+ </DetailSection>
38
+ );
39
+ break;
40
+ }
41
+ case "handoff": {
42
+ const {
43
+ type: _,
44
+ modelId: _modelId,
45
+ modelProfile: _profile,
46
+ reasoningLevel: _reasoning,
47
+ summary,
48
+ ...metadata
49
+ } = data;
50
+ content = (
51
+ <>
52
+ {Object.keys(metadata).length > 0 ? (
53
+ <DetailValue value={metadata} />
54
+ ) : null}
55
+ {summary ? (
56
+ <DetailSection title="Continuation summary">
57
+ <TranscriptText text={summary} />
58
+ </DetailSection>
59
+ ) : null}
60
+ </>
61
+ );
62
+ break;
63
+ }
13
64
  case "message": {
14
65
  const { type: _, text, redacted, ...metadata } = data;
15
66
  content = (
@@ -98,7 +149,30 @@ export function EventDetails({ event }: { event: ConversationReportEvent }) {
98
149
  }
99
150
 
100
151
  return (
101
- <div className="grid min-w-0 gap-6">
152
+ <div className="@container grid min-w-0 gap-6">
153
+ <DetailSection
154
+ title={
155
+ data.type === "handoff" ? "Target configuration" : "Configuration"
156
+ }
157
+ >
158
+ <DetailValue
159
+ value={{
160
+ model: model?.modelId ?? "Not recorded",
161
+ ...(model
162
+ ? {
163
+ modelProfile: model.modelProfile ?? "Not recorded",
164
+ reasoningLevel: model.reasoningLevel ?? "Not recorded",
165
+ }
166
+ : undefined),
167
+ ...callAttributes,
168
+ }}
169
+ />
170
+ </DetailSection>
171
+ {event.modelCall ? (
172
+ <DetailSection title="Call usage">
173
+ <EventUsage usage={usage} />
174
+ </DetailSection>
175
+ ) : null}
102
176
  {content}
103
177
  <details className="border-t border-dashboard-border pt-4">
104
178
  <summary className="cursor-pointer text-xs text-dashboard-text-muted hover:text-dashboard-text focus-visible:outline-2 focus-visible:outline-dashboard-focus">
@@ -114,8 +188,8 @@ export function EventDetails({ event }: { event: ConversationReportEvent }) {
114
188
 
115
189
  function DetailSection(props: { title: string; children: ReactNode }) {
116
190
  return (
117
- <section aria-label={props.title} className="grid min-w-0 gap-3">
118
- <h3 className="m-0 break-words text-sm font-semibold text-dashboard-text">
191
+ <section aria-label={props.title} className="grid min-w-0 gap-2">
192
+ <h3 className="m-0 border-b border-dashboard-border pb-2 text-sm font-semibold text-dashboard-text">
119
193
  {props.title}
120
194
  </h3>
121
195
  {props.children}
@@ -131,7 +205,8 @@ function fieldLabel(key: string): string {
131
205
  return (words.charAt(0).toUpperCase() + words.slice(1))
132
206
  .replace(/\bid\b/gi, "ID")
133
207
  .replace(/\bids\b/gi, "IDs")
134
- .replace(/\busd\b/gi, "USD");
208
+ .replace(/\busd\b/gi, "USD")
209
+ .replace(/\bapi\b/gi, "API");
135
210
  }
136
211
 
137
212
  // Keep arbitrary tool and context payloads readable without guessing their schema.
@@ -150,9 +225,11 @@ function DetailValue({ value }: { value: unknown }) {
150
225
  );
151
226
  }
152
227
  if (typeof value === "object" && value !== null) {
153
- const fields = Object.entries(value);
228
+ const fields = Object.entries(value).filter(
229
+ ([, entry]) => entry !== undefined,
230
+ );
154
231
  return fields.length ? (
155
- <dl className="m-0 grid min-w-0 gap-3">
232
+ <dl className="m-0 grid min-w-0 divide-y divide-dashboard-border-subtle">
156
233
  {fields.map(([key, entry]) => {
157
234
  const nested = typeof entry === "object" && entry !== null;
158
235
  return (
@@ -160,15 +237,21 @@ function DetailValue({ value }: { value: unknown }) {
160
237
  key={key}
161
238
  className={
162
239
  nested
163
- ? "grid min-w-0 gap-2"
164
- : "grid min-w-0 grid-cols-[minmax(0,8rem)_minmax(0,1fr)] gap-3"
240
+ ? "grid min-w-0 gap-2 py-2"
241
+ : "grid min-w-0 grid-cols-[minmax(0,7rem)_minmax(0,1fr)] items-baseline gap-x-4 py-2 @min-[30rem]:grid-cols-[minmax(0,10rem)_minmax(0,1fr)]"
165
242
  }
166
243
  >
167
- <dt className="break-words text-sm text-dashboard-text-muted">
244
+ <dt className="break-words text-xs leading-relaxed text-dashboard-text-muted">
168
245
  {fieldLabel(key)}
169
246
  </dt>
170
247
  <dd className="m-0 min-w-0">
171
- <DetailValue value={entry} />
248
+ {nested ? (
249
+ <div className="border-l border-dashboard-border pl-3">
250
+ <DetailValue value={entry} />
251
+ </div>
252
+ ) : (
253
+ <DetailValue value={entry} />
254
+ )}
172
255
  </dd>
173
256
  </div>
174
257
  );
@@ -179,7 +262,7 @@ function DetailValue({ value }: { value: unknown }) {
179
262
  );
180
263
  }
181
264
  return (
182
- <span className="block whitespace-pre-wrap break-words font-mono text-sm leading-relaxed text-dashboard-text [overflow-wrap:anywhere]">
265
+ <span className="block whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-dashboard-text [overflow-wrap:anywhere]">
183
266
  {value === null ? "null" : value === "" ? "Empty text" : String(value)}
184
267
  </span>
185
268
  );
@@ -0,0 +1,118 @@
1
+ import type { ConversationUsage } from "@sentry/junior/api/schema";
2
+
3
+ import { formatCostBreakdown, summarizeCost } from "../format";
4
+ import { cn } from "../styles";
5
+
6
+ /** Align recorded token counts with their costs without adding overlapping counters. */
7
+ export function EventUsage({ usage }: { usage?: ConversationUsage }) {
8
+ const cost = summarizeCost(usage);
9
+ const rows = [
10
+ { label: "Input", tokens: usage?.inputTokens, cost: usage?.cost?.input },
11
+ { label: "Output", tokens: usage?.outputTokens, cost: usage?.cost?.output },
12
+ {
13
+ label: "Cache read",
14
+ tokens: usage?.cachedInputTokens,
15
+ cost: usage?.cost?.cacheRead,
16
+ },
17
+ {
18
+ label: "Cache write",
19
+ tokens: usage?.cacheCreationTokens,
20
+ cost: usage?.cost?.cacheWrite,
21
+ },
22
+ { label: "Reasoning", tokens: usage?.reasoningTokens },
23
+ { label: "Total", tokens: usage?.totalTokens, cost: cost?.total },
24
+ ].filter((row) => row.tokens !== undefined || row.cost !== undefined);
25
+
26
+ if (rows.length === 0) {
27
+ return (
28
+ <p className="m-0 text-sm text-dashboard-text-muted">
29
+ Token usage and cost were not recorded for this call.
30
+ </p>
31
+ );
32
+ }
33
+
34
+ return (
35
+ <>
36
+ <div className="min-w-0 overflow-x-auto">
37
+ <table
38
+ aria-label="Model call token and cost breakdown"
39
+ className="w-full min-w-72 table-fixed border-collapse text-xs leading-relaxed"
40
+ >
41
+ <colgroup>
42
+ <col />
43
+ <col className="w-24" />
44
+ <col className="w-24" />
45
+ </colgroup>
46
+ <thead>
47
+ <tr className="border-b border-dashboard-border text-dashboard-text-muted">
48
+ <th scope="col" className="py-2 text-left font-normal">
49
+ Usage
50
+ </th>
51
+ <th scope="col" className="py-2 text-right font-normal">
52
+ Tokens
53
+ </th>
54
+ <th scope="col" className="py-2 text-right font-normal">
55
+ Cost (USD)
56
+ </th>
57
+ </tr>
58
+ </thead>
59
+ <tbody>
60
+ {rows.map((row) => (
61
+ <tr
62
+ key={row.label}
63
+ className={cn(
64
+ "border-b border-dashboard-border-subtle last:border-b-0",
65
+ row.label === "Total" &&
66
+ "border-t border-dashboard-border font-semibold",
67
+ )}
68
+ >
69
+ <th
70
+ scope="row"
71
+ className={cn(
72
+ "py-2 text-left font-normal",
73
+ row.label === "Total"
74
+ ? "font-semibold text-dashboard-text"
75
+ : "text-dashboard-text-muted",
76
+ )}
77
+ >
78
+ {row.label}
79
+ </th>
80
+ <td className="py-2 text-right font-mono tabular-nums text-dashboard-text">
81
+ {row.tokens === undefined ? (
82
+ <span
83
+ aria-label="Not recorded"
84
+ className="text-dashboard-text-faint"
85
+ >
86
+ —
87
+ </span>
88
+ ) : (
89
+ row.tokens.toLocaleString("en-US")
90
+ )}
91
+ </td>
92
+ <td className="py-2 text-right font-mono tabular-nums text-dashboard-text">
93
+ {row.cost === undefined ? (
94
+ <span
95
+ aria-label="Not recorded separately"
96
+ className="text-dashboard-text-faint"
97
+ >
98
+ —
99
+ </span>
100
+ ) : (
101
+ <span title={`${row.cost} USD`}>
102
+ {formatCostBreakdown({ total: row.cost })}
103
+ </span>
104
+ )}
105
+ </td>
106
+ </tr>
107
+ ))}
108
+ </tbody>
109
+ </table>
110
+ </div>
111
+ <p className="m-0 text-xs leading-relaxed text-dashboard-text-muted">
112
+ This call only · Estimated costs
113
+ <br />
114
+ Input excludes cache reads and writes. Reasoning is included in output.
115
+ </p>
116
+ </>
117
+ );
118
+ }
@@ -1,6 +1,57 @@
1
- import type { ConversationReportEventData } from "@sentry/junior/api/schema";
1
+ import type {
2
+ ConversationReportEvent,
3
+ ConversationReportEventData,
4
+ } from "@sentry/junior/api/schema";
2
5
 
3
6
  import { toolCallPreview } from "./toolCallPreview";
7
+ import {
8
+ formatCompactNumber,
9
+ formatCostBreakdown,
10
+ summarizeCost,
11
+ } from "../format";
12
+
13
+ /** Read recorded event configuration, including reports from before model context was added. */
14
+ export function eventLogModel(
15
+ event: ConversationReportEvent,
16
+ ): ConversationReportEvent["model"] {
17
+ if (event.model) return event.model;
18
+ const data = event.data;
19
+ if (data.type === "turn_routed" || data.type === "handoff") {
20
+ return {
21
+ modelId: data.modelId,
22
+ modelProfile: data.modelProfile,
23
+ reasoningLevel: data.reasoningLevel,
24
+ };
25
+ }
26
+ return undefined;
27
+ }
28
+
29
+ /** Keep call costs distinct from router cost and never repeat usage on tool results. */
30
+ export function eventLogUsage(event: ConversationReportEvent): string {
31
+ const usage = event.modelCall?.usage;
32
+ const cost = summarizeCost(usage);
33
+ const fields = [
34
+ cost ? `${formatCostBreakdown(cost)} call` : undefined,
35
+ event.data.type === "turn_routed" && event.data.costUsd !== undefined
36
+ ? `${formatCostBreakdown({ total: event.data.costUsd })} router`
37
+ : undefined,
38
+ ...(
39
+ [
40
+ ["inputTokens", "in"],
41
+ ["outputTokens", "out"],
42
+ ["cachedInputTokens", "cache read"],
43
+ ["cacheCreationTokens", "cache write"],
44
+ ["reasoningTokens", "reasoning"],
45
+ ["totalTokens", "total"],
46
+ ] as const
47
+ ).map(([key, label]) =>
48
+ usage?.[key] !== undefined
49
+ ? `${formatCompactNumber(usage[key])} ${label}`
50
+ : undefined,
51
+ ),
52
+ ];
53
+ return fields.filter((field) => field !== undefined).join(" · ");
54
+ }
4
55
 
5
56
  /** Summarize an event without merging it with earlier or later events. */
6
57
  export function eventLogSummary(data: ConversationReportEventData): string {
@@ -10,9 +61,11 @@ export function eventLogSummary(data: ConversationReportEventData): string {
10
61
  case "message_handled":
11
62
  return data.messageId;
12
63
  case "assistant_message":
13
- return data.parts
14
- .map((part) => (part.redacted ? "Content hidden" : part.text))
15
- .join(" · ");
64
+ return (
65
+ data.parts
66
+ .map((part) => (part.redacted ? "Content hidden" : part.text))
67
+ .join(" · ") || "Model call recorded"
68
+ );
16
69
  case "tool_calls":
17
70
  return data.calls
18
71
  .map((call) => {
@@ -25,7 +78,7 @@ export function eventLogSummary(data: ConversationReportEventData): string {
25
78
  ? `${data.state} · ${data.failureCode}${data.failureReason ? ` · ${data.failureReason}` : ""}`
26
79
  : `${data.state} · ${data.turnId}`;
27
80
  case "turn_routed":
28
- return `${data.modelProfile} · ${data.modelId} · ${data.reasoningLevel}`;
81
+ return `Route selected · ${data.source}${data.confidence !== undefined ? ` · ${Math.round(data.confidence * 100)}% confidence` : ""}`;
29
82
  case "turn_context":
30
83
  return `${data.pluginName} · ${data.kind} · v${data.version}`;
31
84
  case "guardian_action_reviewed":
@@ -39,7 +92,7 @@ export function eventLogSummary(data: ConversationReportEventData): string {
39
92
  case "compaction":
40
93
  return data.summary ?? "Agent history compacted";
41
94
  case "handoff":
42
- return `${data.modelProfile} · ${data.modelId}`;
95
+ return "Handoff · New configuration applied";
43
96
  case "subagent":
44
97
  return `${data.subagentKind} · ${data.status} · ${data.childConversationId}`;
45
98
  }
@@ -242,6 +242,8 @@ export function transcriptMessagesFromEvents(
242
242
  }
243
243
 
244
244
  if (data.type === "assistant_message") {
245
+ // Calls with no reasoning still carry usage in the event log, not a chat bubble.
246
+ if (data.parts.length === 0) continue;
245
247
  messages.push(
246
248
  eventMessage(
247
249
  event,
@@ -73,7 +73,7 @@ export function transcriptJuniorMessageVersion(
73
73
  data.redacted ? "redacted" : (data.text?.length ?? 0),
74
74
  ].join(":");
75
75
  }
76
- if (data.type === "assistant_message") {
76
+ if (data.type === "assistant_message" && data.parts.length > 0) {
77
77
  const lastPart = data.parts.at(-1);
78
78
  return [
79
79
  event.seq,
@@ -117,7 +117,7 @@ function changesVisibleTranscript(event: ConversationReportEvent): boolean {
117
117
  if (data.type === "turn_lifecycle") return data.state === "failed";
118
118
  return (
119
119
  data.type === "message" ||
120
- data.type === "assistant_message" ||
120
+ (data.type === "assistant_message" && data.parts.length > 0) ||
121
121
  data.type === "tool_calls" ||
122
122
  data.type === "subagent" ||
123
123
  data.type === "structured_event" ||
@@ -162,8 +162,9 @@ function reportEvent(
162
162
  seq: number,
163
163
  createdAt: string,
164
164
  data: ConversationReportEventData,
165
+ metadata?: Pick<ConversationReportEvent, "model" | "modelCall">,
165
166
  ): ConversationReportEvent {
166
- return { seq, createdAt, data };
167
+ return { seq, createdAt, data, ...metadata };
167
168
  }
168
169
 
169
170
  type DetailOptions = Omit<
@@ -453,38 +454,81 @@ function activeConversation(nowMs: number): ConversationDetailReport {
453
454
  reasoningLevel: "high",
454
455
  confidence: 0.93,
455
456
  source: "router",
457
+ costUsd: 0.0012,
456
458
  }),
457
- reportEvent(4, iso(Date.parse(startedAt), 10_000), {
458
- type: "tool_calls",
459
- calls: [
460
- {
461
- toolCallId: "active-search",
462
- name: "webSearch",
463
- status: "running",
459
+ reportEvent(
460
+ 4,
461
+ iso(Date.parse(startedAt), 10_000),
462
+ {
463
+ type: "tool_calls",
464
+ calls: [
465
+ {
466
+ toolCallId: "active-search",
467
+ name: "webSearch",
468
+ status: "running",
469
+ },
470
+ ],
471
+ },
472
+ {
473
+ model: {
474
+ modelId: "openai/gpt-5.6-sol",
475
+ modelProfile: "handoff",
476
+ reasoningLevel: "high",
464
477
  },
465
- ],
466
- }),
467
- reportEvent(5, iso(Date.parse(startedAt), 14_000), {
468
- type: "tool_calls",
469
- calls: [
470
- {
471
- toolCallId: "active-search",
472
- name: "webSearch",
473
- status: "completed",
474
- startedSeq: 4,
475
- startedAt: iso(Date.parse(startedAt), 10_000),
476
- input: { query: "checkout latency last deployment" },
477
- output: {
478
- results: [
479
- {
480
- title: "payments-v42 deploy notes",
481
- url: "https://docs.sentry.io",
482
- },
483
- ],
478
+ modelCall: {
479
+ provider: "vercel-ai-gateway",
480
+ api: "openai-responses",
481
+ stopReason: "toolUse",
482
+ usage: {
483
+ inputTokens: 12500,
484
+ outputTokens: 800,
485
+ cachedInputTokens: 32000,
486
+ cacheCreationTokens: 2000,
487
+ reasoningTokens: 600,
488
+ totalTokens: 47300,
489
+ cost: {
490
+ input: 0.025,
491
+ output: 0.008,
492
+ cacheRead: 0.0064,
493
+ cacheWrite: 0.005,
494
+ total: 0.0444,
495
+ },
484
496
  },
485
497
  },
486
- ],
487
- }),
498
+ },
499
+ ),
500
+ reportEvent(
501
+ 5,
502
+ iso(Date.parse(startedAt), 14_000),
503
+ {
504
+ type: "tool_calls",
505
+ calls: [
506
+ {
507
+ toolCallId: "active-search",
508
+ name: "webSearch",
509
+ status: "completed",
510
+ startedSeq: 4,
511
+ startedAt: iso(Date.parse(startedAt), 10_000),
512
+ input: { query: "checkout latency last deployment" },
513
+ output: {
514
+ results: [
515
+ {
516
+ title: "payments-v42 deploy notes",
517
+ url: "https://docs.sentry.io",
518
+ },
519
+ ],
520
+ },
521
+ },
522
+ ],
523
+ },
524
+ {
525
+ model: {
526
+ modelId: "openai/gpt-5.6-sol",
527
+ modelProfile: "handoff",
528
+ reasoningLevel: "high",
529
+ },
530
+ },
531
+ ),
488
532
  // Mixed markdown keeps font/legibility QA honest for long assistant replies.
489
533
  reportEvent(6, iso(Date.parse(startedAt), 22_000), {
490
534
  type: "message",