diff --git a/README.md b/README.md index 206d23f..cea7a9f 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,38 @@ traceroot traces export --output ./out traceroot traces list --from 2026-06-23T14:00:00Z --to 2026-06-23T20:00:00Z --limit 5 --json | jq '.data[].trace_id' ``` +## SQL — analytical export + +`traceroot sql` runs read-only ClickHouse SQL against the TraceRoot analytical +export. **input, output, and metadata blobs are excluded from the export** (raw +blob access may be offered as an opt-in in the future). + +```sh +# count spans in the last 24h +traceroot sql "SELECT count() AS spans_24h FROM spans WHERE span_start_time >= now() - INTERVAL 24 HOUR" + +# p95 latency by model +traceroot sql "SELECT model_name, quantile(0.95)(duration_ms) AS p95_ms FROM spans WHERE model_name IS NOT NULL GROUP BY model_name ORDER BY p95_ms DESC" + +# cost by model +traceroot sql "SELECT model_name, sum(cost) AS total_cost FROM spans GROUP BY model_name ORDER BY total_cost DESC" + +# export spans to CSV +traceroot sql "SELECT * FROM spans WHERE span_start_time >= now() - INTERVAL 7 DAY" --csv --output spans.csv + +# find error spans +traceroot sql "SELECT span_id, name, status_message FROM spans WHERE status = 'ERROR' ORDER BY span_start_time DESC LIMIT 100" + +# show the analytical export schema +traceroot sql schema +``` + +Output modes: default table | `--json` (one JSON line) | `--csv` (RFC-4180). +`--output ` writes any mode to a file instead of stdout. + +`traceroot sql schema` prints the curated list of available tables, columns, and +types without running a query. + ## Skills & agents Make your coding agent TraceRoot-aware without touching your application source. The diff --git a/src/commands/sql.ts b/src/commands/sql.ts index ee373fe..b558d57 100644 --- a/src/commands/sql.ts +++ b/src/commands/sql.ts @@ -1,13 +1,12 @@ import type { Command } from "commander"; import { registerSqlQuery } from "./sql/query.js"; +import { registerSqlSchema } from "./sql/schema.js"; export function registerSql(program: Command): void { const sql = program .command("sql") .description("Run read-only SQL against your TraceRoot analytical export") .helpCommand(false); - // NOTE: registerSqlSchema(sql) will be inserted here by the schema task, - // immediately above registerSqlQuery, so `traceroot sql schema` resolves - // as a subcommand before the default query action is evaluated. + registerSqlSchema(sql); registerSqlQuery(sql); } diff --git a/src/commands/sql/schema.ts b/src/commands/sql/schema.ts new file mode 100644 index 0000000..ee8c75d --- /dev/null +++ b/src/commands/sql/schema.ts @@ -0,0 +1,50 @@ +import type { Command } from "commander"; +import type { ApiClient } from "../../api/client.js"; +import { type Writers, defaultWriters, writeJson } from "../../output.js"; +import { createStyler } from "../../render/style.js"; +import { renderTable } from "../../render/table.js"; +import { contextFromCommand, requireApiClient } from "../shared.js"; + +// Human preamble (default output). Mentions blobs excluded + neutral future opt-in (NO flag name). +const SCHEMA_PREAMBLE = + "Analytical export schema. input/output/metadata blobs excluded from MVP.\n" + + "Raw blob export may be offered as an opt-in in the future."; +// JSON note (exact string required by spec). +const SCHEMA_NOTE = "Analytical export schema. input/output/metadata blobs excluded from MVP."; + +export interface RunSqlSchemaDeps { + client: ApiClient; + json: boolean; + writers: Writers; +} + +/** Core logic for `sql schema`. Tests inject a fake client. */ +export async function runSqlSchema(deps: RunSqlSchemaDeps): Promise { + const schema = await deps.client.sqlSchema(); + + if (deps.json) { + // Build explicitly — NOT typed as SqlSchemaResponse — to include the note field. + const out = { tables: schema.tables, note: SCHEMA_NOTE }; + writeJson(out, deps.writers); + return; + } + + // Default: preamble then a 3-column table. + deps.writers.out.write(`${SCHEMA_PREAMBLE}\n\n`); + const rows = schema.tables.flatMap((t) => t.columns.map((c) => [t.name, c.name, c.type])); + const rendered = renderTable(["TABLE", "COLUMN", "TYPE"], rows, { + headerStyle: createStyler(deps.writers.out).bold, + }); + deps.writers.out.write(`${rendered}\n`); +} + +export function registerSqlSchema(sql: Command): void { + sql + .command("schema") + .description("Show the curated analytical export schema (tables, columns, types)") + .action(async (_opts, command: Command) => { + const ctx = contextFromCommand(command); + const client = requireApiClient(ctx); + await runSqlSchema({ client, json: ctx.json, writers: defaultWriters }); + }); +} diff --git a/tests/commands.register.test.ts b/tests/commands.register.test.ts index 442c8e3..3c61ebd 100644 --- a/tests/commands.register.test.ts +++ b/tests/commands.register.test.ts @@ -52,4 +52,12 @@ describe("buildProgram", () => { const names = childNames(program); expect(names).toContain("sql"); }); + + it("registers schema under sql", () => { + const program = buildProgram(); + const sql = program.commands.find((c) => c.name() === "sql"); + expect(sql).toBeDefined(); + const subNames = childNames(sql as Command); + expect(subNames).toContain("schema"); + }); }); diff --git a/tests/commands/sql-schema.test.ts b/tests/commands/sql-schema.test.ts new file mode 100644 index 0000000..1b9f60c --- /dev/null +++ b/tests/commands/sql-schema.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import type { + ApiClient, + SqlQueryRequest, + SqlQueryResponse, + SqlSchemaResponse, +} from "../../src/api/client.js"; +import { runSqlSchema } from "../../src/commands/sql/schema.js"; +import type { Writers } from "../../src/output.js"; +import { StringSink } from "../helpers/stringSink.js"; + +function makeWriters(): { writers: Writers; out: StringSink; err: StringSink } { + const out = new StringSink(); + const err = new StringSink(); + return { writers: { out, err }, out, err }; +} + +// Fixture intentionally contains NO columns named input, output, or metadata — +// those words only appear in the preamble/note sentence as human context. +const SCHEMA_RESPONSE: SqlSchemaResponse = { + tables: [ + { + name: "spans", + columns: [ + { name: "span_id", type: "String" }, + { name: "model_name", type: "Nullable(String)" }, + { name: "duration_ms", type: "Float64" }, + ], + }, + { + name: "traces", + columns: [ + { name: "trace_id", type: "String" }, + { name: "project_id", type: "String" }, + { name: "status", type: "String" }, + ], + }, + ], +}; + +function makeSqlClient(opts: { + schemaResponse?: SqlSchemaResponse; + queryResponse?: SqlQueryResponse; + queryError?: unknown; +}): { client: ApiClient; calls: { sqlQuery: SqlQueryRequest[] } } { + const calls = { sqlQuery: [] as SqlQueryRequest[] }; + const client: ApiClient = { + whoami: () => Promise.reject(new Error("not used")), + listTraces: () => Promise.reject(new Error("not used")), + getTrace: () => Promise.reject(new Error("not used")), + exportTrace: () => Promise.reject(new Error("not used")), + sqlQuery: (body) => { + calls.sqlQuery.push(body); + if (opts.queryError) return Promise.reject(opts.queryError); + return Promise.resolve(opts.queryResponse as SqlQueryResponse); + }, + sqlSchema: () => Promise.resolve(opts.schemaResponse as SqlSchemaResponse), + }; + return { client, calls }; +} + +describe("runSqlSchema", () => { + it("default output contains spans, span_id, traces, trace_id and the preamble", async () => { + const { client } = makeSqlClient({ schemaResponse: SCHEMA_RESPONSE }); + const { writers, out } = makeWriters(); + + await runSqlSchema({ client, json: false, writers }); + + expect(out.data).toContain("spans"); + expect(out.data).toContain("span_id"); + expect(out.data).toContain("traces"); + expect(out.data).toContain("trace_id"); + expect(out.data).toContain("Analytical export schema"); + }); + + it("json output is one compact line equaling { tables, note }", async () => { + const { client } = makeSqlClient({ schemaResponse: SCHEMA_RESPONSE }); + const { writers, out } = makeWriters(); + + await runSqlSchema({ client, json: true, writers }); + + const lines = out.data.trimEnd().split("\n"); + expect(lines).toHaveLength(1); + expect(JSON.parse(out.data)).toEqual({ + tables: SCHEMA_RESPONSE.tables, + note: "Analytical export schema. input/output/metadata blobs excluded from MVP.", + }); + }); + + describe("input/output/metadata columns absent from rendered schema content (regression)", () => { + const FORBIDDEN_COLUMNS = ["input", "output", "metadata"]; + + it("default output: no column cells in the rendered table equal input, output, or metadata", async () => { + const { client } = makeSqlClient({ schemaResponse: SCHEMA_RESPONSE }); + const { writers, out } = makeWriters(); + + await runSqlSchema({ client, json: false, writers }); + + // Parse the COLUMN cells from the rendered out.data — the preamble legitimately + // contains the words "input", "output", "metadata", so a naive substring check + // would false-positive. Instead, find the header line, take all data rows that + // follow it, and extract the second whitespace-separated token (the COLUMN cell). + const lines = out.data.split("\n"); + const headerIdx = lines.findIndex((l) => l.trimStart().startsWith("TABLE")); + const dataLines = lines.slice(headerIdx + 1).filter((l) => l.trim().length > 0); + const columnCells = dataLines.map((l) => l.trim().split(/\s+/)[1] ?? ""); + for (const forbidden of FORBIDDEN_COLUMNS) { + expect(columnCells).not.toContain(forbidden); + } + }); + + it("json output: no JSON column names equal input, output, or metadata", async () => { + const { client } = makeSqlClient({ schemaResponse: SCHEMA_RESPONSE }); + const { writers, out } = makeWriters(); + + await runSqlSchema({ client, json: true, writers }); + + const parsed = JSON.parse(out.data) as { + tables: Array<{ columns: Array<{ name: string }> }>; + }; + const allColNames = parsed.tables.flatMap((t) => t.columns.map((c) => c.name)); + for (const forbidden of FORBIDDEN_COLUMNS) { + expect(allColNames).not.toContain(forbidden); + } + }); + }); +});