Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
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
18 changes: 18 additions & 0 deletions app/src/openapi/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4925,6 +4925,14 @@ export interface operations {
end_time?: string | null;
/** @description Filter by one or more trace IDs */
trace_id?: string[] | null;
/** @description Filter by parent span ID. Use "null" to get root spans only. */
parent_id?: string | null;
/** @description Filter by span name(s) */
name?: string[] | null;
/** @description Filter by status code(s). Values: OK, ERROR, UNSET */
status_code?: string[] | null;
/** @description Filter by attribute key:value pairs. Format: key:value (dot-separated keys, e.g. llm.model_name:gpt-4). Multiple filters are ANDed. Values may contain colons (split is on first colon only). Type-aware comparison: bare integers (42), floats (3.14), and booleans (true/false) are compared as their native types; quoted strings ("42") are compared as strings. */
attribute?: string[] | null;
};
header?: never;
path: {
Expand Down Expand Up @@ -4986,6 +4994,16 @@ export interface operations {
end_time?: string | null;
/** @description Filter by one or more trace IDs */
trace_id?: string[] | null;
/** @description Filter by parent span ID. Use "null" to get root spans only. */
parent_id?: string | null;
/** @description Filter by span name(s) */
name?: string[] | null;
/** @description Filter by span kind(s). Values: LLM, CHAIN, TOOL, RETRIEVER, EMBEDDING, AGENT, RERANKER, GUARDRAIL, EVALUATOR, UNKNOWN */
span_kind?: string[] | null;
/** @description Filter by status code(s). Values: OK, ERROR, UNSET */
status_code?: string[] | null;
/** @description Filter by attribute key:value pairs. Format: key:value (dot-separated keys, e.g. llm.model_name:gpt-4). Multiple filters are ANDed. Values may contain colons (split is on first colon only). Type-aware comparison: bare integers (42), floats (3.14), and booleans (true/false) are compared as their native types; quoted strings ("42") are compared as strings. */
attribute?: string[] | null;
};
header?: never;
path: {
Expand Down
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;
attribute?: string[];
includeAnnotations?: boolean;
}

Expand All @@ -47,6 +48,7 @@ async function fetchSpansForProject(
names?: string[];
spanKinds?: string[];
statusCodes?: string[];
attribute?: 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: options.attribute,
},
},
}
Expand Down Expand Up @@ -171,6 +174,7 @@ async function spanListHandler(
names: options.name,
spanKinds: options.spanKind,
statusCodes: options.statusCode,
attribute: options.attribute,
}
);

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 <filters...>",
'Filter by attribute key-value pairs (e.g., "llm.model_name:gpt-4")'
)
.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 @@ -76,6 +76,14 @@ export const LIST_PROJECT_TRACES: RouteRequirement = {
minServerVersion: [13, 15, 0],
};

export const GET_SPANS_ATTRIBUTE_FILTER: ParameterRequirement = {
kind: "parameter",
parameterName: "attribute",
parameterLocation: "query",
route: "GET /v1/projects/{id}/spans",
minServerVersion: [13, 25, 0],
Comment thread
anticorrelator marked this conversation as resolved.
Outdated
};

/**
* Aggregate list of every known capability requirement.
*
Expand All @@ -90,5 +98,6 @@ export const ALL_REQUIREMENTS: readonly CapabilityRequirement[] = [
ANNOTATE_SESSIONS,
GET_SPANS_TRACE_IDS,
GET_SPANS_FILTERS,
GET_SPANS_ATTRIBUTE_FILTER,
LIST_PROJECT_TRACES,
] as const;
14 changes: 14 additions & 0 deletions js/packages/phoenix-client/src/spans/getSpans.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { operations } from "../__generated__/api/v1";
import { createClient } from "../client";
import {
GET_SPANS_ATTRIBUTE_FILTER,
GET_SPANS_FILTERS,
GET_SPANS_TRACE_IDS,
} from "../constants/serverRequirements";
Expand Down Expand Up @@ -34,6 +35,8 @@ export interface GetSpansParams extends ClientFn {
spanKind?: SpanKindFilter | SpanKindFilter[] | null;
/** Filter by status code(s) (OK, ERROR, UNSET) */
statusCode?: SpanStatusCode | SpanStatusCode[] | null;
/** Filter by attribute value(s). Format: "attribute.path:value". Type-aware: bare integers/floats/booleans compared as native types; quoted strings forced to string comparison. */
attribute?: string | string[] | null;
}

export type GetSpansResponse = operations["getSpans"]["responses"]["200"];
Expand Down Expand Up @@ -116,6 +119,7 @@ export async function getSpans({
name,
spanKind,
statusCode,
attribute,
}: GetSpansParams): Promise<GetSpansResult> {
const client = _client ?? createClient();
if (traceIds) {
Expand All @@ -124,6 +128,12 @@ export async function getSpans({
if (name != null || spanKind != null || statusCode != null) {
await ensureServerCapability({ client, requirement: GET_SPANS_FILTERS });
}
if (attribute != null) {
await ensureServerCapability({
client,
requirement: GET_SPANS_ATTRIBUTE_FILTER,
});
}
const projectIdentifier = resolveProjectIdentifier(project);

const params: NonNullable<operations["getSpans"]["parameters"]["query"]> = {
Expand Down Expand Up @@ -163,6 +173,10 @@ export async function getSpans({
params.status_code = Array.isArray(statusCode) ? statusCode : [statusCode];
}

if (attribute) {
params.attribute = Array.isArray(attribute) ? attribute : [attribute];
}

const { data, error } = await client.GET(
"/v1/projects/{project_identifier}/spans",
{
Expand Down
67 changes: 67 additions & 0 deletions js/packages/phoenix-client/test/spans/getSpans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,73 @@ describe("getSpans", () => {
});
});

describe("attribute parameter", () => {
it("should send attribute as array when given a single string", async () => {
await getSpans({
project: { projectName: "test-project" },
attribute: "llm.model_name:gpt-4",
});

expect(mockGet).toHaveBeenCalledWith(
"/v1/projects/{project_identifier}/spans",
expect.objectContaining({
params: expect.objectContaining({
query: expect.objectContaining({
attribute: ["llm.model_name:gpt-4"],
}),
}),
})
);
});

it("should send attribute as array when given an array", async () => {
await getSpans({
project: { projectName: "test-project" },
attribute: ["llm.model_name:gpt-4", "llm.provider:openai"],
});

expect(mockGet).toHaveBeenCalledWith(
"/v1/projects/{project_identifier}/spans",
expect.objectContaining({
params: expect.objectContaining({
query: expect.objectContaining({
attribute: ["llm.model_name:gpt-4", "llm.provider:openai"],
}),
}),
})
);
});

it("should not send attribute when undefined", async () => {
await getSpans({
project: { projectName: "test-project" },
});

const callArgs = mockGet.mock.calls[0]?.[1];
expect(callArgs.params.query).not.toHaveProperty("attribute");
});

it("should send attribute combined with other filters", async () => {
await getSpans({
project: { projectName: "test-project" },
spanKind: "LLM",
attribute: "llm.model_name:gpt-4",
});

expect(mockGet).toHaveBeenCalledWith(
"/v1/projects/{project_identifier}/spans",
expect.objectContaining({
params: expect.objectContaining({
query: expect.objectContaining({
span_kind: ["LLM"],
attribute: ["llm.model_name:gpt-4"],
}),
}),
})
);
});
});

describe("parentId parameter", () => {
it('should send parent_id="null" to get root spans only', async () => {
await getSpans({
Expand Down
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_V2 = ParameterRequirement(
parameter_name="attribute",
parameter_location="query",
route="GET /v1/projects/{id}/spans",
min_server_version=Version(13, 25, 0),
Comment thread
anticorrelator marked this conversation as resolved.
Outdated
)

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,14 +27,46 @@
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_V2,
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
from phoenix.client.utils.id_handling import is_node_id

logger = logging.getLogger(__name__)


def _serialize_attribute_value(v: Union[str, int, float, bool]) -> str:
"""Serialize a typed attribute value for the ``attribute=key:value`` query param.

- int/float: str(v). Non-finite floats raise ValueError.
- bool: json.dumps(v) → "true"/"false".
- str: passed as-is, unless json.loads(v) would parse it as a non-string type,
in which case it is JSON-quoted to force string comparison on the server.
"""
if isinstance(v, bool):
return json.dumps(v)
if isinstance(v, int):
return str(v)
if isinstance(v, float):
import math

if not math.isfinite(v):
raise ValueError(f"Non-finite float values are not supported: {v!r}")
return str(v)
try:
parsed = json.loads(v)
except (json.JSONDecodeError, ValueError):
return v
if not isinstance(parsed, str):
return json.dumps(v)
return v


# Re-export generated types
AnnotateSpanDocumentsRequestBody = v1.AnnotateSpanDocumentsRequestBody
AnnotateSpanDocumentsResponseBody = v1.AnnotateSpanDocumentsResponseBody
Expand Down Expand Up @@ -442,6 +474,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: Optional[dict[str, Union[str, int, float, bool]]] = None,
limit: int = 100,
timeout: Optional[int] = DEFAULT_TIMEOUT_IN_SECONDS,
) -> list[v1.Span]:
Expand All @@ -464,6 +497,12 @@ 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 (Optional[dict[str, Union[str, int, float, bool]]]): Optional dictionary
of attribute key-value pairs to filter by (AND semantics). Serialized as
repeated ``attribute=key:value`` query params. Type-aware: int/float use
str(v), bool uses json.dumps(v), str values that parse as non-string JSON
are quoted to force string comparison.
Requires Phoenix server >= 13.25.0.
Comment thread
anticorrelator marked this conversation as resolved.
Outdated
limit (int): Maximum number of spans to return. Defaults to 100.
timeout (Optional[int]): Optional request timeout in seconds.

Expand All @@ -472,11 +511,15 @@ def get_spans(

Raises:
httpx.HTTPStatusError: If the API returns an error response.
TypeError: If a value in ``attribute`` is not str, int, float, or bool.
ValueError: If a float value in ``attribute`` is non-finite (nan or inf).
"""
if trace_ids:
self._guard.require(GET_SPANS_TRACE_IDS)
if name or span_kind or status_code:
self._guard.require(GET_SPANS_FILTERS)
if attribute:
self._guard.require(GET_SPANS_ATTRIBUTE_V2)
all_spans: list[v1.Span] = []
cursor: Optional[str] = None
page_size = min(100, limit)
Expand Down Expand Up @@ -505,6 +548,10 @@ def get_spans(
params["status_code"] = (
[status_code] if isinstance(status_code, str) else list(status_code)
)
if attribute:
params["attribute"] = [
f"{k}:{_serialize_attribute_value(v)}" for k, v in attribute.items()
]
if cursor:
params["cursor"] = cursor

Expand Down Expand Up @@ -1704,6 +1751,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: Optional[dict[str, Union[str, int, float, bool]]] = None,
limit: int = 100,
timeout: Optional[int] = DEFAULT_TIMEOUT_IN_SECONDS,
) -> list[v1.Span]:
Expand All @@ -1726,6 +1774,12 @@ 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 (Optional[dict[str, Union[str, int, float, bool]]]): Optional dictionary
of attribute key-value pairs to filter by (AND semantics). Serialized as
repeated ``attribute=key:value`` query params. Type-aware: int/float use
str(v), bool uses json.dumps(v), str values that parse as non-string JSON
are quoted to force string comparison.
Requires Phoenix server >= 13.25.0.
limit (int): Maximum number of spans to return. Defaults to 100.
timeout (Optional[int]): Optional request timeout in seconds.

Expand All @@ -1734,11 +1788,15 @@ async def get_spans(

Raises:
httpx.HTTPStatusError: If the API returns an error response.
TypeError: If a value in ``attribute`` is not str, int, float, or bool.
ValueError: If a float value in ``attribute`` is non-finite (nan or inf).
"""
if trace_ids:
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:
await self._guard.require(GET_SPANS_ATTRIBUTE_V2)
all_spans: list[v1.Span] = []
cursor: Optional[str] = None
page_size = min(100, limit)
Expand Down Expand Up @@ -1767,6 +1825,10 @@ async def get_spans(
params["status_code"] = (
[status_code] if isinstance(status_code, str) else list(status_code)
)
if attribute:
params["attribute"] = [
f"{k}:{_serialize_attribute_value(v)}" for k, v in attribute.items()
]
if cursor:
params["cursor"] = cursor

Expand Down
Loading
Loading