@townco/debugger 0.1.4 → 0.1.6
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.
- package/package.json +6 -3
- package/src/App.tsx +12 -3
- package/src/components/DebuggerHeader.tsx +85 -0
- package/src/components/DebuggerLayout.tsx +30 -0
- package/src/components/LogList.tsx +2 -2
- package/src/components/MessageBubble.tsx +28 -0
- package/src/components/SessionTraceList.tsx +38 -33
- package/src/components/SpanDetailsPanel.tsx +371 -0
- package/src/components/SpanIcon.tsx +42 -0
- package/src/components/SpanTimeline.tsx +450 -0
- package/src/components/SpanTree.tsx +28 -8
- package/src/components/TraceDetailContent.tsx +97 -68
- package/src/components/TurnHeader.tsx +50 -0
- package/src/components/TurnMetadataPanel.tsx +63 -0
- package/src/components/ui/tabs.tsx +50 -0
- package/src/db.ts +33 -10
- package/src/index.ts +7 -1
- package/src/lib/spanTypeDetector.ts +35 -0
- package/src/lib/timelineCalculator.ts +32 -0
- package/src/lib/turnExtractor.ts +132 -0
- package/src/pages/SessionList.tsx +142 -0
- package/src/pages/SessionView.tsx +8 -19
- package/src/pages/TraceDetail.tsx +5 -8
- package/src/pages/TraceList.tsx +66 -61
- package/src/server.ts +53 -1
- package/src/types.ts +23 -1
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
CardHeader,
|
|
7
|
-
CardTitle,
|
|
8
|
-
} from "@/components/ui/card";
|
|
9
|
-
import type { TraceDetail as TraceDetailType } from "../types";
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { Card, CardContent } from "@/components/ui/card";
|
|
3
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
4
|
+
import { aggregateTokenUsage, countToolCalls } from "../lib/turnExtractor";
|
|
5
|
+
import type { Log, Span, TraceDetail as TraceDetailType } from "../types";
|
|
10
6
|
import { LogList } from "./LogList";
|
|
11
|
-
import {
|
|
7
|
+
import { SpanTimeline } from "./SpanTimeline";
|
|
8
|
+
import { TurnHeader } from "./TurnHeader";
|
|
9
|
+
import { TurnMetadataPanel } from "./TurnMetadataPanel";
|
|
12
10
|
|
|
13
11
|
function formatDuration(startNano: number, endNano: number): string {
|
|
14
12
|
const ms = (endNano - startNano) / 1_000_000;
|
|
@@ -25,6 +23,86 @@ interface TraceDetailContentProps {
|
|
|
25
23
|
compact?: boolean;
|
|
26
24
|
}
|
|
27
25
|
|
|
26
|
+
interface TraceContentProps {
|
|
27
|
+
trace: NonNullable<TraceDetailType["trace"]>;
|
|
28
|
+
spans: Span[];
|
|
29
|
+
logs: Log[];
|
|
30
|
+
messages: {
|
|
31
|
+
userInput: string | null;
|
|
32
|
+
llmOutput: string | null;
|
|
33
|
+
};
|
|
34
|
+
compact?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function TraceContent({
|
|
38
|
+
trace,
|
|
39
|
+
spans,
|
|
40
|
+
logs,
|
|
41
|
+
messages,
|
|
42
|
+
compact = false,
|
|
43
|
+
}: TraceContentProps) {
|
|
44
|
+
// Use server-extracted messages
|
|
45
|
+
const tokenUsage = useMemo(() => aggregateTokenUsage(spans), [spans]);
|
|
46
|
+
const toolCallCount = useMemo(() => countToolCalls(spans), [spans]);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
className={
|
|
51
|
+
compact
|
|
52
|
+
? "p-4 space-y-6 bg-background min-h-screen"
|
|
53
|
+
: "p-6 space-y-6 bg-background min-h-screen"
|
|
54
|
+
}
|
|
55
|
+
>
|
|
56
|
+
{/* Top section: Input/Output (left) + Metadata (right) */}
|
|
57
|
+
<div className="grid gap-6 lg:grid-cols-[1fr_326px]">
|
|
58
|
+
{/* Left: Turn header with input/output */}
|
|
59
|
+
<TurnHeader
|
|
60
|
+
userInput={messages.userInput}
|
|
61
|
+
llmOutput={messages.llmOutput}
|
|
62
|
+
/>
|
|
63
|
+
|
|
64
|
+
{/* Right: Metadata panel */}
|
|
65
|
+
<TurnMetadataPanel
|
|
66
|
+
traceId={trace.trace_id}
|
|
67
|
+
duration={formatDuration(
|
|
68
|
+
trace.start_time_unix_nano,
|
|
69
|
+
trace.end_time_unix_nano,
|
|
70
|
+
)}
|
|
71
|
+
startTime={formatTimestamp(trace.start_time_unix_nano)}
|
|
72
|
+
tokens={tokenUsage}
|
|
73
|
+
toolCount={toolCallCount}
|
|
74
|
+
spanCount={spans.length}
|
|
75
|
+
logCount={logs.length}
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
{/* Bottom section: Tabbed interface full width */}
|
|
80
|
+
<div>
|
|
81
|
+
<Tabs defaultValue="spans">
|
|
82
|
+
<TabsList>
|
|
83
|
+
<TabsTrigger value="spans">Span</TabsTrigger>
|
|
84
|
+
<TabsTrigger value="logs">Logs</TabsTrigger>
|
|
85
|
+
</TabsList>
|
|
86
|
+
<TabsContent value="spans" className="mt-6">
|
|
87
|
+
<SpanTimeline
|
|
88
|
+
spans={spans}
|
|
89
|
+
traceStart={trace.start_time_unix_nano}
|
|
90
|
+
traceEnd={trace.end_time_unix_nano}
|
|
91
|
+
/>
|
|
92
|
+
</TabsContent>
|
|
93
|
+
<TabsContent value="logs" className="mt-6">
|
|
94
|
+
<Card>
|
|
95
|
+
<CardContent className="p-4">
|
|
96
|
+
<LogList logs={logs} />
|
|
97
|
+
</CardContent>
|
|
98
|
+
</Card>
|
|
99
|
+
</TabsContent>
|
|
100
|
+
</Tabs>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
28
106
|
export function TraceDetailContent({
|
|
29
107
|
traceId,
|
|
30
108
|
compact = false,
|
|
@@ -56,7 +134,7 @@ export function TraceDetailContent({
|
|
|
56
134
|
|
|
57
135
|
if (loading) {
|
|
58
136
|
return (
|
|
59
|
-
<div className={compact ? "p-4" : "p-
|
|
137
|
+
<div className={compact ? "p-4" : "p-6"}>
|
|
60
138
|
<div className="text-muted-foreground">Loading trace...</div>
|
|
61
139
|
</div>
|
|
62
140
|
);
|
|
@@ -64,68 +142,19 @@ export function TraceDetailContent({
|
|
|
64
142
|
|
|
65
143
|
if (error || !data?.trace) {
|
|
66
144
|
return (
|
|
67
|
-
<div className={compact ? "p-4" : "p-
|
|
145
|
+
<div className={compact ? "p-4" : "p-6"}>
|
|
68
146
|
<div className="text-red-500">Error: {error || "Trace not found"}</div>
|
|
69
147
|
</div>
|
|
70
148
|
);
|
|
71
149
|
}
|
|
72
150
|
|
|
73
|
-
const { trace, spans, logs } = data;
|
|
74
|
-
|
|
75
151
|
return (
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
</CardDescription>
|
|
84
|
-
</CardHeader>
|
|
85
|
-
<CardContent>
|
|
86
|
-
<div className="flex gap-6 text-sm">
|
|
87
|
-
<div>
|
|
88
|
-
<span className="text-muted-foreground">Duration: </span>
|
|
89
|
-
{formatDuration(
|
|
90
|
-
trace.start_time_unix_nano,
|
|
91
|
-
trace.end_time_unix_nano,
|
|
92
|
-
)}
|
|
93
|
-
</div>
|
|
94
|
-
<div>
|
|
95
|
-
<span className="text-muted-foreground">Spans: </span>
|
|
96
|
-
{spans.length}
|
|
97
|
-
</div>
|
|
98
|
-
<div>
|
|
99
|
-
<span className="text-muted-foreground">Logs: </span>
|
|
100
|
-
{logs.length}
|
|
101
|
-
</div>
|
|
102
|
-
<div>
|
|
103
|
-
<span className="text-muted-foreground">Started: </span>
|
|
104
|
-
{formatTimestamp(trace.start_time_unix_nano)}
|
|
105
|
-
</div>
|
|
106
|
-
</div>
|
|
107
|
-
</CardContent>
|
|
108
|
-
</Card>
|
|
109
|
-
|
|
110
|
-
<div className="grid gap-6 lg:grid-cols-2">
|
|
111
|
-
<Card>
|
|
112
|
-
<CardHeader>
|
|
113
|
-
<CardTitle className="text-lg">Spans ({spans.length})</CardTitle>
|
|
114
|
-
</CardHeader>
|
|
115
|
-
<CardContent>
|
|
116
|
-
<SpanTree spans={spans} />
|
|
117
|
-
</CardContent>
|
|
118
|
-
</Card>
|
|
119
|
-
|
|
120
|
-
<Card>
|
|
121
|
-
<CardHeader>
|
|
122
|
-
<CardTitle className="text-lg">Logs ({logs.length})</CardTitle>
|
|
123
|
-
</CardHeader>
|
|
124
|
-
<CardContent>
|
|
125
|
-
<LogList logs={logs} />
|
|
126
|
-
</CardContent>
|
|
127
|
-
</Card>
|
|
128
|
-
</div>
|
|
129
|
-
</div>
|
|
152
|
+
<TraceContent
|
|
153
|
+
trace={data.trace}
|
|
154
|
+
spans={data.spans}
|
|
155
|
+
logs={data.logs}
|
|
156
|
+
messages={data.messages}
|
|
157
|
+
compact={compact}
|
|
158
|
+
/>
|
|
130
159
|
);
|
|
131
160
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { MarkdownRenderer } from "@townco/ui/gui";
|
|
2
|
+
import { Card, CardContent } from "@/components/ui/card";
|
|
3
|
+
|
|
4
|
+
interface TurnHeaderProps {
|
|
5
|
+
userInput: string | null;
|
|
6
|
+
llmOutput: string | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function TurnHeader({ userInput, llmOutput }: TurnHeaderProps) {
|
|
10
|
+
return (
|
|
11
|
+
<Card>
|
|
12
|
+
<CardContent className="p-6">
|
|
13
|
+
{/* User Input */}
|
|
14
|
+
<div className="space-y-1 mb-6">
|
|
15
|
+
<div className="text-xs font-medium text-muted-foreground tracking-wide">
|
|
16
|
+
User Input
|
|
17
|
+
</div>
|
|
18
|
+
{userInput ? (
|
|
19
|
+
<div className="text-sm text-foreground leading-normal">
|
|
20
|
+
{userInput}
|
|
21
|
+
</div>
|
|
22
|
+
) : (
|
|
23
|
+
<div className="text-sm text-muted-foreground italic">
|
|
24
|
+
No user input available
|
|
25
|
+
</div>
|
|
26
|
+
)}
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
{/* Divider */}
|
|
30
|
+
<div className="h-px bg-border mb-6" />
|
|
31
|
+
|
|
32
|
+
{/* Output */}
|
|
33
|
+
<div className="space-y-1">
|
|
34
|
+
<div className="text-xs font-medium text-muted-foreground tracking-wide">
|
|
35
|
+
Output
|
|
36
|
+
</div>
|
|
37
|
+
{llmOutput ? (
|
|
38
|
+
<div className="text-sm text-foreground leading-normal max-h-96 overflow-auto">
|
|
39
|
+
<MarkdownRenderer content={llmOutput} />
|
|
40
|
+
</div>
|
|
41
|
+
) : (
|
|
42
|
+
<div className="text-sm text-muted-foreground italic">
|
|
43
|
+
No LLM output available
|
|
44
|
+
</div>
|
|
45
|
+
)}
|
|
46
|
+
</div>
|
|
47
|
+
</CardContent>
|
|
48
|
+
</Card>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Card, CardContent } from "@/components/ui/card";
|
|
2
|
+
import type { TokenUsage } from "../lib/turnExtractor";
|
|
3
|
+
|
|
4
|
+
interface TurnMetadataPanelProps {
|
|
5
|
+
traceId: string;
|
|
6
|
+
duration: string;
|
|
7
|
+
startTime: string;
|
|
8
|
+
tokens: TokenUsage;
|
|
9
|
+
toolCount: number;
|
|
10
|
+
spanCount: number;
|
|
11
|
+
logCount: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function TurnMetadataPanel({
|
|
15
|
+
traceId,
|
|
16
|
+
duration,
|
|
17
|
+
startTime,
|
|
18
|
+
tokens,
|
|
19
|
+
toolCount,
|
|
20
|
+
spanCount,
|
|
21
|
+
logCount,
|
|
22
|
+
}: TurnMetadataPanelProps) {
|
|
23
|
+
const formatTokens = () => {
|
|
24
|
+
if (tokens.totalTokens === 0) {
|
|
25
|
+
return "N/A";
|
|
26
|
+
}
|
|
27
|
+
return tokens.totalTokens.toLocaleString();
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const formatToolUsage = () => {
|
|
31
|
+
return `${toolCount} tool call${toolCount !== 1 ? "s" : ""}`;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const metadataRows = [
|
|
35
|
+
{ label: "Turn ID", value: traceId, mono: true },
|
|
36
|
+
{ label: "Duration", value: duration },
|
|
37
|
+
{ label: "Started", value: startTime },
|
|
38
|
+
{ label: "Tokens", value: formatTokens() },
|
|
39
|
+
{ label: "Tool Usage", value: formatToolUsage() },
|
|
40
|
+
{ label: "Spans", value: String(spanCount) },
|
|
41
|
+
{ label: "Logs", value: String(logCount) },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className="space-y-0 text-xs leading-[1.5] tracking-[0.18px]">
|
|
46
|
+
{metadataRows.map((row, i) => (
|
|
47
|
+
<div
|
|
48
|
+
key={row.label}
|
|
49
|
+
className={`flex gap-6 h-9 items-center ${i > 0 ? "border-t border-border" : ""}`}
|
|
50
|
+
>
|
|
51
|
+
<div className="text-muted-foreground w-[96px] shrink-0">
|
|
52
|
+
{row.label}
|
|
53
|
+
</div>
|
|
54
|
+
<div
|
|
55
|
+
className={`text-foreground flex-1 truncate ${row.mono ? "font-mono text-[10px]" : ""}`}
|
|
56
|
+
>
|
|
57
|
+
{row.value}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
|
|
6
|
+
const Tabs = TabsPrimitive.Root;
|
|
7
|
+
|
|
8
|
+
const TabsList = React.forwardRef<
|
|
9
|
+
React.ElementRef<typeof TabsPrimitive.List>,
|
|
10
|
+
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
|
11
|
+
>(({ className, ...props }, ref) => (
|
|
12
|
+
<TabsPrimitive.List
|
|
13
|
+
ref={ref}
|
|
14
|
+
className={cn("inline-flex h-auto items-center gap-2", className)}
|
|
15
|
+
{...props}
|
|
16
|
+
/>
|
|
17
|
+
));
|
|
18
|
+
TabsList.displayName = TabsPrimitive.List.displayName;
|
|
19
|
+
|
|
20
|
+
const TabsTrigger = React.forwardRef<
|
|
21
|
+
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
|
22
|
+
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
|
23
|
+
>(({ className, ...props }, ref) => (
|
|
24
|
+
<TabsPrimitive.Trigger
|
|
25
|
+
ref={ref}
|
|
26
|
+
className={cn(
|
|
27
|
+
"inline-flex items-center justify-center whitespace-nowrap rounded-lg px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-muted data-[state=active]:text-foreground data-[state=inactive]:text-muted-foreground data-[state=inactive]:opacity-50",
|
|
28
|
+
className,
|
|
29
|
+
)}
|
|
30
|
+
{...props}
|
|
31
|
+
/>
|
|
32
|
+
));
|
|
33
|
+
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
|
34
|
+
|
|
35
|
+
const TabsContent = React.forwardRef<
|
|
36
|
+
React.ElementRef<typeof TabsPrimitive.Content>,
|
|
37
|
+
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
|
38
|
+
>(({ className, ...props }, ref) => (
|
|
39
|
+
<TabsPrimitive.Content
|
|
40
|
+
ref={ref}
|
|
41
|
+
className={cn(
|
|
42
|
+
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
43
|
+
className,
|
|
44
|
+
)}
|
|
45
|
+
{...props}
|
|
46
|
+
/>
|
|
47
|
+
));
|
|
48
|
+
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
|
49
|
+
|
|
50
|
+
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
package/src/db.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import type { Log, Span, Trace,
|
|
2
|
+
import type { Log, Session, Span, Trace, TraceDetailRaw } from "./types";
|
|
3
3
|
|
|
4
4
|
export class DebuggerDb {
|
|
5
5
|
private db: Database;
|
|
@@ -13,12 +13,11 @@ export class DebuggerDb {
|
|
|
13
13
|
return this.db
|
|
14
14
|
.query<Trace, [string, number, number]>(
|
|
15
15
|
`
|
|
16
|
-
SELECT
|
|
17
|
-
|
|
18
|
-
FROM traces
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
ORDER BY t.start_time_unix_nano DESC
|
|
16
|
+
SELECT trace_id, session_id, service_name, first_span_name,
|
|
17
|
+
start_time_unix_nano, end_time_unix_nano, span_count, created_at
|
|
18
|
+
FROM traces
|
|
19
|
+
WHERE session_id = ?
|
|
20
|
+
ORDER BY start_time_unix_nano ASC
|
|
22
21
|
LIMIT ? OFFSET ?
|
|
23
22
|
`,
|
|
24
23
|
)
|
|
@@ -28,7 +27,7 @@ export class DebuggerDb {
|
|
|
28
27
|
return this.db
|
|
29
28
|
.query<Trace, [number, number]>(
|
|
30
29
|
`
|
|
31
|
-
SELECT trace_id, service_name, first_span_name,
|
|
30
|
+
SELECT trace_id, session_id, service_name, first_span_name,
|
|
32
31
|
start_time_unix_nano, end_time_unix_nano, span_count, created_at
|
|
33
32
|
FROM traces
|
|
34
33
|
ORDER BY start_time_unix_nano DESC
|
|
@@ -38,9 +37,13 @@ export class DebuggerDb {
|
|
|
38
37
|
.all(limit, offset);
|
|
39
38
|
}
|
|
40
39
|
|
|
41
|
-
getTraceById(traceId: string):
|
|
40
|
+
getTraceById(traceId: string): TraceDetailRaw {
|
|
42
41
|
const trace = this.db
|
|
43
|
-
.query<Trace, [string]>(
|
|
42
|
+
.query<Trace, [string]>(
|
|
43
|
+
`SELECT trace_id, session_id, service_name, first_span_name,
|
|
44
|
+
start_time_unix_nano, end_time_unix_nano, span_count, created_at
|
|
45
|
+
FROM traces WHERE trace_id = ?`,
|
|
46
|
+
)
|
|
44
47
|
.get(traceId);
|
|
45
48
|
|
|
46
49
|
const spans = this.db
|
|
@@ -57,4 +60,24 @@ export class DebuggerDb {
|
|
|
57
60
|
|
|
58
61
|
return { trace: trace ?? null, spans, logs };
|
|
59
62
|
}
|
|
63
|
+
|
|
64
|
+
listSessions(limit = 50, offset = 0): Session[] {
|
|
65
|
+
return this.db
|
|
66
|
+
.query<Session, [number, number]>(
|
|
67
|
+
`
|
|
68
|
+
SELECT
|
|
69
|
+
s.session_id,
|
|
70
|
+
COUNT(DISTINCT t.trace_id) as trace_count,
|
|
71
|
+
MIN(t.start_time_unix_nano) as first_trace_time,
|
|
72
|
+
MAX(t.end_time_unix_nano) as last_trace_time
|
|
73
|
+
FROM sessions s
|
|
74
|
+
LEFT JOIN traces t ON t.session_id = s.session_id
|
|
75
|
+
GROUP BY s.session_id
|
|
76
|
+
HAVING trace_count > 0
|
|
77
|
+
ORDER BY last_trace_time DESC
|
|
78
|
+
LIMIT ? OFFSET ?
|
|
79
|
+
`,
|
|
80
|
+
)
|
|
81
|
+
.all(limit, offset);
|
|
82
|
+
}
|
|
60
83
|
}
|
package/src/index.ts
CHANGED
|
@@ -13,8 +13,14 @@ const otlpPort = Number.parseInt(
|
|
|
13
13
|
10,
|
|
14
14
|
);
|
|
15
15
|
const dbPath = process.env.DB_PATH ?? "./traces.db";
|
|
16
|
+
const agentName = process.env.AGENT_NAME ?? "Agent";
|
|
16
17
|
|
|
17
|
-
const { server, otlpServer } = startDebuggerServer({
|
|
18
|
+
const { server, otlpServer } = startDebuggerServer({
|
|
19
|
+
port,
|
|
20
|
+
otlpPort,
|
|
21
|
+
dbPath,
|
|
22
|
+
agentName,
|
|
23
|
+
});
|
|
18
24
|
|
|
19
25
|
console.log(`OTLP server running at ${otlpServer.url}`);
|
|
20
26
|
console.log(`Debugger running at ${server.url}`);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Span } from "../types";
|
|
2
|
+
|
|
3
|
+
export type SpanType = "chat" | "tool_call" | "subagent" | "other";
|
|
4
|
+
|
|
5
|
+
function parseAttributes(attrs: string | null): Record<string, unknown> {
|
|
6
|
+
if (!attrs) return {};
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(attrs);
|
|
9
|
+
} catch {
|
|
10
|
+
return {};
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function detectSpanType(span: Span): SpanType {
|
|
15
|
+
const attrs = parseAttributes(span.attributes);
|
|
16
|
+
|
|
17
|
+
// Check for tool call
|
|
18
|
+
if (span.name === "agent.tool_call") {
|
|
19
|
+
const toolName = attrs["tool.name"];
|
|
20
|
+
|
|
21
|
+
// Subagent is a special type of tool call (Task tool)
|
|
22
|
+
if (toolName === "Task") {
|
|
23
|
+
return "subagent";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return "tool_call";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Check for chat/LLM span
|
|
30
|
+
if (span.name.startsWith("chat") && "gen_ai.input.messages" in attrs) {
|
|
31
|
+
return "chat";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return "other";
|
|
35
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface TimelineBar {
|
|
2
|
+
leftPercent: number;
|
|
3
|
+
widthPercent: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function calculateTimelineBar(
|
|
7
|
+
spanStart: number,
|
|
8
|
+
spanEnd: number,
|
|
9
|
+
traceStart: number,
|
|
10
|
+
traceEnd: number,
|
|
11
|
+
): TimelineBar {
|
|
12
|
+
const traceDuration = traceEnd - traceStart;
|
|
13
|
+
|
|
14
|
+
// Handle edge case of zero duration trace
|
|
15
|
+
if (traceDuration === 0) {
|
|
16
|
+
return { leftPercent: 0, widthPercent: 100 };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const spanOffset = spanStart - traceStart;
|
|
20
|
+
const spanDuration = spanEnd - spanStart;
|
|
21
|
+
|
|
22
|
+
const leftPercent = (spanOffset / traceDuration) * 100;
|
|
23
|
+
const widthPercent = (spanDuration / traceDuration) * 100;
|
|
24
|
+
|
|
25
|
+
// Ensure minimum visible width for very short spans
|
|
26
|
+
const minWidth = 0.5;
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
leftPercent: Math.max(0, Math.min(100, leftPercent)),
|
|
30
|
+
widthPercent: Math.max(minWidth, Math.min(100 - leftPercent, widthPercent)),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { Log, Span } from "../types";
|
|
2
|
+
|
|
3
|
+
export interface TurnMessages {
|
|
4
|
+
userInput: string | null;
|
|
5
|
+
llmOutput: string | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface TokenUsage {
|
|
9
|
+
inputTokens: number;
|
|
10
|
+
outputTokens: number;
|
|
11
|
+
totalTokens: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Message {
|
|
15
|
+
role: string;
|
|
16
|
+
content: string | Array<{ type: string; text?: string }>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseAttributes(attrs: string | null): Record<string, unknown> {
|
|
20
|
+
if (!attrs) return {};
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(attrs);
|
|
23
|
+
} catch {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function extractMessageContent(message: Message): string {
|
|
29
|
+
if (typeof message.content === "string") {
|
|
30
|
+
return message.content;
|
|
31
|
+
}
|
|
32
|
+
if (Array.isArray(message.content)) {
|
|
33
|
+
return message.content
|
|
34
|
+
.map((block) => block.text || "")
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.join("\n");
|
|
37
|
+
}
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function extractTurnMessages(spans: Span[], logs?: Log[]): TurnMessages {
|
|
42
|
+
const result: TurnMessages = {
|
|
43
|
+
userInput: null,
|
|
44
|
+
llmOutput: null,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Extract user input from logs
|
|
48
|
+
if (logs && logs.length > 0) {
|
|
49
|
+
try {
|
|
50
|
+
const userMessageLog = logs.find(
|
|
51
|
+
(log) => log.body === "User message received",
|
|
52
|
+
);
|
|
53
|
+
if (userMessageLog) {
|
|
54
|
+
const attrs = parseAttributes(userMessageLog.attributes);
|
|
55
|
+
if (attrs["log.messagePreview"]) {
|
|
56
|
+
result.userInput = attrs["log.messagePreview"] as string;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.error("Failed to extract user input from logs:", error);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Find all chat spans
|
|
65
|
+
const chatSpans = spans
|
|
66
|
+
.filter((span) => {
|
|
67
|
+
const attrs = parseAttributes(span.attributes);
|
|
68
|
+
return span.name.startsWith("chat") && "gen_ai.input.messages" in attrs;
|
|
69
|
+
})
|
|
70
|
+
.sort((a, b) => a.start_time_unix_nano - b.start_time_unix_nano);
|
|
71
|
+
|
|
72
|
+
if (chatSpans.length === 0) return result;
|
|
73
|
+
|
|
74
|
+
// Extract LLM output from last chat span
|
|
75
|
+
try {
|
|
76
|
+
const lastSpan = chatSpans[chatSpans.length - 1];
|
|
77
|
+
if (!lastSpan) return result;
|
|
78
|
+
|
|
79
|
+
const attrs = parseAttributes(lastSpan.attributes);
|
|
80
|
+
const outputMessages = attrs["gen_ai.output.messages"];
|
|
81
|
+
|
|
82
|
+
if (outputMessages) {
|
|
83
|
+
const messages: Message[] =
|
|
84
|
+
typeof outputMessages === "string"
|
|
85
|
+
? JSON.parse(outputMessages)
|
|
86
|
+
: outputMessages;
|
|
87
|
+
|
|
88
|
+
// Look for assistant message - handle both "assistant" (OpenAI) and "ai" (LangChain) roles
|
|
89
|
+
const assistantMessage = messages
|
|
90
|
+
.filter((msg) => msg.role === "assistant" || msg.role === "ai")
|
|
91
|
+
.pop();
|
|
92
|
+
|
|
93
|
+
if (assistantMessage) {
|
|
94
|
+
result.llmOutput = extractMessageContent(assistantMessage);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
} catch (error) {
|
|
98
|
+
console.error("Failed to extract LLM output:", error);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function aggregateTokenUsage(spans: Span[]): TokenUsage {
|
|
105
|
+
const usage: TokenUsage = {
|
|
106
|
+
inputTokens: 0,
|
|
107
|
+
outputTokens: 0,
|
|
108
|
+
totalTokens: 0,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
for (const span of spans) {
|
|
112
|
+
const attrs = parseAttributes(span.attributes);
|
|
113
|
+
|
|
114
|
+
const inputTokens = attrs["gen_ai.usage.input_tokens"];
|
|
115
|
+
const outputTokens = attrs["gen_ai.usage.output_tokens"];
|
|
116
|
+
|
|
117
|
+
if (typeof inputTokens === "number") {
|
|
118
|
+
usage.inputTokens += inputTokens;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (typeof outputTokens === "number") {
|
|
122
|
+
usage.outputTokens += outputTokens;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
usage.totalTokens = usage.inputTokens + usage.outputTokens;
|
|
127
|
+
return usage;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function countToolCalls(spans: Span[]): number {
|
|
131
|
+
return spans.filter((span) => span.name === "agent.tool_call").length;
|
|
132
|
+
}
|