Skip to content

Commit 37e3367

Browse files
committed
feat: custom span inspectors for top-level AI SDK spans (generateText, streamText, generateObject, toolCall, embed)
1 parent 1a81db4 commit 37e3367

File tree

6 files changed

+582
-41
lines changed

6 files changed

+582
-41
lines changed
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { Header3 } from "~/components/primitives/Headers";
2+
import { Paragraph } from "~/components/primitives/Paragraph";
3+
4+
export type AIEmbedData = {
5+
model: string;
6+
provider: string;
7+
value?: string;
8+
durationMs: number;
9+
};
10+
11+
export function extractAIEmbedData(
12+
properties: Record<string, unknown>,
13+
durationMs: number
14+
): AIEmbedData | undefined {
15+
const ai = properties.ai;
16+
if (!ai || typeof ai !== "object") return undefined;
17+
18+
const a = ai as Record<string, unknown>;
19+
if (a.operationId !== "ai.embed") return undefined;
20+
21+
const aiModel = a.model;
22+
if (!aiModel || typeof aiModel !== "object") return undefined;
23+
24+
const m = aiModel as Record<string, unknown>;
25+
const model = typeof m.id === "string" ? m.id : undefined;
26+
if (!model) return undefined;
27+
28+
return {
29+
model,
30+
provider: typeof m.provider === "string" ? m.provider : "unknown",
31+
value: typeof a.value === "string" ? a.value : undefined,
32+
durationMs,
33+
};
34+
}
35+
36+
export function AIEmbedSpanDetails({ data }: { data: AIEmbedData }) {
37+
return (
38+
<div className="flex h-full flex-col overflow-hidden">
39+
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
40+
<div className="flex flex-col px-3">
41+
{/* Model info */}
42+
<div className="flex flex-col gap-1 py-2.5">
43+
<div className="flex flex-col text-xs @container">
44+
<MetricRow label="Model" value={data.model} />
45+
<MetricRow label="Provider" value={data.provider} />
46+
<MetricRow label="Duration" value={formatDuration(data.durationMs)} />
47+
</div>
48+
</div>
49+
50+
{/* Input value */}
51+
{data.value && (
52+
<div className="flex flex-col gap-1.5 py-2.5">
53+
<Header3>Input</Header3>
54+
<div className="rounded-md border border-grid-bright bg-charcoal-750/50 px-3.5 py-2">
55+
<Paragraph variant="small/dimmed">{data.value}</Paragraph>
56+
</div>
57+
</div>
58+
)}
59+
</div>
60+
</div>
61+
</div>
62+
);
63+
}
64+
65+
function MetricRow({ label, value }: { label: string; value: React.ReactNode }) {
66+
return (
67+
<div className="grid h-7 grid-cols-[1fr_auto] items-center gap-4 rounded-sm px-1.5 transition odd:bg-charcoal-750/40 @[28rem]:grid-cols-[8rem_1fr] hover:bg-white/[0.04]">
68+
<span className="text-text-dimmed">{label}</span>
69+
<span className="text-right text-text-bright @[28rem]:text-left">{value}</span>
70+
</div>
71+
);
72+
}
73+
74+
function formatDuration(ms: number): string {
75+
if (ms < 1000) return `${Math.round(ms)}ms`;
76+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
77+
const mins = Math.floor(ms / 60_000);
78+
const secs = ((ms % 60_000) / 1000).toFixed(0);
79+
return `${mins}m ${secs}s`;
80+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { Header3 } from "~/components/primitives/Headers";
2+
import { CodeBlock } from "~/components/code/CodeBlock";
3+
import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue";
4+
5+
export type AIToolCallData = {
6+
toolName: string;
7+
toolCallId: string;
8+
args?: string;
9+
durationMs: number;
10+
};
11+
12+
export function extractAIToolCallData(
13+
properties: Record<string, unknown>,
14+
durationMs: number
15+
): AIToolCallData | undefined {
16+
const ai = properties.ai;
17+
if (!ai || typeof ai !== "object") return undefined;
18+
19+
const a = ai as Record<string, unknown>;
20+
if (a.operationId !== "ai.toolCall") return undefined;
21+
22+
const toolCall = a.toolCall;
23+
if (!toolCall || typeof toolCall !== "object") return undefined;
24+
25+
const tc = toolCall as Record<string, unknown>;
26+
const toolName = typeof tc.name === "string" ? tc.name : undefined;
27+
if (!toolName) return undefined;
28+
29+
return {
30+
toolName,
31+
toolCallId: typeof tc.id === "string" ? tc.id : "",
32+
args: typeof tc.args === "string" ? tc.args : undefined,
33+
durationMs,
34+
};
35+
}
36+
37+
export function AIToolCallSpanDetails({ data }: { data: AIToolCallData }) {
38+
return (
39+
<div className="flex h-full flex-col overflow-hidden">
40+
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
41+
<div className="flex flex-col px-3">
42+
{/* Tool info */}
43+
<div className="flex flex-col gap-1 py-2.5">
44+
<div className="flex flex-col text-xs @container">
45+
<MetricRow label="Tool" value={data.toolName} />
46+
{data.toolCallId && (
47+
<MetricRow
48+
label="Call ID"
49+
value={<TruncatedCopyableValue value={data.toolCallId} />}
50+
/>
51+
)}
52+
<MetricRow label="Duration" value={formatDuration(data.durationMs)} />
53+
</div>
54+
</div>
55+
56+
{/* Input args */}
57+
{data.args && (
58+
<div className="flex flex-col gap-1.5 py-2.5">
59+
<Header3>Input</Header3>
60+
<CodeBlock
61+
code={tryPrettyJson(data.args)}
62+
maxLines={20}
63+
showLineNumbers={false}
64+
showCopyButton
65+
language="json"
66+
/>
67+
</div>
68+
)}
69+
</div>
70+
</div>
71+
</div>
72+
);
73+
}
74+
75+
function MetricRow({ label, value }: { label: string; value: React.ReactNode }) {
76+
return (
77+
<div className="grid h-7 grid-cols-[1fr_auto] items-center gap-4 rounded-sm px-1.5 transition odd:bg-charcoal-750/40 @[28rem]:grid-cols-[8rem_1fr] hover:bg-white/[0.04]">
78+
<span className="text-text-dimmed">{label}</span>
79+
<span className="text-right text-text-bright @[28rem]:text-left">{value}</span>
80+
</div>
81+
);
82+
}
83+
84+
function formatDuration(ms: number): string {
85+
if (ms < 1000) return `${Math.round(ms)}ms`;
86+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
87+
const mins = Math.floor(ms / 60_000);
88+
const secs = ((ms % 60_000) / 1000).toFixed(0);
89+
return `${mins}m ${secs}s`;
90+
}
91+
92+
function tryPrettyJson(value: string): string {
93+
try {
94+
return JSON.stringify(JSON.parse(value), null, 2);
95+
} catch {
96+
return value;
97+
}
98+
}

0 commit comments

Comments
 (0)