API Reference
Insight SQL API

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
FieldTypeRequiredDefaultConstraints
sqlstringYes—Max 10,000 chars
limitintegerNo1,000Max 10,000
dedupbooleanNotrueDeduplicate 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.

TablePrimary key columnsDescription
tracesproject_id, toDate(timestamp), idTop-level LLM trace records
observationsproject_id, type, toDate(start_time), idSpans, generations, events in traces
scoresproject_id, toDate(timestamp), name, idEval, 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:

QueryTime
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 DESC

JOINs

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 10

For 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 10

Subqueries

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 DESC

Map 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 DESC

Array 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 10

Time 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 day

Available 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 DESC

Scores

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 10

Useful ClickHouse Functions

FunctionExamplePurpose
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) > 0Array/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

LimitValueHTTP status on breach
Max execution time30 seconds408
Max memory4 GB413
Max result rows10,000413
Max SQL length10,000 chars400
Max limit param10,000400

Error Reference

HTTP StatusMeaning
200Success
400Invalid SQL, unknown column, disallowed table, or syntax error
401Missing or invalid API keys
403Mutation attempt (caught by ClickHouse readonly: 1)
408Query timed out (30s limit)
413Memory limit exceeded or result too large
500Unexpected 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

PatternWhat happensWorkaround
WITH name AS (...) SELECT ... FROM nameRejected: "Table 'name' is not allowed"Use inline subqueries
FROM default.tracesRejected: schema-qualified referencesUse FROM traces
SELECT 1; SELECT 2Rejected: multi-statement SQLOne query per request
SETTINGS max_threads=4Rejected: SETTINGS clauseSettings are server-controlled
INSERT, UPDATE, DELETE, DROPRejected: read-only APIN/A
FROM system.tablesRejected: system table accessN/A

Column Reference

traces

ColumnTypeNotes
idStringTrace ID
timestampDateTime64(3)When the trace occurred
nameStringTrace name (can be empty)
user_idNullable(String)End-user identifier
metadataMap(String, String)Key-value metadata
releaseNullable(String)Release version
versionNullable(String)Trace version
publicBoolPublicly accessible
bookmarkedBoolBookmarked
tagsArray(String)Tags array
inputNullable(String)Input payload (large, ZSTD compressed)
outputNullable(String)Output payload (large, ZSTD compressed)
session_idNullable(String)Session grouping ID
environmentNullable(String)Environment
created_atDateTime64(3)Record creation time
updated_atDateTime64(3)Last update time

observations

ColumnTypeNotes
idStringObservation ID
trace_idStringParent trace ID (JOIN key)
typeLowCardinality(String)SPAN, GENERATION, or EVENT
parent_observation_idNullable(String)Parent observation
start_timeDateTime64(3)Start timestamp
end_timeNullable(DateTime64(3))End timestamp
nameStringObservation name
metadataMap(String, String)Key-value metadata
levelLowCardinality(String)DEFAULT, DEBUG, WARNING, ERROR
status_messageNullable(String)Status message
inputNullable(String)Input (large, ZSTD compressed)
outputNullable(String)Output (large, ZSTD compressed)
provided_model_nameNullable(String)Model name as provided
internal_model_idNullable(String)Resolved model ID
provided_usage_detailsMap(String, UInt64)Token usage as provided
usage_detailsMap(String, UInt64)Resolved token usage (input, output, total)
provided_cost_detailsMap(String, Decimal64)Cost as provided
cost_detailsMap(String, Decimal64)Resolved cost (input, output)
total_costNullable(Decimal64(12))Total cost
completion_start_timeNullable(DateTime64(3))Time to first token
prompt_idNullable(String)Prompt template ID
prompt_nameNullable(String)Prompt template name
prompt_versionNullable(UInt16)Prompt template version
environmentNullable(String)Environment

scores

ColumnTypeNotes
idStringScore ID
timestampDateTime64(3)Score timestamp
trace_idNullable(String)Target trace (XOR with session_id)
session_idNullable(String)Target session (XOR with trace_id)
observation_idNullable(String)Target observation
nameStringScore name
valueFloat64Numeric value
sourceStringEVAL, ANNOTATION, or API
commentNullable(String)Score comment
data_typeStringNUMERIC, CATEGORICAL, BOOLEAN
string_valueNullable(String)Categorical value
config_idNullable(String)Eval config ID
queue_idNullable(String)Annotation queue ID

Next Steps