Trace Filter DSL#
Phoenix's Trace Filter DSL lets callers pass a Python-like filter string (e.g., "status_code == 'ERROR' and latency_ms > 1000") that is compiled into a SQLAlchemy WHERE clause at runtime. The entire implementation lives in src/phoenix/trace/dsl/filter.py.
Entry Point: SpanFilter#
SpanFilter is a frozen dataclass that drives the full compilation pipeline.
Construction (__post_init__):
- Parses the raw condition string with
ast.parse(source, mode="eval"). - Calls
_validate_expressionfor structural and light semantic checks . - Rewrites eval references (e.g.,
evals["Q&A"].score) into unique column aliases via_apply_eval_aliasing. - Re-parses the rewritten source and runs it through
_FilterTranslator, anast.NodeTransformer, to produce a new AST . - Calls
ast.fix_missing_locationsandcompile(β¦, mode="eval")to produce an executable code object .
Application (__call__):
Calls eval(self.compiled, namespace) where the namespace contains the SQLAlchemy column expressions (_NAMES) and SQLAlchemy helpers (not_, and_, or_, cast, TextContains, etc.) . The result is passed directly to .where(β¦) on a SQLAlchemy Select.
AST Transformation Pipeline#
The transformation is performed by two ast.NodeTransformer subclasses :
_ProjectionTranslatorβ base class; handlesName,Attribute, andSubscriptnodes, converting bare names and dotted paths intoattributes[["key"]]subscript calls. It also applies backward-compatibility renames (e.g.,context.span_idβspan_id) via_BACKWARD_COMPATIBILITY_REPLACEMENTS._FilterTranslatorβ extends_ProjectionTranslatorwith comparison-aware transforms:visit_Compareβ resolves type mismatches between sides of a comparison by injectingcast(β¦, Float)orcast(β¦, String)calls. ForIn/NotInagainst a column it emitsTextContains; against a list it emits.in_()/.not_in().visit_BoolOpβ rewritesand/orintoand_(β¦)/or_(β¦)SQLAlchemy calls .visit_UnaryOpβ rewritesnotintonot_(β¦).visit_BinOpβ propagates float/string types across arithmetic operators .visit_Callβ allows onlystr(),float(), andint()type-cast calls .
Type inference is done by a family of _is_float / _is_string helpers that inspect the AST node . attributes["key"] subscripts default to String unless one side of the comparison is already known to be a float, at which point they are wrapped with .as_float() / .as_string() . A special _FLOAT_ATTRIBUTES set marks llm.token_count.* fields as floats to avoid an extra cast on PostgreSQL .
The Projector class exposes the same pipeline for single-expression projection (e.g., building an ORDER BY column) without the comparison logic .
Attribute Name Mapping#
The DSL recognises a fixed set of top-level names and maps them directly to SQLAlchemy column expressions :
| Category | Field names | Type |
|---|---|---|
| String | span_id, trace_id, parent_id, span_kind, name, status_code, status_message | String |
| Float | latency_ms, cumulative_llm_token_count_* | Float |
| DateTime | start_time, end_time | DateTime |
| JSON | attributes, events | β |
Any name not in _NAMES is treated as an attribute key path. Dotted access (attributes.llm.model_name) and bracket notation (attributes["llm.model_name"]) are both supported and normalised to a subscript list . metadata["key"] access is also supported with the "metadata" prefix prepended to the key list .
Eval / Annotation Filters#
Expressions like evals["Hallucination"].score > 0.5 or annotations["Q&A"].label == "correct" are handled by a pre-processing step before the main AST transformation .
_apply_eval_aliasing scans the raw filter string using two regex patterns:
EVAL_EXPRESSION_PATTERNβ matches(annotations|evals)[<name>].(label|score).EVAL_NAME_PATTERNβ matches bareevals[<name>]references (existence check) .
Each unique eval name gets an AliasedAnnotationRelation, which aliases the span_annotations SQL table under a deterministic name like span_annotation_0. The label, score, and existence columns each get a UUID-suffixed alias. The filter string is rewritten so evals["Hallucination"].score becomes a bare Python identifier like span_annotation_0_score_<uuid>.
After compilation, SpanFilter.__call__ adds an OUTER JOIN to the statement for every aliased annotation relation .
Status Code & Enum Handling#
In the DSL: status_code is treated as a plain string column . Filter expressions must use uppercase values β status_code == 'ERROR', status_code == 'OK', status_code == 'UNSET' β because the database stores the column in uppercase and enforces this via a CHECK constraint .
At the schema level: SpanStatusCode implements _missing_ so that SpanStatusCode("ok") resolves to SpanStatusCode.OK. The same pattern is used by SpanKind. This case-insensitivity only applies when constructing the Python enum, not inside filter strings evaluated against the DB.
At ingestion: Inbound status_code strings are explicitly .upper()-ed before enum construction to handle lowercase API input, providing a normalisation layer upstream of the filter.
Implication for filter authors: Write filter conditions with uppercase enum strings ('ERROR', 'OK', 'UNSET'). There is no implicit case folding in the DSL itself.
Validation & Error Reporting#
_validate_expression walks every AST node in the parsed expression and raises SyntaxError for:
- Node types outside an allowlist (
BoolOp,Compare,BinOp,Constant,Name,Attribute,Subscript,UnaryOp,List,Tuple, limitedCalls) . - Unknown eval names β if
valid_eval_namesis provided, the validator suggests the closest match usingSequenceMatcher(ratio threshold 0.75) . - Invalid eval attributes (e.g.,
.labelvs.scor) with a similar fuzzy-match suggestion .
The valid_eval_names sequence is optional; if omitted, any eval name is accepted.