What You Can Query
Only
SELECT queries are allowed.
Getting Started
Open the SQL Editor from the sidebar. Write a query:- API:
POST /v1/sql/querywith 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
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 bystart_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: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:
Working with dates
Truncate timestamps for grouping withtoStartOfInterval:
INTERVAL 15 MINUTE, INTERVAL 1 HOUR, etc.
Shortcuts exist for common intervals: toStartOfDay(value), toStartOfHour(value), toStartOfWeek(value).
Working with JSON
Many columns (likeattributes) store JSON as strings. Use simpleJSONExtract* functions for fast extraction:
simpleJSONHas:
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')))
tupleElement(tuple, 'fieldName') to extract fields from tuples:
arrayExists to filter arrays by tuple field values:
ARRAY JOIN to unnest tuple arrays into separate rows:
arrayMap and tupleElement for transformations:
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
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, theparent_span_id will be a 0 UUID, i.e. "00000000-0000-0000-0000-000000000000".
Span type
Here are the values of thespan_type column and their meanings:
Status
Status is normalized to"success" or "error".
Input and output
Theinput 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
Theattributes 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
Themodel 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.
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 sum them across the trace.
Tags
Thetags column contains an array of string tags attached to spans. You can filter by tags using array functions:
Events
Theevents column contains an array of event tuples. Each event has:
timestamp(Int64): Unix nanosecondsname(String): Event nameattributes(String): JSON attributes
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. See Task extraction 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.
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 thetrace_type column and their meanings:
Duration
The duration is in seconds, and is calculated asend_time - start_time.
Status
Status is set toerror 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 safelyJSON.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_eventssignal_id(UUID): which Signal firedseverity(UInt8):0(INFO),1(WARNING),2(CRITICAL)payload(String): the event payload as JSON, same content assignal_events.payload
end_time, and get the name by joining signals on signals.id = e.signal_id.
ARRAY JOIN gives one row per event:
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.
Agent versions and release
agent_versions holds the agent versions 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.
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.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
Thepayload 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). 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:
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 assignal_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
Theattributes 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: a single execution of one evaluation datapoint.evaluation_datapoints is an alias for this table, so existing queries keep working.
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
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 asdataset_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.
signals
One row per Signal in the project, with its current definition.
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 Signal has "disabled": true in metadata.
version is the Signal’s current definition version. Compare it with signal_events.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. 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 run.
Join to
evaluation_trials on evaluations.id = evaluation_id. metadata is JSON stored as a string.
datasets
One row per dataset.
Join to
dataset_datapoints on datasets.id = dataset_id. indexed_on is a legacy setting and is usually NULL.
dashboards
One row per dashboard.
Join to
dashboard_charts on dashboards.id = dashboard_id.
dashboard_charts
One row per chart on a dashboard.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:
Example Queries
Cost breakdown by model: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 withPOST /v1/sql/query. Use it to pull Tracevell data into notebooks, scheduled jobs, or your own dashboards.
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
data array, one object per row, keyed by the columns (or aliases) you selected:
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:
String, UUID, Int64, Float64, DateTime, Array(String), and so on.
From the SDKs
Both SDKs wrap the endpoint and return thedata array directly.
- TypeScript
- Python
Limits
- Only
SELECTqueries 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. SETTINGSclauses 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 aLIMITfor large scans.- Queries are rate limited per project.
Errors
Errors return a JSON body with a singleerror field: