diff --git a/app/app/(obs)/traces/[id]/page.tsx b/app/app/(obs)/traces/[id]/page.tsx new file mode 100644 index 0000000..af31002 --- /dev/null +++ b/app/app/(obs)/traces/[id]/page.tsx @@ -0,0 +1,459 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { + loadTraceSpansAsync, + treeCostFromSpans, +} from "@/lib/traces"; +import { PageTitle, CrumbBack } from "@/components/widgets"; +import type { Span } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +// ─── Formatters ──────────────────────────────────────────────────────── + +function fmtCost(c: number | null | undefined) { + if (c == null || c === 0) return "—"; + if (c < 0.0001) return `$${c.toExponential(1)}`; + if (c < 1) return `$${c.toFixed(4)}`; + return `$${c.toFixed(2)}`; +} + +function fmtLatency(ms: number | null | undefined) { + if (ms == null) return "—"; + if (ms < 1000) return `${ms}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(2)}s`; + return `${Math.round(ms / 60_000)}m`; +} + +function fmtTokens(n: number | null | undefined) { + if (n == null || n === 0) return null; + if (n < 1000) return `${n} tok`; + if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k tok`; + return `${(n / 1_000_000).toFixed(1)}M tok`; +} + +function fmtTime(ts: number) { + return new Date(ts * 1000).toISOString().slice(11, 23); // HH:MM:SS.mmm +} + +// ─── Content extraction ─────────────────────────────────────────────── + +/** Pull a human-readable prompt out of a span's attrs. Looks at the + * shapes our patches actually emit: messages list, single content + * string, generic input field. Truncated; full text is in the raw + * JSON drawer at the bottom. */ +function extractPrompt(s: Span): string | null { + const a = s.attrs ?? {}; + // Shape: attrs.request.messages = [{role, content}, ...] (helicone proxy mode) + const req = a.request as Record | undefined; + if (req && Array.isArray(req.messages)) { + return (req.messages as Array<{ role?: string; content?: unknown }>) + .map((m) => { + const role = m.role ? `[${m.role}] ` : ""; + const c = m.content; + if (typeof c === "string") return role + c; + if (Array.isArray(c)) { + // OpenAI multi-part content: [{type:"text", text:"..."}, ...] + return ( + role + + c + .map((p: unknown) => { + if ( + typeof p === "object" && + p !== null && + "text" in p && + typeof (p as { text?: unknown }).text === "string" + ) { + return (p as { text: string }).text; + } + return ""; + }) + .join("") + ); + } + return role; + }) + .filter(Boolean) + .join("\n"); + } + if (typeof a.prompt === "string") return a.prompt as string; + if (typeof a.input === "string") return a.input as string; + if (typeof a["arg.0"] === "string") return a["arg.0"] as string; + if (typeof a.question === "string") return a.question as string; + return null; +} + +/** Pull a human-readable response out of a span's attrs. */ +function extractResponse(s: Span): string | null { + const a = s.attrs ?? {}; + const resp = a.response as Record | undefined; + if (resp) { + // OpenAI shape: response.choices[0].message.content + if (Array.isArray(resp.choices)) { + const choices = resp.choices as Array<{ + message?: { content?: unknown }; + text?: string; + }>; + const parts = choices + .map((c) => { + if (typeof c.message?.content === "string") return c.message.content; + if (typeof c.text === "string") return c.text; + return ""; + }) + .filter(Boolean); + if (parts.length > 0) return parts.join("\n"); + } + // Anthropic shape: response.content[0].text + if (Array.isArray(resp.content)) { + const content = resp.content as Array<{ text?: unknown }>; + const text = content + .map((p) => (typeof p.text === "string" ? p.text : "")) + .filter(Boolean) + .join("\n"); + if (text) return text; + } + } + if (typeof a.output === "string") return a.output as string; + if (typeof a.answer === "string") return a.answer as string; + return null; +} + +// ─── Tree node ──────────────────────────────────────────────────────── + +const KIND_LABEL: Record = { + agent_call: "Agent", + llm_call: "LLM", + tool_call: "Tool", + agent_action: "Action", + retrieve: "Retrieve", + judge: "Judge", + question: "Question", + eval: "Eval", +}; + +function kindBadge(name: string) { + const label = KIND_LABEL[name] ?? name; + return ( + + {label} + + ); +} + +function SpanNode({ + span, + childrenOf, + allSpans, + depth, +}: { + span: Span; + childrenOf: Map; + allSpans: Span[]; + depth: number; +}) { + const a = span.attrs ?? {}; + const children = childrenOf.get(span.id) ?? []; + const prompt = extractPrompt(span); + const response = extractResponse(span); + const dur = + span.start_ts != null && span.end_ts != null + ? Math.round((span.end_ts - span.start_ts) * 1000) + : null; + const cost = typeof a.cost_usd === "number" ? a.cost_usd : null; + const tokens = + typeof a.total_tokens === "number" + ? a.total_tokens + : typeof a.input_tokens === "number" || typeof a.output_tokens === "number" + ? Number(a.input_tokens || 0) + Number(a.output_tokens || 0) + : null; + const model = typeof a.model === "string" ? a.model : null; + const agent = typeof a.agent === "string" ? a.agent : null; + const tool = typeof a.tool === "string" ? a.tool : null; + + // Subtree rollup for agent_call nodes — shows total cost the + // subagent caused, not just its own llm_call leaves. + const rollup = + span.name === "agent_call" + ? treeCostFromSpans(allSpans, span.id) + : null; + const hasChildren = children.length > 0; + const hasInline = prompt || response; + const isError = span.status === "error"; + + // Auto-expand the root + agent_call nodes; leave llm_calls / tools + // collapsed by default to keep the page scannable. + const defaultOpen = depth === 0 || span.name === "agent_call"; + + return ( +
+ + + {kindBadge(span.name)} + + {agent || tool || model || span.name} + + {span.name === "agent_call" && rollup && rollup.cost_usd > 0 && ( + + ↳ {fmtCost(rollup.cost_usd)} + {rollup.agent_calls > 0 && ` · ${rollup.agent_calls} subagent${rollup.agent_calls === 1 ? "" : "s"}`} + {rollup.llm_calls > 0 && ` · ${rollup.llm_calls} llm`} + + )} + + {dur != null && {fmtLatency(dur)}} + {tokens != null && tokens > 0 && {fmtTokens(tokens)}} + {cost != null && cost > 0 && {fmtCost(cost)}} + {fmtTime(span.start_ts)} + + + + {hasInline && ( +
+ {prompt && ( +
+
+ Prompt +
+
+ {prompt.length > 1500 ? `${prompt.slice(0, 1500)}…` : prompt} +
+
+ )} + {response && ( +
+
+ Response +
+
+ {response.length > 1500 ? `${response.slice(0, 1500)}…` : response} +
+
+ )} +
+ )} + + {span.events && span.events.length > 0 && ( +
+ + {span.events.length} event{span.events.length === 1 ? "" : "s"} + +
    + {span.events.slice(0, 50).map((e, i) => ( +
  • + + {e.type} + + + {JSON.stringify(e).slice(0, 200)} + +
  • + ))} + {span.events.length > 50 && ( +
  • + … {span.events.length - 50} more +
  • + )} +
+
+ )} + + {hasChildren && ( +
+ {children.map((c) => ( + + ))} +
+ )} +
+ ); +} + +// ─── Page ───────────────────────────────────────────────────────────── + +export default async function TraceTreePage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const spans = await loadTraceSpansAsync(id); + if (spans.length === 0) notFound(); + + // Build parent_id index. Roots = spans whose parent_id is null OR + // points to a span outside this trace (edge case, shouldn't happen + // but be defensive). + const idSet = new Set(spans.map((s) => s.id)); + const childrenOf = new Map(); + for (const s of spans) { + const p = s.parent_id && idSet.has(s.parent_id) ? s.parent_id : null; + if (!childrenOf.has(p)) childrenOf.set(p, []); + childrenOf.get(p)!.push(s); + } + // Sort each child list by start_ts for chronological display. + for (const [, list] of childrenOf) list.sort((a, b) => a.start_ts - b.start_ts); + + const roots = childrenOf.get(null) ?? []; + + // Top-level totals — sum from the root rollups (or all spans if no + // proper agent_call exists). + const totalCost = spans.reduce( + (acc, s) => + acc + + (typeof s.attrs?.cost_usd === "number" ? (s.attrs.cost_usd as number) : 0), + 0, + ); + const totalTokens = spans.reduce((acc, s) => { + const t = s.attrs?.total_tokens; + if (typeof t === "number") return acc + t; + const i = s.attrs?.input_tokens; + const o = s.attrs?.output_tokens; + return ( + acc + + (typeof i === "number" ? i : 0) + + (typeof o === "number" ? o : 0) + ); + }, 0); + const traceStart = Math.min(...spans.map((s) => s.start_ts)); + const traceEnd = Math.max(...spans.map((s) => s.end_ts ?? s.start_ts)); + const totalDurMs = Math.round((traceEnd - traceStart) * 1000); + + // Pull session metadata off the first span that carries it. + const withSession = spans.find((s) => s.attrs?.session_id); + const sessionId = (withSession?.attrs?.session_id as string) ?? null; + const userId = (withSession?.attrs?.user_id as string) ?? null; + + // Group roots by session_segment when present (PR #13). Spans + // without session_segment land in segment 0 implicitly. + const segMap = new Map(); + for (const r of roots) { + const seg = Number(r.attrs?.session_segment ?? 0) || 0; + if (!segMap.has(seg)) segMap.set(seg, []); + segMap.get(seg)!.push(r); + } + const segments = Array.from(segMap.entries()).sort(([a], [b]) => a - b); + const hasSegments = segments.length > 1; + + return ( + <> + + + {spans.length} span{spans.length === 1 ? "" : "s"} ·{" "} + {fmtLatency(totalDurMs)} total · {fmtCost(totalCost)} ·{" "} + {totalTokens > 0 ? fmtTokens(totalTokens) : "0 tok"} + {sessionId && ( + <> + {" · "} + + session={sessionId} + + + )} + {userId && ( + <> + {" · "} + user={userId} + + )} + + } + /> + +
+ {hasSegments ? ( +
+ {segments.map(([seg, segRoots]) => ( +
+
+ + {seg === 0 ? "Initial conversation" : `After reset #${seg}`} + + + {segRoots.length} root span{segRoots.length === 1 ? "" : "s"} + +
+
+ {segRoots.map((r) => ( + + ))} +
+
+ ))} +
+ ) : ( +
+ {roots.map((r) => ( + + ))} +
+ )} +
+ +
+ + Raw span data ({spans.length} spans) + +
+          {JSON.stringify(spans, null, 2)}
+        
+
+ + ); +} diff --git a/app/lib/traces.ts b/app/lib/traces.ts index 8242fb4..4891ca2 100644 --- a/app/lib/traces.ts +++ b/app/lib/traces.ts @@ -845,6 +845,14 @@ export type AgentRollup = { user_id?: string; }; +/** Sum cost / tokens / structural counts across the subtree rooted + * at `rootId`. Exported so the trace tree-view page can decorate + * agent_call nodes with their subtree totals without re-loading + * spans. Mirror of Python's `wikitrace.agents.tree_cost`. */ +export function treeCostFromSpans(spans: Span[], rootId: string): AgentRollup | null { + return _treeCostFromSpans(spans, rootId); +} + function _treeCostFromSpans(spans: Span[], rootId: string): AgentRollup | null { const byId = new Map(spans.map((s) => [s.id, s])); const root = byId.get(rootId);