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

# How filtering works

> Understand what a filter matches across query shapes, span-level matching, and ANY_SPAN, and avoid common pitfalls that return unexpected rows.

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](/docs/reference/sql/query-structure) and [Functions and operators](/docs/reference/sql/functions). For query speed, see [SQL best practices](/docs/reference/sql/best-practices).

## What a filter matches

Every filter is evaluated against one span at a time. The [data shape](/docs/reference/sql/query-structure#data-shapes) then determines which rows the query returns.

| Shape             | Each row represents                                      | What a `WHERE` condition does                                                            |
| ----------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `spans` (default) | One span                                                 | Returns only the spans that match.                                                       |
| `traces`          | One span, from every trace containing at least one match | Selects whole traces, then returns all of their spans, including spans that don't match. |
| `summary`         | One trace, with metrics pre-aggregated across its spans  | Selects traces where at least one span matches.                                          |

In the UI, the **Row type** selector on the <Icon icon="activity" /> **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:

```sql theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
SELECT id, error, span_attributes.type
FROM project_logs('<PROJECT_ID>')
WHERE error IS NOT NULL
  AND created > now() - interval 7 day
```

Adding `shape => 'traces'` returns every span from each trace that contains an error, so most returned spans have a null `error`:

```sql theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
SELECT id, error, span_attributes.type
FROM project_logs('<PROJECT_ID>', shape => 'traces')
WHERE error IS NOT NULL
  AND created > now() - interval 7 day
```

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()`](#return-only-the-matching-spans).

<Note>
  On the `traces` shape, `LIMIT` caps the number of traces, not the number of spans returned.
</Note>

<Note>
  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](/docs/reference/sql/query-structure#data-shapes).
</Note>

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

```sql theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
-- Returns nothing: no single span has both metadata.tenant_id and a token count
SELECT id
FROM project_logs('<PROJECT_ID>')
WHERE metadata.tenant_id = 'acme'
  AND metrics.total_tokens > 1000
  AND created > now() - interval 7 day
```

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()`](#match-conditions-across-spans) calls.
* Pair fields at the trace level by aggregating with `GROUP BY root_span_id`. See [Aggregate span data across a trace](/docs/reference/sql/best-practices#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:

```sql theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
-- Traces containing an LLM span that errored
WHERE span_attributes.type = 'llm' AND error IS NOT NULL
```

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

```sql theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
-- Traces tagged "triage" that also have a low Factuality score somewhere
WHERE ANY_SPAN(tags IN ('triage'))
  AND ANY_SPAN(scores.Factuality < 0.5)
```

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:

```sql theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
-- Traces with one span that is both tagged "triage" and scored below 0.5
WHERE tags IN ('triage') AND scores.Factuality < 0.5
```

<Note>
  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)`.
</Note>

For the exact evaluation rules, including nesting and the restrictions on `NOT ANY_SPAN()`, see [Single span filters](/docs/reference/sql/query-structure#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()`.

```sql theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
-- Select traces containing a "PodCollection" span, but return only the score spans
SELECT id, span_attributes.name
FROM project_logs('<PROJECT_ID>', shape => 'traces')
WHERE span_attributes.name = 'PodCollection'
  AND FILTER_SPANS(span_attributes.type = 'score')
  AND created > now() - interval 1 day
```

`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](/docs/reference/sql/query-structure#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.

| To match                     | Use                                             | Notes                                                                                                                                       |
| ---------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| A tag                        | `tags IN ('triage')`                            | Exact array membership. Don't use `=` on `tags`, which doesn't match the array.                                                             |
| Everything except a tag      | `tags NOT IN ('internal')`                      |                                                                                                                                             |
| A whole word in one field    | `output MATCH 'timeout'`                        | Indexed and matches whole terms only, so `MATCH 'time'` doesn't match `timeout`.                                                            |
| Text in any field            | `search('timeout')`                             | Searches `input`, `output`, `expected`, `metadata`, and `span_attributes`.                                                                  |
| A substring                  | `input ILIKE '%time%'`                          | Case-insensitive substring matching, so `'%time%'` also matches `timeout`, unlike `MATCH 'time'`. `ILIKE` works only on strings under 65KB. |
| A value inside a JSON string | `json_extract(metadata.config, 'auth.user_id')` | Dot notation returns null when the field's value is a JSON-encoded string.                                                                  |

`INCLUDES` and `CONTAINS` are not valid in SQL queries. Use `IN` for exact array membership. See [Syntax styles](/docs/reference/sql#syntax-styles) if you maintain older queries that use them.

## Where you filter

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

| Surface                                                                | Default scope                                                                                                                                                                                       | Notes                                                                                                                                                                                                                                                                     |
| ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <Icon icon="activity" /> **Logs**, **Basic** tab                       | Wraps conditions in `ANY_SPAN()` when the row type is **Traces**, the default                                                                                                                       | Conditions can match different spans, so a combination can match a trace you didn't expect.                                                                                                                                                                               |
| <Icon icon="activity" /> **Logs**, **SQL** tab                         | Follows the row type, which defaults to **Traces**                                                                                                                                                  | Accepts a `WHERE` expression only, so you can't set a shape here. Change the scope with the row type selector. The **Basic** tab adds `ANY_SPAN()` automatically, but here you write it yourself.                                                                         |
| Span name button in the trace panel header                             | The current row type, or **Spans** if the span isn't a root span                                                                                                                                    | Changing the row type changes the scope of every applied filter.                                                                                                                                                                                                          |
| <Icon icon="chart-no-axes-column" /> **Dashboards** chart filters      | **Span filter** limits which spans contribute to the measure. **Trace filter**, in the more options menu (<Icon icon="ellipsis-vertical" />), matches traces where any span satisfies the condition | A span filter on root-span metadata matches no child spans, so measures recorded on child spans, such as cost, come back empty. Filter root-span metadata with a trace filter instead. See [Chart editor options](/docs/observe/dashboards/build-charts#chart-editor-options). |
| Online scoring rules                                                   | Trace-scoped rules match if any span satisfies. Span-scoped rules apply per span                                                                                                                    | Adding a time filter causes traces to be skipped. See [Configuration parameters](/docs/evaluate/score-online#configuration-parameters).                                                                                                                                        |
| [`/btql` API](/docs/api-reference/query) and [`bt sql`](/docs/reference/cli/sql) | The `spans` shape, unless you set `shape => 'traces'`                                                                                                                                               | Include a range filter on `created` to bound the scan.                                                                                                                                                                                                                    |

## Common pitfalls

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

| Pitfall                                                                                                   | What you see                                                                     | Solution                                                                                                                                                 |
| --------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Using the `traces` shape when you want only the matching spans                                            | Results include spans that don't match the filter                                | Use the `spans` shape, or add [`FILTER_SPANS()`](#return-only-the-matching-spans).                                                                       |
| Putting conditions for different spans in one plain `WHERE` clause                                        | The filter returns nothing, even though each value exists somewhere in the trace | On the `traces` shape, wrap the conditions for each span in their own [`ANY_SPAN()`](#match-conditions-across-spans).                                    |
| Filtering on a field that only the root span carries                                                      | Child spans drop out of the results                                              | Log the field on the spans you filter, or use [`ANY_SPAN()`](#match-conditions-across-spans).                                                            |
| Putting a root-span metadata condition in a chart's span filter                                           | The chart shows no data                                                          | Move the metadata condition to a **Trace filter**. See [Chart editor options](/docs/observe/dashboards/build-charts#chart-editor-options).                    |
| Using `=` on the `tags` array                                                                             | A tag filter matches nothing                                                     | Use `tags IN ('<TAG>')`.                                                                                                                                 |
| Showing a child-span field in a custom column on the **Traces** row type, where each row is the root span | The column is empty even though the filter matched                               | Switch the row type to **Spans**, or log the field on the root span. See [Create custom columns](/docs/observe/view-logs#create-custom-columns).              |
| Expecting results older than your [retention window](/docs/plans-and-limits#usage-limits)                      | Rows you expected are missing, with no error                                     | Query within the window using a relative interval.                                                                                                       |
| Adding a `created` range to a filter on a known `id` or `root_span_id`                                    | Nothing is returned when the span was created before the range begins            | Drop the `created` range. An ID predicate already bounds the scan. See [Add a time range filter](/docs/reference/sql/best-practices#add-a-time-range-filter). |
| Querying `project_logs()` without a range filter                                                          | The query times out                                                              | Add a range filter on `created`. See [Add a time range filter](/docs/reference/sql/best-practices#add-a-time-range-filter).                                   |

## Next steps

* Look up clause syntax in [Query structure](/docs/reference/sql/query-structure).
* Find operators and functions in [Functions and operators](/docs/reference/sql/functions).
* Make queries faster with [SQL best practices](/docs/reference/sql/best-practices).
* Apply filters in the UI from [Filter and search logs](/docs/observe/filter).
