Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c8cd236
feat(rest-api): add attribute_filter query parameter to GET /v1/spans…
anticorrelator Apr 2, 2026
5044594
fix(rest-api): address PR #12524 self-review findings
anticorrelator Apr 3, 2026
478d92d
style: fix ruff import sorting in test_spans.py
anticorrelator Apr 3, 2026
b350fb7
feat(rest-api): rename attribute_filter → attribute and add type-awar…
anticorrelator Apr 7, 2026
c3e9fc0
fix(rest-api): correct TS version gate, remove dead constant, add str…
anticorrelator Apr 7, 2026
30c0be6
fix(rest-api): fix lint, pyright, and stale OpenAPI schema
anticorrelator Apr 7, 2026
363a17b
fix(rest-api): use direct JSON comparison for type-aware attribute fi…
anticorrelator Apr 8, 2026
14b21fc
fix(rest-api): use JSON text comparison for type-aware attribute filt…
anticorrelator Apr 8, 2026
c555997
test(rest-api): pin attribute-filter behavior + enrich OpenAPI descri…
anticorrelator Apr 16, 2026
0b53342
test(rest-api): slim span-filter agent-trial harness for external con…
anticorrelator Apr 16, 2026
ba0603e
style(rest-api): apply ruff import grouping fixes
anticorrelator Apr 16, 2026
fa80fff
chore(client): regenerate phoenix-client openapi types
anticorrelator Apr 16, 2026
64e3c53
fix(client): use attributes maps for span filters
mikeldking Apr 17, 2026
031473f
fix(tests): rename attribute= → attributes= in integration test
anticorrelator Apr 17, 2026
5524941
style(tests): match CI ruff import grouping for test_spans.py
anticorrelator Apr 17, 2026
1852e08
chore(rest-api): remove span-filter agent-trial harness
anticorrelator Apr 17, 2026
fcd72bb
fix(client): bump attribute filter min server version to 14.9.0
anticorrelator Apr 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions js/packages/phoenix-cli/src/commands/span.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ interface SpanListOptions {
name?: string[];
traceId?: string[];
parentId?: string;
attributeFilter?: string[];
Comment thread
anticorrelator marked this conversation as resolved.
Outdated
includeAnnotations?: boolean;
}

Expand All @@ -47,6 +48,7 @@ async function fetchSpansForProject(
names?: string[];
spanKinds?: string[];
statusCodes?: string[];
attributeFilter?: string[];
limit: number;
}
): Promise<Span[]> {
Expand All @@ -72,6 +74,7 @@ async function fetchSpansForProject(
name: options.names,
span_kind: options.spanKinds,
status_code: options.statusCodes,
attribute_filter: options.attributeFilter,
},
},
}
Expand Down Expand Up @@ -171,6 +174,7 @@ async function spanListHandler(
names: options.name,
spanKinds: options.spanKind,
statusCodes: options.statusCode,
attributeFilter: options.attributeFilter,
}
);

Expand Down Expand Up @@ -292,6 +296,10 @@ export function createSpanListCommand(): Command {
"--parent-id <id>",
'Filter by parent span ID (use "null" for root spans only)'
)
.option(
"--attribute-filter <filters...>",
Comment thread
anticorrelator marked this conversation as resolved.
Outdated
'Filter by attribute key-value pairs (e.g., "llm.token_count > 100")'
Comment thread
anticorrelator marked this conversation as resolved.
Outdated
)
.option("--include-annotations", "Include span annotations in the output")
.action(spanListHandler);
}
Expand Down
4 changes: 4 additions & 0 deletions js/packages/phoenix-client/src/__generated__/api/v1.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@
min_server_version=Version(13, 15, 0),
)

GET_SPANS_ATTRIBUTE_FILTERS = ParameterRequirement(
parameter_name="attribute_filter",
parameter_location="query",
route="GET /v1/projects/{id}/spans",
min_server_version=Version(13, 24, 0),
)

LIST_PROJECT_TRACES = RouteRequirement(
method="GET",
path="/v1/projects/{project_identifier}/traces",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@
import pandas as pd

from phoenix.client.__generated__ import v1
from phoenix.client.constants.server_requirements import GET_SPANS_FILTERS, GET_SPANS_TRACE_IDS
from phoenix.client.constants.server_requirements import (
GET_SPANS_ATTRIBUTE_FILTERS,
GET_SPANS_FILTERS,
GET_SPANS_TRACE_IDS,
)
from phoenix.client.exceptions import DuplicateSpanInfo, InvalidSpanInfo, SpanCreationError
from phoenix.client.helpers.spans import dataframe_to_spans as _dataframe_to_spans
from phoenix.client.types.spans import SpanQuery
Expand Down Expand Up @@ -442,6 +446,7 @@ def get_spans(
name: Optional[Union[str, Sequence[str]]] = None,
span_kind: Optional[Union[str, Sequence[str]]] = None,
status_code: Optional[Union[str, Sequence[str]]] = None,
attribute_filters: Optional[dict[str, str]] = None,
limit: int = 100,
timeout: Optional[int] = DEFAULT_TIMEOUT_IN_SECONDS,
) -> list[v1.Span]:
Expand All @@ -464,6 +469,10 @@ def get_spans(
by (e.g. LLM, CHAIN, TOOL). Requires Phoenix server >= 13.15.0.
status_code (Optional[Union[str, Sequence[str]]]): Optional status code(s) to
filter by (e.g. OK, ERROR, UNSET). Requires Phoenix server >= 13.15.0.
attribute_filters (Optional[dict[str, str]]): Optional dictionary of attribute
key-value pairs to filter by (AND semantics). Serialized as repeated
``attribute_filter=key:value`` query params.
Requires Phoenix server >= 13.24.0.
limit (int): Maximum number of spans to return. Defaults to 100.
timeout (Optional[int]): Optional request timeout in seconds.

Expand All @@ -477,6 +486,8 @@ def get_spans(
self._guard.require(GET_SPANS_TRACE_IDS)
if name or span_kind or status_code:
self._guard.require(GET_SPANS_FILTERS)
if attribute_filters:
self._guard.require(GET_SPANS_ATTRIBUTE_FILTERS)
all_spans: list[v1.Span] = []
cursor: Optional[str] = None
page_size = min(100, limit)
Expand Down Expand Up @@ -505,6 +516,8 @@ def get_spans(
params["status_code"] = (
[status_code] if isinstance(status_code, str) else list(status_code)
)
if attribute_filters:
params["attribute_filter"] = [f"{k}:{v}" for k, v in attribute_filters.items()]
if cursor:
params["cursor"] = cursor

Expand Down Expand Up @@ -1704,6 +1717,7 @@ async def get_spans(
name: Optional[Union[str, Sequence[str]]] = None,
span_kind: Optional[Union[str, Sequence[str]]] = None,
status_code: Optional[Union[str, Sequence[str]]] = None,
attribute_filters: Optional[dict[str, str]] = None,
limit: int = 100,
timeout: Optional[int] = DEFAULT_TIMEOUT_IN_SECONDS,
) -> list[v1.Span]:
Expand All @@ -1726,6 +1740,10 @@ async def get_spans(
by (e.g. LLM, CHAIN, TOOL). Requires Phoenix server >= 13.15.0.
status_code (Optional[Union[str, Sequence[str]]]): Optional status code(s) to
filter by (e.g. OK, ERROR, UNSET). Requires Phoenix server >= 13.15.0.
attribute_filters (Optional[dict[str, str]]): Optional dictionary of attribute
key-value pairs to filter by (AND semantics). Serialized as repeated
``attribute_filter=key:value`` query params.
Requires Phoenix server >= 13.24.0.
limit (int): Maximum number of spans to return. Defaults to 100.
timeout (Optional[int]): Optional request timeout in seconds.

Expand All @@ -1739,6 +1757,8 @@ async def get_spans(
await self._guard.require(GET_SPANS_TRACE_IDS)
if name or span_kind or status_code:
await self._guard.require(GET_SPANS_FILTERS)
if attribute_filters:
await self._guard.require(GET_SPANS_ATTRIBUTE_FILTERS)
all_spans: list[v1.Span] = []
cursor: Optional[str] = None
page_size = min(100, limit)
Expand Down Expand Up @@ -1767,6 +1787,8 @@ async def get_spans(
params["status_code"] = (
[status_code] if isinstance(status_code, str) else list(status_code)
)
if attribute_filters:
params["attribute_filter"] = [f"{k}:{v}" for k, v in attribute_filters.items()]
if cursor:
params["cursor"] = cursor

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,56 @@ def handler(request: httpx.Request) -> httpx.Response:
assert len(spans) == 1


class TestGetSpansAttributeFilters:
def test_single_attribute_filter(self) -> None:
transport = _make_handler(expected_params={"attribute_filter": ["llm.model:gpt-4"]})
client = httpx.Client(transport=transport, base_url="http://test")
spans = Spans(client).get_spans(
project_identifier="my-project",
attribute_filters={"llm.model": "gpt-4"},
)
assert len(spans) == 1

def test_multiple_attribute_filters(self) -> None:
transport = _make_handler(
expected_params={"attribute_filter": ["llm.model:gpt-4", "user.id:abc"]}
)
client = httpx.Client(transport=transport, base_url="http://test")
spans = Spans(client).get_spans(
project_identifier="my-project",
attribute_filters={"llm.model": "gpt-4", "user.id": "abc"},
)
assert len(spans) == 1

def test_no_attribute_filters_omits_param(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
query_string = parse_qs(urlparse(str(request.url)).query)
assert "attribute_filter" not in query_string
return httpx.Response(
200,
json={"data": [_make_span()], "next_cursor": None},
)

client = httpx.Client(transport=httpx.MockTransport(handler), base_url="http://test")
spans = Spans(client).get_spans(project_identifier="my-project")
assert len(spans) == 1

def test_attribute_filters_combined_with_other_filters(self) -> None:
transport = _make_handler(
expected_params={
"span_kind": ["LLM"],
"attribute_filter": ["llm.model:gpt-4"],
}
)
client = httpx.Client(transport=transport, base_url="http://test")
spans = Spans(client).get_spans(
project_identifier="my-project",
span_kind="LLM",
attribute_filters={"llm.model": "gpt-4"},
)
assert len(spans) == 1


class TestAsyncGetSpansFilters:
@pytest.mark.anyio
async def test_single_name_filter(self) -> None:
Expand Down Expand Up @@ -142,3 +192,27 @@ async def test_combined_filters(self) -> None:
status_code="OK",
)
assert len(spans) == 1


class TestAsyncGetSpansAttributeFilters:
@pytest.mark.anyio
async def test_single_attribute_filter(self) -> None:
transport = _make_handler(expected_params={"attribute_filter": ["llm.model:gpt-4"]})
client = httpx.AsyncClient(transport=transport, base_url="http://test")
spans = await AsyncSpans(client).get_spans(
project_identifier="my-project",
attribute_filters={"llm.model": "gpt-4"},
)
assert len(spans) == 1

@pytest.mark.anyio
async def test_multiple_attribute_filters(self) -> None:
transport = _make_handler(
expected_params={"attribute_filter": ["llm.model:gpt-4", "user.id:abc"]}
)
client = httpx.AsyncClient(transport=transport, base_url="http://test")
spans = await AsyncSpans(client).get_spans(
project_identifier="my-project",
attribute_filters={"llm.model": "gpt-4", "user.id": "abc"},
)
assert len(spans) == 1
42 changes: 42 additions & 0 deletions schemas/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2954,6 +2954,27 @@
"title": "Status Code"
},
"description": "Filter by status code(s). Values: OK, ERROR, UNSET"
},
{
"name": "attribute_filter",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "array",
"items": {
"type": "string"
}
},
{
"type": "null"
}
],
"description": "Filter by attribute key:value pairs (dot-separated keys, e.g. llm.model_name:gpt-4). Multiple filters are ANDed.",
"title": "Attribute Filter"
},
"description": "Filter by attribute key:value pairs (dot-separated keys, e.g. llm.model_name:gpt-4). Multiple filters are ANDed."
}
],
"responses": {
Expand Down Expand Up @@ -3191,6 +3212,27 @@
"title": "Status Code"
},
"description": "Filter by status code(s). Values: OK, ERROR, UNSET"
},
{
"name": "attribute_filter",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "array",
"items": {
"type": "string"
}
},
{
"type": "null"
}
],
"description": "Filter by attribute key:value pairs (dot-separated keys, e.g. llm.model_name:gpt-4). Multiple filters are ANDed.",
"title": "Attribute Filter"
},
"description": "Filter by attribute key:value pairs (dot-separated keys, e.g. llm.model_name:gpt-4). Multiple filters are ANDed."
}
],
"responses": {
Expand Down
51 changes: 51 additions & 0 deletions src/phoenix/server/api/routers/v1/spans.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,37 @@

DEFAULT_SPAN_LIMIT = 1000


def _parse_attribute_filter(filter_str: str) -> sa.ColumnElement[bool]:
"""Parse an ``attribute_filter`` query-param value into a SQLAlchemy
Comment thread
anticorrelator marked this conversation as resolved.
Outdated
filter clause.

The expected format is ``key:value`` where *key* is a dot-separated
attribute path (e.g. ``llm.model_name``) and *value* is the exact string
to match. The split is performed on the **first** ``:`` only, so values
may contain additional colons.

Returns ``models.Span.attributes[key_parts].as_string() == value``.

Raises :class:`HTTPException` (422) when the separator is missing or
the key portion is empty.
"""
if ":" not in filter_str:
raise HTTPException(
status_code=422,
detail=f"Invalid attribute_filter '{filter_str}': expected format 'key:value'",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need to think about URI encoding a bit here I think

)
key, value = filter_str.split(":", 1)
if not key:
raise HTTPException(
status_code=422,
detail="Invalid attribute_filter: key must not be empty",
)
key_parts = key.split(".")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should think though how complex the first part should be. Can you come up with some reasonably complicated things you might want to filter for here

clause: sa.ColumnElement[bool] = models.Span.attributes[key_parts].as_string() == value
return clause


router = APIRouter(tags=["spans"])


Expand Down Expand Up @@ -624,6 +655,13 @@ async def span_search_otlpv1(
default=None,
description="Filter by status code(s). Values: OK, ERROR, UNSET",
),
attribute_filter: Optional[list[str]] = Query(
default=None,
description=(
"Filter by attribute key:value pairs (dot-separated keys, "
"e.g. llm.model_name:gpt-4). Multiple filters are ANDed."
),
),
) -> OtlpSpansResponseBody:
"""Search spans with minimal filters instead of the old SpanQuery DSL."""

Expand Down Expand Up @@ -664,6 +702,9 @@ async def span_search_otlpv1(
)
)
)
if attribute_filter:
for af in attribute_filter:
stmt = stmt.where(_parse_attribute_filter(af))

if cursor:
try:
Expand Down Expand Up @@ -801,6 +842,13 @@ async def span_search(
default=None,
description="Filter by status code(s). Values: OK, ERROR, UNSET",
),
attribute_filter: Optional[list[str]] = Query(
default=None,
description=(
"Filter by attribute key:value pairs (dot-separated keys, "
"e.g. llm.model_name:gpt-4). Multiple filters are ANDed."
),
),
) -> SpansResponseBody:
async with request.app.state.db() as session:
project = await get_project_by_identifier(session, project_identifier)
Expand Down Expand Up @@ -847,6 +895,9 @@ async def span_search(
)
)
)
if attribute_filter:
for af in attribute_filter:
stmt = stmt.where(_parse_attribute_filter(af))

if cursor:
try:
Expand Down
Loading
Loading