> ## Documentation Index
> Fetch the complete documentation index at: https://tracevell.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# SQL Editor

Query all your Tracevell data directly with SQL. Find patterns, debug issues, and answer questions the dashboard doesn't anticipate.

## What You Can Query

| Table | Contains |
| - | - |
| spans | Individual spans (LLM/tool/custom/eval spans) with events and tags |
| traces | Trace-level aggregates derived from spans, plus the signal events and clusters attached to the trace |
| trace\_outputs | Extracted final agent output per trace |
| signal\_events | Signal-triggered events and payloads (excludes L0-cluster membership) |
| signal\_events\_all | Same as signal\_events but with L0-cluster membership included |
| signal\_runs | Signal execution runs and statuses |
| clusters | Clusters of similar signal events (excludes L0 clusters) |
| event\_clusters\_all | Mapping of signal events to clusters, including L0 clusters |
| logs | Streaming logs (for example, sandbox command output) |
| dataset\_datapoints | Dataset datapoints (latest version per datapoint) |
| dataset\_datapoint\_versions | Dataset datapoints (all versions/history) |
| evaluation\_trials | Evaluation results, one row per trial, with task id, scores, executor output, dataset links, and associated trace data (alias: `evaluation_datapoints`) |
| labeling\_queue\_items | Per-item rows of labeling queues |
| signals | Signal definitions: instruction (`prompt`), output schema, model, and current version |
| evaluations | Evaluation runs: name, group, and metadata |
| datasets | Datasets: name and creation time |
| dashboards | Dashboards: name and creation time |
| dashboard\_charts | Dashboard charts: title, SQL query, and settings (chart type, layout) |

Only `SELECT` queries are allowed.

## Getting Started

Open the SQL Editor from the sidebar. Write a query:

```sql theme={null}
SELECT name, input, output, start_time
FROM spans
WHERE start_time > now() - INTERVAL 3 DAY
```

Results appear in a table or raw JSON view. Export results to a dataset or labeling queue for further use.

You can also query from outside the UI:

* **API**: `POST /v1/sql/query` with your project API key. See [SQL API](#sql-api).
* **CLI**: `lmnr-cli sql query "<query>" --json`. See [CLI](https://tracevell.com/docs/platform/cli.md).
* **MCP**: connect an MCP client (Claude Code, Cursor, Codex) and call `query_laminar_sql`. See [MCP Server](https://tracevell.com/docs/platform/mcp.md).

## Writing Queries

Tracevell uses ClickHouse, a columnar analytics database. The basics work like standard SQL (`SELECT`, `FROM`, `WHERE`, `GROUP BY`, `ORDER BY`, `LIMIT`), with a few differences.

### Always filter by time

Spans are ordered by `start_time`. Adding a time filter dramatically speeds up queries and prevents memory issues:

```sql theme={null}
-- Slow: scans everything
SELECT * FROM spans WHERE trace_id = 'abc-123'

-- Fast: scans only relevant time range
SELECT * FROM spans 
WHERE trace_id = 'abc-123'
  AND start_time > now() - INTERVAL 1 DAY
```

### Avoid joins

ClickHouse isn't optimized for joins. Instead, run two queries and combine results in your application:

```sql theme={null}
-- First: find the spans you care about
SELECT trace_id, name, input, output
FROM spans
WHERE span_type = 'LLM' AND start_time > now() - INTERVAL 1 DAY

-- Second: get trace-level data for those trace_ids
SELECT id, duration
FROM traces
WHERE id IN ('id1', 'id2', 'id3')
```

Joins to [signals](#signals), [evaluations](#evaluations), [datasets](#datasets), and [dashboards](#dashboards) are the exception. Those tables hold one row per definition, so joining them on an id is cheap:

```sql theme={null}
-- Events produced under an older version of their Signal
SELECT s.name, s.version AS current_version, se.signal_version, count() AS events
FROM signal_events se
JOIN signals s ON s.id = se.signal_id
WHERE se.timestamp > now() - INTERVAL 7 DAY
  AND se.signal_version < s.version
GROUP BY s.name, current_version, se.signal_version
ORDER BY events DESC
```

If all you need is the name, skip the join: `signal_events.signal_name`, `evaluation_trials.evaluation_name`, and `dataset_datapoints.dataset_name` already carry it.

### Read signals and clusters off traces

`traces` carries the signal events that fired on the trace and the clusters those events landed in, so any question of the form *which traces did X* needs no join:

```sql theme={null}
-- Volume and cost per cluster, straight off traces
SELECT
    c.name AS cluster,
    count() AS traces,
    round(sum(total_cost), 4) AS cost
FROM traces
ARRAY JOIN clusters AS c
WHERE start_time > now() - INTERVAL 7 DAY
  AND c.level = 1
GROUP BY cluster
ORDER BY cost DESC
```

Query [signal\_events](#signal_events) directly when the row you want back is an event. See [Signal events on traces](#signal-events-on-traces) and [Clusters on traces](#clusters-on-traces) for the trace-side columns.

### Working with dates

Truncate timestamps for grouping with `toStartOfInterval`:

```sql theme={null}
-- Spans per day over the last month
SELECT
    toStartOfInterval(start_time, INTERVAL 1 DAY) AS day,
    count(*) AS span_count
FROM spans
WHERE start_time > now() - INTERVAL 1 MONTH
GROUP BY day
ORDER BY day
```

Works with any interval: `INTERVAL 15 MINUTE`, `INTERVAL 1 HOUR`, etc.

Shortcuts exist for common intervals: `toStartOfDay(value)`, `toStartOfHour(value)`, `toStartOfWeek(value)`.

### Working with JSON

Many columns (like `attributes`) store JSON as strings. Use `simpleJSONExtract*` functions for fast extraction:

```sql theme={null}
-- Extract token counts from LLM spans
SELECT
    name,
    simpleJSONExtractInt(attributes, 'gen_ai.usage.input_tokens') AS input_tokens,
    simpleJSONExtractInt(attributes, 'gen_ai.usage.output_tokens') AS output_tokens
FROM spans
WHERE span_type = 'LLM' AND start_time > now() - INTERVAL 1 DAY
```

Check if a key exists with `simpleJSONHas`:

```sql theme={null}
SELECT count(*)
FROM spans
WHERE simpleJSONHas(attributes, 'gen_ai.request.structured_output_schema')
```

For complex operations (array indexing, nested paths), use `JSONExtract*` functions: more flexible but slower.

### Working with tuples

Some columns store data as arrays of tuples. The most common examples are:

* `spans.events`: `Array(Tuple(timestamp Int64, name String, attributes String))`
* `evaluation_trials.trace_spans`: `Array(Tuple(name String, duration Float64, type String))`
* `traces.signal_events`: `Array(Tuple(event_id UUID, signal_id UUID, severity UInt8, payload String))`
* `traces.clusters`: `Array(Tuple(id UUID, signal_id UUID, name String, level UInt8, parent_id UUID, num_signal_events UInt32, created_at DateTime64(9, 'UTC'), updated_at DateTime64(9, 'UTC')))`

Use `tupleElement(tuple, 'fieldName')` to extract fields from tuples:

```sql theme={null}
-- Extract event names from spans
SELECT 
    span_id,
    arrayMap(e -> tupleElement(e, 'name'), events) as event_names
FROM spans
WHERE length(events) > 0
  AND start_time > now() - INTERVAL 1 DAY
LIMIT 10
```

Use `arrayExists` to filter arrays by tuple field values:

```sql theme={null}
-- Find spans with cache_hit events
SELECT span_id, name, events
FROM spans
WHERE arrayExists(e -> tupleElement(e, 'name') = 'cache_hit', events)
  AND start_time > now() - INTERVAL 1 DAY
```

Use `ARRAY JOIN` to unnest tuple arrays into separate rows:

```sql theme={null}
-- Get one row per span in each evaluation datapoint
SELECT 
    edp.id as datapoint_id,
    tupleElement(span, 'name') as span_name,
    tupleElement(span, 'duration') as span_duration,
    tupleElement(span, 'type') as span_type
FROM evaluation_trials edp
ARRAY JOIN edp.trace_spans as span
WHERE edp.evaluation_id = 'your-evaluation-id'
LIMIT 100
```

Combine `arrayMap` and `tupleElement` for transformations:

```sql theme={null}
-- Convert event timestamps from nanoseconds to DateTime
SELECT 
    span_id,
    arrayMap(e -> toDateTime64(tupleElement(e, 'timestamp') / 1e9, 9, 'UTC'), events) as event_times
FROM spans
WHERE length(events) > 0
  AND start_time > now() - INTERVAL 1 DAY
LIMIT 10
```

When the tuple fields are named, the shorter `alias.field` form works too, inside a lambda and after an `ARRAY JOIN`:

```sql theme={null}
-- Equivalent to tupleElement(c, 'name')
SELECT id, arrayMap(c -> c.name, clusters) AS cluster_names
FROM traces
WHERE start_time > now() - INTERVAL 1 DAY
  AND notEmpty(clusters)
LIMIT 10
```

`signal_events.cluster_details` is the one exception: its fields are unnamed, so read them positionally (`c.1`, `c.2`, `c.3`) in the order the type declares.

### Data types

| Type | Used for |
| - | - |
| `UUID` | Most ID columns (`trace_id`, `span_id`, `id`) |
| `DateTime64(9, 'UTC')` | Timestamps (always UTC) |
| `DateTime64(6)` | `created_at` on `signals`, `evaluations`, `datasets`, `dashboards`, and `dashboard_charts` |
| `String` | Text, JSON stored as strings, and enum-like columns (`span_type`, `trace_type`, `status`) |
| `Nullable(String)` | Optional text on `signals`, `evaluations`, and `datasets` (for example, `signals.llm_model`) |
| `LowCardinality(String)` | Low-cardinality enums |
| `Float64` | Floating point numbers |
| `Decimal(18, 9)` | Span-level durations in seconds (`spans.duration`, `evaluation_trials.duration`) |
| `Int64` | Counts, token numbers |
| `UInt8` | Small numeric enums (for example, `logs.severity_number`) |
| `UInt32` | Flags and bitmasks (for example, `logs.flags`) |
| `UInt64` | Indexes and counters (`evaluation_trials.index`) |
| `Bool` | Flags like `has_browser_session` |
| `Array(String)` | Tag lists and other string arrays |
| `Array(Tuple(...))` | Complex nested arrays (for example, `events`) |

Cast with `CAST(value AS Type)` or `toDateTime64('2025-01-01 00:00:00', 9, 'UTC')`.

## Table Schemas

These are the logical tables exposed in the SQL Editor. The schemas below reflect the columns available for queries.

### spans

| Column | Type | Example value |
| - | - | - |
| `span_id` | `UUID` | `"00000000-0000-0000-1234-426614174000"` |
| `name` | `String` | `"openai.chat"` |
| `span_type` | `String` | `"LLM"` |
| `start_time` | `DateTime64(9, 'UTC')` | `"2021-01-01 00:00:00"` |
| `end_time` | `DateTime64(9, 'UTC')` | `"2021-01-01 00:00:00"` |
| `duration` | `Decimal(18, 9)` | `1.23` |
| `input_cost` | `Float64` | `0.5667` |
| `output_cost` | `Float64` | `0.123` |
| `total_cost` | `Float64` | `0.6897` |
| `input_tokens` | `Int64` | `150` |
| `output_tokens` | `Int64` | `100` |
| `total_tokens` | `Int64` | `250` |
| `cache_read_input_tokens` | `UInt64` | `33042` |
| `cache_creation_input_tokens` | `UInt64` | `1200` |
| `reasoning_tokens` | `UInt64` | `512` |
| `request_model` | `String` | `"gpt-5-mini"` |
| `response_model` | `String` | `"gpt-5-mini-2025-08-07"` |
| `model` | `String` | `"gpt-5-mini-2025-08-07"` |
| `trace_id` | `UUID` | `"12345678-90ab-4def-8234-426614174000"` |
| `provider` | `String` | `"openai"` |
| `path` | `String` | `"workflow.process.step1.openai.chat"` |
| `input` | `String` | `"[{\"role\": \"user\", \"content\": \"Hello\"}]"` |
| `output` | `String` | `"[{\"role\": \"assistant\", \"content\": \"Hi\"}]"` |
| `status` | `String` | `"success"` |
| `parent_span_id` | `UUID` | `"00000000-0000-0000-a456-abcd5667ef09"` |
| `attributes` | `String` | `"{\"gen_ai.system\": \"openai\", \"gen_ai.model\": \"gpt-4o\"}"` |
| `tags` | `Array(String)` | `["needs-review", "tool-call"]` |
| `events` | `Array(Tuple(timestamp Int64, name String, attributes String))` | `[Tuple(1735689600000000000, 'cache_hit', '{}'), ...]` |
| `tool_definitions` | `String` | `"[{\"name\": \"search\", \"description\": \"Search the web\"}]"` |

#### Path

Tracevell span path is stored as an array of span names in span attributes. However, in SQL queries, it is stored as a string with items joined by a dot.

For example, if the span path is `["outer", "inner"]`, the `path` column will be `"outer.inner"`.

If needed, you can still access the array value by reading `attributes` with `simpleJSONExtractRaw(attributes, 'lmnr.span.path')`.

#### Parent span ID

If the current span is the top span of the trace, the `parent_span_id` will be a 0 UUID, i.e. `"00000000-0000-0000-0000-000000000000"`.

#### Span type

Here are the values of the `span_type` column and their meanings:

```
DEFAULT
LLM
EXECUTOR
EVALUATOR
EVALUATION
TOOL
HUMAN_EVALUATOR
CACHED
UNKNOWN
```

#### Status

Status is normalized to `"success"` or `"error"`.

#### Input and output

The `input` and `output` columns are stored as either raw strings or stringified JSON. The best way to parse them is to try to parse them as JSON, and if it fails, use the raw string. You can also use `isValidJSON` [function](https://clickhouse.com/docs/sql-reference/functions/json-functions#isvalidjson) right in the query to test for this.

`input` and `output` columns are also indexed on content, so you can use them in WHERE conditions. Use `ILIKE` instead of `LIKE`, because the index is case-insensitive.

#### Attributes

The `attributes` column is stored as a string in JSON format. That is, you can safely `JSON.parse` / `json.loads` them. In addition, you can use JSON\* and simpleJSON\* functions on them right in the queries. Attributes are guaranteed to be a valid JSON object.

#### Model

The `model` column is set to the response model if present, otherwise it is set to the request model.

#### Tool definitions

`tool_definitions` holds the tool/function definitions made available to an LLM span, stored as stringified JSON. It is empty for spans without tools. Parse it as JSON, same rules as [attributes](#attributes).

#### Total tokens and total cost

Usually, `total_tokens = input_tokens + output_tokens` and `total_cost = input_cost + output_cost`.

However, you can manually report these values using the relevant attributes. In this case, totals may not be equal to the sum of the input and output tokens and costs.

#### Cache and reasoning tokens

`cache_read_input_tokens` and `cache_creation_input_tokens` count prompt-cache reads and writes on an LLM span, and `reasoning_tokens` counts reasoning tokens. They are `0` on non-LLM spans and when the provider reports nothing. The same columns on [traces](#traces) sum them across the trace.

#### Tags

The `tags` column contains an array of string tags attached to spans. You can filter by tags using array functions:

```sql theme={null}
-- Find spans with a specific tag
SELECT name, tags
FROM spans
WHERE has(tags, 'needs-review')
  AND start_time > now() - INTERVAL 1 DAY
```

#### Events

The `events` column contains an array of event tuples. Each event has:

* `timestamp` (Int64): Unix nanoseconds
* `name` (String): Event name
* `attributes` (String): JSON attributes

To work with events, you can extract timestamps, filter by event name, or unnest events using `arrayJoin`:

```sql theme={null}
-- Convert event timestamps to DateTime
SELECT 
    span_id,
    arrayMap(t -> toDateTime64(t / 1e9, 9, 'UTC'), tupleElement(events, 'timestamp')) as event_timestamps
FROM spans
WHERE length(events) > 0
  AND start_time > now() - INTERVAL 1 DAY
```

```sql theme={null}
-- Find spans with specific event names
SELECT span_id, name, events
FROM spans
WHERE arrayExists(e -> tupleElement(e, 'name') = 'cache_hit', events)
  AND start_time > now() - INTERVAL 1 DAY
```

```sql theme={null}
-- Unnest events to get one row per event
SELECT 
    span_id,
    name as span_name,
    toDateTime64(tupleElement(event, 'timestamp') / 1e9, 9, 'UTC') as event_time,
    tupleElement(event, 'name') as event_name,
    tupleElement(event, 'attributes') as event_attributes
FROM spans
ARRAY JOIN events as event
WHERE start_time > now() - INTERVAL 1 DAY
LIMIT 100
```

### traces

| Column | Type | Example value |
| - | - | - |
| `id` | `UUID` | `"01234567-1234-4def-8234-426614174000"` |
| `start_time` | `DateTime64(9, 'UTC')` | `"2021-01-01 00:00:00"` |
| `end_time` | `DateTime64(9, 'UTC')` | `"2021-01-01 00:00:00"` |
| `input_tokens` | `Int64` | `150` |
| `output_tokens` | `Int64` | `100` |
| `total_tokens` | `Int64` | `250` |
| `cache_read_input_tokens` | `UInt64` | `33042` |
| `cache_creation_input_tokens` | `UInt64` | `1200` |
| `reasoning_tokens` | `UInt64` | `512` |
| `input_cost` | `Float64` | `0.5667` |
| `output_cost` | `Float64` | `0.123` |
| `total_cost` | `Float64` | `0.6897` |
| `duration` | `Float64` | `1.23` |
| `metadata` | `String` | `"{\"key\": \"value\"}"` |
| `session_id` | `String` | `"session_123"` |
| `user_id` | `String` | `"user_123"` |
| `status` | `String` | `"success"` |
| `top_span_id` | `UUID` | `"00000000-0000-0000-1234-426614174000"` |
| `top_span_name` | `String` | `"run"` |
| `top_span_type` | `String` | `"DEFAULT"` |
| `trace_type` | `String` | `"DEFAULT"` |
| `tags` | `Array(String)` | `["needs-review", "production"]` |
| `trace_tags` | `Array(String)` | `["reviewed", "flagged"]` |
| `span_names` | `Array(String)` | `["run", "openai.chat", "tool.search"]` |
| `agent_input` | `String` | `"Where is my order A-1002?"` |
| `has_browser_session` | `Bool` | `true` |
| `signal_events` | `Array(Tuple(event_id UUID, signal_id UUID, severity UInt8, payload String))` | `[Tuple('01234567-89ab-4def-8234-426614174000', '11111111-2222-4333-9444-555555555555', 2, '{"error_type": "timeout"}')]` |
| `clusters` | `Array(Tuple(id UUID, signal_id UUID, name String, level UInt8, parent_id UUID, num_signal_events UInt32, created_at DateTime64(9, 'UTC'), updated_at DateTime64(9, 'UTC')))` | `[Tuple('aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', '11111111-2222-4333-9444-555555555555', 'Checkout timeouts', 1, 'ffffffff-0000-4111-8222-333333333333', 42, '2021-01-01 00:00:00', '2021-01-02 00:00:00')]` |
| `agent_versions` | `Array(Tuple(agent_hash String, version_hash String, model String))` | `[Tuple('3f9c2a', '8b1e07', 'gpt-5.6-terra')]` |
| `release` | `String` | `"4f2a9c1"` |

`id` is the trace ID; join to spans with `spans.trace_id = traces.id`.

#### Tags and trace\_tags

`tags` is the union of all tags attached to any span inside the trace (inherited from span-level tagging). `trace_tags` is a separate set of tags applied directly to the trace itself (for example, from the trace-view UI or via `updateTraceTags`). The two columns are independent: a trace can have `trace_tags` without any span-level `tags`, and vice versa.

#### Span names

`span_names` is the de-duplicated list of span names produced anywhere in the trace. Useful for filtering traces that contain (or don't contain) a specific operation: `has(span_names, 'tool.search')`.

#### Agent input

`agent_input` is the extracted agent task / user input for the trace. Same parsing rules apply as [span input/output](#input-and-output): try to parse as JSON, fall back to raw string. See [Task extraction](https://tracevell.com/docs/tracing/task-extraction.md) for how it is extracted and example queries, such as error rate per task.

There is no matching output column on `traces`. The trace's final agent output lives in [trace\_outputs](#trace_outputs).

#### Cache and reasoning tokens

`cache_read_input_tokens` and `cache_creation_input_tokens` count prompt-cache reads and writes. `reasoning_tokens` counts reasoning tokens. All three are `UInt64` and are `0` when the provider reports nothing.

#### Trace type

Here are the values of the `trace_type` column and their meanings:

```
DEFAULT
EVALUATION
PLAYGROUND
```

#### Duration

The duration is in seconds, and is calculated as `end_time - start_time`.

#### Status

Status is set to `error` if any span in the trace has status `error`, otherwise it is `success`.

#### Metadata

Metadata is stored as a string in JSON format. That is, you can safely `JSON.parse` / `json.loads` it. In addition, you can use JSON\* and simpleJSON\* functions on it right in the queries. Metadata is guaranteed to be a valid JSON object.

#### Signal events on traces

`signal_events` holds every [signal](https://tracevell.com/docs/signals/introduction.md) event that fired on the trace, so you can get from a trace to its signals without a join. Each tuple has:

* `event_id` (UUID): the matching row in [signal\_events](#signal_events)
* `signal_id` (UUID): which Signal fired
* `severity` (UInt8): `0` (INFO), `1` (WARNING), `2` (CRITICAL)
* `payload` (String): the event payload as JSON, same content as `signal_events.payload`

The array is empty for traces no Signal fired on, and older traces are filled in progressively, so a trace can read empty here while its events are present in the [signal\_events](#signal_events) table.

The tuple carries no timestamp or Signal name. An event happens at the end of the trace it came from, so use the trace's `end_time`, and get the name by joining [signals](#signals) on `signals.id = e.signal_id`.

`ARRAY JOIN` gives one row per event:

```sql theme={null}
-- Warning-and-above signal events on recent traces
SELECT
    id AS trace_id,
    end_time,
    e.signal_id,
    e.severity,
    substring(e.payload, 1, 2000) AS payload
FROM traces
ARRAY JOIN signal_events AS e
WHERE start_time > now() - INTERVAL 1 DAY
  AND e.severity >= 1
ORDER BY end_time DESC
LIMIT 100
```

Payloads are the largest thing on the row, and reading any field of the tuple reads the payload with it. Keep the `start_time` filter, and truncate with `substring(e.payload, 1, 2000)` when selecting many rows. Counting is cheap by comparison: `empty()` and `notEmpty()` only read the array length.

#### Clusters on traces

`clusters` holds the named [clusters](https://tracevell.com/docs/signals/clusters.md) this trace's signal events belong to, with names and counts already resolved. Fields match the [clusters](#clusters) table: `id`, `signal_id`, `name`, `level`, `parent_id`, `num_signal_events`, `created_at`, `updated_at`. The array is empty until the trace's events have been clustered.

Ancestors are separate elements rather than nested: a trace whose event sits in the level-1 cluster *Checkout timeouts* under the level-2 cluster *Payment failures* gets both, and you walk between them with `parent_id`. Filter on `level` to pick a granularity; `level = 1` is the finest named grouping.

```sql theme={null}
-- Traces in a named cluster, no unnesting needed
SELECT id, start_time, total_cost
FROM traces
WHERE start_time > now() - INTERVAL 1 DAY
  AND arrayExists(c -> c.name = 'Checkout timeouts', clusters)
ORDER BY start_time DESC
LIMIT 100
```

`num_signal_events` is the size of the whole cluster, not this trace's contribution to it.

#### Agent versions and release

`agent_versions` holds the [agent versions](https://tracevell.com/docs/tracing/agent-versions.md) that ran in the trace, one element per agent, version, and model. `ARRAY JOIN agent_versions AS v` to group by `v.model` or `v.version_hash`. `release` is the release the SDK stamped on the trace's spans, usually the short git SHA, and is empty when none was sent.

### trace\_outputs

The extracted final agent output per trace: the output-message array of the last LLM call on the trace's main-agent path. One row per trace.

| Column | Type | Example value |
| - | - | - |
| `trace_id` | `UUID` | `"01234567-1234-4def-8234-426614174000"` |
| `start_time` | `DateTime64(9, 'UTC')` | `"2021-01-01 00:00:00"` |
| `agent_output` | `Array(String)` | `["{\"role\": \"assistant\", \"content\": \"Hi\"}"]` |

Join to traces with `trace_outputs.trace_id = traces.id`.

#### Agent output

`agent_output` holds one stringified JSON message per array element. Parse each element separately; the array itself is not JSON.

```sql theme={null}
-- Final agent output for recent traces
SELECT t.id, o.agent_output
FROM traces t
JOIN trace_outputs o ON o.trace_id = t.id
WHERE t.start_time > now() - INTERVAL 1 DAY
LIMIT 10
```

### signal\_events

| Column | Type | Example value |
| - | - | - |
| `id` | `UUID` | `"01234567-89ab-4def-8234-426614174000"` |
| `signal_id` | `UUID` | `"11111111-2222-4333-9444-555555555555"` |
| `signal_name` | `String` | `"Checkout failures"` |
| `trace_id` | `UUID` | `"01234567-1234-4def-a234-426614174000"` |
| `run_id` | `UUID` | `"22222222-3333-4444-b555-666666666666"` |
| `name` | `String` | `"Checkout failures"` |
| `payload` | `String` | `"{\"error_type\": \"timeout\", \"count\": 3}"` |
| `timestamp` | `DateTime64(9, 'UTC')` | `"2021-01-01 00:00:00"` |
| `severity` | `UInt8` | `2` |
| `signal_version` | `UInt32` | `3` |
| `clusters` | `Array(UUID)` | `["aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"]` |
| `leaf_clusters` | `Array(UUID)` | `["aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"]` |
| `cluster_details` | `Array(Tuple(UUID, String, UInt8))` | `[Tuple('aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', 'Checkout timeouts', 1)]` |

> [!NOTE]
>
> If the row you want back is a trace rather than an event, read [signal events](#signal-events-on-traces) and [clusters](#clusters-on-traces) off the `traces` table instead. They answer the same questions with no join.

#### Signal name

`signal_name` is the Signal's current name, so renaming a Signal renames its past events too (within about a minute). `name` returns the same value and is kept so older queries keep working; use `signal_name` in new ones.

Filtering on `signal_name` reads events from every Signal in the project. When you know the Signal, filter on `signal_id` instead, which only reads that Signal's events.

#### Payload

The `payload` column is stored as a string in JSON format. That is, you can safely `JSON.parse` / `json.loads` it. In addition, you can use JSON\* and simpleJSON\* functions on it right in the queries. Payload is guaranteed to be a valid JSON object.

Payloads can be large. When selecting many rows, truncate with `substring(payload, 1, 2000)` rather than pulling the whole column.

#### Severity

`severity` is a numeric level matching the alert thresholds: `0` (INFO), `1` (WARNING), `2` (CRITICAL).

#### Signal version

`signal_version` tracks which revision of the Signal produced the event. It increments whenever the Signal's definition changes: its instruction, output schema, trigger, filters, or any other setting. Use it to tell events extracted under an older definition from current ones. It is `0` for events that predate versioning.

#### Cluster columns

Three columns describe cluster membership (see [Clusters](https://tracevell.com/docs/signals/clusters.md)). All three exclude L0 clusters and are empty for events that have not been clustered yet.

| Column | Contains |
| - | - |
| `clusters` | Every named cluster the event belongs to: its finest cluster plus that cluster's ancestors |
| `leaf_clusters` | Only the finest named clusters (level 1), without the ancestors |
| `cluster_details` | The same clusters as `clusters`, with `name` and `level` resolved, so you don't have to join |

`cluster_details` is an unnamed tuple of `(id, name, level)`, so read it positionally:

```sql theme={null}
SELECT
    id,
    severity,
    arrayStringConcat(arrayMap(c -> c.2, cluster_details), ', ') AS cluster_names
FROM signal_events
WHERE timestamp > now() - INTERVAL 1 DAY
  AND notEmpty(cluster_details)
LIMIT 100
```

Unnest `leaf_clusters` to count each event exactly once. Unnesting `clusters` counts an event once per level of the hierarchy it sits in, which is what you want for a roll-up and not for a total.

### signal\_events\_all

Same columns as `signal_events`, but `clusters` and `cluster_details` include L0-cluster membership as well. Use this when you need to resolve or list events that belong to an L0 cluster; use `signal_events` for everything else.

L0 clusters are unnamed, so they show up in `cluster_details` with an empty `name` and `level` of `0`. `leaf_clusters` is unaffected: it lists level-1 clusters on both tables.

### signal\_runs

| Column | Type | Example value |
| - | - | - |
| `signal_id` | `UUID` | `"11111111-2222-4333-8444-555555555555"` |
| `job_id` | `UUID` | `"33333333-4444-4555-9666-777777777777"` |
| `trigger_id` | `UUID` | `"44444444-5555-4666-a777-888888888888"` |
| `run_id` | `UUID` | `"22222222-3333-4444-b555-666666666666"` |
| `trace_id` | `UUID` | `"01234567-1234-4def-8234-426614174000"` |
| `status` | `String` | `"COMPLETED"` |
| `mode` | `String` | `"REALTIME"` |
| `event_id` | `UUID` | `"55555555-6666-4777-8888-999999999999"` |
| `error_message` | `String` | `"Timed out waiting for model response"` |
| `updated_at` | `DateTime64(9, 'UTC')` | `"2021-01-01 00:00:00"` |
| `input_tokens` | `UInt32` | `4200` |
| `cache_read_tokens` | `UInt32` | `3100` |
| `output_tokens` | `UInt32` | `180` |

#### Status

`status` is one of `"PENDING"` (waiting for the agent), `"PROCESSING"` (the agent is running), `"COMPLETED"`, `"FAILED"`, or `"UNKNOWN"`.

#### Mode

`mode` is one of `"BATCH"` (historical backfill job) or `"REALTIME"` (live trigger against a new trace).

#### Tokens

`input_tokens`, `cache_read_tokens`, and `output_tokens` count what the signal agent spent evaluating the signal. `cache_read_tokens` is a subset of `input_tokens`, not an addition to it.

### clusters

Clusters group similar signal events into a hierarchy (see [Clusters](https://tracevell.com/docs/signals/clusters.md)). Each row is one cluster. L0 (lowest-level) clusters are excluded.

Use this table for questions about the clusters themselves: their sizes, their names, how the hierarchy is shaped. For *which traces are in this cluster*, read the [clusters column on traces](#clusters-on-traces) instead.

| Column | Type | Example value |
| - | - | - |
| `id` | `UUID` | `"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"` |
| `signal_id` | `UUID` | `"11111111-2222-4333-9444-555555555555"` |
| `name` | `String` | `"Checkout timeouts"` |
| `level` | `UInt8` | `1` |
| `parent_id` | `UUID` | `"ffffffff-0000-4111-8222-333333333333"` |
| `num_signal_events` | `UInt32` | `42` |
| `num_children_clusters` | `UInt16` | `3` |
| `created_at` | `DateTime64(9, 'UTC')` | `"2021-01-01 00:00:00"` |
| `updated_at` | `DateTime64(9, 'UTC')` | `"2021-01-01 00:00:00"` |

#### Level

`level` is the cluster's position in the hierarchy. Leaf-level (L0) clusters are excluded from this table; `level` is always greater than `0`.

#### Event counts

`num_signal_events` is the cluster's own size, including its descendants. It counts cluster assignments rather than distinct events: an event that describes more than one behavior is clustered once per behavior. Unnest `traces.clusters` or `signal_events.clusters` when you need a distinct-event count.

### event\_clusters\_all

Maps individual signal events to the clusters they belong to, including L0 clusters. One row per (event, cluster) pair.

| Column | Type | Example value |
| - | - | - |
| `event_id` | `UUID` | `"01234567-89ab-4def-8234-426614174000"` |
| `cluster_id` | `UUID` | `"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"` |
| `signal_id` | `UUID` | `"11111111-2222-4333-9444-555555555555"` |
| `level` | `UInt8` | `0` |
| `cluster_name` | `String` | `"Checkout timeouts"` |
| `parent_id` | `UUID` | `"ffffffff-0000-4111-8222-333333333333"` |
| `num_signal_events` | `UInt32` | `42` |
| `num_children_clusters` | `UInt16` | `3` |
| `created_at` | `DateTime64(9, 'UTC')` | `"2021-01-01 00:00:00"` |
| `updated_at` | `DateTime64(9, 'UTC')` | `"2021-01-01 00:00:00"` |

### logs

| Column | Type | Example value |
| - | - | - |
| `log_id` | `UUID` | `"01234567-89ab-4def-8234-426614174000"` |
| `time` | `DateTime64(9, 'UTC')` | `"2021-01-01 00:00:00"` |
| `observed_time` | `DateTime64(9, 'UTC')` | `"2021-01-01 00:00:01"` |
| `severity_number` | `UInt8` | `9` |
| `severity_text` | `String` | `"INFO"` |
| `body` | `String` | `"Processing iteration 1 of 5"` |
| `attributes` | `String` | `"{\"stream\": \"stdout\", \"sandbox_id\": \"sbx_123\"}"` |
| `trace_id` | `UUID` | `"01234567-1234-4def-9234-426614174000"` |
| `span_id` | `UUID` | `"00000000-0000-0000-1234-426614174000"` |
| `flags` | `UInt32` | `0` |
| `event_name` | `String` | `"process.stdout"` |

#### Attributes

The `attributes` column is stored as a string in JSON format. That is, you can safely `JSON.parse` / `json.loads` it. In addition, you can use JSON\* and simpleJSON\* functions on it right in the queries. Attributes are guaranteed to be a valid JSON object.

### evaluation\_trials

One row per [trial](https://tracevell.com/docs/evaluations/concepts.md#tasks-and-trials): a single execution of one evaluation datapoint. `evaluation_datapoints` is an alias for this table, so existing queries keep working.

| Column | Type | Example value |
| - | - | - |
| `id` | `UUID` | `"01234567-89ab-4def-8234-426614174000"` |
| `evaluation_id` | `UUID` | `"98765432-1098-4654-9210-987654321098"` |
| `evaluation_name` | `String` | `"prompt-v2"` |
| `data` | `String` | `"{\"key\": \"value\"}"` |
| `target` | `String` | `"{\"key\": \"value\"}"` |
| `metadata` | `String` | `"{\"key\": \"value\"}"` |
| `executor_output` | `String` | `"{\"key\": \"value\"}"` |
| `index` | `UInt64` | `0` |
| `task_id` | `String` | `"france"` |
| `trace_id` | `UUID` | `"01234567-1234-4def-a234-426614174000"` |
| `group_id` | `String` | `"group_a"` |
| `scores` | `String` | `"{\"score1\": 0.85}"` |
| `created_at` | `DateTime64(9, 'UTC')` | `"2021-01-01 00:00:00"` |
| `dataset_id` | `UUID` | `"00000000-0000-0000-0000-000000000000"` |
| `dataset_datapoint_id` | `UUID` | `"00000000-0000-0000-0000-000000000000"` |
| `dataset_datapoint_created_at` | `DateTime64(9, 'UTC')` | `"1970-01-01 00:00:00"` |
| `duration` | `Decimal(18, 9)` | `1.23` |
| `input_cost` | `Float64` | `0.5667` |
| `output_cost` | `Float64` | `0.123` |
| `total_cost` | `Float64` | `0.6897` |
| `start_time` | `DateTime64(9, 'UTC')` | `"2021-01-01 00:00:00"` |
| `end_time` | `DateTime64(9, 'UTC')` | `"2021-01-01 00:00:00"` |
| `input_tokens` | `Int64` | `150` |
| `output_tokens` | `Int64` | `100` |
| `total_tokens` | `Int64` | `250` |
| `trace_status` | `LowCardinality(String)` | `"success"` |
| `trace_metadata` | `String` | `"{\"key\": \"value\"}"` |
| `trace_tags` | `Array(String)` | `["production", "experiment-a"]` |
| `trace_spans` | `Array(Tuple(name String, duration Float64, type String))` | `[Tuple('openai.chat', 1.23, 'LLM'), ...]` |
| `top_span_id` | `UUID` | `"00000000-0000-0000-1234-426614174000"` |
| `updated_at` | `DateTime64(9, 'UTC')` | `"2021-01-01 00:00:00"` |

`data`, `target`, `metadata`, `executor_output`, `scores`, and `trace_metadata` are JSON stored as strings. `scores` is a JSON object of string keys to numeric values.

`task_id` identifies the Task (test case). Rows sharing a `task_id` within an evaluation are repeated trials of one Task, and evaluation comparison matches rows across runs on `task_id`. Rows written before task ids existed read their `index` as the task id. `created_at` is when a trial was first saved and `updated_at` when it was last written (the SDK saves each trial once before it runs and again with its output).

`evaluation_name` is the evaluation's current name, so a renamed evaluation shows its new name here within about a minute.

When the datapoint is not sourced from a dataset, `dataset_id` and `dataset_datapoint_id` are a nil UUID (all zeroes) and `dataset_datapoint_created_at` is the Unix epoch.

The trace-related columns (`duration`, `input_cost`, `output_cost`, `total_cost`, `start_time`, `end_time`, `input_tokens`, `output_tokens`, `total_tokens`, `trace_status`, `trace_metadata`, `trace_tags`, `trace_spans`, `top_span_id`) are joined from the associated trace. If no trace exists for the datapoint, these columns will be nil/empty/zero.

### dataset\_datapoints

| Column | Type | Example value |
| - | - | - |
| `id` | `UUID` | `"019c6634-0cb4-7f9d-8192-f36604488483"` |
| `created_at` | `DateTime64(9, 'UTC')` | `"2021-01-01 00:00:00"` |
| `dataset_id` | `UUID` | `"11111111-2222-4333-a444-555555555555"` |
| `dataset_name` | `String` | `"support-golden-set"` |
| `data` | `String` | `"{\"query\": \"What is 2+2?\"}"` |
| `target` | `String` | `"{\"answer\": \"4\"}"` |
| `metadata` | `String` | `"{\"source\": \"prod\"}"` |
| `task_id` | `String` | `"019c6634-0cb4-7f9d-8192-f36604488483"` |

`data`, `target`, and `metadata` are JSON stored as strings. `task_id` is the id evaluations group trials by; it defaults to the datapoint's `id` and is unique within the dataset. `dataset_name` is the dataset's current name.

### dataset\_datapoint\_versions

Same schema as `dataset_datapoints`, but includes all versions and history for each datapoint.

### labeling\_queue\_items

One row per item in a labeling queue.

| Column | Type | Example value |
| - | - | - |
| `id` | `UUID` | `"01234567-89ab-4def-8234-426614174000"` |
| `queue_id` | `UUID` | `"11111111-2222-4333-a444-555555555555"` |
| `payload` | `String` | `"{\"data\": {...}, \"target\": {...}, \"metadata\": {...}}"` |
| `metadata` | `String` | `"{\"source\": \"prod\"}"` |
| `status` | `UInt8` | `0` |
| `edit` | `String` | `"{\"answer\": \"4\"}"` |
| `created_at` | `DateTime64(3, 'UTC')` | `"2021-01-01 00:00:00"` |
| `updated_at` | `DateTime64(3, 'UTC')` | `"2021-01-01 00:00:00"` |

`payload` is the original seeded `{data, target, metadata}` as stringified JSON. `edit` is the current canonical target as stringified JSON.

`status` is `0` (unlabeled) or `1` (approved).

Note the timestamps here are `DateTime64(3, 'UTC')` (millisecond precision), not the `DateTime64(9, 'UTC')` used by the other tables.

### signals

One row per [Signal](https://tracevell.com/docs/signals/introduction.md) in the project, with its current definition.

| Column | Type | Example value |
| - | - | - |
| `id` | `UUID` | `"11111111-2222-4333-9444-555555555555"` |
| `name` | `String` | `"Checkout failures"` |
| `prompt` | `String` | `"Flag traces where the agent failed to complete checkout..."` |
| `structured_output_schema` | `String` | `"{\"type\": \"object\", \"properties\": {...}}"` |
| `metadata` | `String` | `"{\"disabled\": true}"` |
| `llm_model` | `Nullable(String)` | `NULL` |
| `created_at` | `DateTime64(6)` | `"2021-01-01 00:00:00.000000"` |
| `version` | `Int32` | `3` |

Join to `signal_events`, `signal_runs`, or `traces.signal_events` on `signals.id = signal_id`.

`prompt` holds the Signal's instruction. `structured_output_schema` and `metadata` are JSON stored as strings. A [paused](https://tracevell.com/docs/signals/quickstart.md#pause-and-resume-a-signal) Signal has `"disabled": true` in `metadata`.

`version` is the Signal's current definition version. Compare it with [`signal_events.signal_version`](#signal-version) to find events produced under an older definition.

`llm_model` is the model a self-hosted Signal runs on, picked from its [LLM profile](https://tracevell.com/docs/platform/llm-profiles.md). It is `NULL` on Tracevell Cloud, where Signals run on Tracevell's own model, and for self-hosted Signals created before LLM profiles, which still run on the `LLM_PROVIDER` configuration.

### evaluations

One row per [evaluation](https://tracevell.com/docs/evaluations/introduction.md) run.

| Column | Type | Example value |
| - | - | - |
| `id` | `UUID` | `"98765432-1098-4654-9210-987654321098"` |
| `name` | `String` | `"prompt-v2"` |
| `group_id` | `String` | `"group_a"` |
| `metadata` | `Nullable(String)` | `"{\"model\": \"gpt-5.6-terra\"}"` |
| `created_at` | `DateTime64(6)` | `"2021-01-01 00:00:00.000000"` |

Join to `evaluation_trials` on `evaluations.id = evaluation_id`. `metadata` is JSON stored as a string.

### datasets

One row per [dataset](https://tracevell.com/docs/datasets/introduction.md).

| Column | Type | Example value |
| - | - | - |
| `id` | `UUID` | `"11111111-2222-4333-a444-555555555555"` |
| `name` | `String` | `"support-golden-set"` |
| `indexed_on` | `Nullable(String)` | `NULL` |
| `created_at` | `DateTime64(6)` | `"2021-01-01 00:00:00.000000"` |

Join to `dataset_datapoints` on `datasets.id = dataset_id`. `indexed_on` is a legacy setting and is usually `NULL`.

### dashboards

One row per [dashboard](https://tracevell.com/docs/platform/dashboards.md).

| Column | Type | Example value |
| - | - | - |
| `id` | `UUID` | `"22222222-3333-4444-a555-666666666666"` |
| `name` | `String` | `"Agent health"` |
| `created_at` | `DateTime64(6)` | `"2021-01-01 00:00:00.000000"` |

Join to `dashboard_charts` on `dashboards.id = dashboard_id`.

### dashboard\_charts

One row per chart on a [dashboard](https://tracevell.com/docs/platform/dashboards.md).

| Column | Type | Example value |
| - | - | - |
| `id` | `UUID` | `"33333333-4444-4555-a666-777777777777"` |
| `dashboard_id` | `UUID` | `"22222222-3333-4444-a555-666666666666"` |
| `name` | `String` | `"Traces by status"` |
| `query` | `String` | `"SELECT toStartOfInterval(start_time, toInterval(1, {interval_unit:String})) AS time, count() AS traces FROM traces WHERE start_time >= {start_time:DateTime64} AND start_time <= {end_time:DateTime64} GROUP BY time ORDER BY time"` |
| `settings` | `String` | `"{\"config\": {\"type\": \"line\", ...}, \"layout\": {\"x\": 0, \"y\": 0, \"w\": 6, \"h\": 6}}"` |
| `created_at` | `DateTime64(6)` | `"2021-01-01 00:00:00.000000"` |

`query` is the SQL the chart runs, with the `{start_time:DateTime64}`, `{end_time:DateTime64}`, and `{interval_unit:String}` parameters the dashboard fills in from its time range. `settings` is JSON stored as a string. A chart's position is `settings.layout` (`x`, `y`, `w`, `h`) on a 12-column grid, so read it with `JSONExtract`:

```sql theme={null}
-- Charts on each dashboard, top to bottom
SELECT d.name AS dashboard, c.name AS chart,
       JSONExtractInt(c.settings, 'layout', 'y') AS grid_row,
       JSONExtractInt(c.settings, 'layout', 'x') AS grid_col
FROM dashboard_charts c
JOIN dashboards d ON d.id = c.dashboard_id
ORDER BY dashboard, grid_row, grid_col
```

## Example Queries

**Cost breakdown by model:**

```sql theme={null}
SELECT
    model,
    sum(total_cost) AS total_cost,
    count(*) AS call_count
FROM spans
WHERE span_type = 'LLM' AND start_time > now() - INTERVAL 7 DAY
GROUP BY model
ORDER BY total_cost DESC
```

**Slowest operations:**

```sql theme={null}
SELECT name, avg(end_time - start_time) AS avg_duration_ms
FROM spans
WHERE start_time > now() - INTERVAL 1 DAY
GROUP BY name
ORDER BY avg_duration_ms DESC
LIMIT 10
```

**Error rate by span type:**

```sql theme={null}
SELECT
    name,
    countIf(status = 'error') AS errors,
    count(*) AS total,
    round(errors / total * 100, 2) AS error_rate
FROM spans
WHERE start_time > now() - INTERVAL 1 DAY
GROUP BY name
HAVING total > 10
ORDER BY error_rate DESC
```

**Find spans with specific tags:**

```sql theme={null}
SELECT 
    span_id, 
    name, 
    tags, 
    start_time
FROM spans
WHERE has(tags, 'needs-review')
  AND start_time > now() - INTERVAL 7 DAY
ORDER BY start_time DESC
LIMIT 100
```

**Count events by type:**

```sql theme={null}
SELECT 
    tupleElement(event, 'name') as event_name,
    count(*) as event_count
FROM spans
ARRAY JOIN events as event
WHERE start_time > now() - INTERVAL 1 DAY
GROUP BY event_name
ORDER BY event_count DESC
```

**Find cache hit rate from events:**

```sql theme={null}
SELECT 
    countIf(arrayExists(e -> tupleElement(e, 'name') = 'cache_hit', events)) as cache_hits,
    count(*) as total_spans,
    round(cache_hits / total_spans * 100, 2) as cache_hit_rate
FROM spans
WHERE span_type = 'LLM'
  AND start_time > now() - INTERVAL 1 DAY
```

**Severity mix per Signal:**

```sql theme={null}
SELECT
    e.signal_id AS signal_id,
    countIf(e.severity = 2) AS critical,
    countIf(e.severity = 1) AS warning,
    countIf(e.severity = 0) AS info
FROM traces
ARRAY JOIN signal_events AS e
WHERE start_time > now() - INTERVAL 7 DAY
GROUP BY signal_id
ORDER BY critical DESC
```

**Traces that fired a Signal but haven't been clustered yet:**

```sql theme={null}
SELECT count() AS traces
FROM traces
WHERE start_time > now() - INTERVAL 1 DAY
  AND notEmpty(signal_events)
  AND empty(clusters)
```

**Events per Signal by name:**

```sql theme={null}
SELECT
    signal_name,
    count() AS events,
    countIf(severity = 2) AS critical
FROM signal_events
WHERE timestamp > now() - INTERVAL 7 DAY
GROUP BY signal_name
ORDER BY events DESC
```

**Average score per evaluation in a group:**

```sql theme={null}
SELECT
    evaluation_id,
    evaluation_name,
    min(created_at) AS started,
    avg(simpleJSONExtractFloat(scores, 'accuracy')) AS avg_accuracy,
    count() AS datapoints
FROM evaluation_trials
WHERE group_id = {group_id:String}
GROUP BY evaluation_id, evaluation_name
ORDER BY started
```

**Average score per task across its trials:**

```sql theme={null}
SELECT
    task_id,
    count() AS trials,
    avg(simpleJSONExtractFloat(scores, 'accuracy')) AS avg_accuracy
FROM evaluation_trials
WHERE evaluation_id = {evaluation_id:UUID}
GROUP BY task_id
ORDER BY avg_accuracy
```

**Average cost per evaluation datapoint:**

```sql theme={null}
SELECT 
    evaluation_id,
    avg(total_cost) as avg_cost,
    avg(duration) as avg_duration_seconds,
    count(*) as datapoint_count
FROM evaluation_trials
WHERE evaluation_id = {evaluation_id:UUID}
GROUP BY evaluation_id
```

**Find evaluation datapoints with specific span types:**

```sql theme={null}
SELECT 
    id,
    group_id,
    scores,
    trace_spans
FROM evaluation_trials
WHERE arrayExists(s -> tupleElement(s, 'type') = 'LLM', trace_spans)
  AND evaluation_id = {evaluation_id:UUID}
LIMIT 100
```

**Count span types across evaluation datapoints:**

```sql theme={null}
SELECT 
    tupleElement(span, 'type') as span_type,
    count(*) as span_count,
    avg(tupleElement(span, 'duration')) as avg_duration
FROM evaluation_trials
ARRAY JOIN trace_spans as span
WHERE evaluation_id = {evaluation_id:UUID}
GROUP BY span_type
ORDER BY span_count DESC
```

## Exporting Results

Select results and click "Export to Dataset." Map columns to dataset fields (`data`, `target`, `metadata`). Use this to build evaluation datasets from query results.

## SQL API

Run the same queries over HTTP with `POST /v1/sql/query`. Use it to pull Tracevell data into notebooks, scheduled jobs, or your own dashboards.

```
POST https://api.lmnr.ai/v1/sql/query
```

Authenticate with a project API key as a Bearer token (`Authorization: Bearer <PROJECT_API_KEY>`). The key identifies the project, so queries never reference a project id.

> [!NOTE]
>
> Self-hosting: replace the base URL with your app-server HTTP origin, including the port. For example, `http://localhost:8000/v1/sql/query`.

### Request

| Field | Type | Required | Description |
| - | - | - | - |
| `query` | string | Yes | A single `SELECT` statement, same syntax as the editor |
| `parameters` | object | No | Values for the `{name:Type}` placeholders in `query`. Defaults to `{}` |

```bash theme={null}
curl -X POST https://api.lmnr.ai/v1/sql/query \
  -H "Authorization: Bearer $LMNR_PROJECT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "SELECT name, span_type, start_time FROM spans WHERE start_time > now() - INTERVAL 1 DAY ORDER BY start_time DESC LIMIT 3"
  }'
```

Rows come back in a `data` array, one object per row, keyed by the columns (or aliases) you selected:

```json theme={null}
{
  "data": [
    {
      "name": "openai.chat",
      "span_type": "LLM",
      "start_time": "2026-09-02 21:14:07.925746000"
    }
  ]
}
```

64-bit integers are returned as JSON numbers, not strings.

### Passing parameters

Don't build query strings by hand. Write `{name:Type}` placeholders in the SQL and send the values in `parameters`, where `Type` is the ClickHouse type of the value:

```bash theme={null}
curl -X POST https://api.lmnr.ai/v1/sql/query \
  -H "Authorization: Bearer $LMNR_PROJECT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "SELECT name, start_time FROM spans WHERE span_type = {span_type:String} AND start_time > {since:DateTime} ORDER BY start_time DESC LIMIT 100",
    "parameters": { "span_type": "LLM", "since": "2026-09-01 00:00:00" }
  }'
```

Types match the column types in [Table Schemas](#table-schemas): `String`, `UUID`, `Int64`, `Float64`, `DateTime`, `Array(String)`, and so on.

### From the SDKs

Both SDKs wrap the endpoint and return the `data` array directly.

> [tab] TypeScript
>
> ```typescript theme={null}
> import { LaminarClient } from '@lmnr-ai/lmnr';
>
> const client = new LaminarClient({
>   projectApiKey: process.env.LMNR_PROJECT_API_KEY,
> });
>
> const rows = await client.sql.query(
>   `SELECT count(*) AS spans
>    FROM spans
>    WHERE span_type = {span_type:String}
>      AND start_time > now() - INTERVAL 1 DAY`,
>   { span_type: 'LLM' },
> );
>
> console.log(rows[0].spans);
> ```

> [tab] Python
>
> ```python theme={null}
> import os
> from lmnr import LaminarClient
>
> client = LaminarClient(project_api_key=os.environ["LMNR_PROJECT_API_KEY"])
>
> rows = client.sql.query(
>     """
>     SELECT count(*) AS spans
>     FROM spans
>     WHERE span_type = {span_type:String}
>       AND start_time > now() - INTERVAL 1 DAY
>     """,
>     {"span_type": "LLM"},
> )
>
> print(rows[0]["spans"])
> ```

### Limits

* Only `SELECT` queries against the tables listed in [What You Can Query](#what-you-can-query) are accepted. Anything else is rejected before it reaches the database.
* Queries cannot reference `project_id`. The API key already scopes results to one project.
* `SETTINGS` clauses in your query are ignored. Every query runs under server-side execution-time, memory, and result-size limits, so narrow the time range and add a `LIMIT` for large scans.
* Queries are rate limited per project.

### Errors

Errors return a JSON body with a single `error` field:

```json theme={null}
{ "error": "Query validation failed: Only SELECT statements are allowed" }
```

| Status | Meaning |
| - | - |
| 400 | The query failed validation or the database rejected it (bad syntax, unknown column, missing parameter) |
| 401 | Missing or invalid project API key |
| 429 | Project rate limit exceeded. Retry with backoff |

## Full Reference

For complete ClickHouse SQL syntax, see the [ClickHouse documentation](https://clickhouse.com/docs/en/sql-reference).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.