Skip to main content
Query all your Laminar data directly with SQL. Find patterns, debug issues, and answer questions the dashboard doesn’t anticipate.

What You Can Query

Only SELECT queries are allowed.

Getting Started

Open the SQL Editor from the sidebar. Write a query:
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.
  • CLI: lmnr-cli sql query "<query>" --json. See CLI.
  • MCP: connect an MCP client (Claude Code, Cursor, Codex) and call query_laminar_sql. See MCP Server.

Writing Queries

Laminar 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:

Avoid joins

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

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:
Query signal_events directly when the row you want back is an event. See Signal events on traces and Clusters on traces for the trace-side columns.

Working with dates

Truncate timestamps for grouping with toStartOfInterval:
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:
Check if a key exists with simpleJSONHas:
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_datapoints.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:
Use arrayExists to filter arrays by tuple field values:
Use ARRAY JOIN to unnest tuple arrays into separate rows:
Combine arrayMap and tupleElement for transformations:
When the tuple fields are named, the shorter alias.field form works too, inside a lambda and after an ARRAY JOIN:
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

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

Path

Laminar 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:

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 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.

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.

Tags

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

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:

traces

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: try to parse as JSON, fall back to raw string. There is no matching output column on traces. The trace’s final agent output lives in 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:

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 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_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 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 resolve names from signal_id. ARRAY JOIN gives one row per event:
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 this trace’s signal events belong to, with names and counts already resolved. Fields match the 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.
num_signal_events is the size of the whole cluster, not this trace’s contribution to it.

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. 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.

signal_events

If the row you want back is a trace rather than an event, read signal events and clusters off the traces table instead. They answer the same questions with no join.

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 prompt, 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). All three exclude L0 clusters and are empty for events that have not been clustered yet. cluster_details is an unnamed tuple of (id, name, level), so read it positionally:
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

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). 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 instead.

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.

logs

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_datapoints

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. 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

data, target, and metadata are JSON stored as strings.

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. 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.

Example Queries

Cost breakdown by model:
Slowest operations:
Error rate by span type:
Find spans with specific tags:
Count events by type:
Find cache hit rate from events:
Severity mix per Signal:
Traces that fired a Signal but haven’t been clustered yet:
Average cost per evaluation datapoint:
Find evaluation datapoints with specific span types:
Count span types across evaluation datapoints:

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 Laminar data into notebooks, scheduled jobs, or your own dashboards.
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.
Self-hosting: replace the base URL with your app-server HTTP origin, including the port. For example, http://localhost:8000/v1/sql/query.

Request

Rows come back in a data array, one object per row, keyed by the columns (or aliases) you selected:
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:
Types match the column types in 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.

Limits

  • Only SELECT queries against the tables listed in 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:

Full Reference

For complete ClickHouse SQL syntax, see the ClickHouse documentation.