@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.
@@ -0,0 +1,450 @@
1
+ import { useState } from "react";
2
+ import { detectSpanType } from "../lib/spanTypeDetector";
3
+ import { calculateTimelineBar } from "../lib/timelineCalculator";
4
+ import type { Span, SpanNode } from "../types";
5
+ import { AttributeViewer } from "./AttributeViewer";
6
+ import { SpanDetailsPanel } from "./SpanDetailsPanel";
7
+ import { SpanIcon } from "./SpanIcon";
8
+
9
+ function parseAttributes(attrs: string | null): Record<string, unknown> {
10
+ if (!attrs) return {};
11
+ try {
12
+ return JSON.parse(attrs);
13
+ } catch {
14
+ return {};
15
+ }
16
+ }
17
+
18
+ export function buildSpanTree(spans: Span[]): SpanNode[] {
19
+ const spanMap = new Map<string, SpanNode>();
20
+ const roots: SpanNode[] = [];
21
+
22
+ // First pass: create nodes
23
+ for (const span of spans) {
24
+ spanMap.set(span.span_id, {
25
+ ...span,
26
+ children: [],
27
+ depth: 0,
28
+ durationMs:
29
+ (span.end_time_unix_nano - span.start_time_unix_nano) / 1_000_000,
30
+ });
31
+ }
32
+
33
+ // Second pass: build tree
34
+ for (const span of spans) {
35
+ const node = spanMap.get(span.span_id);
36
+ if (!node) continue;
37
+
38
+ if (span.parent_span_id && spanMap.has(span.parent_span_id)) {
39
+ const parent = spanMap.get(span.parent_span_id);
40
+ if (parent) {
41
+ node.depth = parent.depth + 1;
42
+ parent.children.push(node);
43
+ }
44
+ } else {
45
+ roots.push(node);
46
+ }
47
+ }
48
+
49
+ return roots;
50
+ }
51
+
52
+ interface SpanRowProps {
53
+ span: SpanNode;
54
+ traceStart: number;
55
+ traceEnd: number;
56
+ }
57
+
58
+ function SpanRow({ span, traceStart, traceEnd }: SpanRowProps) {
59
+ const [expanded, setExpanded] = useState(false);
60
+ const [showRaw, setShowRaw] = useState(false);
61
+
62
+ const attrs = parseAttributes(span.attributes);
63
+ const spanType = detectSpanType(span);
64
+
65
+ // Determine span type
66
+ const isToolCall = span.name === "agent.tool_call";
67
+ const isChatSpan =
68
+ span.name.startsWith("chat") && "gen_ai.input.messages" in attrs;
69
+ const isSpecialSpan = isToolCall || isChatSpan;
70
+
71
+ // Helper to get display name for tool calls (with special handling for Task/subagent)
72
+ const getToolDisplayName = (): string => {
73
+ const toolName = attrs["tool.name"] as string;
74
+ if (toolName !== "Task") return toolName || span.name;
75
+
76
+ // Parse tool.input to extract agentName for subagent spans
77
+ try {
78
+ const toolInput = attrs["tool.input"];
79
+ const input =
80
+ typeof toolInput === "string" ? JSON.parse(toolInput) : toolInput;
81
+ if (input?.agentName) {
82
+ return `Subagent (${input.agentName})`;
83
+ }
84
+ } catch {
85
+ // Fall back to "Task" if parsing fails
86
+ }
87
+ return toolName || span.name;
88
+ };
89
+
90
+ // Get display name based on span type
91
+ const displayName = isToolCall
92
+ ? getToolDisplayName()
93
+ : isChatSpan
94
+ ? (attrs["gen_ai.request.model"] as string) || span.name
95
+ : span.name;
96
+
97
+ const hasDetails =
98
+ (span.attributes && span.attributes !== "{}") ||
99
+ (span.events && span.events !== "[]") ||
100
+ (span.resource_attributes && span.resource_attributes !== "{}");
101
+
102
+ // Calculate timeline bar position and width
103
+ const timeline = calculateTimelineBar(
104
+ span.start_time_unix_nano,
105
+ span.end_time_unix_nano,
106
+ traceStart,
107
+ traceEnd,
108
+ );
109
+
110
+ // Color for timeline bar based on span type
111
+ const timelineColor =
112
+ spanType === "chat"
113
+ ? "bg-blue-500/30"
114
+ : spanType === "tool_call"
115
+ ? "bg-purple-500/30"
116
+ : spanType === "subagent"
117
+ ? "bg-green-500/30"
118
+ : "bg-gray-500/30";
119
+
120
+ return (
121
+ <div>
122
+ <div
123
+ className={`py-1 px-2 ${hasDetails ? "cursor-pointer" : ""}`}
124
+ style={{ paddingLeft: span.depth * 20 + 8 }}
125
+ onClick={() => hasDetails && setExpanded(!expanded)}
126
+ >
127
+ <div className="flex items-center hover:bg-muted rounded">
128
+ <SpanIcon type={spanType} className="mr-2" />
129
+ <span className="font-medium flex-1 truncate">{displayName}</span>
130
+ <span className="text-muted-foreground text-sm ml-2">
131
+ {span.durationMs.toFixed(2)}ms
132
+ </span>
133
+ </div>
134
+
135
+ {/* Timeline bar */}
136
+ <div
137
+ className="mt-1 h-2 bg-muted/30 rounded relative"
138
+ style={{ marginLeft: span.depth * 20 }}
139
+ >
140
+ <div
141
+ className={`absolute h-full rounded ${timelineColor}`}
142
+ style={{
143
+ left: `${timeline.leftPercent}%`,
144
+ width: `${timeline.widthPercent}%`,
145
+ }}
146
+ title={`Start: ${new Date(span.start_time_unix_nano / 1_000_000).toLocaleString()}\nEnd: ${new Date(span.end_time_unix_nano / 1_000_000).toLocaleString()}`}
147
+ />
148
+ </div>
149
+ </div>
150
+
151
+ {expanded && (
152
+ <div
153
+ className="py-2 px-4 bg-muted/50 rounded mb-1"
154
+ style={{ marginLeft: span.depth * 20 + 28 }}
155
+ >
156
+ <div className="text-xs text-muted-foreground mb-2">
157
+ {span.span_id}
158
+ </div>
159
+
160
+ {/* Tool call span: show input/output */}
161
+ {isToolCall && (
162
+ <>
163
+ <AttributeViewer
164
+ label="Input"
165
+ data={attrs["tool.input"] as string}
166
+ />
167
+ <AttributeViewer
168
+ label="Output"
169
+ data={attrs["tool.output"] as string}
170
+ />
171
+ </>
172
+ )}
173
+
174
+ {/* Chat span: show system instructions, input/output messages */}
175
+ {isChatSpan && (
176
+ <>
177
+ <AttributeViewer
178
+ label="System Instructions"
179
+ data={attrs["gen_ai.system_instructions"] as string}
180
+ />
181
+ <AttributeViewer
182
+ label="Input Messages"
183
+ data={attrs["gen_ai.input.messages"] as string}
184
+ />
185
+ <AttributeViewer
186
+ label="Output Messages"
187
+ data={attrs["gen_ai.output.messages"] as string}
188
+ />
189
+ </>
190
+ )}
191
+
192
+ {/* For special spans, show raw attributes collapsed; for default, show them directly */}
193
+ {isSpecialSpan ? (
194
+ <div className="mt-2">
195
+ <button
196
+ type="button"
197
+ className="text-xs text-muted-foreground hover:text-foreground"
198
+ onClick={(e) => {
199
+ e.stopPropagation();
200
+ setShowRaw(!showRaw);
201
+ }}
202
+ >
203
+ {showRaw ? "▼ Hide" : "▶ Show"} raw attributes
204
+ </button>
205
+ {showRaw && (
206
+ <div className="mt-2">
207
+ <AttributeViewer label="Attributes" data={span.attributes} />
208
+ <AttributeViewer label="Events" data={span.events} />
209
+ <AttributeViewer
210
+ label="Resource"
211
+ data={span.resource_attributes}
212
+ />
213
+ </div>
214
+ )}
215
+ </div>
216
+ ) : (
217
+ <>
218
+ <AttributeViewer label="Attributes" data={span.attributes} />
219
+ <AttributeViewer label="Events" data={span.events} />
220
+ <AttributeViewer
221
+ label="Resource"
222
+ data={span.resource_attributes}
223
+ />
224
+ </>
225
+ )}
226
+
227
+ {span.status_message && (
228
+ <div className="text-xs">
229
+ <span className="text-muted-foreground uppercase font-medium">
230
+ Status Message
231
+ </span>
232
+ <div className="text-red-500">{span.status_message}</div>
233
+ </div>
234
+ )}
235
+ </div>
236
+ )}
237
+
238
+ {span.children.map((child) => (
239
+ <SpanRow
240
+ key={child.span_id}
241
+ span={child}
242
+ traceStart={traceStart}
243
+ traceEnd={traceEnd}
244
+ />
245
+ ))}
246
+ </div>
247
+ );
248
+ }
249
+
250
+ interface SpanTimelineProps {
251
+ spans: Span[];
252
+ traceStart: number;
253
+ traceEnd: number;
254
+ }
255
+
256
+ export function SpanTimeline({
257
+ spans,
258
+ traceStart,
259
+ traceEnd,
260
+ }: SpanTimelineProps) {
261
+ const tree = buildSpanTree(spans);
262
+ const traceDurationMs = (traceEnd - traceStart) / 1_000_000;
263
+ const [selectedSpan, setSelectedSpan] = useState<SpanNode | null>(null);
264
+
265
+ if (tree.length === 0) {
266
+ return <div className="text-muted-foreground text-sm">No spans</div>;
267
+ }
268
+
269
+ // Generate time markers for the timeline header
270
+ const timeMarkers = [0, 0.25, 0.5, 0.75, 1].map((fraction) => {
271
+ const timeMs = traceDurationMs * fraction;
272
+ return timeMs < 1000
273
+ ? `${timeMs.toFixed(0)}ms`
274
+ : `${(timeMs / 1000).toFixed(1)}s`;
275
+ });
276
+
277
+ return (
278
+ <>
279
+ <div className="border border-border rounded-lg overflow-hidden bg-card">
280
+ {/* Table layout */}
281
+ <div className="flex">
282
+ {/* Left column: Process names */}
283
+ <div className="w-[276px] border-r border-border flex-shrink-0">
284
+ {/* Header */}
285
+ <div className="h-6 px-4 py-1 bg-muted border-b border-border flex items-center">
286
+ <span className="text-xs font-medium text-foreground">
287
+ Processes
288
+ </span>
289
+ </div>
290
+ {/* Rows */}
291
+ {tree.map((span) => (
292
+ <RenderSpanTreeRow
293
+ key={span.span_id}
294
+ span={span}
295
+ onSpanClick={setSelectedSpan}
296
+ />
297
+ ))}
298
+ </div>
299
+
300
+ {/* Right column: Timeline */}
301
+ <div className="flex-1 overflow-x-auto">
302
+ {/* Timeline header with time markers */}
303
+ <div className="h-6 px-4 py-1 bg-muted border-b border-border flex items-center text-[10px] text-muted-foreground gap-16">
304
+ {timeMarkers.map((marker, i) => (
305
+ <span key={i} className="w-[260px]">
306
+ {marker}
307
+ </span>
308
+ ))}
309
+ </div>
310
+ {/* Timeline rows */}
311
+ <div className="relative">
312
+ {tree.map((span) => (
313
+ <RenderSpanTreeTimeline
314
+ key={span.span_id}
315
+ span={span}
316
+ traceStart={traceStart}
317
+ traceEnd={traceEnd}
318
+ onSpanClick={setSelectedSpan}
319
+ />
320
+ ))}
321
+ </div>
322
+ </div>
323
+ </div>
324
+ </div>
325
+
326
+ <SpanDetailsPanel
327
+ span={selectedSpan}
328
+ onClose={() => setSelectedSpan(null)}
329
+ allSpans={tree}
330
+ />
331
+ </>
332
+ );
333
+ }
334
+
335
+ // Render the left column (process names) recursively
336
+ function RenderSpanTreeRow({
337
+ span,
338
+ onSpanClick,
339
+ }: {
340
+ span: SpanNode;
341
+ onSpanClick: (span: SpanNode) => void;
342
+ }) {
343
+ const attrs = parseAttributes(span.attributes);
344
+ const spanType = detectSpanType(span);
345
+
346
+ const isToolCall = span.name === "agent.tool_call";
347
+ const isChatSpan =
348
+ span.name.startsWith("chat") && "gen_ai.input.messages" in attrs;
349
+
350
+ const getToolDisplayName = (): string => {
351
+ const toolName = attrs["tool.name"] as string;
352
+ if (toolName !== "Task") return toolName || span.name;
353
+
354
+ try {
355
+ const toolInput = attrs["tool.input"];
356
+ const input =
357
+ typeof toolInput === "string" ? JSON.parse(toolInput) : toolInput;
358
+ if (input?.agentName) {
359
+ return `Subagent (${input.agentName})`;
360
+ }
361
+ } catch {
362
+ // Fall back
363
+ }
364
+ return toolName || span.name;
365
+ };
366
+
367
+ const displayName = isToolCall
368
+ ? getToolDisplayName()
369
+ : isChatSpan
370
+ ? (attrs["gen_ai.request.model"] as string) || span.name
371
+ : span.name;
372
+
373
+ return (
374
+ <>
375
+ <div
376
+ className="h-[38px] px-4 py-2 border-b border-border flex items-center gap-2 hover:bg-muted/50 cursor-pointer"
377
+ style={{ paddingLeft: `${span.depth * 20 + 16}px` }}
378
+ onClick={() => onSpanClick(span)}
379
+ >
380
+ <SpanIcon type={spanType} className="shrink-0" />
381
+ <span className="text-sm truncate flex-1">{displayName}</span>
382
+ </div>
383
+ {span.children.map((child) => (
384
+ <RenderSpanTreeRow
385
+ key={child.span_id}
386
+ span={child}
387
+ onSpanClick={onSpanClick}
388
+ />
389
+ ))}
390
+ </>
391
+ );
392
+ }
393
+
394
+ // Render the right column (timeline bars) recursively
395
+ function RenderSpanTreeTimeline({
396
+ span,
397
+ traceStart,
398
+ traceEnd,
399
+ onSpanClick,
400
+ }: {
401
+ span: SpanNode;
402
+ traceStart: number;
403
+ traceEnd: number;
404
+ onSpanClick: (span: SpanNode) => void;
405
+ }) {
406
+ const spanType = detectSpanType(span);
407
+ const timeline = calculateTimelineBar(
408
+ span.start_time_unix_nano,
409
+ span.end_time_unix_nano,
410
+ traceStart,
411
+ traceEnd,
412
+ );
413
+
414
+ const barColor =
415
+ spanType === "chat"
416
+ ? "bg-orange-400"
417
+ : spanType === "tool_call"
418
+ ? "bg-purple-500"
419
+ : spanType === "subagent"
420
+ ? "bg-purple-500"
421
+ : "bg-blue-400";
422
+
423
+ return (
424
+ <>
425
+ <div className="h-[38px] border-b border-border relative flex items-center px-4 hover:bg-muted/50 cursor-pointer">
426
+ <div
427
+ className={`absolute h-[18px] rounded ${barColor} flex items-center px-1`}
428
+ style={{
429
+ left: `calc(${timeline.leftPercent}% + 16px)`,
430
+ width: `calc(${Math.max(timeline.widthPercent, 0.5)}% - 32px)`,
431
+ }}
432
+ onClick={() => onSpanClick(span)}
433
+ >
434
+ <span className="text-xs text-white whitespace-nowrap">
435
+ {span.durationMs.toFixed(2)}ms
436
+ </span>
437
+ </div>
438
+ </div>
439
+ {span.children.map((child) => (
440
+ <RenderSpanTreeTimeline
441
+ key={child.span_id}
442
+ span={child}
443
+ traceStart={traceStart}
444
+ traceEnd={traceEnd}
445
+ onSpanClick={onSpanClick}
446
+ />
447
+ ))}
448
+ </>
449
+ );
450
+ }
@@ -1,6 +1,7 @@
1
1
  import { useState } from "react";
2
2
  import type { Span, SpanNode } from "../types";
3
3
  import { AttributeViewer } from "./AttributeViewer";
4
+ import { SpanDetailsPanel } from "./SpanDetailsPanel";
4
5
 
5
6
  function parseAttributes(attrs: string | null): Record<string, unknown> {
6
7
  if (!attrs) return {};
@@ -45,7 +46,13 @@ export function buildSpanTree(spans: Span[]): SpanNode[] {
45
46
  return roots;
46
47
  }
47
48
 
48
- function SpanRow({ span }: { span: SpanNode }) {
49
+ function SpanRow({
50
+ span,
51
+ onSpanClick,
52
+ }: {
53
+ span: SpanNode;
54
+ onSpanClick: (span: SpanNode) => void;
55
+ }) {
49
56
  const [expanded, setExpanded] = useState(false);
50
57
  const [showRaw, setShowRaw] = useState(false);
51
58
 
@@ -100,7 +107,7 @@ function SpanRow({ span }: { span: SpanNode }) {
100
107
  <div
101
108
  className={`flex items-center py-1 px-2 hover:bg-muted rounded ${hasDetails ? "cursor-pointer" : ""}`}
102
109
  style={{ paddingLeft: span.depth * 20 + 8 }}
103
- onClick={() => hasDetails && setExpanded(!expanded)}
110
+ onClick={() => onSpanClick(span)}
104
111
  >
105
112
  <span className={`${statusColor} mr-2`}>●</span>
106
113
  <span className="font-medium flex-1 truncate">{displayName}</span>
@@ -197,7 +204,7 @@ function SpanRow({ span }: { span: SpanNode }) {
197
204
  )}
198
205
 
199
206
  {span.children.map((child) => (
200
- <SpanRow key={child.span_id} span={child} />
207
+ <SpanRow key={child.span_id} span={child} onSpanClick={onSpanClick} />
201
208
  ))}
202
209
  </div>
203
210
  );
@@ -209,16 +216,29 @@ interface SpanTreeProps {
209
216
 
210
217
  export function SpanTree({ spans }: SpanTreeProps) {
211
218
  const tree = buildSpanTree(spans);
219
+ const [selectedSpan, setSelectedSpan] = useState<SpanNode | null>(null);
212
220
 
213
221
  if (tree.length === 0) {
214
222
  return <div className="text-muted-foreground text-sm">No spans</div>;
215
223
  }
216
224
 
217
225
  return (
218
- <div className="font-mono text-sm">
219
- {tree.map((span) => (
220
- <SpanRow key={span.span_id} span={span} />
221
- ))}
222
- </div>
226
+ <>
227
+ <div className="font-mono text-sm">
228
+ {tree.map((span) => (
229
+ <SpanRow
230
+ key={span.span_id}
231
+ span={span}
232
+ onSpanClick={setSelectedSpan}
233
+ />
234
+ ))}
235
+ </div>
236
+
237
+ <SpanDetailsPanel
238
+ span={selectedSpan}
239
+ onClose={() => setSelectedSpan(null)}
240
+ allSpans={tree}
241
+ />
242
+ </>
223
243
  );
224
244
  }