Skip to main content
A filter in Braintrust matches individual spans, then the query shape decides what comes back. Understanding that two-step behavior explains most filters that return more rows than expected, or none at all. This page covers what a filter matches and why. For the full clause and operator syntax, see Query structure and Functions and operators. For query speed, see SQL best practices.

What a filter matches

Every filter is evaluated against one span at a time. The data shape then determines which rows the query returns. In the UI, the Row type selector on the Logs page chooses the shape for you. Traces selects the traces shape and Spans selects the spans shape. The same filter returns different rows on different shapes. For example, this query returns only errored spans:
Adding shape => 'traces' returns every span from each trace that contains an error, so most returned spans have a null error:
If you expected only the errored spans, use the default spans shape, or keep the trace selection and narrow the returned spans with FILTER_SPANS().
On the traces shape, LIMIT caps the number of traces, not the number of spans returned.
On the summary shape, a WHERE condition is still evaluated one span at a time, so it selects traces where at least one span matches rather than comparing against the trace’s aggregate. To filter on a value aggregated across the whole trace, use HAVING, for example HAVING avg(scores.Factuality) > 0.8. See Data shapes.

Filters match one span at a time

Braintrust indexes fields per span. A condition only sees the fields on the span it is evaluating, and fields are not inherited from a trace’s root span down to its child spans. This matters because applications typically log different fields on different spans:
  • Request context such as metadata.user_id or metadata.tenant_id is usually logged on the root span.
  • Token and cost metrics are recorded on the child LLM spans.
  • Scores are written on scorer spans.
So a filter that combines a root-span field with a child-span field matches no single span and returns nothing, even though both values exist somewhere in the trace:
You have three ways to resolve this:
  • Log the field on every span you intend to filter on.
  • Match the conditions across different spans with separate ANY_SPAN() calls.
  • Pair fields at the trace level by aggregating with GROUP BY root_span_id. See Aggregate span data across a trace.

Match conditions across spans

On the traces and summary shapes, a plain WHERE clause matches a trace only when one span satisfies every condition. That is what you want when the conditions describe a single span. For example, a failed LLM call is one span that is both an LLM span and errored:
ANY_SPAN() covers the other case, where the conditions describe different spans in the same trace. Wrap the conditions for each span in their own call. Each call is matched independently, so each can be satisfied by a different span. For example, tags are typically set on the root span while scores are written on a scorer span, so no single span carries both:
Scores can also be written directly to a tagged root span. When both conditions must hold on the same span, drop the wrappers and let the plain WHERE clause match one span:
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).
For the exact evaluation rules, including nesting and the restrictions on NOT ANY_SPAN(), see Single span filters.

Return only the matching spans

On the traces shape, a WHERE condition selects traces and returns all of their spans. To keep that trace selection but return only the spans meeting a condition, wrap the condition in FILTER_SPANS().
FILTER_SPANS() works on the traces and summary shapes. On the spans shape it does nothing, because that shape already returns only matching spans. See Matching spans filters.

Choose the right operator

Several operators look interchangeable but match differently. These are the distinctions that most often produce empty or unexpected results. INCLUDES and CONTAINS are not valid in SQL queries. Use IN for exact array membership. See Syntax styles if you maintain older queries that use them.

Where you filter

Each product surface applies these rules with its own default scope.

Common pitfalls

These are common causes of unexpected results. The same symptom can also have other causes.

Next steps