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/neat-traces-show.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Add `eve trace ls` and `eve trace [trace]` commands for inspecting locally persisted agent traces after or during `eve dev`.
6 changes: 4 additions & 2 deletions docs/guides/instrumentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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.

## Three observability surfaces
Expand Down Expand Up @@ -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}
Expand Down
15 changes: 14 additions & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
@@ -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`.
Expand All @@ -17,6 +17,8 @@ The `eve` binary (`bin: eve`) runs from your app root, and every command first l
| `eve dev <url>` | 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 [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 |
Expand Down Expand Up @@ -213,6 +215,17 @@ 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 the most recent span tree
eve trace <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` 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
Expand Down
212 changes: 212 additions & 0 deletions packages/eve/src/cli/commands/trace.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
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 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."]);

await writeSegment(
root,
TRACE_ONE,
span("a", "agent.turn", 10, 20, undefined, {
"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([
expect.objectContaining({ sessionId: "session-one", spanCount: 1, traceId: TRACE_ONE }),
]);
});

async function createRoot(): Promise<string> {
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<typeof span>,
): Promise<void> {
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<string, string | number> = {},
) {
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,
};
}
Loading
Loading