From d7365503053d968bf740476f7e6730d441943a14 Mon Sep 17 00:00:00 2001 From: Chad Hietala Date: Mon, 27 Jul 2026 15:46:02 -0400 Subject: [PATCH 1/2] feat(eve): add local trace inspection commands Signed-off-by: Chad Hietala --- .changeset/neat-traces-show.md | 5 + docs/guides/instrumentation.md | 6 +- docs/reference/cli.md | 14 +- .../cli/commands/trace.integration.test.ts | 205 ++++++++ packages/eve/src/cli/commands/trace.ts | 437 ++++++++++++++++++ packages/eve/src/cli/run.test.ts | 14 + packages/eve/src/cli/run.ts | 22 + ...l-instrumentation-runtime.scenario.test.ts | 4 + 8 files changed, 704 insertions(+), 3 deletions(-) create mode 100644 .changeset/neat-traces-show.md create mode 100644 packages/eve/src/cli/commands/trace.integration.test.ts create mode 100644 packages/eve/src/cli/commands/trace.ts diff --git a/.changeset/neat-traces-show.md b/.changeset/neat-traces-show.md new file mode 100644 index 000000000..5177dd2c8 --- /dev/null +++ b/.changeset/neat-traces-show.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Add `eve trace ls` and `eve trace show` commands for inspecting locally persisted agent traces after or during `eve dev`. diff --git a/docs/guides/instrumentation.md b/docs/guides/instrumentation.md index ab0c4f386..6e9083e02 100644 --- a/docs/guides/instrumentation.md +++ b/docs/guides/instrumentation.md @@ -13,6 +13,8 @@ When no authored `instrumentation.ts` exists, `eve dev` records agent, AI SDK, a The directory is an immutable OTLP/JSON spool and remains available after `eve dev` exits. Inspection tools may build a query index from these segments, but the index is derived and can be rebuilt without changing the captured trace data. +Use `eve trace ls` to list captured traces and `eve trace show ` to inspect a session's span tree. + The local writer is an internal development default, not a second provider layered over authored instrumentation. When `instrumentation.ts` exists, its setup retains control and the zero-config writer is not installed. ## Three observability surfaces @@ -106,9 +108,9 @@ A channel exposes its identity through `kind`. For authored channels it is `chan Channel metadata is channel-owned. Built-in channels expose only the fields they choose to make observable; Slack, for example, projects `channelId`, `teamId`, `threadTs`, and `triggeringUserId` from its durable channel state. User-authored channels expose their own projection by returning `metadata(state)` from `defineChannel`. Runtime instrumentation never falls back to raw channel state. -## Trace hierarchy +## Authored trace hierarchy -When telemetry is enabled, each turn produces a trace like: +The existing authored `instrumentation.ts` path remains separate from zero-config local traces. When authored telemetry is enabled, each turn currently produces a trace like: ```text ai.eve.turn {eve.session.id} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index e6a00e9a0..9ec9e5002 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1,6 +1,6 @@ --- title: "CLI" -description: "Reference for every eve CLI command: init, info, build, start, dev, logs, link, deploy, eval, channels, and extension." +description: "Reference for every eve CLI command: init, info, build, start, dev, logs, trace, link, deploy, eval, channels, and extension." --- The `eve` binary (`bin: eve`) runs from your app root, and every command first loads `.env`/`.env.local` from that root. Running `eve` with no command runs `eve dev`. @@ -17,6 +17,8 @@ The `eve` binary (`bin: eve`) runs from your app root, and every command first l | `eve dev ` | Connect the UI to an existing server URL (e.g. a remote deployment) instead of booting a local server | | `eve logs [logid]` | Print an `eve dev` diagnostic log (the most recent when `logid` is omitted) | | `eve logs ls` | List `eve dev` diagnostic logs, most recent first | +| `eve trace ls` | List locally captured agent traces, most recent first | +| `eve trace show ` | Show the span tree for a local trace | | `eve link` | Link the directory to a Vercel project and pull AI Gateway credentials | | `eve deploy` | Deploy the agent to Vercel production (links first if needed) | | `eve eval` | Run evals against the local app or a remote target | @@ -213,6 +215,16 @@ A log id is the file name without `.log` (for example `dev-2026-07-15T12-00-00.0 Each log has a same-named `.dump` sibling holding environment diagnostics and session stats as one JSON document. `eve logs --dump` (with or without a log id) prepends that document to the JSONL log body; the combined output is a valid JSON value stream (`eve logs --dump | jq -c .`), one self-contained report to attach to an issue. When a log has no dump, the flag is silently a no-op. +## `eve trace` + +```bash +eve trace ls # list traces, most recent first +eve trace ls --json # emit machine-readable trace summaries +eve trace show # show one span tree +``` + +`eve trace ls` reads the immutable OTLP/JSON segments captured under `.eve/traces/v1`; `eve dev` does not need to be running. `eve trace show` accepts a full trace id, an `agent.session.id`, or an unambiguous prefix of either. Malformed or incomplete segments are skipped without hiding valid spans from the same trace. + ## `eve link` ```bash diff --git a/packages/eve/src/cli/commands/trace.integration.test.ts b/packages/eve/src/cli/commands/trace.integration.test.ts new file mode 100644 index 000000000..dbc970ee4 --- /dev/null +++ b/packages/eve/src/cli/commands/trace.integration.test.ts @@ -0,0 +1,205 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + listLocalTraces, + resolveLocalTrace, + runTraceListCommand, + runTraceShowCommand, +} from "./trace.js"; + +const TRACE_ONE = "1".repeat(32); +const TRACE_TWO = "2".repeat(32); + +describe("eve trace", () => { + const roots: string[] = []; + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); + }); + + it("lists traces newest first and skips malformed segments", async () => { + const root = await createRoot(); + await writeSegment( + root, + TRACE_ONE, + span("a", "agent.turn", 10, 20, undefined, { + "agent.name": "weather", + "agent.session.id": "session-one", + }), + ); + await writeSegment( + root, + TRACE_TWO, + span("b", "agent.turn", 30, 40, undefined, { + "agent.name": "research", + "agent.session.id": "session-two", + }), + ); + await writeFile( + join(root, ".eve", "traces", "v1", TRACE_TWO, "segments", `${"c".repeat(16)}.otlp.json`), + "not json", + ); + await writeSegment(root, TRACE_TWO, { + ...span("d", "invalid-time", 1, 2), + startTimeUnixNano: "18446744073709551616", + }); + + const traces = await listLocalTraces(root); + + expect(traces.map((trace) => trace.traceId)).toEqual([TRACE_TWO, TRACE_ONE]); + expect(traces[0]).toMatchObject({ agentName: "research", sessionId: "session-two" }); + }); + + it("resolves trace ids, session ids, and unambiguous prefixes", async () => { + const root = await createRoot(); + await writeSegment( + root, + TRACE_ONE, + span("a", "agent.turn", 10, 20, undefined, { + "agent.session.id": "session-one", + }), + ); + await writeSegment( + root, + TRACE_TWO, + span("b", "agent.turn", 20, 30, undefined, { + "agent.session.id": "session-two", + }), + ); + const traces = await listLocalTraces(root); + + expect(resolveLocalTrace(traces, TRACE_ONE).traceId).toBe(TRACE_ONE); + expect(resolveLocalTrace(traces, "session-two").traceId).toBe(TRACE_TWO); + expect(resolveLocalTrace(traces, "session-o").traceId).toBe(TRACE_ONE); + expect(() => resolveLocalTrace(traces, "session-")).toThrow(/matches 2 local traces/u); + expect(() => resolveLocalTrace(traces, "missing")).toThrow(/No local trace matches/u); + }); + + it("renders a parented span tree and tolerates missing parents", async () => { + const root = await createRoot(); + const turn = "a".repeat(16); + const step = "b".repeat(16); + const action = "c".repeat(16); + await writeSegment( + root, + TRACE_ONE, + span(turn, "agent.turn", 10, 100, undefined, { + "agent.name": "weather", + "agent.session.id": "session-one", + "agent.turn.id": "turn-1", + }), + ); + await writeSegment( + root, + TRACE_ONE, + span(step, "agent.step", 20, 90, turn, { + "agent.session.id": "session-one", + "agent.step.attempt": 0, + "agent.step.index": 0, + }), + ); + await writeSegment( + root, + TRACE_ONE, + span(action, "agent.action", 30, 80, step, { + "agent.action.kind": "tool", + "agent.action.name": "\u001B[31mweather\u001B[0m", + "agent.session.id": "session-one", + }), + ); + await writeSegment(root, TRACE_ONE, { + ...span("d", "failed", 40, 50, "f".repeat(16)), + status: { code: 2 }, + }); + const output = collectingLogger(); + + await runTraceShowCommand(output.logger, root, "session-one"); + + expect(output.out[0]).toContain("agent.turn [turn-1]"); + expect(output.out[0]).toContain("└─ agent.step [step 0, attempt 0]"); + expect(output.out[0]).toContain("└─ agent.action [tool: weather]"); + expect(output.out[0]).toContain("failed 10ms ERROR"); + expect(output.out[0]).not.toContain("\u001B"); + }); + + it("prints empty and JSON list output", async () => { + const root = await createRoot(); + const empty = collectingLogger(); + await runTraceListCommand(empty.logger, root); + expect(empty.out).toEqual(["No local traces found under .eve/traces/v1."]); + + await writeSegment( + root, + TRACE_ONE, + span("a", "agent.turn", 10, 20, undefined, { + "agent.session.id": "session-one", + }), + ); + const json = collectingLogger(); + await runTraceListCommand(json.logger, root, { json: true }); + expect(JSON.parse(json.out[0]!)).toEqual([ + expect.objectContaining({ sessionId: "session-one", spanCount: 1, traceId: TRACE_ONE }), + ]); + }); + + async function createRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), "eve-trace-cmd-")); + roots.push(root); + return root; + } +}); + +function collectingLogger() { + const out: string[] = []; + return { + out, + logger: { + error: (_message: string) => {}, + log: (message: string) => out.push(message), + }, + }; +} + +async function writeSegment( + root: string, + traceId: string, + value: ReturnType, +): Promise { + const directory = join(root, ".eve", "traces", "v1", traceId, "segments"); + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, `${value.spanId}.otlp.json`), + JSON.stringify({ + resourceSpans: [ + { scopeSpans: [{ scope: { name: "eve.agent" }, spans: [{ ...value, traceId }] }] }, + ], + }), + ); +} + +function span( + id: string, + name: string, + start: number, + end: number, + parentSpanId?: string, + attributes: Record = {}, +) { + return { + attributes: Object.entries(attributes).map(([key, value]) => ({ + key, + value: typeof value === "number" ? { intValue: value } : { stringValue: value }, + })), + endTimeUnixNano: String(end * 1_000_000), + name, + parentSpanId, + spanId: id.repeat(16).slice(0, 16), + startTimeUnixNano: String(start * 1_000_000), + status: { code: 0 }, + traceId: TRACE_ONE, + }; +} diff --git a/packages/eve/src/cli/commands/trace.ts b/packages/eve/src/cli/commands/trace.ts new file mode 100644 index 000000000..2a2a992e9 --- /dev/null +++ b/packages/eve/src/cli/commands/trace.ts @@ -0,0 +1,437 @@ +import { readdir, readFile, stat } from "node:fs/promises"; +import { basename, join } from "node:path"; + +import { formatElapsed } from "#cli/format-elapsed.js"; +import { createCliTheme, renderCliSection, sanitizeForTerminal } from "#cli/ui/output.js"; + +const TRACE_ID_PATTERN = /^[0-9a-f]{32}$/u; +const SPAN_FILE_PATTERN = /^([0-9a-f]{16})\.otlp\.json$/u; +const TRACE_DIRECTORY_SEGMENTS = [".eve", "traces", "v1"] as const; +const TRACE_DISPLAY_DIRECTORY = TRACE_DIRECTORY_SEGMENTS.join("/"); +const MAX_SEGMENT_BYTES = 8 * 1024 * 1024; +const MAX_UINT64 = 18_446_744_073_709_551_615n; + +interface CliTraceLogger { + error(message: string): void; + log(message: string): void; +} + +export interface LocalTraceSpan { + readonly attributes: Readonly>; + readonly endTimeNs: bigint; + readonly name: string; + readonly parentSpanId?: string; + readonly scope?: string; + readonly spanId: string; + readonly startTimeNs: bigint; + readonly statusCode: number; + readonly traceId: string; +} + +export interface LocalTrace { + readonly agentName?: string; + readonly endTimeNs: bigint; + readonly sessionId?: string; + readonly spans: readonly LocalTraceSpan[]; + readonly startTimeNs: bigint; + readonly traceId: string; +} + +/** Reads valid local traces, newest first, while ignoring malformed segments. */ +export async function listLocalTraces(appRoot: string): Promise { + const root = join(appRoot, ...TRACE_DIRECTORY_SEGMENTS); + let entries; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch (error) { + if (isMissing(error)) return []; + throw error; + } + + const traces: LocalTrace[] = []; + for (const entry of entries) { + if (!entry.isDirectory() || !TRACE_ID_PATTERN.test(entry.name)) continue; + const trace = await readTrace(root, entry.name).catch(() => undefined); + if (trace !== undefined) traces.push(trace); + } + return traces.sort((left, right) => + left.startTimeNs === right.startTimeNs + ? left.traceId.localeCompare(right.traceId) + : left.startTimeNs > right.startTimeNs + ? -1 + : 1, + ); +} + +/** Resolves an exact trace/session id or an unambiguous prefix. */ +export function resolveLocalTrace(traces: readonly LocalTrace[], reference: string): LocalTrace { + const raw = reference.replaceAll("\\", "/"); + const normalized = basename(raw); + const references = raw === normalized ? [raw] : [raw, normalized]; + const exactTrace = traces.find((trace) => references.includes(trace.traceId)); + if (exactTrace !== undefined) return exactTrace; + const exactSessions = traces.filter( + (trace) => trace.sessionId !== undefined && references.includes(trace.sessionId), + ); + if (exactSessions.length === 1) return exactSessions[0]!; + if (exactSessions.length > 1) throw ambiguousTraceError(exactSessions, reference); + + const matches = traces.filter((trace) => + references.some( + (candidate) => trace.traceId.startsWith(candidate) || trace.sessionId?.startsWith(candidate), + ), + ); + if (matches.length === 1) return matches[0]!; + if (matches.length === 0) { + throw new Error( + `No local trace matches "${sanitizeForTerminal(reference)}". Run \`eve trace ls\` to list traces.`, + ); + } + throw ambiguousTraceError(matches, reference); +} + +export async function runTraceListCommand( + logger: CliTraceLogger, + appRoot: string, + options: { readonly json?: boolean } = {}, +): Promise { + const traces = await listLocalTraces(appRoot); + if (options.json === true) { + logger.log( + JSON.stringify( + traces.map((trace) => ({ + agentName: trace.agentName ?? null, + durationMs: durationMs(trace.startTimeNs, trace.endTimeNs), + sessionId: trace.sessionId ?? null, + spanCount: trace.spans.length, + startedAt: toDate(trace.startTimeNs).toISOString(), + traceId: trace.traceId, + })), + null, + 2, + ), + ); + return; + } + if (traces.length === 0) { + logger.log(`No local traces found under ${TRACE_DISPLAY_DIRECTORY}.`); + return; + } + + const rows = traces.map((trace) => [ + trace.traceId, + sanitizeForTerminal(trace.sessionId ?? "unknown"), + sanitizeForTerminal(trace.agentName ?? "unknown"), + toDate(trace.startTimeNs).toISOString(), + formatElapsed(durationMs(trace.startTimeNs, trace.endTimeNs)), + String(trace.spans.length), + ]); + const headers = ["TRACE", "SESSION", "AGENT", "STARTED", "DURATION", "SPANS"]; + const widths = headers.map((header, index) => + Math.max(header.length, ...rows.map((row) => row[index]!.length)), + ); + logger.log( + [headers, ...rows] + .map((row) => + row + .map((value, index) => value.padEnd(widths[index]!)) + .join(" ") + .trimEnd(), + ) + .join("\n"), + ); +} + +export async function runTraceShowCommand( + logger: CliTraceLogger, + appRoot: string, + reference: string, +): Promise { + const trace = resolveLocalTrace(await listLocalTraces(appRoot), reference); + const theme = createCliTheme(); + logger.log( + [ + renderCliSection(theme, { + rows: [ + { label: "Trace ID", value: trace.traceId }, + { label: "Session ID", value: trace.sessionId ?? "unknown" }, + { 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) }, + ], + title: "Trace", + }), + `${theme.accent("Spans")}\n${renderSpanTree(trace.spans)}`, + ].join("\n\n"), + ); +} + +async function readTrace(root: string, traceId: string): Promise { + const segmentsRoot = join(root, traceId, "segments"); + let entries; + try { + entries = await readdir(segmentsRoot, { withFileTypes: true }); + } catch (error) { + if (isMissing(error)) return undefined; + throw error; + } + const spans = new Map(); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + if (!entry.isFile() || !SPAN_FILE_PATTERN.test(entry.name)) continue; + let content: string; + try { + const path = join(segmentsRoot, entry.name); + if ((await stat(path)).size > MAX_SEGMENT_BYTES) continue; + content = await readFile(path, "utf8"); + } catch { + continue; + } + for (const span of parseSegment(content, traceId)) { + if (!spans.has(span.spanId)) spans.set(span.spanId, span); + } + } + if (spans.size === 0) return undefined; + const ordered = [...spans.values()].sort(compareSpans); + const attributes = ordered.map((span) => span.attributes); + return { + agentName: firstAttribute(attributes, "agent.name"), + endTimeNs: ordered.reduce( + (value, span) => (span.endTimeNs > value ? span.endTimeNs : value), + 0n, + ), + sessionId: firstAttribute(attributes, "agent.session.id"), + spans: ordered, + startTimeNs: ordered.reduce( + (value, span) => (span.startTimeNs < value ? span.startTimeNs : value), + ordered[0]!.startTimeNs, + ), + traceId, + }; +} + +function parseSegment(content: string, expectedTraceId: string): LocalTraceSpan[] { + try { + const request = JSON.parse(content) as unknown; + if (!isRecord(request) || !Array.isArray(request.resourceSpans)) return []; + const spans: LocalTraceSpan[] = []; + for (const resource of request.resourceSpans) { + if (!isRecord(resource) || !Array.isArray(resource.scopeSpans)) continue; + for (const scoped of resource.scopeSpans) { + if (!isRecord(scoped) || !Array.isArray(scoped.spans)) continue; + const scope = + isRecord(scoped.scope) && typeof scoped.scope.name === "string" + ? scoped.scope.name + : undefined; + for (const rawSpan of scoped.spans) { + const span = parseSpan(rawSpan, expectedTraceId, scope); + if (span !== undefined) spans.push(span); + } + } + } + return spans; + } catch { + return []; + } +} + +function parseSpan( + raw: unknown, + expectedTraceId: string, + scope?: string, +): LocalTraceSpan | undefined { + if (!isRecord(raw)) return undefined; + if ( + raw.traceId !== expectedTraceId || + typeof raw.spanId !== "string" || + !/^[0-9a-f]{16}$/u.test(raw.spanId) || + typeof raw.name !== "string" + ) { + return undefined; + } + const startTimeNs = parseNanos(raw.startTimeUnixNano); + const endTimeNs = parseNanos(raw.endTimeUnixNano); + if (startTimeNs === undefined || endTimeNs === undefined || endTimeNs < startTimeNs) + return undefined; + return { + attributes: parseAttributes(raw.attributes), + endTimeNs, + name: raw.name, + parentSpanId: + typeof raw.parentSpanId === "string" && /^[0-9a-f]{16}$/u.test(raw.parentSpanId) + ? raw.parentSpanId + : undefined, + scope, + spanId: raw.spanId, + startTimeNs, + statusCode: parseStatusCode(raw.status), + traceId: expectedTraceId, + }; +} + +function parseAttributes(value: unknown): Record { + if (!Array.isArray(value)) return {}; + const attributes: Record = {}; + for (const entry of value) { + if (!isRecord(entry) || typeof entry.key !== "string" || !isRecord(entry.value)) continue; + const parsed = parseAnyValue(entry.value); + if (parsed !== undefined) attributes[entry.key] = parsed; + } + return attributes; +} + +function parseAnyValue(value: Record): unknown { + for (const key of ["stringValue", "boolValue", "intValue", "doubleValue"] as const) { + if (key in value) return value[key]; + } + if (isRecord(value.arrayValue) && Array.isArray(value.arrayValue.values)) { + return value.arrayValue.values.filter(isRecord).map((entry) => parseAnyValue(entry)); + } + return undefined; +} + +function renderSpanTree(spans: readonly LocalTraceSpan[]): string { + const byId = new Map(spans.map((span) => [span.spanId, span])); + const children = new Map(); + const roots: LocalTraceSpan[] = []; + for (const span of spans) { + if (span.parentSpanId === undefined || !byId.has(span.parentSpanId)) { + roots.push(span); + } else { + const siblings = children.get(span.parentSpanId) ?? []; + siblings.push(span); + children.set(span.parentSpanId, siblings); + } + } + roots.sort(compareSpans); + for (const siblings of children.values()) siblings.sort(compareSpans); + + const lines: string[] = []; + const visited = new Set(); + 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)}`); + const descendants = children.get(span.spanId) ?? []; + descendants.forEach((child, index) => { + const last = index === descendants.length - 1; + render( + child, + `${prefix}${connector === "" ? "" : connector === "└─ " ? " " : "│ "}`, + last ? "└─ " : "├─ ", + ); + }); + }; + for (const root of roots) render(root, "", ""); + for (const span of [...spans].sort(compareSpans)) { + if (!visited.has(span.spanId)) render(span, "", ""); + } + return lines.join("\n"); +} + +function spanLabel(span: LocalTraceSpan): string { + const details: string[] = []; + const turnId = stringAttribute(span, "agent.turn.id"); + const stepIndex = valueAttribute(span, "agent.step.index"); + const attempt = valueAttribute(span, "agent.step.attempt"); + const actionKind = stringAttribute(span, "agent.action.kind"); + const actionName = stringAttribute(span, "agent.action.name"); + const model = + stringAttribute(span, "agent.model.id") ?? stringAttribute(span, "gen_ai.request.model"); + if (span.name === "agent.turn" && turnId !== undefined) { + details.push(sanitizeForTerminal(turnId)); + } + if (span.name === "agent.step" && stepIndex !== undefined) { + details.push( + `step ${sanitizeForTerminal(String(stepIndex))}${ + attempt === undefined ? "" : `, attempt ${sanitizeForTerminal(String(attempt))}` + }`, + ); + } + if (span.name === "agent.action" && actionName !== undefined) { + details.push( + `${sanitizeForTerminal(actionKind ?? "action")}: ${sanitizeForTerminal(actionName)}`, + ); + } + if (model !== undefined && span.name.includes("do")) { + details.push(`model ${sanitizeForTerminal(model)}`); + } + const detail = details.length === 0 ? "" : ` [${details.join(", ")}]`; + const error = span.statusCode === 2 ? " ERROR" : ""; + return `${sanitizeForTerminal(span.name)}${detail} ${formatElapsed(durationMs(span.startTimeNs, span.endTimeNs))}${error}`; +} + +function compareSpans(left: LocalTraceSpan, right: LocalTraceSpan): number { + if (left.startTimeNs !== right.startTimeNs) return left.startTimeNs < right.startTimeNs ? -1 : 1; + if (left.endTimeNs !== right.endTimeNs) return left.endTimeNs < right.endTimeNs ? -1 : 1; + return left.name.localeCompare(right.name) || left.spanId.localeCompare(right.spanId); +} + +function firstAttribute( + attributes: readonly Readonly>[], + key: string, +): string | undefined { + for (const values of attributes) { + const value = values[key]; + if (typeof value === "string" && value.length > 0) return value; + } + return undefined; +} + +function stringAttribute(span: LocalTraceSpan, key: string): string | undefined { + const value = span.attributes[key]; + return typeof value === "string" ? value : undefined; +} + +function valueAttribute(span: LocalTraceSpan, key: string): string | number | undefined { + const value = span.attributes[key]; + return typeof value === "string" || typeof value === "number" ? value : undefined; +} + +function parseNanos(value: unknown): bigint | undefined { + if (typeof value !== "string" && typeof value !== "number") return undefined; + try { + const parsed = BigInt(value); + return parsed >= 0n && parsed <= MAX_UINT64 ? parsed : undefined; + } catch { + return undefined; + } +} + +function parseStatusCode(value: unknown): number { + if (!isRecord(value)) return 0; + if (typeof value.code === "number") return value.code; + return value.code === "STATUS_CODE_ERROR" ? 2 : 0; +} + +function durationMs(start: bigint, end: bigint): number { + return Number(end - start) / 1_000_000; +} + +function toDate(nanoseconds: bigint): Date { + return new Date(Number(nanoseconds / 1_000_000n)); +} + +function isMissing(error: unknown): boolean { + return (error as NodeJS.ErrnoException).code === "ENOENT"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function ambiguousTraceError(traces: readonly LocalTrace[], reference: string): Error { + return new Error( + [ + `"${sanitizeForTerminal(reference)}" matches ${traces.length} local traces:`, + ...traces.map( + (trace) => + ` ${trace.traceId} ${sanitizeForTerminal(trace.sessionId ?? "unknown session")}`, + ), + "Pass a longer prefix or the full trace/session id.", + ].join("\n"), + ); +} diff --git a/packages/eve/src/cli/run.test.ts b/packages/eve/src/cli/run.test.ts index 29eb8fa2b..46c4d24e0 100644 --- a/packages/eve/src/cli/run.test.ts +++ b/packages/eve/src/cli/run.test.ts @@ -104,6 +104,20 @@ describe("CLI command registration", () => { expect(help).toContain("show [options] [logid]"); expect(help).toContain("ls"); }); + + it("registers the local trace inspection commands", async () => { + const output: string[] = []; + const logger = { + error: (message: string) => output.push(message), + log: (message: string) => output.push(message), + }; + + await runCli(["trace", "--help"], logger).catch(() => {}); + + const help = output.join("\n"); + expect(help).toContain("show "); + expect(help).toContain("ls"); + }); }); describe("eve init compatibility flags", () => { diff --git a/packages/eve/src/cli/run.ts b/packages/eve/src/cli/run.ts index c1b32d1a2..029c77b4c 100644 --- a/packages/eve/src/cli/run.ts +++ b/packages/eve/src/cli/run.ts @@ -603,6 +603,28 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm await runLogsListCommand(logger, appRoot, options); }); + const traces = program + .command("trace") + .description("Inspect local agent traces captured by `eve dev` (.eve/traces).") + .alias("traces"); + + traces + .command("ls") + .description("List local traces, most recent first.") + .option("--json", "Output as JSON") + .action(async (options: { json?: boolean }) => { + const { runTraceListCommand } = await import("#cli/commands/trace.js"); + await runTraceListCommand(logger, appRoot, options); + }); + + traces + .command("show ", { isDefault: true }) + .description("Show one local trace by trace id, session id, or unambiguous prefix.") + .action(async (reference: string) => { + const { runTraceShowCommand } = await import("#cli/commands/trace.js"); + await runTraceShowCommand(logger, appRoot, reference); + }); + program .command("info") .description("Print resolved application information.") diff --git a/packages/eve/src/harness/local-instrumentation-runtime.scenario.test.ts b/packages/eve/src/harness/local-instrumentation-runtime.scenario.test.ts index 213f1bf98..329fe000b 100644 --- a/packages/eve/src/harness/local-instrumentation-runtime.scenario.test.ts +++ b/packages/eve/src/harness/local-instrumentation-runtime.scenario.test.ts @@ -7,6 +7,7 @@ import { BasicTracerProvider } from "@opentelemetry/sdk-trace-base"; import { afterEach, describe, expect, it } from "vitest"; import { ContextContainer, contextStorage } from "#context/container.js"; +import { listLocalTraces } from "#cli/commands/trace.js"; import { createAiSdkHookBridge } from "#harness/ai-sdk-hook-bridge.js"; import type { InstrumentationAttemptScope } from "#harness/instrumentation-lifecycle.js"; import { installLocalInstrumentationRuntime } from "#harness/local-instrumentation-runtime.js"; @@ -148,6 +149,9 @@ describe("local instrumentation runtime", () => { expect(span(spans, "agent.action").parentSpanId).toBe(span(spans, "agent.step").spanId); expect(span(spans, "ai.toolCall").parentSpanId).toBe(span(spans, "agent.action").spanId); expect(span(spans, "user.tool-work").parentSpanId).toBe(span(spans, "ai.toolCall").spanId); + const listed = await listLocalTraces(appRoot); + expect(listed).toHaveLength(1); + expect(listed[0]).toMatchObject({ sessionId: "session-1", traceId }); }); it("keeps segments from overlapping worker writers", async () => { From 7de6c532d8014aad76c4a0ce83a06480a097acb0 Mon Sep 17 00:00:00 2001 From: Chad Hietala Date: Mon, 27 Jul 2026 16:01:28 -0400 Subject: [PATCH 2/2] refactor(eve): simplify trace command UX Signed-off-by: Chad Hietala --- .changeset/neat-traces-show.md | 2 +- docs/guides/instrumentation.md | 2 +- docs/reference/cli.md | 7 ++++--- .../src/cli/commands/trace.integration.test.ts | 7 +++++++ packages/eve/src/cli/commands/trace.ts | 11 +++++++++-- packages/eve/src/cli/run.test.ts | 3 ++- packages/eve/src/cli/run.ts | 18 +++++++----------- .../provider-neutral-local-observability.md | 2 +- 8 files changed, 32 insertions(+), 20 deletions(-) diff --git a/.changeset/neat-traces-show.md b/.changeset/neat-traces-show.md index 5177dd2c8..52105a020 100644 --- a/.changeset/neat-traces-show.md +++ b/.changeset/neat-traces-show.md @@ -2,4 +2,4 @@ "eve": patch --- -Add `eve trace ls` and `eve trace show` commands for inspecting locally persisted agent traces after or during `eve dev`. +Add `eve trace ls` and `eve trace [trace]` commands for inspecting locally persisted agent traces after or during `eve dev`. diff --git a/docs/guides/instrumentation.md b/docs/guides/instrumentation.md index 6e9083e02..0f2aef372 100644 --- a/docs/guides/instrumentation.md +++ b/docs/guides/instrumentation.md @@ -13,7 +13,7 @@ When no authored `instrumentation.ts` exists, `eve dev` records agent, AI SDK, a The directory is an immutable OTLP/JSON spool and remains available after `eve dev` exits. Inspection tools may build a query index from these segments, but the index is derived and can be rebuilt without changing the captured trace data. -Use `eve trace ls` to list captured traces and `eve trace show ` to inspect a session's span tree. +Use `eve trace ls` to list captured traces and `eve trace ` to inspect a session's span tree. The local writer is an internal development default, not a second provider layered over authored instrumentation. When `instrumentation.ts` exists, its setup retains control and the zero-config writer is not installed. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 9ec9e5002..ef5ad57d7 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -18,7 +18,7 @@ The `eve` binary (`bin: eve`) runs from your app root, and every command first l | `eve logs [logid]` | Print an `eve dev` diagnostic log (the most recent when `logid` is omitted) | | `eve logs ls` | List `eve dev` diagnostic logs, most recent first | | `eve trace ls` | List locally captured agent traces, most recent first | -| `eve trace show ` | Show the span tree for a local trace | +| `eve trace [trace]` | Show a local span tree (the most recent when omitted) | | `eve link` | Link the directory to a Vercel project and pull AI Gateway credentials | | `eve deploy` | Deploy the agent to Vercel production (links first if needed) | | `eve eval` | Run evals against the local app or a remote target | @@ -220,10 +220,11 @@ Each log has a same-named `.dump` sibling holding environment diagnostics and se ```bash eve trace ls # list traces, most recent first eve trace ls --json # emit machine-readable trace summaries -eve trace show # show one span tree +eve trace # show the most recent span tree +eve trace # show one span tree ``` -`eve trace ls` reads the immutable OTLP/JSON segments captured under `.eve/traces/v1`; `eve dev` does not need to be running. `eve trace show` accepts a full trace id, an `agent.session.id`, or an unambiguous prefix of either. Malformed or incomplete segments are skipped without hiding valid spans from the same trace. +`eve trace ls` reads the immutable OTLP/JSON segments captured under `.eve/traces/v1`; `eve dev` does not need to be running. `eve trace` accepts a full trace id, an `agent.session.id`, or an unambiguous prefix of either. Malformed or incomplete segments are skipped without hiding valid spans from the same trace. ## `eve link` diff --git a/packages/eve/src/cli/commands/trace.integration.test.ts b/packages/eve/src/cli/commands/trace.integration.test.ts index dbc970ee4..19f57c016 100644 --- a/packages/eve/src/cli/commands/trace.integration.test.ts +++ b/packages/eve/src/cli/commands/trace.integration.test.ts @@ -129,6 +129,10 @@ describe("eve trace", () => { it("prints empty and JSON list output", async () => { const root = await createRoot(); const empty = collectingLogger(); + await runTraceShowCommand(empty.logger, root); + expect(empty.out).toEqual(["No local traces found under .eve/traces/v1."]); + + empty.out.length = 0; await runTraceListCommand(empty.logger, root); expect(empty.out).toEqual(["No local traces found under .eve/traces/v1."]); @@ -139,6 +143,9 @@ describe("eve trace", () => { "agent.session.id": "session-one", }), ); + const latest = collectingLogger(); + await runTraceShowCommand(latest.logger, root); + expect(latest.out[0]).toContain(TRACE_ONE); const json = collectingLogger(); await runTraceListCommand(json.logger, root, { json: true }); expect(JSON.parse(json.out[0]!)).toEqual([ diff --git a/packages/eve/src/cli/commands/trace.ts b/packages/eve/src/cli/commands/trace.ts index 2a2a992e9..3c5a4de6b 100644 --- a/packages/eve/src/cli/commands/trace.ts +++ b/packages/eve/src/cli/commands/trace.ts @@ -145,9 +145,16 @@ export async function runTraceListCommand( export async function runTraceShowCommand( logger: CliTraceLogger, appRoot: string, - reference: string, + reference?: string, ): Promise { - const trace = resolveLocalTrace(await listLocalTraces(appRoot), reference); + const traces = await listLocalTraces(appRoot); + if (traces.length === 0) { + const message = `No local traces found under ${TRACE_DISPLAY_DIRECTORY}.`; + if (reference !== undefined) throw new Error(message); + logger.log(message); + return; + } + const trace = reference === undefined ? traces[0]! : resolveLocalTrace(traces, reference); const theme = createCliTheme(); logger.log( [ diff --git a/packages/eve/src/cli/run.test.ts b/packages/eve/src/cli/run.test.ts index 46c4d24e0..b8e5db59a 100644 --- a/packages/eve/src/cli/run.test.ts +++ b/packages/eve/src/cli/run.test.ts @@ -115,7 +115,8 @@ describe("CLI command registration", () => { await runCli(["trace", "--help"], logger).catch(() => {}); const help = output.join("\n"); - expect(help).toContain("show "); + expect(help).toContain("Usage: eve trace [options] [trace]"); + expect(help).not.toContain("show "); expect(help).toContain("ls"); }); }); diff --git a/packages/eve/src/cli/run.ts b/packages/eve/src/cli/run.ts index 029c77b4c..05bd50036 100644 --- a/packages/eve/src/cli/run.ts +++ b/packages/eve/src/cli/run.ts @@ -604,9 +604,13 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm }); const traces = program - .command("trace") - .description("Inspect local agent traces captured by `eve dev` (.eve/traces).") - .alias("traces"); + .command("trace [trace]") + .usage("[options] [trace]\n eve trace 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); + }); traces .command("ls") @@ -617,14 +621,6 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm await runTraceListCommand(logger, appRoot, options); }); - traces - .command("show ", { isDefault: true }) - .description("Show one local trace by trace id, session id, or unambiguous prefix.") - .action(async (reference: string) => { - const { runTraceShowCommand } = await import("#cli/commands/trace.js"); - await runTraceShowCommand(logger, appRoot, reference); - }); - program .command("info") .description("Print resolved application information.") diff --git a/research/provider-neutral-local-observability.md b/research/provider-neutral-local-observability.md index ef35c5667..a373ff515 100644 --- a/research/provider-neutral-local-observability.md +++ b/research/provider-neutral-local-observability.md @@ -197,7 +197,7 @@ local-tracing work. convention and proves the trace tree in memory. Production remains unchanged. 4. **Persistence.** Write session traces as OTLP/JSON, restore provider-owned session context across dev worker restarts, and apply payload capture policy. No browser UI. -5. **Inspection.** Add minimal `eve trace ls` and `eve trace show` commands. A graphical viewer is a +5. **Inspection.** Add minimal `eve trace ls` and `eve trace [trace]` commands. A graphical viewer is a separate design after the trace model stabilizes. 6. **Public providers.** Promote the proven lifecycle contract into public hooks and migrate OTel, Vercel, Braintrust, and custom instrumentation onto it.