A filter is a Python boolean expression that Phoenix compiles to a database query. Type one into a filter bar to narrow a table, or pass one to the API to scope a result set.
Phoenix has three filter languages — one each for spans, traces, and sessions. They share the same core syntax; trace and session filters add aggregates and comprehensions.
Session filters require Phoenix 19.18.0+.
Where filters work#
| Surface | Span filters | Trace filters | Session filters |
|---|---|---|---|
| UI filter bar | Yes — spans table (via GraphQL) | Yes — traces table (via GraphQL) | Yes — sessions table (via GraphQL) |
| GraphQL | filterCondition argument | traceFilterCondition argument | sessionFilterCondition argument |
| Python / TypeScript clients | Yes — SpanQuery().where(...), sent to POST /v1/spans (Python only) | filter argument on traces.get_traces(...) / getTraces(...) | filter argument on sessions.list(...) / listSessions(...) |
| REST list / search endpoints | Discrete query params only — no filter expression | filter query param on GET /v1/projects/{project_identifier}/traces | filter query param on GET /v1/projects/{project_identifier}/sessions |
Span filter expressions on the REST list endpoints are tracked in #15099.
The Experiment Compare view has its own filter language for filtering experiment runs, separate from the span, trace, and session filter languages.
Span filters#
A span filter matches individual spans by built-in fields like span_kind, latency_ms, and
input.value:
Access span attributes by name. Any identifier that isn’t a built-in field is treated as an
attribute path, and metadata[...] is shorthand for the metadata attribute:
Filter on annotations by label, score, or explanation. is None matches spans that an
annotation hasn’t been written to yet:
Use parent_span is None to match root spans, including spans whose recorded parent was never
received. To match only spans with no parent id at all, use parent_id is None:
Span-specific notes:
- Trace-level annotations:
trace_annotations["name"]filters spans by annotations on their parent trace, with the same.score/.label/.explanation/ existence syntax asannotations. - Enum values: string literals compared against
span_kindorstatus_codeare uppercased automatically —span_kind == 'llm'andspan_kind == 'LLM'match the same spans. - Unknown names: a name that isn’t a built-in field is read as an attribute path, so a typo filters on a nonexistent attribute and matches nothing rather than producing an error.
The same expressions run in the spans filter bar and in the Python client’s
SpanQuery().where(...)
export path.
Trace filters#
A trace filter matches whole traces by fields like latency_ms and by values rolled up from their
spans. Rollups with no matching data are 0, never null:
Token rollups include token_count_prompt, token_count_completion, and token_count_total. Cost
rollups include prompt_cost, completion_cost, and total_cost. Use tool_span_count and
llm_span_count to count spans by kind.
Read input, output, attributes, user.id, and metadata[...] from the trace’s root span:
Filter on a trace annotation by name with trace_annotations["name"]. It exposes .score,
.label, and .explanation; a lookup without one of these fields checks whether the annotation
exists:
If you use another annotation lookup, the validation error points to trace_annotations[...] for
trace annotations or the span_annotations collection for span annotations.
Comprehensions quantify and aggregate over the trace’s spans, trace annotations, span annotations, and span cost details:
A span exposes its children and parent_span, so you can filter by parent-child relationships:
Trace-specific notes:
- Strict names: unknown names are rejected with a “did you mean” suggestion, unlike span filters, which fall back to attribute paths.
- Loop variables only: inside a comprehension, reference the loop variable’s fields
(
span.latency_ms), not bare trace-level names. - The available aggregate, collection, and element-field names are project-specific — see Finding field names.
Session filters#
A session groups the traces of one conversation. A session filter can test aggregate properties of the whole session and inspect the traces and spans inside it.
Each trace in a session typically corresponds to one turn of the conversation — a user message
and the application’s response. Reading filters in terms of turns helps: num_traces > 5 means
“more than five turns”, and for trace in traces asks a question of every turn.
Aggregates roll up the session’s traces and spans. An aggregate with no matching data is 0, never
null; dividing by an aggregate that is 0 matches nothing rather than producing an error:
Read session-level annotations and the session’s root-span attributes and I/O. first_input
and last_output are the session’s opening input and final output as strings — use ==, in, or
is None. any_input and any_output test containment across all of the session’s inputs and
outputs, and support only in / not in:
Comprehensions quantify and aggregate over a session’s members using Python comprehension
syntax. any and all ask a yes/no question; len, sum, max, and min reduce to a number:
A traces element exposes its own spans, so you can ask per-turn questions with one level of
nesting:
Session-specific notes:
- Strict names: unknown names are rejected with a “did you mean” suggestion, unlike span filters, which fall back to attribute paths.
- Loop variables only: inside a comprehension, reference the loop variable’s fields
(
span.latency_ms), not bare session-level names. - The available aggregate, collection, and element-field names are project-specific — see Finding field names.
Finding field names#
Field names are project-specific: attribute keys and annotation names come from your data. To discover what’s available:
- In any filter bar, start typing to get a typeahead of the names available in your project, grouped by kind (fields, aggregates, collections, attributes, annotations).
- For traces and sessions, query the
traceFilterVocabularyandsessionFilterVocabularyGraphQL fields to enumerate valid names programmatically. Span filters have no equivalent endpoint; use the filter-bar typeahead.
Syntax rules#
These rules apply to span, trace, and session filters unless a note says otherwise.
- Operators. Compare with
==!=<<=>>=(chained comparisons like0.5 < latency_ms < 1000are supported); combine conditions withand/or/not; test membership within/not in; check for missing values withis None/is not None; and do arithmetic on numeric fields with+-*/%(e.g.num_traces_with_error / num_traces,total_cost - prompt_cost). The whole expression must be a condition, not a bare value. - Annotations. Use
trace_annotations["name"]in a trace filter andsession_annotations["name"]in a session filter. Span filters useannotations["name"]and its legacy aliasevals["name"]; they also accepttrace_annotations["name"]for annotations on the containing trace. Each lookup exposes.score,.label, and.explanation, and a lookup without one of these fields is an existence check. in/not inignore case. Containment against text is case-insensitive:'refund' in first_inputmatchesREFUND please. Equality (==/!=) and membership in a literal list (span_kind in ['LLM']) are exact.- Missing values match nothing. When a value is absent, every comparison against it is false —
including
!=. Useis None/is not Noneto match missing values (see the note below). - Datetime literals need a timezone offset. Write
start_time > '2026-07-01T00:00:00+00:00'or use a trailingZ; a literal without an offset is rejected as ambiguous. - Function calls.
float()andstr()convert an attribute of unknown type; span filters also acceptint(), which behaves likefloat()and does not truncate. Trace and session comprehensions accept the reducersany/all/len/max/min/sum. All other function and method calls (e.g.name.startswith(...),len(span_id)) are rejected, as are**,//, and the bitwise operators&|^. Strings are not implicitly converted to numbers.
For example:
Missing values behave differently than in Python. In Python, None != 'premium' evaluates to
True. In a filter, a span with no user.tier attribute matches neither of these expressions:
To also match rows where the value is missing, spell it out:
attributes['user.tier'] != 'premium' or attributes['user.tier'] is None.

