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
57 changes: 53 additions & 4 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,32 @@ export type TraceList = Ok200<paths["/api/v1/public/traces"]["get"]>;
export type TraceDetail = Ok200<paths["/api/v1/public/traces/{trace_id}"]["get"]>;
export type TraceExport = Ok200<paths["/api/v1/public/traces/{trace_id}/export"]["get"]>;

// Hand-written types for SQL Gateway endpoints (not in openapi.json).
export interface SqlColumn {
name: string;
type: string;
}
export interface SqlQueryRequest {
query: string;
parameters?: Record<string, unknown>;
max_rows?: number;
}
export interface SqlQueryResponse {
columns: SqlColumn[];
rows: unknown[][];
row_count: number;
truncated: boolean;
elapsed_ms: number;
statistics: Record<string, unknown>;
}
export interface SqlSchemaTable {
name: string;
columns: SqlColumn[];
}
export interface SqlSchemaResponse {
tables: SqlSchemaTable[];
}

export interface ApiClientOptions {
host: string;
apiKey: string;
Expand All @@ -38,6 +64,8 @@ export interface ApiClient {
listTraces(params?: ListTracesParams): Promise<TraceList>;
getTrace(traceId: string): Promise<TraceDetail>;
exportTrace(traceId: string): Promise<TraceExport>;
sqlQuery(body: SqlQueryRequest): Promise<SqlQueryResponse>;
sqlSchema(): Promise<SqlSchemaResponse>;
}

/** Shape of a backend JSON error body. */
Expand Down Expand Up @@ -70,17 +98,32 @@ export function createApiClient(opts: ApiClientOptions): ApiClient {
accept: "application/json",
};

async function request<T>(path: string): Promise<T> {
async function request<T>(
path: string,
reqInit?: { method?: string; body?: unknown },
): Promise<T> {
const url = `${base}${path}`;
const init: RequestInit = { method: "GET", headers };
const method = reqInit?.method ?? "GET";

// Merge in content-type only for requests that carry a body.
const requestHeaders =
reqInit?.body !== undefined ? { ...headers, "content-type": "application/json" } : headers;

const fetchInit: RequestInit = { method, headers: requestHeaders };

if (reqInit?.body !== undefined) {
fetchInit.body = JSON.stringify(reqInit.body);
}

if (opts.timeoutMs !== undefined) {
// A fresh signal per request; aborts the fetch on timeout so a stalled
// socket can't hang the process indefinitely.
init.signal = AbortSignal.timeout(opts.timeoutMs);
fetchInit.signal = AbortSignal.timeout(opts.timeoutMs);
}

let res: Response;
try {
res = await fetchImpl(url, init);
res = await fetchImpl(url, fetchInit);
} catch (err) {
// Deliberately do NOT interpolate the underlying error message: it could
// echo back request contents and leak the api key. Mention only the host.
Expand Down Expand Up @@ -129,5 +172,11 @@ export function createApiClient(opts: ApiClientOptions): ApiClient {
exportTrace(traceId) {
return request<TraceExport>(`/api/v1/public/traces/${encodeURIComponent(traceId)}/export`);
},
sqlQuery(body) {
return request<SqlQueryResponse>("/api/v1/public/sql", { method: "POST", body });
},
sqlSchema() {
return request<SqlSchemaResponse>("/api/v1/public/sql/schema");
},
};
}
2 changes: 2 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { registerDoctor } from "./doctor.js";
import { registerInstrument } from "./instrument.js";
import { registerLogin } from "./login.js";
import { registerSkills } from "./skills.js";
import { registerSql } from "./sql.js";
import { registerStatus } from "./status.js";
import { registerTraces } from "./traces.js";

Expand All @@ -14,6 +15,7 @@ export function registerCommands(program: Command): void {
registerLogin(program);
registerStatus(program);
registerTraces(program);
registerSql(program);
registerSkills(program);
registerInstrument(program);
registerDoctor(program);
Expand Down
13 changes: 13 additions & 0 deletions src/commands/sql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Command } from "commander";
import { registerSqlQuery } from "./sql/query.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.
registerSqlQuery(sql);
}
195 changes: 195 additions & 0 deletions src/commands/sql/query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { readFileSync, writeFileSync } from "node:fs";
import type { Command } from "commander";
import type { ApiClient, SqlQueryRequest } from "../../api/client.js";
import { CliError, type Writers, defaultWriters, logProgress, logWarn } from "../../output.js";
import { renderCsv } from "../../render/csv.js";
import { createStyler } from "../../render/style.js";
import { renderTable } from "../../render/table.js";
import { contextFromCommand, requireApiClient } from "../shared.js";

export type SqlFormat = "table" | "json" | "csv";

const SQL_HELP_EXAMPLES = `
Examples:
# 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.`;

/**
* Exactly one of positional/file. Reads the file (utf8) when given.
* Throws CliError otherwise.
*/
export function resolveQuery(input: { positional?: string; file?: string }): string {
const { positional, file } = input;
if (positional === undefined && file === undefined) {
throw new CliError("provide a query argument or --file");
}
if (positional !== undefined && file !== undefined) {
throw new CliError("provide either a query argument or --file, not both");
}
let query: string;
if (file !== undefined) {
try {
query = readFileSync(file, "utf8");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new CliError(`could not read query file ${file}: ${message}`);
}
} else {
query = positional as string;
}
if (query.trim().length === 0) {
throw new CliError("the query is empty");
}
return query;
}

/** --json and --csv are mutually exclusive. Default is table. */
export function resolveFormat(opts: { json: boolean; csv: boolean }): SqlFormat {
if (opts.json && opts.csv) {
throw new CliError("--json and --csv cannot be combined");
}
if (opts.csv) {
return "csv";
}
if (opts.json) {
return "json";
}
return "table";
}

/**
* Positive-integer parse for --max-rows; undefined when absent. Mirror
* parseLimit in traces/list.ts.
*/
export function parseMaxRows(raw: string | undefined): number | undefined {
if (raw === undefined) {
return undefined;
}
if (!/^\d+$/.test(raw)) {
throw new CliError("--max-rows must be a positive integer");
}
const value = Number.parseInt(raw, 10);
if (!Number.isInteger(value) || value < 1) {
throw new CliError("--max-rows must be a positive integer");
}
return value;
}

export interface RunSqlQueryDeps {
client: ApiClient;
query: string;
format: SqlFormat;
maxRows?: number;
outputPath?: string;
writers: Writers;
}

/** Converts a single cell value to a string for table rendering. */
function cellToString(cell: unknown): string {
if (cell === null || cell === undefined) {
return "";
}
if (typeof cell === "string") {
return cell;
}
if (typeof cell === "object") {
return JSON.stringify(cell);
}
return String(cell);
}

/** Core logic for `sql` query. Tests inject a fake client. */
export async function runSqlQuery(deps: RunSqlQueryDeps): Promise<void> {
const { client, query, format, maxRows, outputPath, writers } = deps;

// 1. Build request body.
const body: SqlQueryRequest = { query };
if (maxRows !== undefined) {
body.max_rows = maxRows;
}

// 2. Execute the query.
const res = await client.sqlQuery(body);

// 3. Build output string by format.
let payload: string;
if (format === "json") {
payload = `${JSON.stringify(res)}\n`;
} else if (format === "csv") {
payload = renderCsv(
res.columns.map((c) => c.name),
res.rows,
);
} else {
// table
const rendered = renderTable(
res.columns.map((c) => c.name),
res.rows.map((row) => row.map(cellToString)),
{ headerStyle: createStyler(writers.out).bold },
);
payload = `${rendered}\n`;
}

// 4. Truncation warning (table and csv modes only; json is self-describing).
if (format !== "json" && res.truncated) {
logWarn(
`result truncated to ${res.row_count} row(s); add a LIMIT to the query or raise --max-rows to see more`,
writers,
);
}

// 5. Output routing.
if (outputPath !== undefined) {
writeFileSync(outputPath, payload, "utf8");
logProgress(`wrote ${format} output to ${outputPath}`, writers);
} else {
writers.out.write(payload);
}
}

export function registerSqlQuery(sql: Command): void {
sql
.argument("[query]", "SQL query string (omit when using --file)")
.option("-f, --file <path>", "read the query from a file instead of the positional arg")
.option("--csv", "emit CSV output")
.option("--output <file>", "write output to a file instead of stdout")
.option("--max-rows <n>", "maximum number of rows to return (positive integer)")
.addHelpText("after", SQL_HELP_EXAMPLES)
.action(async (query: string | undefined, _opts, command: Command) => {
const opts = command.optsWithGlobals();
// Validate/resolve BEFORE requiring auth so arg errors surface without credentials.
const queryText = resolveQuery({
positional: query,
file: opts.file as string | undefined,
});
const format = resolveFormat({ json: opts.json === true, csv: opts.csv === true });
const maxRows = parseMaxRows(opts.maxRows as string | undefined);
const ctx = contextFromCommand(command);
const client = requireApiClient(ctx);
await runSqlQuery({
client,
query: queryText,
format,
maxRows,
outputPath: opts.output as string | undefined,
writers: defaultWriters,
});
});
}
29 changes: 29 additions & 0 deletions src/render/csv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Renders an RFC-4180-style CSV string. The first line is the header row;
* subsequent lines are data rows. Every line, including the last, ends with
* `\n`. No external dependencies; output is fully deterministic.
*/
export function renderCsv(headers: string[], rows: unknown[][]): string {
const serializeCell = (value: unknown): string => {
if (value === null || value === undefined) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
// object, array, or anything else
return JSON.stringify(value);
};

const quoteCell = (text: string): string => {
if (/[,"\r\n]/.test(text)) {
return `"${text.replaceAll('"', '""')}"`;
}
return text;
};

const renderRow = (cells: unknown[]): string =>
headers.map((_, col) => quoteCell(serializeCell(cells[col]))).join(",");

const lines = [renderRow(headers), ...rows.map((row) => renderRow(row))];
return `${lines.join("\n")}\n`;
}
Loading
Loading