Insight SQL API
Run read-only SQL queries directly against your ClickHouse analytics data.
Overview
The SQL API (POST /api/public/sql) gives you direct SQL access to your traces, observations, and scores in ClickHouse. Unlike the REST API which returns pre-shaped objects, the SQL API lets you write arbitrary SELECT queries for custom analytics, aggregations, and cross-table joins.
Use it when you need:
- Custom aggregations the REST API doesn't support (percentiles, time-bucketed rollups, conditional counts)
- Cross-table joins (e.g. traces with their scores and observation costs in one query)
- Bulk data exploration with ClickHouse-native functions
- Building custom dashboards or reporting pipelines
Project isolation is automatic. Every query is wrapped in system CTEs that filter all tables to your project. You never need to include WHERE project_id = ....
Authentication
Same Basic Auth as all other Insight public API endpoints — use your project API keys.
curl -X POST https://insight.withmetis.ai/api/public/sql \
-u "$INSIGHT_PUBLIC_KEY:$INSIGHT_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"sql": "SELECT id, name, timestamp FROM traces ORDER BY timestamp DESC",
"limit": 5
}'Request
POST /api/public/sql
Content-Type: application/json| Field | Type | Required | Default | Constraints |
|---|---|---|---|---|
sql | string | Yes | — | Max 10,000 chars |
limit | integer | No | 1,000 | Max 10,000 |
dedup | boolean | No | true | Deduplicate rows at query time. See Deduplication. |
Response
{
"data": [
{ "id": "abc-123", "name": "my-trace", "timestamp": "2025-01-15 10:30:00.000" }
],
"meta": {
"rowCount": 1,
"executionTimeMs": 42,
"limitApplied": 5
}
}On error, the response includes an error field:
{
"data": [],
"meta": { "rowCount": 0, "executionTimeMs": 0, "limitApplied": 1 },
"error": "Table 'users' is not allowed. Allowed tables: traces, observations, scores."
}Available Tables
You have access to exactly three tables. All other tables are blocked.
| Table | Primary key columns | Description |
|---|---|---|
traces | project_id, toDate(timestamp), id | Top-level LLM trace records |
observations | project_id, type, toDate(start_time), id | Spans, generations, events in traces |
scores | project_id, toDate(timestamp), name, id | Eval, annotation, and API scores |
Best Practices
Always filter by date
Tables are partitioned by month. Date filters let ClickHouse skip entire partitions. The performance impact is significant — on a dataset with ~2.8M traces:
| Query | Time |
|---|---|
SELECT count() FROM traces (no filter) | 194 ms |
... WHERE timestamp >= '2026-04-10' (7 days) | 93 ms |
... WHERE timestamp >= '2026-04-17' (1 day) | 76 ms |
The gap grows with dataset size. Without a date filter on multi-million-row tables, you risk hitting the 30-second timeout.
-- Day-level (uses partition key directly — fastest)
WHERE toDate(timestamp) = '2026-04-17'
-- Range
WHERE timestamp >= '2026-04-10' AND timestamp < '2026-04-17'Select only what you need
The input and output columns are large ZSTD-compressed text blobs. Selecting them across many rows wastes bandwidth and memory budget.
-- Good: lightweight columns only
SELECT id, name, timestamp, user_id, session_id, tags
FROM traces ORDER BY timestamp DESC
-- OK when needed: use length() to check size without reading full content
SELECT id, name, length(input) as input_len, length(output) as output_len
FROM traces WHERE timestamp >= '2026-04-17'Use count() not count(*)
ClickHouse's count() (no arguments) is faster than count(*).
Use LIMIT
Always include a LIMIT unless you specifically need all rows. The API enforces one via the limit parameter, but an explicit LIMIT in your SQL can short-circuit query execution earlier.
WITH (CTE) clauses are not supported. The API uses system CTEs for project isolation, and user-defined CTEs conflict with the table validation. Use inline subqueries instead — see Subqueries below.
Query Patterns
Basic queries
-- Recent traces
SELECT id, name, timestamp, user_id, session_id
FROM traces
WHERE timestamp >= '2026-04-10'
ORDER BY timestamp DESC
LIMIT 100
-- Count observations by type
SELECT type, count() as cnt
FROM observations
WHERE start_time >= '2026-04-10'
GROUP BY type
-- Score overview
SELECT name, data_type, avg(value) as avg_val, count() as cnt
FROM scores
WHERE timestamp >= '2026-04-01'
GROUP BY name, data_type
ORDER BY cnt DESCJOINs
Two-table JOINs work well. Always filter both sides by date.
-- Cost per trace
SELECT
t.id,
t.name,
count() as span_count,
sum(o.total_cost) as total_cost
FROM traces t
JOIN observations o ON t.id = o.trace_id
WHERE t.timestamp >= '2026-04-10'
AND o.start_time >= '2026-04-10'
GROUP BY t.id, t.name
ORDER BY total_cost DESC NULLS LAST
LIMIT 10For three-table JOINs, expect 5–10x slower execution. Use the aggregate-first pattern to avoid cartesian explosions:
-- Pre-aggregate scores, then join (faster than a flat 3-way JOIN)
SELECT t.name, agg.avg_score, agg.score_count
FROM traces t
JOIN (
SELECT trace_id, avg(value) as avg_score, count() as score_count
FROM scores
WHERE name = 'correctness' AND timestamp >= '2026-04-10'
GROUP BY trace_id
) agg ON t.id = agg.trace_id
WHERE t.timestamp >= '2026-04-10'
ORDER BY agg.score_count DESC
LIMIT 10Subqueries
Since WITH clauses are not supported, use inline subqueries wherever you'd normally use a CTE:
-- Traces with above-average span count
SELECT name, trace_count, avg_spans FROM (
SELECT
t.name,
count(DISTINCT t.id) as trace_count,
avg(span_cnt) as avg_spans
FROM traces t
JOIN (
SELECT trace_id, count() as span_cnt
FROM observations
WHERE start_time >= '2026-04-10'
GROUP BY trace_id
) o ON t.id = o.trace_id
WHERE t.timestamp >= '2026-04-10'
GROUP BY t.name
) sub
WHERE trace_count > 5
ORDER BY trace_count DESCMap columns
metadata, usage_details, cost_details, and provided_usage_details are Map types. Access keys with bracket notation.
-- Discover available metadata keys
SELECT mapKeys(metadata) as keys
FROM traces
WHERE timestamp >= '2026-04-15' AND length(mapKeys(metadata)) > 0
LIMIT 10
-- Token usage by model
SELECT
provided_model_name,
sum(usage_details['input']) as input_tokens,
sum(usage_details['output']) as output_tokens,
sum(usage_details['total']) as total_tokens,
count() as calls
FROM observations
WHERE type = 'GENERATION'
AND start_time >= '2026-04-10'
AND provided_model_name != ''
GROUP BY provided_model_name
ORDER BY total_tokens DESC
-- Cost breakdown by model
SELECT
provided_model_name,
sum(cost_details['input']) as input_cost,
sum(cost_details['output']) as output_cost,
sum(total_cost) as total,
count() as calls
FROM observations
WHERE type = 'GENERATION'
AND start_time >= '2026-04-10'
AND total_cost IS NOT NULL AND total_cost > 0
GROUP BY provided_model_name
ORDER BY total DESCArray columns
The tags column on traces is Array(String).
-- Explode tags to individual rows
SELECT arrayJoin(tags) as tag, count() as cnt
FROM traces
WHERE timestamp >= '2026-04-10' AND length(tags) > 0
GROUP BY tag ORDER BY cnt DESC
-- Filter traces by specific tag
SELECT id, name FROM traces
WHERE has(tags, 'production') AND timestamp >= '2026-04-10'
LIMIT 10Time series
ClickHouse has built-in time bucketing functions.
-- Traces per hour
SELECT toStartOfHour(timestamp) as hour, count() as cnt
FROM traces
WHERE timestamp >= '2026-04-16'
GROUP BY hour ORDER BY hour
-- Daily dashboard with unique counts
SELECT
toStartOfDay(timestamp) as day,
count() as traces,
uniq(session_id) as unique_sessions,
uniq(user_id) as unique_users
FROM traces
WHERE timestamp >= '2026-04-01'
GROUP BY day ORDER BY dayAvailable bucketing functions: toStartOfMinute, toStartOfFiveMinutes, toStartOfFifteenMinutes, toStartOfHour, toStartOfDay, toStartOfWeek, toStartOfMonth.
Latency & error analysis
-- p50/p95/p99 latency per generation
SELECT
name,
quantile(0.5)(toUnixTimestamp64Milli(end_time) - toUnixTimestamp64Milli(start_time)) AS p50_ms,
quantile(0.95)(toUnixTimestamp64Milli(end_time) - toUnixTimestamp64Milli(start_time)) AS p95_ms,
quantile(0.99)(toUnixTimestamp64Milli(end_time) - toUnixTimestamp64Milli(start_time)) AS p99_ms,
count() AS cnt
FROM observations
WHERE type = 'GENERATION'
AND start_time >= '2026-04-10'
AND end_time IS NOT NULL
GROUP BY name
HAVING cnt >= 10
ORDER BY p95_ms DESC
-- Error rate by operation
SELECT
name,
countIf(level = 'ERROR') as errors,
count() as total,
round(countIf(level = 'ERROR') * 100.0 / count(), 2) as error_rate_pct
FROM observations
WHERE start_time >= '2026-04-10' AND name != ''
GROUP BY name
HAVING total >= 10
ORDER BY error_rate_pct DESCScores
Scores target either a trace_id or a session_id (never both). They come in three data types: NUMERIC, BOOLEAN, CATEGORICAL.
-- Score statistics
SELECT name, data_type, min(value), max(value), avg(value), count()
FROM scores
WHERE timestamp >= '2026-04-01'
GROUP BY name, data_type
ORDER BY count() DESC
-- Session-level scores
SELECT name, source, avg(value) as avg_val, count() as cnt
FROM scores
WHERE session_id IS NOT NULL AND session_id != ''
AND timestamp >= '2026-04-01'
GROUP BY name, source
ORDER BY cnt DESC
-- Categorical score distribution
SELECT name, string_value, count() as cnt
FROM scores
WHERE data_type = 'CATEGORICAL'
AND timestamp >= '2026-04-01'
AND string_value IS NOT NULL
GROUP BY name, string_value
ORDER BY cnt DESC
-- Session duration and trace count
SELECT
session_id,
count() as trace_count,
min(timestamp) as first_trace,
max(timestamp) as last_trace
FROM traces
WHERE timestamp >= '2026-04-15'
AND session_id IS NOT NULL AND session_id != ''
GROUP BY session_id
ORDER BY trace_count DESC
LIMIT 10Useful ClickHouse Functions
| Function | Example | Purpose |
|---|---|---|
count() | count() | Row count (faster than count(*)) |
countIf(cond) | countIf(level = 'ERROR') | Conditional count |
uniq(col) | uniq(user_id) | Approximate distinct count (fast) |
uniqExact(col) | uniqExact(session_id) | Exact distinct count (slower) |
quantile(q)(expr) | quantile(0.95)(latency) | Percentile |
toStartOfHour(ts) | toStartOfHour(timestamp) | Time bucketing |
toDate(ts) | toDate(timestamp) | Extract date |
arrayJoin(arr) | arrayJoin(tags) | Explode array to rows |
has(arr, val) | has(tags, 'prod') | Check array membership |
length(arr) | length(tags) > 0 | Array/string length |
mapKeys(m) | mapKeys(metadata) | Get map keys |
m['key'] | metadata['env'] | Access map value |
round(x, n) | round(rate, 2) | Round to n decimals |
if(c, t, f) | if(cnt > 0, x/cnt, 0) | Conditional expression |
Resource Limits
| Limit | Value | HTTP status on breach |
|---|---|---|
| Max execution time | 30 seconds | 408 |
| Max memory | 4 GB | 413 |
| Max result rows | 10,000 | 413 |
| Max SQL length | 10,000 chars | 400 |
Max limit param | 10,000 | 400 |
Error Reference
| HTTP Status | Meaning |
|---|---|
200 | Success |
400 | Invalid SQL, unknown column, disallowed table, or syntax error |
401 | Missing or invalid API keys |
403 | Mutation attempt (caught by ClickHouse readonly: 1) |
408 | Query timed out (30s limit) |
413 | Memory limit exceeded or result too large |
500 | Unexpected server error |
Error messages are descriptive:
"Table 'users' is not allowed. Allowed tables: traces, observations, scores."
"Column 'nonexistent_column' not found. Check table schema."
"Multi-statement SQL is not allowed. Remove semicolons."
"Only SELECT queries are allowed."
"SQL query must not be empty"Deduplication
ClickHouse tables use ReplacingMergeTree, which deduplicates rows during background merges. The dedup parameter controls whether deduplication is also enforced at query time.
dedup: true (default) — Applies ClickHouse's FINAL modifier, which merge-sorts all project data before returning results. This guarantees no duplicate rows and excludes soft-deleted records, but uses more memory and time on large projects.
dedup: false — Skips FINAL for faster queries with lower memory usage. ClickHouse can use primary key indexes and bloom filters to read only matching rows. The trade-off: very recently ingested data (before background merges complete, typically within seconds) may show duplicate rows, stale versions, or recently deleted records.
{
"sql": "SELECT count() FROM observations WHERE type = 'GENERATION'",
"limit": 1,
"dedup": true
}For most queries, the default dedup: true is correct. Use dedup: false when querying large projects where speed matters more than brief duplicate visibility — for example, point lookups by trace_id or exploratory SELECT * queries.
Known Limitations
| Pattern | What happens | Workaround |
|---|---|---|
WITH name AS (...) SELECT ... FROM name | Rejected: "Table 'name' is not allowed" | Use inline subqueries |
FROM default.traces | Rejected: schema-qualified references | Use FROM traces |
SELECT 1; SELECT 2 | Rejected: multi-statement SQL | One query per request |
SETTINGS max_threads=4 | Rejected: SETTINGS clause | Settings are server-controlled |
INSERT, UPDATE, DELETE, DROP | Rejected: read-only API | N/A |
FROM system.tables | Rejected: system table access | N/A |
Column Reference
traces
| Column | Type | Notes |
|---|---|---|
id | String | Trace ID |
timestamp | DateTime64(3) | When the trace occurred |
name | String | Trace name (can be empty) |
user_id | Nullable(String) | End-user identifier |
metadata | Map(String, String) | Key-value metadata |
release | Nullable(String) | Release version |
version | Nullable(String) | Trace version |
public | Bool | Publicly accessible |
bookmarked | Bool | Bookmarked |
tags | Array(String) | Tags array |
input | Nullable(String) | Input payload (large, ZSTD compressed) |
output | Nullable(String) | Output payload (large, ZSTD compressed) |
session_id | Nullable(String) | Session grouping ID |
environment | Nullable(String) | Environment |
created_at | DateTime64(3) | Record creation time |
updated_at | DateTime64(3) | Last update time |
observations
| Column | Type | Notes |
|---|---|---|
id | String | Observation ID |
trace_id | String | Parent trace ID (JOIN key) |
type | LowCardinality(String) | SPAN, GENERATION, or EVENT |
parent_observation_id | Nullable(String) | Parent observation |
start_time | DateTime64(3) | Start timestamp |
end_time | Nullable(DateTime64(3)) | End timestamp |
name | String | Observation name |
metadata | Map(String, String) | Key-value metadata |
level | LowCardinality(String) | DEFAULT, DEBUG, WARNING, ERROR |
status_message | Nullable(String) | Status message |
input | Nullable(String) | Input (large, ZSTD compressed) |
output | Nullable(String) | Output (large, ZSTD compressed) |
provided_model_name | Nullable(String) | Model name as provided |
internal_model_id | Nullable(String) | Resolved model ID |
provided_usage_details | Map(String, UInt64) | Token usage as provided |
usage_details | Map(String, UInt64) | Resolved token usage (input, output, total) |
provided_cost_details | Map(String, Decimal64) | Cost as provided |
cost_details | Map(String, Decimal64) | Resolved cost (input, output) |
total_cost | Nullable(Decimal64(12)) | Total cost |
completion_start_time | Nullable(DateTime64(3)) | Time to first token |
prompt_id | Nullable(String) | Prompt template ID |
prompt_name | Nullable(String) | Prompt template name |
prompt_version | Nullable(UInt16) | Prompt template version |
environment | Nullable(String) | Environment |
scores
| Column | Type | Notes |
|---|---|---|
id | String | Score ID |
timestamp | DateTime64(3) | Score timestamp |
trace_id | Nullable(String) | Target trace (XOR with session_id) |
session_id | Nullable(String) | Target session (XOR with trace_id) |
observation_id | Nullable(String) | Target observation |
name | String | Score name |
value | Float64 | Numeric value |
source | String | EVAL, ANNOTATION, or API |
comment | Nullable(String) | Score comment |
data_type | String | NUMERIC, CATEGORICAL, BOOLEAN |
string_value | Nullable(String) | Categorical value |
config_id | Nullable(String) | Eval config ID |
queue_id | Nullable(String) | Annotation queue ID |
Next Steps
- Insight REST API — Fetch pre-shaped trace, observation, and score objects
- OTel Ingestion — Send traces to Insight via OpenTelemetry
- Tracing Overview — Understand traces, spans, and attributes