Skip to content
Draft
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
16 changes: 16 additions & 0 deletions docs-site/src/content/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,22 @@ Tracing is **off by default** and is configured by environment variable at proxy
Outbound capture covers adapters that send through the shared upstream fetch helper; the inbound request
and response are captured for `/v1/responses`, `/v1/messages`, and `/v1/chat/completions`.

Stored traces can be inspected directly from the local SQLite store; the proxy does not need to be running:

```bash
ocx trace list
ocx trace list --conversation <conversation-id> --limit 20
ocx trace show <trace-id>
ocx trace show <trace-id> --json
ocx trace show <trace-id> --body
```

`trace list` and `trace show` are payload-free by default. `--json` changes only the output format and
does **not** reveal request or response bodies. Bodies are included only when `--body` is explicitly
provided. Human `--body` output JSON-escapes stored strings so terminal control sequences from traced
content are not executed. Metadata-only trace summaries remain in `usage.jsonl`; `ocx trace list`
enumerates rows whose bodies were actually persisted in `trace.sqlite`.

## Updating

### `ocx update`
Expand Down
10 changes: 10 additions & 0 deletions src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,15 @@ const helpEntries: Record<string, HelpEntry> = {
usage: "ocx observe <logs|usage|storage|memory|cache|debug|claude-inbound|injection> ...",
summary: "Inspect proxy requests, usage, storage, memory, response cache, and debug data.",
},
trace: {
usage: "ocx trace <list|show> ...",
summary: "Inspect locally stored request traces without exposing payloads by default.",
details: [
"list [--conversation <id>] [--limit <n>] [--json]",
"show <trace-id> [--body] [--json]",
"Bodies are never printed unless --body is provided; --json alone remains metadata-only.",
],
},
logs: { usage: "ocx logs [filters] [--follow] [--json|--jsonl]", summary: "Alias of ocx observe logs." },
usage: { usage: "ocx usage [--range <7d|30d|all>] [--surface <all|codex|claude|grok>] [--json]", summary: "Alias of ocx observe usage." },
storage: { usage: "ocx storage [--json]", summary: "Alias of ocx observe storage." },
Expand Down Expand Up @@ -286,6 +295,7 @@ Usage:
ocx combo <sub> Combo failover/round-robin routing
ocx agent <sub> Subagents, injection, effort caps, and sidecars
ocx observe <sub> Logs, usage, storage, memory, cache, and debug data
ocx trace <sub> Inspect local trace metadata; bodies require explicit --body
ocx access <sub> External API keys and endpoint information
ocx grok <sub> Grok Build model selection and apply
ocx system <sub> Runtime settings, startup, sync, and updates
Expand Down
5 changes: 5 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,11 @@ switch (command) {
process.exitCode = await handleObserveCommand([command, ...args.slice(1)]);
break;
}
case "trace": {
const { handleTraceCommand } = await import("./trace");
process.exitCode = await handleTraceCommand(args.slice(1));
break;
}
case "access": {
const { handleAccessCommand } = await import("./access");
process.exitCode = await handleAccessCommand(args.slice(1));
Expand Down
128 changes: 128 additions & 0 deletions src/cli/trace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import {
listTraces,
readTrace,
type TraceRowSummary,
} from "../trace/store";
import {
CliUsageError,
rejectArgs,
runCliAction,
takeFlag,
takeIntegerOption,
takeOption,
} from "./runtime-api";

const USAGE = `Usage:
ocx trace list [--conversation <id>] [--limit <n>] [--json]
ocx trace show <trace-id> [--body] [--json]

Bodies are never printed unless --body is provided.
Human body output is JSON-escaped so stored terminal control sequences are not executed.`;

type TraceRecord = NonNullable<ReturnType<typeof readTrace>>;

function metadataOnly(row: TraceRecord): TraceRowSummary {
const { inbound: _inbound, outbound: _outbound, response: _response, ...summary } = row;
return summary;
}

function formatBytes(value: number | undefined): string {
return value === undefined ? "-" : String(value);
}

function terminalText(value: string): string {
return JSON.stringify(value).slice(1, -1);
}

function traceLine(row: TraceRowSummary): string {
const route = [row.provider, row.model]
.filter((value): value is string => typeof value === "string")
.map(terminalText)
.join("/") || "-";
return [
new Date(row.createdAt).toISOString(),
terminalText(row.traceId),
row.mode,
route,
row.status === undefined ? "-" : String(row.status),
`req=${formatBytes(row.meta.requestBytes)}B`,
`out=${formatBytes(row.meta.outboundBytes)}B`,
`res=${formatBytes(row.meta.responseBytes)}B`,
row.truncated ? "truncated" : "",
].filter(Boolean).join(" ");
}

function printTraceMetadata(row: TraceRowSummary): void {
console.log(`traceId: ${terminalText(row.traceId)}`);
console.log(`createdAt: ${new Date(row.createdAt).toISOString()}`);
console.log(`expiresAt: ${new Date(row.expiresAt).toISOString()}`);
console.log(`mode: ${row.mode}`);
console.log(`provider: ${row.provider ? terminalText(row.provider) : "-"}`);
console.log(`model: ${row.model ? terminalText(row.model) : "-"}`);
console.log(`status: ${row.status ?? "-"}`);
console.log(`conversationId: ${row.conversationId ? terminalText(row.conversationId) : "-"}`);
console.log(`truncated: ${row.truncated ? "yes" : "no"}`);
console.log(`meta: ${JSON.stringify(row.meta)}`);
}

function printEscapedBody(label: string, value: string | undefined): void {
console.log(`${label}: ${value === undefined ? "(not captured)" : JSON.stringify(value)}`);
}

function listCommand(argv: string[]): void {
const args = [...argv];
const wantsJson = takeFlag(args, "--json");
const conversationId = takeOption(args, "--conversation");
const limit = takeIntegerOption(args, "--limit", { min: 1 }) ?? 50;
if (limit > 500) throw new CliUsageError("--limit must be between 1 and 500", USAGE);
rejectArgs(args, USAGE);

const rows = listTraces({ conversationId, limit });
if (wantsJson) {
console.log(JSON.stringify(rows, null, 2));
return;
}
if (rows.length === 0) {
console.log("(no stored traces)");
return;
}
for (const row of rows) console.log(traceLine(row));
}

function showCommand(argv: string[]): void {
const args = [...argv];
const wantsJson = takeFlag(args, "--json");
const includeBody = takeFlag(args, "--body");
const traceId = args.shift();
if (!traceId || traceId.startsWith("-")) {
throw new CliUsageError("trace show requires a trace id", USAGE);
}
rejectArgs(args, USAGE);

const row = readTrace(traceId);
if (!row) throw new Error(`trace not found or expired: ${traceId}`);

const output = includeBody ? row : metadataOnly(row);
if (wantsJson) {
console.log(JSON.stringify(output, null, 2));
return;
}

printTraceMetadata(metadataOnly(row));
if (!includeBody) return;
console.log("");
printEscapedBody("inbound", row.inbound);
printEscapedBody("outbound", row.outbound);
printEscapedBody("response", row.response);
}

export async function handleTraceCommand(argv: string[]): Promise<number> {
return runCliAction(async () => {
const [sub = "list", ...rest] = argv;
if (sub === "list") listCommand(rest);
else if (sub === "show") showCommand(rest);
else throw new CliUsageError(`unknown trace command ${sub}`, USAGE);
});
}

export const TRACE_USAGE = USAGE;
145 changes: 145 additions & 0 deletions tests/cli-trace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import {
afterEach,
beforeEach,
describe,
expect,
spyOn,
test,
} from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { handleTraceCommand } from "../src/cli/trace";
import {
closeTraceStore,
writeTrace,
} from "../src/trace/store";
import {
resetTraceSettingsForTests,
setTraceSettings,
} from "../src/trace/settings";

let testDir = "";
let previousHome: string | undefined;

beforeEach(() => {
previousHome = process.env.OPENCODEX_HOME;
testDir = mkdtempSync(join(tmpdir(), "ocx-cli-trace-"));
process.env.OPENCODEX_HOME = testDir;
closeTraceStore();
resetTraceSettingsForTests();
setTraceSettings({ mode: "full", ttlHours: 24 });
});

afterEach(() => {
closeTraceStore();
resetTraceSettingsForTests();
if (previousHome === undefined) delete process.env.OPENCODEX_HOME;
else process.env.OPENCODEX_HOME = previousHome;
if (testDir) rmSync(testDir, { recursive: true, force: true });
});

async function captureLogs(argv: string[]): Promise<{ code: number; output: string }> {
const lines: string[] = [];
const log = spyOn(console, "log").mockImplementation((...args: unknown[]) => {
lines.push(args.map(value => String(value)).join(" "));
});
const error = spyOn(console, "error").mockImplementation((...args: unknown[]) => {
lines.push(args.map(value => String(value)).join(" "));
});
try {
const code = await handleTraceCommand(argv);
return { code, output: lines.join("\n") };
} finally {
log.mockRestore();
error.mockRestore();
}
}

function seedTrace(
traceId: string,
conversationId: string,
createdAt: number,
model = "gpt-5.6-terra",
): void {
const stored = writeTrace({
traceId,
createdAt,
mode: "full",
conversationId,
provider: "openai",
model,
status: 200,
meta: {
mode: "full",
stored: true,
requestBytes: 22,
outboundBytes: 23,
responseBytes: 24,
},
inbound: "request-private-marker",
outbound: "outbound-private-marker",
response: "\u001b[31mresponse-private-marker",
truncated: false,
});
expect(stored).toBe(true);
}

describe("trace CLI", () => {
test("show is metadata-only even in JSON unless --body is explicit", async () => {
seedTrace("trace-1", "conv-1", Date.now());

const safe = await captureLogs(["show", "trace-1", "--json"]);
expect(safe.code).toBe(0);
expect(safe.output).toContain('"traceId": "trace-1"');
expect(safe.output).not.toContain("request-private-marker");
expect(safe.output).not.toContain("outbound-private-marker");
expect(safe.output).not.toContain("response-private-marker");
expect(safe.output).not.toContain('"inbound"');
expect(safe.output).not.toContain('"outbound"');
expect(safe.output).not.toContain('"response"');

const withBody = await captureLogs(["show", "trace-1", "--body", "--json"]);
expect(withBody.code).toBe(0);
expect(withBody.output).toContain("request-private-marker");
expect(withBody.output).toContain("outbound-private-marker");
expect(withBody.output).toContain("response-private-marker");
});

test("human output escapes terminal control sequences in metadata and bodies", async () => {
seedTrace("trace-ansi", "conv-1", Date.now(), "\u001b[2Jgpt-private");

const result = await captureLogs(["show", "trace-ansi", "--body"]);
expect(result.code).toBe(0);
expect(result.output).not.toContain("\u001b[");
expect(result.output).toContain("\\u001b[2Jgpt-private");
expect(result.output).toContain("\\u001b[31mresponse-private-marker");
});

test("list filters by conversation and remains payload-free", async () => {
const now = Date.now();
seedTrace("trace-a", "conv-a", now - 1000);
seedTrace("trace-b", "conv-b", now);

const result = await captureLogs([
"list",
"--conversation",
"conv-a",
"--limit",
"10",
"--json",
]);
expect(result.code).toBe(0);
expect(result.output).toContain('"traceId": "trace-a"');
expect(result.output).not.toContain('"traceId": "trace-b"');
expect(result.output).not.toContain("request-private-marker");
expect(result.output).not.toContain('"inbound"');
});

test("rejects unsafe or malformed command shapes", async () => {
expect((await captureLogs(["show"])).code).toBe(2);
expect((await captureLogs(["list", "--limit", "501"])).code).toBe(2);
expect((await captureLogs(["list", "--body"])).code).toBe(2);
expect((await captureLogs(["show", "missing"])).code).toBe(1);
});
});
Loading