From df09fc45c73e78346fb7c0c4167f641223a287a6 Mon Sep 17 00:00:00 2001 From: Chad Hietala Date: Tue, 4 Aug 2026 17:11:51 -0500 Subject: [PATCH 1/3] feat(eve): show full span details in eve traces Span rows carry inline token/cost/tool chips and the header aggregates models, token totals, cost, and errors across the trace's step spans. Two new flags expose everything else the spool records: --verbose expands every span with all attributes and events, and --json dumps the full trace machine-readably. The local trace reader now parses span events, status messages, and span kind instead of dropping them. Signed-off-by: Chad Hietala --- .changeset/rich-trace-span-details.md | 5 + docs/reference/cli.md | 4 + .../eve/src/cli/commands/trace-detail.test.ts | 191 +++++++++++++++++ packages/eve/src/cli/commands/trace-detail.ts | 202 ++++++++++++++++++ .../cli/commands/trace.integration.test.ts | 131 +++++++++++- packages/eve/src/cli/commands/trace.ts | 124 +++++++++-- .../cli/dev/tui/traces/trace-content.test.ts | 4 +- .../src/cli/dev/tui/traces/trace-content.ts | 22 +- .../dev/tui/traces/trace-conversation.test.ts | 1 + .../src/cli/dev/tui/traces/trace-view.test.ts | 1 + .../eve/src/cli/dev/tui/traces/trace-view.ts | 2 +- .../tui/traces/trace-viewer-session.test.ts | 1 + .../dev/tui/traces/trace-viewer-state.test.ts | 1 + packages/eve/src/cli/run.ts | 12 +- .../src/tracing/local-trace-reader.test.ts | 110 ++++++++++ .../eve/src/tracing/local-trace-reader.ts | 37 ++++ 16 files changed, 803 insertions(+), 45 deletions(-) create mode 100644 .changeset/rich-trace-span-details.md create mode 100644 packages/eve/src/cli/commands/trace-detail.test.ts create mode 100644 packages/eve/src/cli/commands/trace-detail.ts create mode 100644 packages/eve/src/tracing/local-trace-reader.test.ts diff --git a/.changeset/rich-trace-span-details.md b/.changeset/rich-trace-span-details.md new file mode 100644 index 000000000..7221efa71 --- /dev/null +++ b/.changeset/rich-trace-span-details.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Show far more of what local traces record in `eve traces`: span rows carry inline token/cost/tool chips, the header aggregates models, token totals, cost, and errors, and two new flags expose everything else — `--verbose` expands every span with all attributes and events, and `--json` dumps the full trace machine-readably. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index e4d4af791..0fc32aefa 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -254,10 +254,14 @@ eve traces ls # list traces, most recent first eve traces ls --json # emit machine-readable trace summaries eve traces # show the most recent span tree eve traces # show one span tree +eve traces --verbose # expand every span with all attributes and events +eve traces --json # dump the full trace — every span's attributes and events — as JSON ``` Reads the immutable OTLP/JSON segments under `.eve/traces/v1`, so `eve dev` need not be running. Accepts a full trace id, an `agent.session.id`, or an unambiguous prefix of either. Malformed segments are skipped without hiding valid spans from the same trace. +Span rows carry inline metrics when the span recorded them — `↑input`/`↓output` token counts, gateway cost, and the tool name on `ai.toolCall` rows — and the header aggregates models, token totals, cost, and error count across the trace's step spans. `--verbose` nests each span's full record under its tree row: status (with the error message when failed), timing, ids, every attribute (prompts, responses, and tool payloads rendered as readable blocks), and every span event with its offset from span start. `--json` emits the same records machine-readably, one object per selected trace. + A subagent keeps its own session id but records into the trace its parent had open at dispatch, so delegated work appears under the session that caused it, tagged with `agent.root.session.id`. Either session id resolves to that trace. A remote agent traces under its own deployment and is not recorded here. A session long enough to outgrow one trace — far longer than anything you will drive locally — continues into a new one. Each is a session window, numbered from zero on `agent.session.window`; passing a session id shows every window it produced, oldest first, and a trace id shows just that window. diff --git a/packages/eve/src/cli/commands/trace-detail.test.ts b/packages/eve/src/cli/commands/trace-detail.test.ts new file mode 100644 index 000000000..c7d0c6579 --- /dev/null +++ b/packages/eve/src/cli/commands/trace-detail.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest"; + +import type { LocalTraceSpan } from "#tracing/local-trace-reader.js"; + +import { + formatCostUsd, + formatTokenSummary, + renderSpanDetailLines, + spanMetricChips, + summarizeLocalTrace, +} from "./trace-detail.js"; + +function span(overrides: Partial = {}): LocalTraceSpan { + return { + attributes: {}, + endTimeNs: 20_000_000n, + events: [], + name: "agent.step", + spanId: "a".repeat(16), + startTimeNs: 10_000_000n, + statusCode: 0, + traceId: "1".repeat(32), + ...overrides, + }; +} + +const identity = (text: string): string => text; + +describe("spanMetricChips", () => { + it("emits token, cost, and tool chips only when present", () => { + expect( + spanMetricChips( + span({ + attributes: { + "agent.usage.input_tokens": 1400, + "agent.usage.output_tokens": 213, + "gen_ai.usage.gateway_cost": 0.0031, + }, + }), + ), + ).toEqual(["↑1.4K", "↓213", "$0.0031"]); + + expect(spanMetricChips(span())).toEqual([]); + expect( + spanMetricChips( + span({ name: "ai.toolCall", attributes: { "gen_ai.tool.name": "get_weather" } }), + ), + ).toEqual(["get_weather"]); + }); + + it("prefers gateway cost over provider cost and parses string ints", () => { + expect( + spanMetricChips( + span({ attributes: { "gen_ai.usage.cost": 0.5, "agent.usage.input_tokens": "900" } }), + ), + ).toEqual(["↑900", "$0.5000"]); + }); +}); + +describe("summarizeLocalTrace", () => { + it("sums usage over agent.step spans only, avoiding double counts", () => { + const summary = summarizeLocalTrace([ + span({ + attributes: { + "agent.model.id": "gpt-5", + "agent.usage.input_tokens": 1000, + "agent.usage.output_tokens": 100, + "gen_ai.usage.cache_read.input_tokens": 800, + "gen_ai.usage.cost": 0.01, + }, + }), + // Same usage repeated on the model span must not double-count. + span({ + name: "ai.streamText.doStream", + attributes: { + "agent.usage.input_tokens": 1000, + "agent.usage.output_tokens": 100, + "gen_ai.request.model": "gpt-5", + }, + }), + span({ + attributes: { + "agent.model.id": "claude-sonnet-4", + "agent.usage.input_tokens": 500, + "agent.usage.output_tokens": 50, + }, + }), + ]); + + expect(summary.inputTokens).toBe(1500); + expect(summary.outputTokens).toBe(150); + expect(summary.cacheReadTokens).toBe(800); + expect(summary.costUsd).toBeCloseTo(0.01); + expect(summary.models).toEqual(["gpt-5", "claude-sonnet-4"]); + expect(summary.errorCount).toBe(0); + }); + + it("reports errors and leaves cost undefined when unreported", () => { + const summary = summarizeLocalTrace([span({ statusCode: 2 }), span()]); + expect(summary.errorCount).toBe(1); + expect(summary.costUsd).toBeUndefined(); + }); +}); + +describe("formatTokenSummary / formatCostUsd", () => { + it("formats the header tokens row with cache parts when present", () => { + expect( + formatTokenSummary({ + cacheReadTokens: 1100, + cacheWriteTokens: 0, + errorCount: 0, + inputTokens: 1200, + models: [], + outputTokens: 340, + }), + ).toBe("↑1.2K in · ↓340 out · 1.1K cached"); + }); + + it("scales cost precision", () => { + expect(formatCostUsd(0.0031)).toBe("$0.0031"); + expect(formatCostUsd(1.5)).toBe("$1.50"); + }); +}); + +describe("renderSpanDetailLines", () => { + it("renders facts, sorted attributes, and events with offsets", () => { + const lines = renderSpanDetailLines( + span({ + attributes: { + "agent.model.id": "gpt-5", + "gen_ai.tool.call.arguments": '{"city":"SF"}', + }, + events: [ + { attributes: {}, name: "step.started", timeNs: 10_000_000n }, + { + attributes: { "step.index": 0 }, + name: "step.completed", + timeNs: 19_500_000n, + }, + ], + parentSpanId: "b".repeat(16), + scope: "eve.agent", + }), + { dim: identity, width: 80 }, + ); + + expect(lines).toEqual([ + "status: ok", + "duration: 10ms", + `started: ${new Date(10).toISOString()}`, + `span: ${"a".repeat(16)}`, + `parent: ${"b".repeat(16)}`, + "scope: eve.agent", + "agent.model.id: gpt-5", + "gen_ai.tool.call.arguments:", + " {", + ' "city": "SF"', + " }", + "events:", + " step.started +0ms", + " step.completed +10ms", + " step.index: 0", + ]); + }); + + it("shows the status message on error spans and kind when non-internal", () => { + const lines = renderSpanDetailLines( + span({ kind: 2, statusCode: 2, statusMessage: "model call failed" }), + { dim: identity, width: 80 }, + ); + + expect(lines[0]).toBe("status: ERROR — model call failed"); + expect(lines).toContain("kind: server"); + }); + + it("sanitizes attribute keys, values, and event names", () => { + const lines = renderSpanDetailLines( + span({ + attributes: { "evil\x1b[2Jkey": "va\x1b[31mlue" }, + events: [{ attributes: {}, name: "bad\x1b]0;owned\x07event", timeNs: 10_000_000n }], + }), + { dim: identity, width: 80 }, + ); + + const joined = lines.join("\n"); + expect(joined).not.toContain("\x1b"); + expect(joined).not.toContain("\x07"); + expect(joined).toContain("evil"); + expect(joined).toContain("badevent"); + }); +}); diff --git a/packages/eve/src/cli/commands/trace-detail.ts b/packages/eve/src/cli/commands/trace-detail.ts new file mode 100644 index 000000000..c9380ba4b --- /dev/null +++ b/packages/eve/src/cli/commands/trace-detail.ts @@ -0,0 +1,202 @@ +/** + * Span-level detail rendering and trace-level usage aggregation for + * `eve traces`. Kept separate from the command module: the tree renderer + * stays compact while these helpers answer "what does this span carry" — + * inline metric chips for tree rows, the `--verbose` per-span block, and + * the usage/cost totals summarized in the trace header. + */ + +import { formatCompactTokenCount } from "#cli/dev/tui/stream-format.js"; +import { formatAttributeContent } from "#cli/dev/tui/traces/trace-content.js"; +import { formatElapsed } from "#cli/format-elapsed.js"; +import { sanitizeForTerminal } from "#cli/ui/output.js"; +import type { LocalTraceSpan } from "#tracing/local-trace-reader.js"; + +/** Usage and cost totals aggregated over a trace's `agent.step` spans. */ +export interface LocalTraceSummary { + readonly cacheReadTokens: number; + readonly cacheWriteTokens: number; + /** Total gateway cost in USD; undefined when no span reported cost. */ + readonly costUsd?: number; + readonly errorCount: number; + readonly inputTokens: number; + /** Distinct model ids seen on any span, first-seen order. */ + readonly models: readonly string[]; + readonly outputTokens: number; +} + +/** + * Compact metrics for one tree row: token chips (`↑1.4K`/`↓213`), cost + * (`$0.0031`), and the tool name for `ai.toolCall` spans. Only chips whose + * attributes the span actually carries — rows without usage stay clean. + * Raw values: callers sanitize for their output surface. + */ +export function spanMetricChips(span: LocalTraceSpan): string[] { + const chips: string[] = []; + const input = numberAttribute(span, "agent.usage.input_tokens"); + const output = numberAttribute(span, "agent.usage.output_tokens"); + if (input !== undefined) chips.push(`↑${formatCompactTokenCount(input)}`); + if (output !== undefined) chips.push(`↓${formatCompactTokenCount(output)}`); + const cost = spanCostUsd(span); + if (cost !== undefined) chips.push(formatCostUsd(cost)); + if (span.name === "ai.toolCall") { + const tool = span.attributes["gen_ai.tool.name"]; + if (typeof tool === "string" && tool.length > 0) chips.push(tool); + } + return chips; +} + +/** + * Aggregates usage, cost, models, and errors across one trace. Only + * `agent.step` spans contribute usage: model spans carry the same counters + * and a subagent's totals already appear as its own step spans in the same + * trace, so summing anything else would double-count. + */ +export function summarizeLocalTrace(spans: readonly LocalTraceSpan[]): LocalTraceSummary { + const models: string[] = []; + let cacheReadTokens = 0; + let cacheWriteTokens = 0; + let costUsd: number | undefined; + let errorCount = 0; + let inputTokens = 0; + let outputTokens = 0; + for (const span of spans) { + const model = + stringAttribute(span, "agent.model.id") ?? stringAttribute(span, "gen_ai.request.model"); + if (model !== undefined && !models.includes(model)) models.push(model); + if (span.statusCode === 2) errorCount += 1; + if (span.name !== "agent.step") continue; + inputTokens += numberAttribute(span, "agent.usage.input_tokens") ?? 0; + outputTokens += numberAttribute(span, "agent.usage.output_tokens") ?? 0; + cacheReadTokens += numberAttribute(span, "gen_ai.usage.cache_read.input_tokens") ?? 0; + cacheWriteTokens += numberAttribute(span, "gen_ai.usage.cache_creation.input_tokens") ?? 0; + const cost = spanCostUsd(span); + if (cost !== undefined) costUsd = (costUsd ?? 0) + cost; + } + return { + cacheReadTokens, + cacheWriteTokens, + costUsd, + errorCount, + inputTokens, + models, + outputTokens, + }; +} + +/** One-line `Tokens` header value: `↑1.2K in · ↓340 out · 1.1K cached`. */ +export function formatTokenSummary(summary: LocalTraceSummary): string { + const parts = [ + `↑${formatCompactTokenCount(summary.inputTokens)} in`, + `↓${formatCompactTokenCount(summary.outputTokens)} out`, + ]; + if (summary.cacheReadTokens > 0) + parts.push(`${formatCompactTokenCount(summary.cacheReadTokens)} cached`); + if (summary.cacheWriteTokens > 0) + parts.push(`${formatCompactTokenCount(summary.cacheWriteTokens)} cache write`); + return parts.join(" · "); +} + +/** Formats a USD cost: `$1.50` at scale, `$0.0031` for typical spans. */ +export function formatCostUsd(costUsd: number): string { + return costUsd >= 1 ? `$${costUsd.toFixed(2)}` : `$${costUsd.toFixed(4)}`; +} + +/** + * The `--verbose` block for one span: facts (status, timing, ids), every + * attribute sorted with payloads rendered as transcripts/pretty JSON, then + * every event with its offset from span start. Returned lines carry no tree + * prefix — the caller nests them under the span's tree row. + */ +export function renderSpanDetailLines( + span: LocalTraceSpan, + options: { readonly dim: (text: string) => string; readonly width: number }, +): string[] { + const width = Math.max(40, options.width); + const lines: string[] = []; + const error = span.statusCode === 2; + const status = + error && span.statusMessage !== undefined + ? `ERROR — ${sanitizeForTerminal(span.statusMessage)}` + : error + ? "ERROR" + : "ok"; + lines.push(`status: ${status}`); + lines.push(`duration: ${formatElapsed(durationMs(span.startTimeNs, span.endTimeNs))}`); + lines.push(`started: ${new Date(Number(span.startTimeNs / 1_000_000n)).toISOString()}`); + lines.push(`span: ${span.spanId}`); + if (span.parentSpanId !== undefined) lines.push(`parent: ${span.parentSpanId}`); + if (span.scope !== undefined) lines.push(`scope: ${sanitizeForTerminal(span.scope)}`); + if (span.kind !== undefined && span.kind !== 1) lines.push(`kind: ${spanKind(span.kind)}`); + + const keys = Object.keys(span.attributes).sort(); + for (const key of keys) { + const block = formatAttributeContent(key, span.attributes[key], options.dim, width - 2); + const cleanKey = sanitizeForTerminal(key); + if (block.length === 1) { + lines.push(`${cleanKey}: ${block[0]}`); + } else { + lines.push(`${cleanKey}:`); + for (const line of block) lines.push(` ${line}`); + } + } + + if (span.events.length > 0) { + lines.push("events:"); + for (const event of span.events) { + const offsetMs = Math.max(0, durationMs(span.startTimeNs, event.timeNs)); + lines.push(` ${sanitizeForTerminal(event.name)} +${formatElapsed(offsetMs)}`); + for (const key of Object.keys(event.attributes).sort()) { + const block = formatAttributeContent(key, event.attributes[key], options.dim, width - 4); + const cleanKey = sanitizeForTerminal(key); + if (block.length === 1) { + lines.push(` ${cleanKey}: ${block[0]}`); + } else { + lines.push(` ${cleanKey}:`); + for (const line of block) lines.push(` ${line}`); + } + } + } + } + return lines; +} + +function spanCostUsd(span: LocalTraceSpan): number | undefined { + return ( + numberAttribute(span, "gen_ai.usage.gateway_cost") ?? numberAttribute(span, "gen_ai.usage.cost") + ); +} + +function spanKind(kind: number): string { + switch (kind) { + case 2: + return "server"; + case 3: + return "client"; + case 4: + return "producer"; + case 5: + return "consumer"; + default: + return `unknown (${kind})`; + } +} + +function numberAttribute(span: LocalTraceSpan, key: string): number | undefined { + const value = span.attributes[key]; + if (typeof value === "number") return value; + if (typeof value === "string" && value !== "") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function stringAttribute(span: LocalTraceSpan, key: string): string | undefined { + const value = span.attributes[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function durationMs(start: bigint, end: bigint): number { + return Number(end - start) / 1_000_000; +} diff --git a/packages/eve/src/cli/commands/trace.integration.test.ts b/packages/eve/src/cli/commands/trace.integration.test.ts index 7ab0f464b..9bb299f0f 100644 --- a/packages/eve/src/cli/commands/trace.integration.test.ts +++ b/packages/eve/src/cli/commands/trace.integration.test.ts @@ -225,6 +225,117 @@ describe("eve traces", () => { expect(output.out[0]).toContain("agent.turn.terminal 0ms"); }); + it("shows usage and cost chips on span rows and totals in the header", async () => { + const root = await createRoot(); + await writeSegment( + root, + TRACE_ONE, + span("a", "agent.step", 10, 80, undefined, { + "agent.model.id": "gpt-5", + "agent.session.id": "session-one", + "agent.step.attempt": 0, + "agent.step.index": 0, + "agent.usage.input_tokens": 1400, + "agent.usage.output_tokens": 213, + "gen_ai.usage.gateway_cost": 0.0031, + }), + ); + await writeSegment( + root, + TRACE_ONE, + span("b", "ai.toolCall", 20, 30, "a", { "gen_ai.tool.name": "get_weather" }), + ); + const output = collectingLogger(); + + await runTraceShowCommand(output.logger, root, "session-one"); + + expect(output.out[0]).toContain("Models"); + expect(output.out[0]).toContain("gpt-5"); + expect(output.out[0]).toContain("Tokens"); + expect(output.out[0]).toContain("↑1.4K in · ↓213 out"); + expect(output.out[0]).toContain("Cost"); + expect(output.out[0]).toContain("$0.0031"); + expect(output.out[0]).toContain("agent.step [step 0, attempt 0] 70ms ↑1.4K ↓213 $0.0031"); + expect(output.out[0]).toContain("ai.toolCall 10ms get_weather"); + // Attributes and events stay behind --verbose. + expect(output.out[0]).not.toContain("events:"); + expect(output.out[0]).not.toContain("agent.model.id:"); + }); + + it("expands every span with all attributes and events under --verbose", async () => { + const root = await createRoot(); + await writeSegment(root, TRACE_ONE, { + ...span("a", "agent.step", 10, 80, undefined, { + "agent.model.id": "gpt-5", + "agent.session.id": "session-one", + "agent.step.index": 0, + }), + events: [ + { name: "step.started", timeUnixNano: "10000000" }, + { + attributes: [{ key: "step.index", value: { intValue: 0 } }], + name: "step.completed", + timeUnixNano: "80000000", + }, + ], + }); + await writeSegment(root, TRACE_ONE, { + ...span("b", "ai.toolCall", 20, 30, "a", { "gen_ai.tool.name": "get_weather" }), + status: { code: 2, message: "tool exploded" }, + }); + const output = collectingLogger(); + + await runTraceShowCommand(output.logger, root, "session-one", { verbose: true }); + + expect(output.out[0]).toContain("agent.step [step 0]"); + expect(output.out[0]).toContain("status: ok"); + expect(output.out[0]).toContain("agent.model.id: gpt-5"); + expect(output.out[0]).toContain("events:"); + expect(output.out[0]).toContain("step.started +0ms"); + expect(output.out[0]).toContain("step.completed +70ms"); + expect(output.out[0]).toContain("step.index: 0"); + expect(output.out[0]).toContain("status: ERROR — tool exploded"); + expect(output.out[0]).toContain("gen_ai.tool.name: get_weather"); + expect(output.out[0]).toContain("Errors"); + expect(output.out[0]).not.toContain("\u001B"); + }); + + it("dumps full span data including attributes and events as JSON", async () => { + const root = await createRoot(); + await writeSegment(root, TRACE_ONE, { + ...span("a", "agent.step", 10, 80, undefined, { + "agent.model.id": "gpt-5", + "agent.session.id": "session-one", + }), + events: [{ name: "step.started", timeUnixNano: "10000000" }], + }); + await writeSegment(root, TRACE_ONE, { + ...span("b", "ai.toolCall", 20, 30, "a", { "gen_ai.tool.name": "get_weather" }), + status: { code: 2, message: "tool exploded" }, + }); + const output = collectingLogger(); + + await runTraceShowCommand(output.logger, root, "session-one", { json: true }); + + const [trace] = JSON.parse(output.out[0]!) as [ + { + traceId: string; + spans: { + attributes: Record; + events: { name: string; timeNs: string }[]; + name: string; + statusMessage: string | null; + }[]; + }, + ]; + expect(trace.traceId).toBe(TRACE_ONE); + const stepSpan = trace.spans.find((span) => span.name === "agent.step")!; + expect(stepSpan.attributes["agent.model.id"]).toBe("gpt-5"); + expect(stepSpan.events).toEqual([{ attributes: {}, name: "step.started", timeNs: "10000000" }]); + const toolSpan = trace.spans.find((span) => span.name === "ai.toolCall")!; + expect(toolSpan.statusMessage).toBe("tool exploded"); + }); + it("prints empty and JSON list output", async () => { const root = await createRoot(); const empty = collectingLogger(); @@ -274,11 +385,19 @@ function collectingLogger() { }; } -async function writeSegment( - root: string, - traceId: string, - value: ReturnType, -): Promise { +interface TestSegmentSpan { + readonly attributes: readonly { key: string; value: Record }[]; + readonly endTimeUnixNano: string; + readonly events?: readonly unknown[]; + readonly name: string; + readonly parentSpanId?: string; + readonly spanId: string; + readonly startTimeUnixNano: string; + readonly status: { readonly code: number; readonly message?: string }; + readonly traceId: string; +} + +async function writeSegment(root: string, traceId: string, value: TestSegmentSpan): Promise { const directory = join(root, ".eve", "traces", "v1", traceId, "segments"); await mkdir(directory, { recursive: true }); await writeFile( @@ -298,7 +417,7 @@ function span( end: number, parentSpanId?: string, attributes: Record = {}, -) { +): TestSegmentSpan { return { attributes: Object.entries(attributes).map(([key, value]) => ({ key, diff --git a/packages/eve/src/cli/commands/trace.ts b/packages/eve/src/cli/commands/trace.ts index 55e9a5d72..cb86f7f09 100644 --- a/packages/eve/src/cli/commands/trace.ts +++ b/packages/eve/src/cli/commands/trace.ts @@ -1,5 +1,12 @@ import { basename } from "node:path"; +import { + formatCostUsd, + formatTokenSummary, + renderSpanDetailLines, + spanMetricChips, + summarizeLocalTrace, +} from "#cli/commands/trace-detail.js"; import { formatElapsed } from "#cli/format-elapsed.js"; import { createCliTheme, renderCliSection, sanitizeForTerminal } from "#cli/ui/output.js"; import type { LocalTrace, LocalTraceSpan } from "#tracing/local-trace-reader.js"; @@ -123,6 +130,7 @@ export async function runTraceShowCommand( logger: CliTraceLogger, appRoot: string, reference?: string, + options: { readonly json?: boolean; readonly verbose?: boolean } = {}, ): Promise { const traces = await listLocalTraces(appRoot); if (traces.length === 0) { @@ -132,36 +140,95 @@ export async function runTraceShowCommand( return; } const selected = reference === undefined ? [traces[0]!] : resolveLocalTraces(traces, reference); + if (options.json === true) { + logger.log(JSON.stringify(selected.map(serializeTraceForJson), null, 2)); + return; + } const theme = createCliTheme(); logger.log( selected .map((trace) => [ renderCliSection(theme, { - rows: [ - { label: "Trace ID", value: trace.traceId }, - { label: "Session ID", value: trace.sessionId ?? "unknown" }, - ...(trace.window === undefined - ? [] - : [{ label: "Window", value: String(trace.window) }]), - { label: "Agent", value: trace.agentName ?? "unknown" }, - { label: "Started", value: toDate(trace.startTimeNs).toISOString() }, - { - label: "Duration", - value: formatElapsed(durationMs(trace.startTimeNs, trace.endTimeNs)), - }, - { label: "Spans", value: String(trace.spans.length) }, - ], + rows: traceHeaderRows(trace), title: "Trace", }), - `${theme.accent("Spans")}\n${renderSpanTree(trace.spans)}`, + `${theme.accent("Spans")}\n${renderSpanTree(trace.spans, { + dim: theme.muted, + verbose: options.verbose === true, + })}`, ].join("\n\n"), ) .join("\n\n"), ); } -function renderSpanTree(spans: readonly LocalTraceSpan[]): string { +function traceHeaderRows(trace: LocalTrace): { label: string; value: string }[] { + const summary = summarizeLocalTrace(trace.spans); + return [ + { label: "Trace ID", value: trace.traceId }, + { label: "Session ID", value: trace.sessionId ?? "unknown" }, + ...(trace.window === undefined ? [] : [{ label: "Window", value: String(trace.window) }]), + { label: "Agent", value: trace.agentName ?? "unknown" }, + { label: "Started", value: toDate(trace.startTimeNs).toISOString() }, + { + label: "Duration", + value: formatElapsed(durationMs(trace.startTimeNs, trace.endTimeNs)), + }, + { label: "Spans", value: String(trace.spans.length) }, + ...(summary.models.length === 0 ? [] : [{ label: "Models", value: summary.models.join(", ") }]), + ...(summary.inputTokens === 0 && summary.outputTokens === 0 + ? [] + : [{ label: "Tokens", value: formatTokenSummary(summary) }]), + ...(summary.costUsd === undefined + ? [] + : [{ label: "Cost", value: formatCostUsd(summary.costUsd) }]), + ...(summary.errorCount === 0 + ? [] + : [ + { + label: "Errors", + value: `${summary.errorCount} span${summary.errorCount === 1 ? "" : "s"}`, + }, + ]), + ]; +} + +function serializeTraceForJson(trace: LocalTrace): Record { + return { + agentName: trace.agentName ?? null, + durationMs: durationMs(trace.startTimeNs, trace.endTimeNs), + sessionId: trace.sessionId ?? null, + sessionIds: trace.sessionIds, + spanCount: trace.spans.length, + spans: trace.spans.map((span) => ({ + attributes: span.attributes, + durationMs: durationMs(span.startTimeNs, span.endTimeNs), + endTimeNs: span.endTimeNs.toString(), + events: span.events.map((event) => ({ + attributes: event.attributes, + name: event.name, + timeNs: event.timeNs.toString(), + })), + kind: span.kind ?? null, + name: span.name, + parentSpanId: span.parentSpanId ?? null, + scope: span.scope ?? null, + spanId: span.spanId, + startTimeNs: span.startTimeNs.toString(), + statusCode: span.statusCode, + statusMessage: span.statusMessage ?? null, + })), + startedAt: toDate(trace.startTimeNs).toISOString(), + traceId: trace.traceId, + window: trace.window ?? null, + }; +} + +function renderSpanTree( + spans: readonly LocalTraceSpan[], + options: { readonly dim?: (text: string) => string; readonly verbose?: boolean } = {}, +): string { const byId = new Map(spans.map((span) => [span.spanId, span])); const children = new Map(); const roots: LocalTraceSpan[] = []; @@ -177,6 +244,7 @@ function renderSpanTree(spans: readonly LocalTraceSpan[]): string { roots.sort(compareLocalTraceSpans); for (const siblings of children.values()) siblings.sort(compareLocalTraceSpans); const extents = subtreeExtents(spans, children); + const width = terminalWidth(); const lines: string[] = []; const visited = new Set(); @@ -184,14 +252,19 @@ function renderSpanTree(spans: readonly LocalTraceSpan[]): string { if (visited.has(span.spanId)) return; visited.add(span.spanId); lines.push(`${prefix}${connector}${spanLabel(span, extents.get(span.spanId))}`); + const childPrefix = `${prefix}${connector === "" ? "" : connector === "└─ " ? " " : "│ "}`; + if (options.verbose === true) { + for (const line of renderSpanDetailLines(span, { + dim: options.dim ?? ((text) => text), + width: width - childPrefix.length - 2, + })) { + lines.push(`${childPrefix} ${line}`); + } + } const descendants = children.get(span.spanId) ?? []; descendants.forEach((child, index) => { const last = index === descendants.length - 1; - render( - child, - `${prefix}${connector === "" ? "" : connector === "└─ " ? " " : "│ "}`, - last ? "└─ " : "├─ ", - ); + render(child, childPrefix, last ? "└─ " : "├─ "); }); }; for (const root of roots) render(root, "", ""); @@ -240,13 +313,20 @@ function subtreeExtents( function spanLabel(span: LocalTraceSpan, extent?: SpanExtent): string { const details = describeLocalTraceSpan(span).map(sanitizeForTerminal); const detail = details.length === 0 ? "" : ` [${details.join(", ")}]`; + const chips = spanMetricChips(span).map(sanitizeForTerminal); + const metrics = chips.length === 0 ? "" : ` ${chips.join(" ")}`; const error = span.statusCode === 2 ? " ERROR" : ""; const recorded = durationMs(span.startTimeNs, span.endTimeNs); const elapsed = recorded === 0 && extent !== undefined ? durationMs(extent.startTimeNs, extent.endTimeNs) : recorded; - return `${sanitizeForTerminal(span.name)}${detail} ${formatElapsed(elapsed)}${error}`; + return `${sanitizeForTerminal(span.name)}${detail} ${formatElapsed(elapsed)}${metrics}${error}`; +} + +function terminalWidth(): number { + const columns = process.stdout.columns; + return typeof columns === "number" && columns >= 40 ? columns : 100; } function durationMs(start: bigint, end: bigint): number { diff --git a/packages/eve/src/cli/dev/tui/traces/trace-content.test.ts b/packages/eve/src/cli/dev/tui/traces/trace-content.test.ts index 795a2898b..00b00798e 100644 --- a/packages/eve/src/cli/dev/tui/traces/trace-content.test.ts +++ b/packages/eve/src/cli/dev/tui/traces/trace-content.test.ts @@ -9,7 +9,7 @@ const THEME = createTheme({ color: false, unicode: true }); const WIDTH = 60; function format(key: string, value: unknown, width = WIDTH): string[] { - return formatAttributeContent(key, value, THEME, width).map(stripAnsi); + return formatAttributeContent(key, value, THEME.colors.dim, width).map(stripAnsi); } describe("formatAttributeContent", () => { @@ -24,7 +24,7 @@ describe("formatAttributeContent", () => { const raw = formatAttributeContent( "agent.session.ids", ["safe", "evil\x1b[2J\x1b]0;owned\x07text"], - THEME, + THEME.colors.dim, WIDTH, ).join("\n"); expect(raw).not.toContain("\x1b"); diff --git a/packages/eve/src/cli/dev/tui/traces/trace-content.ts b/packages/eve/src/cli/dev/tui/traces/trace-content.ts index 520436410..e38a9f66a 100644 --- a/packages/eve/src/cli/dev/tui/traces/trace-content.ts +++ b/packages/eve/src/cli/dev/tui/traces/trace-content.ts @@ -12,8 +12,6 @@ import { stripTerminalControls, visibleLength, wrapVisibleLine } from "#cli/ui/terminal-text.js"; -import type { Theme } from "../theme.js"; - /** Indent applied to continuation lines within a block. */ const CONTINUATION = " "; @@ -21,12 +19,13 @@ const CONTINUATION = " "; * Formats one attribute's value into display lines, each at most `width` * columns. A single scalar stays on one line so the panel can keep it beside * its key; multi-line content comes back unindented for the caller to nest - * under the key. + * under the key. `dim` styles de-emphasized parts (role prefixes, the + * truncation notice), so callers pass their surface's dim style. */ export function formatAttributeContent( key: string, value: unknown, - theme: Theme, + dim: (text: string) => string, width: number, ): string[] { // Non-string OTLP values include arrays whose elements can carry raw @@ -37,7 +36,7 @@ export function formatAttributeContent( return [stripTerminalControls(shortJson(value))]; } if (key === "ai.prompt.messages") { - const transcript = messageTranscript(value, theme, width); + const transcript = messageTranscript(value, dim, width); if (transcript !== undefined) return transcript; } return formatPayloadContent(value, width); @@ -62,7 +61,11 @@ export function formatPayloadContent(text: string, width: number): string[] { return wrapPlainText(text, width); } -function messageTranscript(raw: string, theme: Theme, width: number): string[] | undefined { +function messageTranscript( + raw: string, + dim: (text: string) => string, + width: number, +): string[] | undefined { const parsed = parseJson(raw); if (!Array.isArray(parsed)) return undefined; // Long conversations are front-truncated at capture time with an @@ -82,11 +85,10 @@ function messageTranscript(raw: string, theme: Theme, width: number): string[] | if (!messages.every((message) => typeof message.role === "string")) { return undefined; } - const { colors } = theme; const lines: string[] = []; if (omitted > 0) { lines.push( - colors.dim(`… ${omitted} earlier message${omitted === 1 ? "" : "s"} omitted (long context)`), + dim(`… ${omitted} earlier message${omitted === 1 ? "" : "s"} omitted (long context)`), ); } for (const message of messages) { @@ -95,8 +97,8 @@ function messageTranscript(raw: string, theme: Theme, width: number): string[] | message.role === "tool" ? toolRoleLabel(parts) : stripTerminalControls(String(message.role)); // The first part sits beside the role prefix; the rest hang underneath. parts.forEach((part, index) => { - const prefix = index === 0 ? `${colors.dim(`${role}:`)} ` : CONTINUATION; - lines.push(...wrapPrefixed(prefix, part.text, part.dim ? colors.dim : undefined, width)); + const prefix = index === 0 ? `${dim(`${role}:`)} ` : CONTINUATION; + lines.push(...wrapPrefixed(prefix, part.text, part.dim ? dim : undefined, width)); }); } return lines; diff --git a/packages/eve/src/cli/dev/tui/traces/trace-conversation.test.ts b/packages/eve/src/cli/dev/tui/traces/trace-conversation.test.ts index abe84b2b6..822335aca 100644 --- a/packages/eve/src/cli/dev/tui/traces/trace-conversation.test.ts +++ b/packages/eve/src/cli/dev/tui/traces/trace-conversation.test.ts @@ -22,6 +22,7 @@ function span( return { attributes, endTimeNs: BASE + BigInt(endMs) * 1_000_000n, + events: [], name, parentSpanId, spanId, diff --git a/packages/eve/src/cli/dev/tui/traces/trace-view.test.ts b/packages/eve/src/cli/dev/tui/traces/trace-view.test.ts index dc31accae..32e02b67b 100644 --- a/packages/eve/src/cli/dev/tui/traces/trace-view.test.ts +++ b/packages/eve/src/cli/dev/tui/traces/trace-view.test.ts @@ -30,6 +30,7 @@ function span( return { attributes, endTimeNs: BASE + BigInt(endMs) * 1_000_000n, + events: [], name, parentSpanId, spanId, diff --git a/packages/eve/src/cli/dev/tui/traces/trace-view.ts b/packages/eve/src/cli/dev/tui/traces/trace-view.ts index e856dbc32..3164db3ab 100644 --- a/packages/eve/src/cli/dev/tui/traces/trace-view.ts +++ b/packages/eve/src/cli/dev/tui/traces/trace-view.ts @@ -237,7 +237,7 @@ export function renderSpanDetail( } else { lines.push(muted(`attributes (${keys.length})`)); for (const key of keys) { - const block = formatAttributeContent(key, span.attributes[key], theme, innerWidth - 2); + const block = formatAttributeContent(key, span.attributes[key], colors.dim, innerWidth - 2); const cleanKey = stripTerminalControls(key); if (block.length === 1) { // Scalars stay beside the key; multi-line content nests beneath it. diff --git a/packages/eve/src/cli/dev/tui/traces/trace-viewer-session.test.ts b/packages/eve/src/cli/dev/tui/traces/trace-viewer-session.test.ts index 00b8ee3a5..650af2084 100644 --- a/packages/eve/src/cli/dev/tui/traces/trace-viewer-session.test.ts +++ b/packages/eve/src/cli/dev/tui/traces/trace-viewer-session.test.ts @@ -16,6 +16,7 @@ function span(spanId: string, sessionId: string): LocalTraceSpan { "agent.turn.id": "turn_0", }, endTimeNs: 1_000_000n, + events: [], name: "agent.turn", spanId, startTimeNs: 1_000_000n, diff --git a/packages/eve/src/cli/dev/tui/traces/trace-viewer-state.test.ts b/packages/eve/src/cli/dev/tui/traces/trace-viewer-state.test.ts index 5262376fb..13df73bda 100644 --- a/packages/eve/src/cli/dev/tui/traces/trace-viewer-state.test.ts +++ b/packages/eve/src/cli/dev/tui/traces/trace-viewer-state.test.ts @@ -19,6 +19,7 @@ function span(overrides: Partial = {}): LocalTraceSpan { return { attributes: {}, endTimeNs: startTimeNs + 500_000n, + events: [], name: `span-${spanSequence}`, spanId: String(spanSequence).padStart(16, "0"), startTimeNs, diff --git a/packages/eve/src/cli/run.ts b/packages/eve/src/cli/run.ts index 61491a9f2..ef710fb4c 100644 --- a/packages/eve/src/cli/run.ts +++ b/packages/eve/src/cli/run.ts @@ -541,10 +541,14 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm .command("traces [trace]") .usage("[options] [trace]\n eve traces ls [options]") .description("Show a local `eve dev` trace (the most recent when trace is omitted).") - .action(async (reference: string | undefined) => { - const { runTraceShowCommand } = await import("#cli/commands/trace.js"); - await runTraceShowCommand(logger, appRoot, reference); - }); + .option("--verbose", "Expand every span with all attributes and events") + .option("--json", "Output as JSON") + .action( + async (reference: string | undefined, options: { json?: boolean; verbose?: boolean }) => { + const { runTraceShowCommand } = await import("#cli/commands/trace.js"); + await runTraceShowCommand(logger, appRoot, reference, options); + }, + ); traces .command("ls") diff --git a/packages/eve/src/tracing/local-trace-reader.test.ts b/packages/eve/src/tracing/local-trace-reader.test.ts new file mode 100644 index 000000000..90f974707 --- /dev/null +++ b/packages/eve/src/tracing/local-trace-reader.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; + +import { parseLocalTraceSegment } from "./local-trace-reader.js"; + +const TRACE_ID = "1".repeat(32); +const SPAN_ID = "a".repeat(16); + +function segment(spans: Record[]): string { + return JSON.stringify({ + resourceSpans: [ + { + scopeSpans: [ + { scope: { name: "eve.agent" }, spans: spans.map((s) => ({ ...s, traceId: TRACE_ID })) }, + ], + }, + ], + }); +} + +function span(overrides: Record = {}): Record { + return { + attributes: [], + endTimeUnixNano: "20000000", + name: "agent.step", + spanId: SPAN_ID, + startTimeUnixNano: "10000000", + status: { code: 0 }, + ...overrides, + }; +} + +describe("parseLocalTraceSegment", () => { + it("parses span events with attributes, sorted by time", () => { + const spans = parseLocalTraceSegment( + segment([ + span({ + events: [ + { + attributes: [{ key: "step.index", value: { intValue: 2 } }], + name: "step.completed", + timeUnixNano: "19000000", + }, + { name: "step.started", timeUnixNano: "11000000" }, + ], + }), + ]), + TRACE_ID, + ); + + expect(spans).toHaveLength(1); + expect(spans[0]!.events).toEqual([ + { attributes: {}, name: "step.started", timeNs: 11_000_000n }, + { attributes: { "step.index": 2 }, name: "step.completed", timeNs: 19_000_000n }, + ]); + }); + + it("skips malformed events while keeping valid ones", () => { + const spans = parseLocalTraceSegment( + segment([ + span({ + events: [ + { name: "step.started", timeUnixNano: "11000000" }, + { timeUnixNano: "12000000" }, + { name: "step.failed" }, + "not an event", + { name: "step.completed", timeUnixNano: "18446744073709551616" }, + ], + }), + ]), + TRACE_ID, + ); + + expect(spans[0]!.events).toEqual([ + { attributes: {}, name: "step.started", timeNs: 11_000_000n }, + ]); + }); + + it("parses status message and span kind", () => { + const spans = parseLocalTraceSegment( + segment([ + span({ + kind: 2, + status: { code: 2, message: "model call failed" }, + }), + ]), + TRACE_ID, + ); + + expect(spans[0]!.statusCode).toBe(2); + expect(spans[0]!.statusMessage).toBe("model call failed"); + expect(spans[0]!.kind).toBe(2); + }); + + it("defaults to no events, status message, or kind", () => { + const spans = parseLocalTraceSegment(segment([span()]), TRACE_ID); + + expect(spans[0]!.events).toEqual([]); + expect(spans[0]!.statusMessage).toBeUndefined(); + expect(spans[0]!.kind).toBeUndefined(); + }); + + it("drops empty status messages", () => { + const spans = parseLocalTraceSegment( + segment([span({ status: { code: 1, message: "" } })]), + TRACE_ID, + ); + + expect(spans[0]!.statusMessage).toBeUndefined(); + }); +}); diff --git a/packages/eve/src/tracing/local-trace-reader.ts b/packages/eve/src/tracing/local-trace-reader.ts index 8dbcd8e4f..5b3cac83b 100644 --- a/packages/eve/src/tracing/local-trace-reader.ts +++ b/packages/eve/src/tracing/local-trace-reader.ts @@ -21,15 +21,26 @@ const SPAN_FILE_PATTERN = /^[0-9a-f]{16}\.otlp\.json$/u; const MAX_SEGMENT_BYTES = 8 * 1024 * 1024; const MAX_UINT64 = 18_446_744_073_709_551_615n; +export interface LocalTraceSpanEvent { + readonly attributes: Readonly>; + readonly name: string; + readonly timeNs: bigint; +} + export interface LocalTraceSpan { readonly attributes: Readonly>; readonly endTimeNs: bigint; + readonly events: readonly LocalTraceSpanEvent[]; + /** OTLP span kind (1 internal … 5 consumer); undefined when absent. */ + readonly kind?: number; readonly name: string; readonly parentSpanId?: string; readonly scope?: string; readonly spanId: string; readonly startTimeNs: bigint; readonly statusCode: number; + /** Message carried by an ERROR status, when the span recorded one. */ + readonly statusMessage?: string; readonly traceId: string; } @@ -272,6 +283,8 @@ function parseLocalTraceSpan( return { attributes: parseAttributes(raw.attributes), endTimeNs, + events: parseEvents(raw.events), + kind: typeof raw.kind === "number" ? raw.kind : undefined, name: raw.name, parentSpanId: typeof raw.parentSpanId === "string" && /^[0-9a-f]{16}$/u.test(raw.parentSpanId) @@ -281,10 +294,29 @@ function parseLocalTraceSpan( spanId: raw.spanId, startTimeNs, statusCode: parseStatusCode(raw.status), + statusMessage: parseStatusMessage(raw.status), traceId: expectedTraceId, }; } +function parseEvents(value: unknown): LocalTraceSpanEvent[] { + if (!Array.isArray(value)) return []; + const events: LocalTraceSpanEvent[] = []; + for (const entry of value) { + if (!isRecord(entry) || typeof entry.name !== "string") continue; + const timeNs = parseNanos(entry.timeUnixNano); + if (timeNs === undefined) continue; + events.push({ attributes: parseAttributes(entry.attributes), name: entry.name, timeNs }); + } + return events.sort((left, right) => + left.timeNs === right.timeNs + ? left.name.localeCompare(right.name) + : left.timeNs < right.timeNs + ? -1 + : 1, + ); +} + function parseAttributes(value: unknown): Record { if (!Array.isArray(value)) return {}; const attributes: Record = {}; @@ -322,6 +354,11 @@ function parseStatusCode(value: unknown): number { return value.code === "STATUS_CODE_ERROR" ? 2 : 0; } +function parseStatusMessage(value: unknown): string | undefined { + if (!isRecord(value) || typeof value.message !== "string") return undefined; + return value.message.length === 0 ? undefined : value.message; +} + function firstAttribute( attributes: readonly Readonly>[], key: string, From a5b95a68b6f3487a8f2eac9808ea18431d34d624 Mon Sep 17 00:00:00 2001 From: Chad Hietala Date: Tue, 4 Aug 2026 17:22:03 -0500 Subject: [PATCH 2/3] fix(docs): tighten the eve traces output wording Signed-off-by: Chad Hietala --- docs/reference/cli.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 0fc32aefa..39934e443 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -255,12 +255,12 @@ eve traces ls --json # emit machine-readable trace summaries eve traces # show the most recent span tree eve traces # show one span tree eve traces --verbose # expand every span with all attributes and events -eve traces --json # dump the full trace — every span's attributes and events — as JSON +eve traces --json # dump the full trace as JSON ``` Reads the immutable OTLP/JSON segments under `.eve/traces/v1`, so `eve dev` need not be running. Accepts a full trace id, an `agent.session.id`, or an unambiguous prefix of either. Malformed segments are skipped without hiding valid spans from the same trace. -Span rows carry inline metrics when the span recorded them — `↑input`/`↓output` token counts, gateway cost, and the tool name on `ai.toolCall` rows — and the header aggregates models, token totals, cost, and error count across the trace's step spans. `--verbose` nests each span's full record under its tree row: status (with the error message when failed), timing, ids, every attribute (prompts, responses, and tool payloads rendered as readable blocks), and every span event with its offset from span start. `--json` emits the same records machine-readably, one object per selected trace. +Span rows carry inline metrics when the span recorded them — `↑input`/`↓output` token counts, gateway cost, and the tool name for `ai.toolCall` spans — and the header aggregates models, token totals, cost, and error count across the trace's step spans. `--verbose` expands each span under its tree row: status (with the error message on failures), timing, ids, every attribute (prompts, responses, and tool payloads as transcripts or pretty-printed JSON), and every span event with its offset from span start. `--json` prints the same records as JSON, one object per selected trace. A subagent keeps its own session id but records into the trace its parent had open at dispatch, so delegated work appears under the session that caused it, tagged with `agent.root.session.id`. Either session id resolves to that trace. A remote agent traces under its own deployment and is not recorded here. From 94ad6ec9bcc66f1ff8007c635a5d4d63f9766178 Mon Sep 17 00:00:00 2001 From: Chad Hietala Date: Tue, 4 Aug 2026 17:49:31 -0500 Subject: [PATCH 3/3] fix(eve): render verbose trace details as tree entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detail lines under --verbose now render as tree(1)-style entries: every fact, attribute key, and event gets a connector beneath the span's row, with payload content on a plain margin, so the tree's rails never break and each block reads as part of its span. All tree chrome — bars and connectors on span rows and detail lines alike — is dimmed so only labels and values render bright. Signed-off-by: Chad Hietala --- .../eve/src/cli/commands/trace-detail.test.ts | 68 +++++----- packages/eve/src/cli/commands/trace-detail.ts | 119 +++++++++++++----- .../cli/commands/trace.integration.test.ts | 19 +-- packages/eve/src/cli/commands/trace.ts | 22 ++-- 4 files changed, 151 insertions(+), 77 deletions(-) diff --git a/packages/eve/src/cli/commands/trace-detail.test.ts b/packages/eve/src/cli/commands/trace-detail.test.ts index c7d0c6579..eb7e205ea 100644 --- a/packages/eve/src/cli/commands/trace-detail.test.ts +++ b/packages/eve/src/cli/commands/trace-detail.test.ts @@ -5,7 +5,7 @@ import type { LocalTraceSpan } from "#tracing/local-trace-reader.js"; import { formatCostUsd, formatTokenSummary, - renderSpanDetailLines, + renderSpanDetailTree, spanMetricChips, summarizeLocalTrace, } from "./trace-detail.js"; @@ -24,8 +24,6 @@ function span(overrides: Partial = {}): LocalTraceSpan { }; } -const identity = (text: string): string => text; - describe("spanMetricChips", () => { it("emits token, cost, and tool chips only when present", () => { expect( @@ -122,9 +120,11 @@ describe("formatTokenSummary / formatCostUsd", () => { }); }); -describe("renderSpanDetailLines", () => { - it("renders facts, sorted attributes, and events with offsets", () => { - const lines = renderSpanDetailLines( +describe("renderSpanDetailTree", () => { + const mute = (text: string): string => text; + + it("renders facts, sorted attributes, and events as tree entries", () => { + const lines = renderSpanDetailTree( span({ attributes: { "agent.model.id": "gpt-5", @@ -141,45 +141,57 @@ describe("renderSpanDetailLines", () => { parentSpanId: "b".repeat(16), scope: "eve.agent", }), - { dim: identity, width: 80 }, + { childrenFollow: false, margin: "│ ", mute, width: 80 }, ); expect(lines).toEqual([ - "status: ok", - "duration: 10ms", - `started: ${new Date(10).toISOString()}`, - `span: ${"a".repeat(16)}`, - `parent: ${"b".repeat(16)}`, - "scope: eve.agent", - "agent.model.id: gpt-5", - "gen_ai.tool.call.arguments:", - " {", - ' "city": "SF"', - " }", - "events:", - " step.started +0ms", - " step.completed +10ms", - " step.index: 0", + "│ ├─ status: ok", + "│ ├─ duration: 10ms", + `│ ├─ started: ${new Date(10).toISOString()}`, + `│ ├─ span: ${"a".repeat(16)}`, + `│ ├─ parent: ${"b".repeat(16)}`, + "│ ├─ scope: eve.agent", + "│ ├─ agent.model.id: gpt-5", + "│ ├─ gen_ai.tool.call.arguments:", + "│ │ {", + '│ │ "city": "SF"', + "│ │ }", + "│ └─ events:", + "│ ├─ step.started +0ms", + "│ └─ step.completed +10ms", + "│ step.index: 0", ]); }); + it("keeps the last entry open when child spans follow", () => { + const lines = renderSpanDetailTree(span(), { + childrenFollow: true, + margin: "", + mute, + width: 80, + }); + + expect(lines[0]).toBe("├─ status: ok"); + expect(lines[lines.length - 1]).toMatch(/^├─ /u); + }); + it("shows the status message on error spans and kind when non-internal", () => { - const lines = renderSpanDetailLines( + const lines = renderSpanDetailTree( span({ kind: 2, statusCode: 2, statusMessage: "model call failed" }), - { dim: identity, width: 80 }, + { childrenFollow: false, margin: "", mute, width: 80 }, ); - expect(lines[0]).toBe("status: ERROR — model call failed"); - expect(lines).toContain("kind: server"); + expect(lines[0]).toBe("├─ status: ERROR — model call failed"); + expect(lines).toContain("└─ kind: server"); }); it("sanitizes attribute keys, values, and event names", () => { - const lines = renderSpanDetailLines( + const lines = renderSpanDetailTree( span({ attributes: { "evil\x1b[2Jkey": "va\x1b[31mlue" }, events: [{ attributes: {}, name: "bad\x1b]0;owned\x07event", timeNs: 10_000_000n }], }), - { dim: identity, width: 80 }, + { childrenFollow: false, margin: "", mute, width: 80 }, ); const joined = lines.join("\n"); diff --git a/packages/eve/src/cli/commands/trace-detail.ts b/packages/eve/src/cli/commands/trace-detail.ts index c9380ba4b..da41539e0 100644 --- a/packages/eve/src/cli/commands/trace-detail.ts +++ b/packages/eve/src/cli/commands/trace-detail.ts @@ -105,15 +105,61 @@ export function formatCostUsd(costUsd: number): string { /** * The `--verbose` block for one span: facts (status, timing, ids), every * attribute sorted with payloads rendered as transcripts/pretty JSON, then - * every event with its offset from span start. Returned lines carry no tree - * prefix — the caller nests them under the span's tree row. + * every event with its offset from span start, rendered as `tree(1)`-style + * entries: each fact, attribute key, and event is an entry with a connector + * beneath the span's row, so the rails never break. Payload content wraps + * under its key on a plain margin. `childrenFollow` decides whether the last + * entry closes the branch — child span rows come after the detail entries. */ -export function renderSpanDetailLines( +export function renderSpanDetailTree( span: LocalTraceSpan, - options: { readonly dim: (text: string) => string; readonly width: number }, + options: { + readonly childrenFollow: boolean; + readonly margin: string; + readonly mute: (text: string) => string; + readonly width: number; + }, ): string[] { - const width = Math.max(40, options.width); + const entries = spanDetailEntries(span, options.width - options.margin.length - 3); const lines: string[] = []; + entries.forEach((entry, index) => { + const last = index === entries.length - 1 && !options.childrenFollow; + emit(entry, options.margin, last ? "└─ " : "├─ "); + }); + return lines; + + function emit(entry: SpanDetailEntry, margin: string, connector: string): void { + lines.push(options.mute(`${margin}${connector}${entry.head}`)); + const childMargin = `${margin}${connector === "└─ " ? " " : "│ "}`; + for (const line of entry.lines) lines.push(options.mute(`${childMargin} ${line}`)); + entry.entries.forEach((nested, index) => { + emit(nested, childMargin, index === entry.entries.length - 1 ? "└─ " : "├─ "); + }); + } +} + +/** One entry in a span's detail block: a head line plus nested content. */ +interface SpanDetailEntry { + readonly head: string; + /** Payload content, rendered on a plain margin under the head. */ + readonly lines: readonly string[]; + /** Structural sub-entries (span events), rendered with connectors. */ + readonly entries: readonly SpanDetailEntry[]; +} + +function spanDetailEntries(span: LocalTraceSpan, width: number): SpanDetailEntry[] { + // Payload formatting takes a dim style for de-emphasized parts; detail + // lines are dimmed wholesale at emit time, so payloads get the identity. + const dim = (text: string): string => text; + const attrWidth = Math.max(40, width); + const entries: SpanDetailEntry[] = []; + const push = ( + head: string, + lines: readonly string[] = [], + nested: readonly SpanDetailEntry[] = [], + ): void => { + entries.push({ entries: nested, head, lines }); + }; const error = span.statusCode === 2; const status = error && span.statusMessage !== undefined @@ -121,44 +167,49 @@ export function renderSpanDetailLines( : error ? "ERROR" : "ok"; - lines.push(`status: ${status}`); - lines.push(`duration: ${formatElapsed(durationMs(span.startTimeNs, span.endTimeNs))}`); - lines.push(`started: ${new Date(Number(span.startTimeNs / 1_000_000n)).toISOString()}`); - lines.push(`span: ${span.spanId}`); - if (span.parentSpanId !== undefined) lines.push(`parent: ${span.parentSpanId}`); - if (span.scope !== undefined) lines.push(`scope: ${sanitizeForTerminal(span.scope)}`); - if (span.kind !== undefined && span.kind !== 1) lines.push(`kind: ${spanKind(span.kind)}`); - - const keys = Object.keys(span.attributes).sort(); - for (const key of keys) { - const block = formatAttributeContent(key, span.attributes[key], options.dim, width - 2); + push(`status: ${status}`); + push(`duration: ${formatElapsed(durationMs(span.startTimeNs, span.endTimeNs))}`); + push(`started: ${new Date(Number(span.startTimeNs / 1_000_000n)).toISOString()}`); + push(`span: ${span.spanId}`); + if (span.parentSpanId !== undefined) push(`parent: ${span.parentSpanId}`); + if (span.scope !== undefined) push(`scope: ${sanitizeForTerminal(span.scope)}`); + if (span.kind !== undefined && span.kind !== 1) push(`kind: ${spanKind(span.kind)}`); + + for (const key of Object.keys(span.attributes).sort()) { + const block = formatAttributeContent(key, span.attributes[key], dim, attrWidth - 2); const cleanKey = sanitizeForTerminal(key); if (block.length === 1) { - lines.push(`${cleanKey}: ${block[0]}`); + push(`${cleanKey}: ${block[0]}`); } else { - lines.push(`${cleanKey}:`); - for (const line of block) lines.push(` ${line}`); + push(`${cleanKey}:`, block); } } if (span.events.length > 0) { - lines.push("events:"); - for (const event of span.events) { - const offsetMs = Math.max(0, durationMs(span.startTimeNs, event.timeNs)); - lines.push(` ${sanitizeForTerminal(event.name)} +${formatElapsed(offsetMs)}`); - for (const key of Object.keys(event.attributes).sort()) { - const block = formatAttributeContent(key, event.attributes[key], options.dim, width - 4); - const cleanKey = sanitizeForTerminal(key); - if (block.length === 1) { - lines.push(` ${cleanKey}: ${block[0]}`); - } else { - lines.push(` ${cleanKey}:`); - for (const line of block) lines.push(` ${line}`); + push( + "events:", + [], + span.events.map((event) => { + const offsetMs = Math.max(0, durationMs(span.startTimeNs, event.timeNs)); + const lines: string[] = []; + for (const key of Object.keys(event.attributes).sort()) { + const block = formatAttributeContent(key, event.attributes[key], dim, attrWidth - 6); + const cleanKey = sanitizeForTerminal(key); + if (block.length === 1) { + lines.push(`${cleanKey}: ${block[0]}`); + } else { + lines.push(`${cleanKey}:`, ...block.map((line) => ` ${line}`)); + } } - } - } + return { + entries: [], + head: `${sanitizeForTerminal(event.name)} +${formatElapsed(offsetMs)}`, + lines, + }; + }), + ); } - return lines; + return entries; } function spanCostUsd(span: LocalTraceSpan): number | undefined { diff --git a/packages/eve/src/cli/commands/trace.integration.test.ts b/packages/eve/src/cli/commands/trace.integration.test.ts index 9bb299f0f..8c16fd4b4 100644 --- a/packages/eve/src/cli/commands/trace.integration.test.ts +++ b/packages/eve/src/cli/commands/trace.integration.test.ts @@ -280,7 +280,9 @@ describe("eve traces", () => { ], }); await writeSegment(root, TRACE_ONE, { - ...span("b", "ai.toolCall", 20, 30, "a", { "gen_ai.tool.name": "get_weather" }), + ...span("b", "ai.toolCall", 20, 30, "a".repeat(16), { + "gen_ai.tool.name": "get_weather", + }), status: { code: 2, message: "tool exploded" }, }); const output = collectingLogger(); @@ -288,14 +290,15 @@ describe("eve traces", () => { await runTraceShowCommand(output.logger, root, "session-one", { verbose: true }); expect(output.out[0]).toContain("agent.step [step 0]"); - expect(output.out[0]).toContain("status: ok"); - expect(output.out[0]).toContain("agent.model.id: gpt-5"); - expect(output.out[0]).toContain("events:"); - expect(output.out[0]).toContain("step.started +0ms"); - expect(output.out[0]).toContain("step.completed +70ms"); + expect(output.out[0]).toContain("├─ status: ok"); + expect(output.out[0]).toContain("├─ agent.model.id: gpt-5"); + expect(output.out[0]).toContain("├─ events:"); + expect(output.out[0]).toContain("├─ step.started +0ms"); + expect(output.out[0]).toContain("└─ step.completed +70ms"); expect(output.out[0]).toContain("step.index: 0"); - expect(output.out[0]).toContain("status: ERROR — tool exploded"); - expect(output.out[0]).toContain("gen_ai.tool.name: get_weather"); + expect(output.out[0]).toContain("└─ ai.toolCall 10ms get_weather ERROR"); + expect(output.out[0]).toContain("├─ status: ERROR — tool exploded"); + expect(output.out[0]).toContain("└─ gen_ai.tool.name: get_weather"); expect(output.out[0]).toContain("Errors"); expect(output.out[0]).not.toContain("\u001B"); }); diff --git a/packages/eve/src/cli/commands/trace.ts b/packages/eve/src/cli/commands/trace.ts index cb86f7f09..b5d761be7 100644 --- a/packages/eve/src/cli/commands/trace.ts +++ b/packages/eve/src/cli/commands/trace.ts @@ -3,7 +3,7 @@ import { basename } from "node:path"; import { formatCostUsd, formatTokenSummary, - renderSpanDetailLines, + renderSpanDetailTree, spanMetricChips, summarizeLocalTrace, } from "#cli/commands/trace-detail.js"; @@ -248,20 +248,28 @@ function renderSpanTree( const lines: string[] = []; const visited = new Set(); + // All tree chrome — bars and connectors, on span rows and detail lines + // alike — is dimmed so only labels and values render bright. + const mute = options.dim ?? ((text: string) => text); const render = (span: LocalTraceSpan, prefix: string, connector: string): void => { if (visited.has(span.spanId)) return; visited.add(span.spanId); - lines.push(`${prefix}${connector}${spanLabel(span, extents.get(span.spanId))}`); + const chrome = `${prefix}${connector}`; + lines.push(`${chrome === "" ? "" : mute(chrome)}${spanLabel(span, extents.get(span.spanId))}`); const childPrefix = `${prefix}${connector === "" ? "" : connector === "└─ " ? " " : "│ "}`; + const descendants = children.get(span.spanId) ?? []; if (options.verbose === true) { - for (const line of renderSpanDetailLines(span, { - dim: options.dim ?? ((text) => text), - width: width - childPrefix.length - 2, + // Detail entries render as `tree(1)`-style entries beneath the span's + // row, dimmed so the span rows keep visual priority. + for (const line of renderSpanDetailTree(span, { + childrenFollow: descendants.length > 0, + margin: childPrefix, + mute, + width, })) { - lines.push(`${childPrefix} ${line}`); + lines.push(line); } } - const descendants = children.get(span.spanId) ?? []; descendants.forEach((child, index) => { const last = index === descendants.length - 1; render(child, childPrefix, last ? "└─ " : "├─ ");