@townco/debugger 0.1.5 → 0.1.7

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,14 +1,12 @@
1
- import { useEffect, useState } from "react";
2
- import {
3
- Card,
4
- CardContent,
5
- CardDescription,
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 { SpanTree } from "./SpanTree";
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-8"}>
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-8"}>
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
- <div className={compact ? "p-4" : "p-8"}>
77
- <Card className="mb-6">
78
- <CardHeader>
79
- <CardTitle>{trace.first_span_name || "Unknown"}</CardTitle>
80
- <CardDescription className="space-y-1">
81
- <div>{trace.service_name || "Unknown service"}</div>
82
- <div className="font-mono text-xs">{trace.trace_id}</div>
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, TraceDetail } from "./types";
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 DISTINCT t.trace_id, t.service_name, t.first_span_name,
17
- t.start_time_unix_nano, t.end_time_unix_nano, t.span_count, t.created_at
18
- FROM traces t
19
- INNER JOIN spans s ON s.trace_id = t.trace_id
20
- WHERE json_extract(s.attributes, '$."agent.session_id"') = ?
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): TraceDetail {
40
+ getTraceById(traceId: string): TraceDetailRaw {
42
41
  const trace = this.db
43
- .query<Trace, [string]>(`SELECT * FROM traces WHERE trace_id = ?`)
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({ port, otlpPort, dbPath });
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
+ }