Skip to main content
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

Support for session filters in the REST API and Python client is tracked in #15099 and #15112.
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 as annotations.
  • Enum values: string literals compared against span_kind or status_code are uppercased automatically — span_kind == 'llm' and span_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 traceFilterVocabulary and sessionFilterVocabulary GraphQL 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 like 0.5 < latency_ms < 1000 are supported); combine conditions with and / or / not; test membership with in / not in; check for missing values with is 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 and session_annotations["name"] in a session filter. Span filters use annotations["name"] and its legacy alias evals["name"]; they also accept trace_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 in ignore case. Containment against text is case-insensitive: 'refund' in first_input matches REFUND 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 !=. Use is None / is not None to 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 trailing Z; a literal without an offset is rejected as ambiguous.
  • Function calls. float() and str() convert an attribute of unknown type; span filters also accept int(), which behaves like float() and does not truncate. Trace and session comprehensions accept the reducers any / 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.