Skip to main content
SQL queries in Braintrust provide a precise, standard syntax for querying Braintrust logs, experiments, and datasets. Use SQL to:
  • Filter and search for relevant logs and experiments. Use WHERE clauses to filter individual records and HAVING clauses to filter aggregated results after grouping.
  • Create consistent, reusable queries for monitoring.
  • Build automated reporting and analysis pipelines.
  • Write complex queries to analyze model performance.
For tips on query performance and common pitfalls, see SQL best practices. Braintrust supports two syntax styles: standard SQL syntax, and the legacy BTQL syntax with pipe-delimited clauses. The parser automatically detects which style you’re using. SQL syntax is recommended for all new queries.
Self-hosted deployments: SQL syntax support requires data plane version v1.1.29 or later.

Run SQL queries

Run SQL from the SQL sandbox, the bt sql CLI, or the API.

SQL sandbox

To test SQL with autocomplete, validation, and a table of results, use the SQL sandbox in your project. In the sandbox, you can use Loop to generate and optimize queries from natural language: Example queries:
  • “Find the most common errors in logs over the last week”
  • “What are the highest scoring rows in my experiment”
  • “Show me error distribution over time”
  • “List all traces where latency exceeded 60 seconds”
Loop automatically populates the sandbox with the generated query, runs it, and provides a text summary of the results along with suggestions for additional queries. Once you have a query in the sandbox, ask Loop to refine it:
  • “Update the query to show error distribution over time”
  • “Add a filter to only show errors from specific models”
  • “Group by user instead”
When your query has errors, Loop can help fix them. Select Fix with Loop next to the error message in the sandbox. Loop analyzes the issue type and context to provide targeted fixes for:
  • Syntax errors
  • Schema validation issues
  • Field name corrections
If a project_logs() query is missing a range filter on created, _xact_id, _pagination_key, or a specific root_span_id/id, the sandbox proactively warns you so you don’t have to wait for a timeout to discover the issue.

bt CLI

Run SQL from your terminal with bt sql. It opens an interactive editor, accepts an inline query, or reads from stdin for scripting.

API

Access SQL programmatically with the Braintrust API:
The API accepts these parameters:
  • query (required): your SQL query string.
  • fmt: response format (json or parquet, defaults to json).
  • lint_mode: root-level lint handling mode (default or strict, defaults to default). In strict mode, lint warnings fail the query.
  • tz_offset: timezone offset in minutes for time-based operations.
  • audit_log: include audit log data.
  • version: an _xact_id string to query data as it existed at a specific point in time (useful for recovering deleted rows). Supported for experiment and dataset sources; not supported for project_logs.
For correct day boundaries, set tz_offset to match your timezone. For example, use 480 for US Pacific Standard Time.

Query structure

SQL queries follow a familiar structure that lets you define what data you want, how you want it returned, and how to analyze it. This example returns logs from the last 7 days from a project where Factuality is greater than 0.8, sorts by created date descending, and limits the results to 100.
Always include a range filter (created, _xact_id, or _pagination_key) or scope to a specific root_span_id/id in every project_logs() query. Without one, queries scan your entire project history and will be slow or time out on large datasets.

BTQL syntax

Braintrust also supports BTQL, an alternative pipe-delimited clause syntax. The parser automatically detects whether your query is SQL or BTQL:
  • SQL queries start with SELECT, WITH, etc. followed by whitespace
  • BTQL queries use clause syntax like select:, filter:, etc.
SQL ClauseBTQL Clause
SELECT ...select: ...
FROM table('id', shape => 'traces')from: table('id') traces
WHERE ...filter: ...
GROUP BY ...dimensions: ...
GROUP BY ROLLUP(...)rollup: ...
GROUP BY GROUPING SETS (...)grouping_sets: (...)
HAVING ...final_filter: ...
TABLESAMPLE n PERCENT [SEED (seed)]sample: n%
TABLESAMPLE n ROWS [SEED (seed)]sample: n
ORDER BY ...sort: ...
LIMIT nlimit: n
OFFSET '<CURSOR_TOKEN>'cursor: '<CURSOR_TOKEN>'
SETTINGS key = value, ...settings: key = value, ...
SQL syntax specifies the shape with a named parameter (e.g., FROM experiment('id', shape => 'traces')), while BTQL uses a trailing token (e.g., from: experiment('id') traces). Table aliases on top-level table functions (e.g., FROM project_logs('id') AS t) are reserved for future use. Aliases on subquery sources (e.g., FROM (...) AS sub) are required.
Full-text search: Use the MATCH infix operator for full-text search:
  • WHERE input MATCH 'search term'filter: input MATCH 'search term'
  • Multiple columns require OR: WHERE input MATCH 'x' OR output MATCH 'x'filter: input MATCH 'x' OR output MATCH 'x'
Unsupported SQL features: The SQL parser does not support the following. For queries that require them, use BTQL’s native syntax.
  • JOIN, UNION/INTERSECT/EXCEPT, and window functions.
  • Subqueries in WHERE, HAVING, SELECT, or PIVOT IN (they are supported only in FROM).
  • PIVOT with explicit value lists, subqueries, or ORDER BY (only IN (ANY) is supported).
  • INCLUDES and CONTAINS operators. For exact array membership in SQL mode, use IN/NOT IN (for example, tags IN ('value')), or switch to BTQL’s filter: tags INCLUDES 'value' syntax. MATCH is a fuzzy approximation.

SELECT

SELECT lets you choose specific fields, compute values, or use * to retrieve every field. Use SELECT alone to retrieve individual records, or combine it with GROUP BY to aggregate results. Both work with all data shapes (spans, traces, and summary).
Implicit aliasing: Multi-part identifiers like metadata.model automatically create implicit aliases using their last component (e.g., model), which you can use in WHERE, ORDER BY, and GROUP BY clauses when unambiguous. See Field access for details.
SQL allows you to transform data directly in the SELECT clause. This query returns metadata.model, whether metrics.tokens is greater than 1000, and a quality indicator of either “high” or “low” depending on whether or not the Factuality score is greater than 0.8.
You can also use functions in the SELECT clause to transform values and create meaningful aliases for your results. This query extracts the day the log was created, the hour, and a Factuality score rounded to 2 decimal places.

GROUP BY for aggregations

Instead of a simple SELECT, you can use SELECT ... GROUP BY to group and aggregate data. This query returns a row for each distinct model with the day it was created, the total number of calls, the average Factuality score, and the latency percentile.
The available aggregate functions are:
  • count(expr): number of rows
  • count_distinct(expr): number of distinct values
  • count_if(expr): number of rows where expr is true
  • sum(expr): sum of numeric values
  • avg(expr): mean (average) of numeric values
  • min(expr): minimum value
  • max(expr): maximum value
  • any_value(expr): an arbitrary non-null value from the group for the given expression
  • percentile(expr, p): a percentile where p is between 0 and 1
  • GROUPING(expr): returns 1 if expr is a rolled-up dimension in the current ROLLUP/GROUPING SETS row, 0 if it is a real value
LIMIT works with GROUP BY queries to restrict the number of grouped results returned. When combined with ORDER BY, rows are sorted before limiting. See LIMIT for examples.

ROLLUP and GROUPING SETS

ROLLUP and GROUPING SETS extend GROUP BY to produce multiple levels of aggregation in a single query, eliminating the need for several GROUP BY queries. ROLLUP produces a row for each grouping level: individual groups, subtotals for each prefix of the column list, and a grand total. GROUP BY ROLLUP(model, date) generates groups for (model, date), (model), and () (the grand total). Rolled-up dimensions appear as NULL in the output.
GROUPING SETS lets you specify exactly which grouping combinations to compute. Each set is one aggregation level. The example below is equivalent to the ROLLUP query above, but makes the groupings explicit.
GROUPING(expr) returns 1 when expr is a rolled-up dimension in the current row (meaning this row is a subtotal or grand total that aggregated over that dimension), and 0 when it holds a real group key value. Use it to label or filter subtotal rows.

HAVING for filtering aggregations

HAVING filters the results after aggregation, letting you narrow down grouped data based on aggregate values. Use HAVING with GROUP BY when you need to filter by aggregated metrics like counts, averages, or sums. This query returns models with high average scores:
You can combine WHERE and HAVING to filter both before and after aggregation. This query filters individual logs before grouping, then filters the aggregated results:
HAVING supports the same operators and aggregate functions as other clauses. You can reference aggregated values by their alias or by repeating the aggregate expression.

FROM

The FROM clause identifies where the records are coming from.

Data sources

The FROM clause accepts these table functions. Each accepts one or more IDs. Most also accept one or more object names, which Braintrust resolves to IDs before running the query.
Data sourceQuery by IDQuery by name
experimentexperiment('<id1>', '<id2>')experiment('<name1>', '<name2>')
datasetdataset('<id1>', '<id2>')dataset('<name1>', '<name2>')
promptprompt('<id1>', '<id2>')Not supported
functionfunction('<id1>', '<id2>')Not supported
viewview('<id1>', '<id2>')Not supported
project_logsproject_logs('<id1>', '<id2>')project_logs('<name1>', '<name2>')
project_promptsproject_prompts('<id1>', '<id2>')project_prompts('<name1>', '<name2>')
project_functionsproject_functions('<id1>', '<id2>')project_functions('<name1>', '<name2>')
org_promptsorg_prompts('<id1>', '<id2>')org_prompts('<name1>', '<name2>')
org_functionsorg_functions('<id1>', '<id2>')org_functions('<name1>', '<name2>')
audit_logsaudit_logs('<org_id>')audit_logs('<org_name>')
The project_* and org_* functions return all objects of that type for a project or organization. audit_logs returns all audit logs for an organization. See Audit logging for the available fields and events.

Querying by name

When you pass a name instead of a UUID, Braintrust looks up the matching object and substitutes its ID before running the query. The Query by name column above shows which functions support this.
If the same name matches more than one object across different projects, the query returns an error listing the candidates with their IDs. Use a two-element array to disambiguate by including the parent object name:
To query several objects at once, pass multiple names separated by commas, the same way you pass multiple IDs.
If you pass an ID, Braintrust skips name lookup and uses the ID directly. You can mix names, qualified names, and IDs in a single call.

Subqueries

A subquery uses the result of an inner query as the data source for an outer query:
The alias is required. Subqueries can be nested to multiple levels. Constraints: Outer queries use standard SQL only. Custom Braintrust syntax (span_filter, trace_filter, ANY_SPAN(), FILTER_SPANS()) is only supported in the innermost query, where it runs against raw data.
  • span_filter and trace_filter are not supported on subquery sources.
  • ANY_SPAN() and FILTER_SPANS() are not supported in the outer WHERE clause when the source is a subquery.
  • HAVING (final_filter) is supported.
Example:

Data shapes

You can add an optional parameter to the FROM clause that defines how the data is returned. The options are spans (default), traces, and summary.

spans

spans returns individual spans that match the filter criteria. This example returns 10 LLM call spans that took more than 0.2 seconds to use the first token.
The response is an array of spans. Check out the Extend traces page for more details on span structure.

traces

traces returns all spans from traces that contain at least one matching span. This is useful when you want to see the full context of a specific event or behavior, for example if you want to see all spans in traces where an error occurred. This example returns all spans for a specific trace where one span in the trace had an error.
The response is an array of spans. Check out the Extend traces page for more details on span structure.

summary

summary provides trace-level views of your data by aggregating metrics across all spans in a trace. This shape is useful for analyzing overall performance and comparing results across experiments. The summary shape can be used in two ways:
  • Individual trace summaries (using SELECT): Returns one row per trace with aggregated span metrics. Use this to see trace-level details. Example: “What are the details of traces with errors?”
  • Aggregated trace analytics (using GROUP BY): Groups multiple traces and computes statistics. Use this to analyze patterns across many traces. Example: “What’s the average cost per model per day?”
Use SELECT with the summary shape to retrieve individual traces with aggregated metrics. This is useful for inspecting specific trace details, debugging issues, or exporting trace-level data.This example returns 10 summary rows from the project logs for ‘my-project-id’:
Summary rows include some aggregated metrics and some preview fields that show data from the root span of the trace.The following fields are aggregated metrics across all spans in the trace. Nested fields are accessed using dot notation (for example, metrics.prompt_tokens, not prompt_tokens).
  • scores: object of all scores averaged across all spans. Access individual scores as scores.<score_name> (for example, scores.Factuality).
  • metrics: object of aggregated metrics across all spans. Fields below are accessed as metrics.<field_name>.
    • metrics.prompt_tokens: total number of prompt tokens used.
    • metrics.completion_tokens: total number of completion tokens used.
    • metrics.prompt_cached_tokens: total number of cached prompt tokens used.
    • metrics.prompt_cache_creation_tokens: total number of tokens used to create cache entries.
    • metrics.prompt_cache_creation_5m_tokens: total number of tokens used to create cache entries with a 5-minute time to live.
    • metrics.prompt_cache_creation_1h_tokens: total number of tokens used to create cache entries with a 1-hour time to live.
    • metrics.total_tokens: total number of tokens used (prompt + completion).
    • metrics.estimated_cost: total estimated cost of the trace in US dollars (prompt + completion costs).
    • metrics.llm_calls: total number of LLM calls.
    • metrics.tool_calls: total number of tool calls.
    • metrics.errors: total number of errors (LLM + tool errors).
    • metrics.llm_errors: total number of LLM errors.
    • metrics.tool_errors: total number of tool errors.
    • metrics.start: Unix timestamp of the first span start time.
    • metrics.end: Unix timestamp of the last span end time.
    • metrics.duration: wall-clock elapsed time of the trace in seconds, from the earliest span start to the latest span end.
    • metrics.llm_duration: sum of all durations across LLM spans in seconds.
    • metrics.time_to_first_token: the average time to first token across LLM spans in seconds.
  • span_type_info: object with span type info. Some fields are aggregated across all spans, others reflect attributes from the root span. Fields below are accessed as span_type_info.<field_name>.
    • span_type_info.cached: true only if all LLM spans were cached.
    • span_type_info.has_error: true if any span had an error.
Root span preview fields are top-level (not nested under another object): input, output, expected, error, and metadata.For example, to select nested metric fields alongside top-level preview fields:
In the summary shape, WHERE scores.foo evaluates at the span level, returning traces where at least one span matches the condition. This is usually correct, since scorers typically run once per span. To filter by the averaged score across all spans in a trace, use HAVING avg(scores.foo). For example, HAVING avg(scores.Factuality) > 0.8 returns traces where the average Factuality score exceeds 0.8.

WHERE

The WHERE clause lets you specify conditions to narrow down results. It supports a wide range of operators and functions, including complex conditions. This example WHERE clause only retrieves data where:
  • Factuality score is greater than 0.8
  • model is “gpt-4”
  • tag list contains “triage” (exact array membership with IN)
  • input contains the word “question” (case-insensitive)
  • created date is later than January 1, 2024
  • more than 1000 tokens were used or the data being traced was made in production

Single span filters

By default, each returned trace includes at least one span that matches all filter conditions. Use ANY_SPAN() to wrap any filter expression and find traces where at least one span matches the specified condition. Single span filters work with the traces and summary data shapes.
ANY_SPAN() can be combined with GROUP BY to aggregate traces based on span-level conditions. This is useful for analyzing patterns across traces that contain specific types of spans. See Analyze based on tags and scores, Analyze based on tags, and Analyze traces with span filters for examples.
By default, ANY_SPAN() matches against all spans in a trace. To restrict matching to only root spans, add is_root to the condition: ANY_SPAN(is_root AND error IS NOT NULL).
ANY_SPAN() supports one level of nesting. Nested calls are flattened, which allows query builders and the Braintrust UI to compose filters by wrapping conditions in ANY_SPAN(). For example:
Triple or deeper nesting (ANY_SPAN(ANY_SPAN(ANY_SPAN(...)))) is not supported. NOT ANY_SPAN() does not support multiple span filter clauses — for example, NOT ANY_SPAN(ANY_SPAN(a) AND ANY_SPAN(b)) is not supported.

Matching spans filters

While ANY_SPAN() helps you find traces you care about, matching spans filters let you filter spans within the traces you’ve already found. Use FILTER_SPANS() to return only the matching spans from those traces, rather than entire traces. This is analogous to trace_filter in BTQL. Matching spans filters work with the traces and summary data shapes. On the spans shape, FILTER_SPANS() acts as a no-op wrapper.
You can combine FILTER_SPANS() with other filter conditions:
Use MATCH to search a specific field for exact word matches, or search() to search across all text fields at once.
search() is equivalent to writing input MATCH query OR output MATCH query OR ... for each text field. When log search optimization is enabled, search() also benefits from bloom filter acceleration to skip irrelevant segments. Enabling shingled search optimization extends this acceleration to multi-word and phrase queries.

Pattern matching

SQL supports the % wildcard for pattern matching with LIKE (case-sensitive) and ILIKE (case-insensitive). The % wildcard matches any sequence of zero or more characters.
LIKE and ILIKE do substring matching for string values under 65KB. To search any strings larger than 65KB, use the MATCH operator. MATCH is indexed and more efficient, but it matches whole terms rather than substrings. For example, output MATCH 'time' does not match timeout.

Time intervals

SQL supports intervals for time-based operations. This query returns all project logs from ‘my-project-id’ that were created in the last day.
This query returns all project logs from ‘my-project-id’ that were created up to an hour ago.
This query returns all project logs from ‘my-project-id’ that were created within the last month but not within the last week

ORDER BY

The ORDER BY clause determines the order of results. The options are DESC (descending) and ASC (ascending) on a numerical field. You can sort by a single field, multiple fields, or computed values.

SAMPLE

TABLESAMPLE (or the shorter SAMPLE alias) randomly samples a subset of the filtered data. Use it to work with a representative slice of a large dataset without scanning every row. Two forms are supported:
  • Percent-based: TABLESAMPLE n PERCENT — samples approximately n% of rows (0–100).
  • Row-based: TABLESAMPLE n ROWS or TABLESAMPLE n — samples approximately n rows.
An optional SEED (seed) clause makes sampling deterministic. Without a seed, each query returns a different random sample.
SAMPLE is part of the table reference in the FROM clause, not a post-filter clause. The percentage or row count applies to the full table before WHERE filtering. TABLESAMPLE BERNOULLI, TABLESAMPLE SYSTEM, and other named sampling methods are not supported.

PIVOT and UNPIVOT

PIVOT and UNPIVOT are advanced operations that transform your results for easier analysis and comparison. Both SQL and BTQL syntax support these operations.

PIVOT

PIVOT transforms rows into columns, which makes comparisons easier by creating a column for each distinct value. This is useful when comparing metrics across different categories, models, or time periods. Structure:
Requirements:
  • The pivot column must be a single identifier (e.g., metadata.model)
  • Must include at least one aggregate measure (e.g., SUM(value), AVG(score))
  • Only IN (ANY) is supported (explicit value lists, subqueries, ORDER BY, and DEFAULT ON NULL are not supported)
  • SELECT list must include the pivot column, all measures, and all GROUP BY columns (or use SELECT *)
Pivot columns are automatically named by combining the pivot value and measure name. For example, if you pivot metadata.model with a model named “gpt-4” for measure avg_score, the column becomes gpt-4_avg_score. When using aliases, the alias replaces the measure name in the output column. Single aggregate - pivot one metric across categories:
Multiple aggregates - pivot multiple metrics at once:
With aliases - name your pivoted columns:
With grouping - combine PIVOT with GROUP BY for multi-dimensional analysis:
Using SELECT * - automatically includes all required columns:

UNPIVOT

UNPIVOT transforms columns into rows, which is useful when you need to analyze arbitrary scores and metrics without specifying each field name in advance. This is helpful when working with dynamic sets of metrics or when you want to normalize data for aggregation. Key-value unpivot - transforms an object into rows with key-value pairs:
When using key-value unpivot, the source column must be an object (e.g., scores). When using array unpivot with _, the source column must be an array (e.g., tags).
Array unpivot - expands arrays by using _ as the name column:
Array of objects unpivot - expands arrays of objects and allows accessing nested fields:
This pattern is useful for analyzing classifications where each log may have multiple topic classifications, and you want to aggregate by specific properties of those classifications. Multiple unpivots - chain multiple UNPIVOT operations to expand multiple columns:
With aggregations - use UNPIVOT with GROUP BY to aggregate across unpivoted rows:

LIMIT and cursors

LIMIT

The LIMIT clause controls the size of the result in number of records.
When using LIMIT with GROUP BY, it restricts the number of grouped results returned. This is useful for getting top-N results after aggregation. When combined with ORDER BY, rows are sorted before limiting.

Cursors for pagination

Cursors are supported in both SQL and BTQL queries. Cursors are automatically returned in query responses. After an initial query, pass the returned cursor token in the follow-on query. When a cursor has reached the end of the result set, the data array will be empty, and no cursor token will be returned. In SQL syntax, pass cursor tokens using OFFSET '<CURSOR_TOKEN>'. Numeric offsets are not supported. For cursor pagination, use cursor-compatible sorts such as _pagination_key (recommended) or _xact_id.

SETTINGS

The SETTINGS clause passes per-query hints to the Brainstore execution engine. It appears at the end of the query. Multiple options can be set in a single clause, separated by commas. The following options are supported:
OptionTypeDescription
max_bloom_termsNonneg integerMaximum number of disjunctive terms (for example, OR branches or IN list values) for which Brainstore consults a bloom filter during segment elimination. Above this threshold, the bloom filter is skipped because checking it adds overhead without filtering many segments. Set this option to override the engine default for a specific query.
preview_lengthIntegerTruncates the preview fields (input, output, expected, error, metadata) to this many characters, appending ... when truncated. Use -1 for no truncation and 0 for maximum truncation. For the summary shape, an engine default is applied when this option is not set. In BTQL, preview_length is a top-level clause (preview_length: N), not a settings: option.
Multiple options:

Expressions

SQL operators

You can use the following operators in your SQL queries.
INCLUDES, NOT INCLUDES, and CONTAINS are BTQL-only operators and raise a syntax error in SQL mode. For exact array membership in SQL mode, use IN and NOT IN (e.g., tags IN ('value')). MATCH is a fuzzy approximation that tokenizes text, so tags MATCH 'value' also matches values containing the term as a token, such as value-added.

SQL functions

You can use the following functions in SELECT, WHERE, GROUP BY clauses, and aggregate measures.

Field access

SQL provides flexible ways to access nested data in arrays and objects:
Array indices are 0-based, and negative indices count from the end (-1 is the last element).
When you have JSON data stored as a string field (rather than as native SQL objects), use the json_extract function to access values within it. The path is a JSONPath expression that supports nested object keys, array indexing, and an optional $ root prefix:

Implicit aliasing

When you reference multi-part identifiers (e.g., metadata.category), SQL automatically creates an implicit alias using the last component of the path (e.g., category). This allows you to use the short form in your queries when unambiguous.
Important notes about implicit aliasing:
  • Ambiguity prevention: If multiple fields share the same last component (e.g., metadata.name and user.name), the short form name becomes ambiguous and cannot be used. You must use the full path instead.
  • Top-level field priority: Top-level fields take precedence over nested fields. If you have both id and metadata.id, the short form id refers to the top-level field.
  • Explicit aliases override: When you provide an explicit alias (e.g., metadata.category AS cat), the implicit alias is disabled and you must use either the explicit alias or the full path.
  • Duplicate alias detection: SQL will detect and reject queries with duplicate aliases in the SELECT list, whether explicit or implicit. For example, SELECT id, user.number AS id will raise an error.
Examples of ambiguous references:
Freeing up short forms with explicit aliases: When one field uses an explicit alias, its short form becomes available for other fields:

Conditional expressions

SQL supports conditional logic through CASE WHEN for multi-branch conditions, IF for two-branch conditions, and COUNT_IF for conditional aggregation.

CASE WHEN

Use CASE WHEN ... THEN ... ELSE ... END for one or more branches:

IF and COUNT_IF

For two-branch conditions, IF(condition, then_value, else_value) is a shorter alternative to CASE WHEN. Use CASE WHEN when you need more than two branches.
COUNT_IF(condition) counts rows where the condition is true. It is equivalent to count(CASE WHEN condition THEN 1 ELSE NULL END).
IF composes with other aggregates. For example, count distinct users who hit a condition:

Examples

Track token usage

This query helps you monitor token consumption across your application.
The response shows daily token usage:

Analyze cost breakdown

Use estimated_cost_component() to break total cost into its components — useful for understanding how much of your spend comes from cached reads vs. uncached prompt tokens vs. completions.
For models with two-tier cache creation (e.g. Anthropic), replace promptCacheCreationTokensCost with promptCacheCreation5mTokensCost and promptCacheCreation1hTokensCost to split by tier.
Use estimated_cost_breakdown() when you want all components at once as a JSON object, for example to export or inspect the full breakdown per span.

Monitor model quality

Track model performance across different versions and configurations.

Analyze errors

Identify and investigate errors in your application.

Analyze latency

Monitor and optimize response times.

Analyze prompts

Analyze prompt effectiveness and patterns.

Analyze based on tags

Use tags to track and analyze specific behaviors. In SQL mode, use IN and NOT IN to filter tags by exact membership:
tags IN ('needs-review') matches rows where needs-review is one of the tags, and tags NOT IN ('resolved') matches rows without the resolved tag. NOT IN excludes rows where tags is null or absent.
MATCH (tags MATCH 'feedback') is a fuzzy approximation. It tokenizes text, so it also matches tags that contain the term as a token, such as feedback-given.
INCLUDES is not supported in SQL mode. The examples below use BTQL syntax, which supports INCLUDES for exact array membership:

Analyze based on tags and scores

A common pattern is filtering traces by both tags and scores when automated scorers apply scores at the span level while tags exist on root spans. Use separate ANY_SPAN() clauses to match traces where any span contains the tag AND any span contains the score. Each ANY_SPAN() clause evaluates independently across all spans in a trace. The conditions don’t need to match on the same span - the first ANY_SPAN() finds traces where at least one span has the tag, while the second finds traces where at least one span has the score.
For score names with spaces or special characters, wrap the name in double quotes:

Analyze traces with span filters

Use single span filters with aggregations to analyze traces based on span-level conditions. This is useful for understanding patterns across complex, multi-step operations.
Use FILTER_SPANS() to analyze only the spans that match specific criteria:

Extract data from JSON strings

Use json_extract to pull values out of a field whose value is a JSON-encoded string (rather than a native SQL object). The second argument is a JSONPath expression that supports nested object keys, array indexing (including negative indices), and an optional $ root prefix.
json_extract returns null for invalid JSON or missing keys rather than raising an error, making it safe to use in filters and aggregations. Because the path is a JSONPath expression, dots and brackets are interpreted as traversal: a.b descends into key a then key b, and a[0] indexes into an array. A JSON key whose name literally contains a dot or bracket can’t be addressed this way.

Query by classifications

Classifications are categorical labels generated by topics automations (for example, classifications.Task[0].label). Each classification object has these nested fields, accessed as classifications.<name>[<i>].<field>:
  • classifications.<name>[<i>].label: the category label.
  • classifications.<name>[<i>].metadata.distance: distance metric for the classification.
  • classifications.<name>[<i>].source: source of the classification.
Filter and analyze logs by topic classifications to understand patterns in your data.
To analyze all classifications in an array rather than just the first element, use UNPIVOT to expand the array. See Array of objects unpivot for examples.
Filter by specific topic and distance threshold:

Analyze facet distributions

Facets are AI-extracted attributes that summarize logs (e.g., facets.task, facets.sentiment). They’re generated by topics automations. Query logs by facet values to identify patterns and issues.

Combine facets and classifications

Analyze relationships between facets and classifications to gain deeper insights.
Analyze topic distribution with distance metrics: