Skip to content

Commit 5bc658f

Browse files
pescnclaude
andauthored
feat: add KQL search for completions (#70)
* feat: add KQL search for completions (#54) Implement a KQL (Kibana Query Language) search engine for querying completion logs. Includes a full lexer/parser/compiler pipeline, aggregation support, time-series histograms, and a search UI integrated into the requests page. Backend: - KQL lexer, parser, and SQL compiler with parameterized queries - Search, histogram, validate, fields, and export API endpoints - Aggregation support (count, avg, sum, min, max, p50/p95/p99) - Time-bucketed histogram queries for visualizations - Field autocomplete with distinct value suggestions Frontend: - Search bar with KQL query input and time range picker - Aggregation results display - CSV/JSON export functionality - Integrated into existing requests page with URL search params Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: move KQL validation to frontend, remove CLAUDE.md/TODOs.md - Copy KQL lexer/parser/types to frontend/src/lib/kql/ for client-side query validation instead of calling backend /search/validate endpoint - Remove backend /search/validate endpoint (no longer needed) - Add CLAUDE.md and TODOs.md to .gitignore Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address code review issues in KQL search - Sanitize error responses: replace String(err) with server-side logging via createLogger, remove internal details from client responses - Fix parseTimeRange: require both from and to (no silent defaults) - Consolidate duplicated rangeMs lookups into shared TIME_RANGE_MS constant and getTimeRangeISO() utility in time-range-picker.tsx - Fix null safety in aggregation-results.tsx for results[0] - Add SAFETY comment documenting trust boundary for sql.raw() in aggregation queries - Remove unused useTimeRangeDates hook and useMemo import Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address automated review comments in KQL search - Guard jsonb_array_elements against non-array values in compiler - Add explicit error for multiple GROUP BY fields - Fix number lexer to reject multi-dot numbers (e.g. 1.2.3) - Wrap parseTimeRange with try-catch in /search/export endpoint - Use escapeCsvField for all CSV fields in export - Add safe JSON.parse wrapper in search results normalization - Sync search bar text with URL query param on navigation - Add NaN fallback for histogram chart data - Log export errors to console instead of silently swallowing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: return 400 for CompilerError in search endpoints compileSearch throws CompilerError for user input errors (unknown fields, invalid enum values, etc.). Wrap all three endpoints with try-catch to return 400 instead of letting it bubble up as 500. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent fc9db19 commit 5bc658f

22 files changed

Lines changed: 3999 additions & 6 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ coverage
2222
# Docs build output (generated from docs/ workspace)
2323
backend/docs/
2424

25+
CLAUDE.md
26+
TODOs.md
27+
2528
.turbo
2629

2730
# Python test code (ignore all except essential files)

backend/src/api/admin/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { adminEmbeddings } from "./embeddings";
1010
import { adminModels } from "./models";
1111
import { adminProviders } from "./providers";
1212
import { adminRateLimits } from "./rateLimits";
13+
import { adminSearch } from "./search";
1314
import { adminSettings } from "./settings";
1415
import { adminStats } from "./stats";
1516
import { adminUpstream } from "./upstream";
@@ -34,6 +35,7 @@ export const routes = new Elysia({
3435
.use(adminModels)
3536
.use(adminEmbeddings)
3637
.use(adminStats)
38+
.use(adminSearch)
3739
.use(adminSettings)
3840
.use(adminDashboards)
3941
.use(adminGrafana)

backend/src/api/admin/search.ts

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
import { Elysia, t } from "elysia";
2+
import {
3+
parseKql,
4+
compileSearch,
5+
getSearchableFields,
6+
} from "@/search";
7+
import {
8+
searchCompletions,
9+
aggregateCompletions,
10+
searchCompletionsTimeSeries,
11+
getDistinctFieldValues,
12+
} from "@/db";
13+
import { createLogger } from "@/utils/logger";
14+
15+
const logger = createLogger("search");
16+
17+
function parseTimeRange(
18+
from?: string,
19+
to?: string,
20+
): { from: Date; to: Date } | undefined {
21+
if (!from || !to) {
22+
return undefined;
23+
}
24+
const fromDate = new Date(from);
25+
const toDate = new Date(to);
26+
if (Number.isNaN(fromDate.getTime()) || Number.isNaN(toDate.getTime())) {
27+
throw new Error("Invalid timeRange date");
28+
}
29+
if (fromDate > toDate) {
30+
throw new Error("timeRange.from must be <= timeRange.to");
31+
}
32+
return { from: fromDate, to: toDate };
33+
}
34+
35+
function escapeCsvField(value: unknown): string {
36+
if (value == null) {
37+
return "";
38+
}
39+
const str = typeof value === "object" ? JSON.stringify(value) : String(value as string | number | boolean);
40+
if (str.includes(",") || str.includes("\n") || str.includes("\r") || str.includes('"')) {
41+
return `"${str.replace(/"/g, '""')}"`;
42+
}
43+
return str;
44+
}
45+
46+
export const adminSearch = new Elysia()
47+
// Search completions
48+
.post(
49+
"/search",
50+
async ({ body, status }) => {
51+
const result = parseKql(body.query);
52+
if (!result.success) {
53+
return status(400, {
54+
error: "Invalid query",
55+
details: result.error,
56+
});
57+
}
58+
59+
let timeRange: { from: Date; to: Date } | undefined;
60+
try {
61+
timeRange = parseTimeRange(body.timeRange?.from, body.timeRange?.to);
62+
} catch (err) {
63+
return status(400, { error: err instanceof Error ? err.message : "Invalid timeRange" });
64+
}
65+
let compiled;
66+
try {
67+
compiled = compileSearch(result.query, { timeRange });
68+
} catch (err) {
69+
return status(400, {
70+
error: "Invalid query",
71+
details: err instanceof Error ? err.message : "Compilation failed",
72+
});
73+
}
74+
75+
// If the query has aggregation, return aggregation results
76+
if (compiled.aggregation) {
77+
try {
78+
const results = await aggregateCompletions(compiled);
79+
return { type: "aggregation" as const, results };
80+
} catch (err) {
81+
logger.error("Aggregation failed", { error: err });
82+
return status(500, {
83+
error: "Aggregation failed",
84+
});
85+
}
86+
}
87+
88+
// Otherwise, return paginated document results
89+
try {
90+
const data = await searchCompletions(
91+
compiled,
92+
body.offset ?? 0,
93+
body.limit ?? 100,
94+
);
95+
// Truncate model names that contain '@'
96+
data.data.forEach((row) => {
97+
if (row.model && row.model.includes("@")) {
98+
row.model = row.model.split("@", 2)[0]!;
99+
}
100+
});
101+
return { type: "documents" as const, ...data };
102+
} catch (err) {
103+
logger.error("Search failed", { error: err });
104+
return status(500, {
105+
error: "Search failed",
106+
});
107+
}
108+
},
109+
{
110+
body: t.Object({
111+
query: t.String({ maxLength: 2000 }),
112+
timeRange: t.Optional(
113+
t.Object({
114+
from: t.Optional(t.String()),
115+
to: t.Optional(t.String()),
116+
}),
117+
),
118+
offset: t.Optional(t.Integer({ minimum: 0 })),
119+
limit: t.Optional(t.Integer({ minimum: 1, maximum: 500 })),
120+
}),
121+
},
122+
)
123+
// Search histogram (time series)
124+
.post(
125+
"/search/histogram",
126+
async ({ body, status }) => {
127+
const result = parseKql(body.query);
128+
if (!result.success) {
129+
return status(400, {
130+
error: "Invalid query",
131+
details: result.error,
132+
});
133+
}
134+
135+
let timeRange: { from: Date; to: Date } | undefined;
136+
try {
137+
timeRange = parseTimeRange(body.timeRange?.from, body.timeRange?.to);
138+
} catch (err) {
139+
return status(400, { error: err instanceof Error ? err.message : "Invalid timeRange" });
140+
}
141+
let compiled;
142+
try {
143+
compiled = compileSearch(result.query, { timeRange });
144+
} catch (err) {
145+
return status(400, {
146+
error: "Invalid query",
147+
details: err instanceof Error ? err.message : "Compilation failed",
148+
});
149+
}
150+
151+
try {
152+
const buckets = await searchCompletionsTimeSeries(
153+
compiled,
154+
body.bucketSeconds ?? 60,
155+
);
156+
return { buckets };
157+
} catch (err) {
158+
logger.error("Histogram query failed", { error: err });
159+
return status(500, {
160+
error: "Histogram query failed",
161+
});
162+
}
163+
},
164+
{
165+
body: t.Object({
166+
query: t.String({ maxLength: 2000 }),
167+
timeRange: t.Optional(
168+
t.Object({
169+
from: t.Optional(t.String()),
170+
to: t.Optional(t.String()),
171+
}),
172+
),
173+
bucketSeconds: t.Optional(t.Integer({ minimum: 1 })),
174+
}),
175+
},
176+
)
177+
// Get searchable fields (for autocomplete)
178+
.get("/search/fields", async () => {
179+
const fields = getSearchableFields();
180+
181+
// Enrich with distinct values for key fields
182+
const modelValues = await getDistinctFieldValues("model");
183+
184+
return {
185+
fields: fields.map((f) => {
186+
if (f.name === "model") {
187+
return Object.assign({}, f, { values: modelValues });
188+
}
189+
return f;
190+
}),
191+
};
192+
})
193+
// Export search results
194+
.post(
195+
"/search/export",
196+
async ({ body, status, set }) => {
197+
const result = parseKql(body.query);
198+
if (!result.success) {
199+
return status(400, {
200+
error: "Invalid query",
201+
details: result.error,
202+
});
203+
}
204+
205+
let timeRange: { from: Date; to: Date } | undefined;
206+
try {
207+
timeRange = parseTimeRange(body.timeRange?.from, body.timeRange?.to);
208+
} catch (err) {
209+
return status(400, { error: err instanceof Error ? err.message : "Invalid timeRange" });
210+
}
211+
let compiled;
212+
try {
213+
compiled = compileSearch(result.query, { timeRange });
214+
} catch (err) {
215+
return status(400, {
216+
error: "Invalid query",
217+
details: err instanceof Error ? err.message : "Compilation failed",
218+
});
219+
}
220+
221+
try {
222+
// Fetch all results (up to 10000 for export)
223+
const data = await searchCompletions(compiled, 0, 10000);
224+
225+
if (body.format === "csv") {
226+
set.headers["content-type"] = "text/csv";
227+
set.headers["content-disposition"] =
228+
'attachment; filename="search-results.csv"';
229+
230+
const headers = [
231+
"id",
232+
"model",
233+
"status",
234+
"duration",
235+
"ttft",
236+
"prompt_tokens",
237+
"completion_tokens",
238+
"created_at",
239+
"provider_name",
240+
"api_format",
241+
"rating",
242+
];
243+
const rows = data.data.map((row) =>
244+
[
245+
escapeCsvField(row.id),
246+
escapeCsvField(row.model),
247+
escapeCsvField(row.status),
248+
escapeCsvField(row.duration),
249+
escapeCsvField(row.ttft),
250+
escapeCsvField(row.prompt_tokens),
251+
escapeCsvField(row.completion_tokens),
252+
escapeCsvField(row.created_at),
253+
escapeCsvField(row.provider_name),
254+
escapeCsvField(row.api_format),
255+
escapeCsvField(row.rating),
256+
].join(","),
257+
);
258+
return [headers.join(","), ...rows].join("\n");
259+
}
260+
261+
// JSON format
262+
set.headers["content-type"] = "application/json";
263+
set.headers["content-disposition"] =
264+
'attachment; filename="search-results.json"';
265+
return JSON.stringify(data.data, null, 2);
266+
} catch (err) {
267+
logger.error("Export failed", { error: err });
268+
return status(500, {
269+
error: "Export failed",
270+
});
271+
}
272+
},
273+
{
274+
body: t.Object({
275+
query: t.String({ maxLength: 2000 }),
276+
timeRange: t.Optional(
277+
t.Object({
278+
from: t.Optional(t.String()),
279+
to: t.Optional(t.String()),
280+
}),
281+
),
282+
format: t.Union([t.Literal("csv"), t.Literal("json")]),
283+
}),
284+
},
285+
);

0 commit comments

Comments
 (0)