Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rich-trace-span-details.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <trace> # show one span tree
eve traces --verbose # expand every span with all attributes and events
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 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.

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.
Expand Down
203 changes: 203 additions & 0 deletions packages/eve/src/cli/commands/trace-detail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { describe, expect, it } from "vitest";

import type { LocalTraceSpan } from "#tracing/local-trace-reader.js";

import {
formatCostUsd,
formatTokenSummary,
renderSpanDetailTree,
spanMetricChips,
summarizeLocalTrace,
} from "./trace-detail.js";

function span(overrides: Partial<LocalTraceSpan> = {}): 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,
};
}

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("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",
"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",
}),
{ 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",
]);
});

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 = renderSpanDetailTree(
span({ kind: 2, statusCode: 2, statusMessage: "model call failed" }),
{ childrenFollow: false, margin: "", mute, 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 = renderSpanDetailTree(
span({
attributes: { "evil\x1b[2Jkey": "va\x1b[31mlue" },
events: [{ attributes: {}, name: "bad\x1b]0;owned\x07event", timeNs: 10_000_000n }],
}),
{ childrenFollow: false, margin: "", mute, 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");
});
});
Loading