Skip to content

feat(rest-api): add type-aware attribute filter to GET /v1/spans#12524

Open
anticorrelator wants to merge 15 commits intomainfrom
dustin/span-attributes-api
Open

feat(rest-api): add type-aware attribute filter to GET /v1/spans#12524
anticorrelator wants to merge 15 commits intomainfrom
dustin/span-attributes-api

Conversation

@anticorrelator
Copy link
Copy Markdown
Contributor

@anticorrelator anticorrelator commented Apr 2, 2026

Resolves #12522

Adds a repeatable attribute query parameter to GET /v1/projects/{id}/spans and GET /v1/projects/{id}/spans/otlpv1 for filtering spans by stored attribute values, with matching Python, TypeScript, and CLI support.

The REST wire format remains singular attribute=key:value. The Python and TypeScript clients expose an attributes map and serialize it into repeated attribute query params.

Usage

REST

GET /v1/projects/my-project/spans?attribute=user.id:user-42
GET /v1/projects/my-project/spans?attribute=llm.model_name:gpt-4&attribute=metadata.tier:premium
GET /v1/projects/my-project/spans?attribute=user.id:"12345"          # string-valued numeric id
GET /v1/projects/my-project/spans?attribute=session.id:sess:abc:123  # colon-in-value
GET /v1/projects/my-project/spans?attribute=metadata.flag:true       # boolean

Python client (arize-phoenix-client >= 13.25.0)

from phoenix.client import Client

client = Client()
spans = client.spans.get_spans(
    project_identifier="my-project",
    attributes={"user.id": "user-42", "metadata.tier": "premium"},
)

TypeScript client (@arizeai/phoenix-client)

import { getSpans } from "@arizeai/phoenix-client";

const result = await getSpans({
  client,
  project: { projectName: "my-project" },
  attributes: {
    "user.id": "user-42",
    "metadata.tier": "premium",
  },
});

The Python and TypeScript clients preserve type intent. For example, { "user.id": "12345" } (string) and { "user.id": 12345 } (int) emit distinct filters on the wire.

CLI

px span list --project my-project \
  --attribute "llm.model_name:gpt-4" \
  --attribute "metadata.tier:premium"

Filter semantics

  • Key is a dot-path into the stored attributes (user.id, metadata.tier, llm.token_count.prompt). Nested paths traverse JSON objects at any depth. OpenInference attributes that SDKs emit as JSON blobs (metadata, tool.parameters, etc.) are normalized at ingestion, so metadata.tier:premium matches regardless of the SDK's wire shape.
  • Value is JSON-parsed. key:12345 matches integer 12345, key:true matches boolean, otherwise string. To match a numeric- or boolean-looking string, JSON-quote it: user.id:"12345" (URL-encoded %2212345%22).
  • Empty strings are represented as key:"". The Python and TypeScript clients handle this quoting automatically.
  • Colon-in-value is supported: split is on the first : only. session.id:sess:abc:123 and ISO timestamps work without pre-escaping.
  • Repeat the parameter to AND multiple filters.
  • List-valued attributes (e.g. OpenInference tag.tags) cannot be matched by this filter; use the query DSL for list membership.
  • Returns 422 on malformed input (missing colon, empty key/value, or a value that parses to list/dict/null).

Test plan

  • Unit tests cover type-aware dispatch, forced-string quoting, colon-in-value, nested-object paths, list-valued-attribute silent-zero-rows, array-indexed paths per dialect, and malformed-input 422s across SQLite and PostgreSQL.
  • Integration tests cover the full OTLP -> REST round-trip, including the OpenInference metadata JSON-string ingestion path.
  • Python client unit tests cover typed serialization, empty-string quoting, and the attributes map API.
  • TypeScript client unit tests cover typed serialization, empty-string quoting, and the attributes map API.
  • Manual smoke test against a local Phoenix.

… endpoints

Add attribute-based filtering to both GET spans endpoints so users can
query spans by arbitrary key-value attributes (e.g., llm.model_name,
session.id) using exact string comparison via repeatable
`attribute_filter=key:value` query parameters with AND semantics.

- Server: `_parse_attribute_filter` helper + Query param on both
  `span_search` and `span_search_otlpv1` handlers
- Client SDK: `attribute_filters` param on sync/async `get_spans()`
  with version gating (>= 13.24.0)
- CLI: `--attribute-filter` option on `px span list`
- OpenAPI schema and generated types regenerated
- Unit, integration, and client serialization tests added

Closes #12522
@anticorrelator anticorrelator requested review from a team as code owners April 2, 2026 22:21
@github-project-automation github-project-automation bot moved this to 📘 Todo in phoenix Apr 2, 2026
@dosubot dosubot bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Apr 2, 2026
@mintlify
Copy link
Copy Markdown
Contributor

mintlify bot commented Apr 2, 2026

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
arize-phoenix 🟢 Ready View Preview Apr 2, 2026, 10:23 PM

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 2, 2026

Open in StackBlitz

@arizeai/phoenix-cli

npm i https://pkg.pr.new/@arizeai/phoenix-cli@12524

@arizeai/phoenix-client

npm i https://pkg.pr.new/@arizeai/phoenix-client@12524

@arizeai/phoenix-evals

npm i https://pkg.pr.new/@arizeai/phoenix-evals@12524

@arizeai/phoenix-mcp

npm i https://pkg.pr.new/@arizeai/phoenix-mcp@12524

@arizeai/phoenix-otel

npm i https://pkg.pr.new/@arizeai/phoenix-otel@12524

commit: 5524941

Comment thread js/packages/phoenix-cli/src/commands/span.ts Outdated
- Fix CLI help text to show key:value format instead of operator syntax
- Rename Python client param from attribute_filters to attribute_filter
  (singular) to match existing naming convention
- Add attributeFilter support to TS SDK with capability check and
  array normalization
- Add test coverage: OTLP error paths, colon-in-value, async client
  parity
- Document colon-in-value behavior in OpenAPI description
@dosubot dosubot bot added size:XL This PR changes 500-999 lines, ignoring generated files. and removed size:L This PR changes 100-499 lines, ignoring generated files. labels Apr 3, 2026
@claude
Copy link
Copy Markdown
Contributor

claude bot commented Apr 3, 2026

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

@mikeldking
Copy link
Copy Markdown
Collaborator

mikeldking commented Apr 3, 2026

Let's work through non KV based approach:

use cases: metadata, threads (sessions), include, exclude

Comment thread js/packages/phoenix-cli/src/commands/span.ts Outdated
Comment thread js/packages/phoenix-cli/src/commands/span.ts Outdated
Comment thread src/phoenix/server/api/routers/v1/spans.py Outdated
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

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

Copy link
Copy Markdown
Collaborator

@mikeldking mikeldking left a comment

Choose a reason for hiding this comment

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

Let's re-name the param

Also we need to think about the Test case first - e.g. the types of filters that need to work. This is things like sessionID, metadata

We also need to think about UI encoding, numbers, boolean values, lists of primitives and the like.

@github-project-automation github-project-automation bot moved this from 📘 Todo to 🔍. Needs Review in phoenix Apr 3, 2026
…e comparison

Rename the `attribute_filter` query parameter to `attribute` on GET /v1/spans
endpoints per reviewer feedback. Add type-aware equality comparison via
json.loads() dispatch — integers use .as_integer(), floats use .as_float(),
booleans use .as_boolean(), and strings use .as_string() (with fallback for
bare values). This fixes a correctness bug where non-string attribute values
silently failed to match.

- Server: rename _parse_attribute_filter → _parse_attribute, add type dispatch
- OpenAPI schema: rename param, update description with type-aware examples
- Python client: widen type to dict[str, Union[str, int, float, bool]], add
  smart-quoting serialization, bump server requirement to GET_SPANS_ATTRIBUTE_V2
- TS client + CLI: rename attributeFilter → attribute, update version constant
- Tests: rename all param references, add 9 type-aware comparison tests,
  add 8 smart-quoting serialization tests
…ing quoting tests

Fix GET_SPANS_ATTRIBUTE_FILTER minServerVersion from 13.24.0 to 13.25.0
to match the Python client. Remove vestigial GET_SPANS_ATTRIBUTE_FILTERS
constant. Add sync and async tests for string "true" vs bool True
serialization distinction.
- Fix ruff import sorting in integration test_spans.py
- Remove unnecessary isinstance(v, str) check in _serialize_attribute_value
  (pyright correctly narrows type after bool/int/float checks)
- Regenerate schemas/openapi.json with updated attribute description
…ltering

The previous approach using type-specific accessors (as_integer, as_float,
etc.) lost JSON type information on PostgreSQL because JSONB text extraction
(#>>) strips type info before casting, causing "42" (string) and 42 (int)
to match interchangeably. Switch to direct JSON-level comparison via
sa.type_coerce which preserves type semantics on both backends. Also
regenerate stale TypeScript types from updated OpenAPI schema.
…ering

The previous type_coerce approach generated invalid SQL on PostgreSQL.
Switch to CAST(col AS TEXT) == json.dumps(parsed), which:
- PostgreSQL: CAST(JSONB AS TEXT) preserves JSON format (42 vs "42")
- SQLite: JSON_QUOTE(JSON_EXTRACT(...)) also preserves JSON format
- Booleans use as_boolean() since SQLite represents JSON bools as 0/1
…ption

Adds Phase 1 unit tests pinning the current `attribute` query param
contract across the OpenInference context-attribute shapes (user.id,
session.id, metadata.*, tag.tags, ISO timestamps): type-aware dispatch,
forced-string quoting, colon-in-value, nested-object paths, and the
list-valued-attribute silent-zero-rows footgun.

Adds an integration test pinning the OpenInference SDK-emitted
`metadata` JSON-string round-trip through `load_json_strings` ingestion
and the dotted-path attribute filter.

Enriches the `attribute` OpenAPI description with the rules agents need
to pick the right encoding: JSON-parsed values, `key:"value"` escape
hatch for numeric-/boolean-looking strings, first-colon-only split, AND
across repeated params, and the list-valued-attribute caveat. Extracted
to a shared constant so `/spans` and `/spans/otlpv1` stay in sync.

Adds scripts/agent_api_testing/span_filtering/ — an automated harness
that measures whether independent Claude and Codex sessions can build
correct filter URLs from schema alone (Phase 2 agent-usability trial).
@anticorrelator anticorrelator changed the title feat(rest-api): add attribute_filter to GET /v1/spans endpoints feat(rest-api): add type-aware attribute filter to GET /v1/spans Apr 16, 2026
@dosubot dosubot bot added size:XXL This PR changes 1000+ lines, ignoring generated files. and removed size:XL This PR changes 500-999 lines, ignoring generated files. labels Apr 16, 2026
…sumption

Strips internal plan/task references, removes the URL-extraction /
summary-aggregation layer in `run_trial.py` (raw JSONL transcripts are
the ground truth anyway), deletes the redundant `manifest.json`, and
condenses the `prompts.md` rubrics. Net reduction: ~1024 → ~496 lines.
@dosubot dosubot bot added size:XL This PR changes 500-999 lines, ignoring generated files. and removed size:XXL This PR changes 1000+ lines, ignoring generated files. labels Apr 16, 2026
parameterName: "attribute",
parameterLocation: "query",
route: "GET /v1/projects/{id}/spans",
minServerVersion: [13, 25, 0],
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're in the 14 versions now. So this is going to be wrong.

parameter_name="attribute",
parameter_location="query",
route="GET /v1/projects/{id}/spans",
min_server_version=Version(13, 25, 0),
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.

same, version is wrong

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.
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.

string for version is incorrect

Comment on lines +57 to +62
"attributes": {
"metadata.tier": "premium",
"metadata.count": 5,
"metadata.ratio": 0.7,
"metadata.flag": True,
"openinference.span.kind": "CHAIN",
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.

can we testa a flat dump of metadata as this is the common pattern from our instrumentors

Upstream SDK rename in 64e3c53 changed the `get_spans` keyword from
`attribute` (singular) to `attributes` (plural). Update the integration
test call site to match so Type Check and Integration Tests CI pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

Status: 🔍. Needs Review

Development

Successfully merging this pull request may close these issues.

[rest api] add attribute filters for GET /v1/spans

2 participants