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
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,38 @@ traceroot traces export <trace-id> --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 <file>` 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
Expand Down
5 changes: 2 additions & 3 deletions src/commands/sql.ts
Original file line number Diff line number Diff line change
@@ -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);
}
50 changes: 50 additions & 0 deletions src/commands/sql/schema.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 });
});
}
8 changes: 8 additions & 0 deletions tests/commands.register.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
127 changes: 127 additions & 0 deletions tests/commands/sql-schema.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
});
Loading