diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index 4abfab58..9df5d72c 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -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 --limit 20 +ocx trace show +ocx trace show --json +ocx trace show --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` diff --git a/src/cli/help.ts b/src/cli/help.ts index ef0b84c8..05aa833d 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -160,6 +160,15 @@ const helpEntries: Record = { usage: "ocx observe ...", summary: "Inspect proxy requests, usage, storage, memory, response cache, and debug data.", }, + trace: { + usage: "ocx trace ...", + summary: "Inspect locally stored request traces without exposing payloads by default.", + details: [ + "list [--conversation ] [--limit ] [--json]", + "show [--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 ] [--json]", summary: "Alias of ocx observe usage." }, storage: { usage: "ocx storage [--json]", summary: "Alias of ocx observe storage." }, @@ -286,6 +295,7 @@ Usage: ocx combo Combo failover/round-robin routing ocx agent Subagents, injection, effort caps, and sidecars ocx observe Logs, usage, storage, memory, cache, and debug data + ocx trace Inspect local trace metadata; bodies require explicit --body ocx access External API keys and endpoint information ocx grok Grok Build model selection and apply ocx system Runtime settings, startup, sync, and updates diff --git a/src/cli/index.ts b/src/cli/index.ts index e0d0c62c..88246e89 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -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)); diff --git a/src/cli/trace.ts b/src/cli/trace.ts new file mode 100644 index 00000000..53a8fdca --- /dev/null +++ b/src/cli/trace.ts @@ -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 ] [--limit ] [--json] + ocx trace show [--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>; + +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 { + 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; diff --git a/tests/cli-trace.test.ts b/tests/cli-trace.test.ts new file mode 100644 index 00000000..6462125a --- /dev/null +++ b/tests/cli-trace.test.ts @@ -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); + }); +});