fix: read phoenix_project_name from team/key metadata in ArizePhoenixLogger

When phoenix_project_name is set on a team or API key via the management
endpoints the proxy merges those fields into user_api_key_auth_metadata
inside StandardLoggingMetadata. _get_dynamic_project_name was only checking
the top-level metadata dict, so team/key values were silently dropped and
traces always flowed to the default Phoenix project.

Fix: also check metadata.user_api_key_auth_metadata.phoenix_project_name
with priority: per-request > team/key (auth_metadata) > litellm_params > env var.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
RoyVivat 2026-04-06 16:03:12 -07:00
parent 50f88c8642
commit 9c59c908b3
No known key found for this signature in database
GPG key ID: 59743472EC86530E
2 changed files with 196 additions and 3 deletions

View file

@ -118,18 +118,32 @@ class ArizePhoenixLogger(OpenTelemetry): # type: ignore
"""
Retrieve dynamic Phoenix project name from request metadata.
Users can set `metadata.phoenix_project_name` in their request to route
traces to different Phoenix projects dynamically.
Priority order (highest to lowest):
1. Per-request metadata: ``metadata.phoenix_project_name``
2. Team / key metadata via ``metadata.user_api_key_auth_metadata.phoenix_project_name``
(the proxy merges both team and key metadata into ``user_api_key_auth_metadata``
inside the standard logging payload this is where ``phoenix_project_name`` lands
when it is set on a team or API key via the management endpoints)
3. ``litellm_params.metadata.phoenix_project_name`` (SDK / non-proxy usage)
"""
standard_logging_payload = kwargs.get("standard_logging_object")
if isinstance(standard_logging_payload, dict):
metadata = standard_logging_payload.get("metadata")
if isinstance(metadata, dict):
# 1. Per-request metadata (highest priority)
project_name = metadata.get("phoenix_project_name")
if project_name:
return str(project_name)
# Also check litellm_params.metadata for SDK usage
# 2. Team / key metadata — the proxy stores merged team+key metadata
# in user_api_key_auth_metadata inside StandardLoggingMetadata.
auth_metadata = metadata.get("user_api_key_auth_metadata")
if isinstance(auth_metadata, dict):
project_name = auth_metadata.get("phoenix_project_name")
if project_name:
return str(project_name)
# 3. litellm_params.metadata for direct SDK usage (non-proxy)
litellm_params = kwargs.get("litellm_params")
if isinstance(litellm_params, dict):
metadata = litellm_params.get("metadata") or {}

View file

@ -228,6 +228,139 @@ class TestGetDynamicProjectName:
kwargs = {"standard_logging_object": "not-a-dict"}
assert ArizePhoenixLogger._get_dynamic_project_name(kwargs) is None
# ------------------------------------------------------------------
# Tests for team / key metadata path (the bug reported by Viktor O.)
# ------------------------------------------------------------------
def test_extracts_from_user_api_key_auth_metadata(self):
"""
phoenix_project_name set on a team is merged into
user_api_key_auth_metadata by the proxy's pre-call utils.
_get_dynamic_project_name must check that nested dict.
"""
kwargs = {
"standard_logging_object": {
"metadata": {
"user_api_key_auth_metadata": {
"phoenix_project_name": "team-project"
}
}
}
}
assert ArizePhoenixLogger._get_dynamic_project_name(kwargs) == "team-project"
def test_per_request_metadata_takes_priority_over_team_metadata(self):
"""
A per-request phoenix_project_name must win over a team-level one.
"""
kwargs = {
"standard_logging_object": {
"metadata": {
"phoenix_project_name": "request-project",
"user_api_key_auth_metadata": {
"phoenix_project_name": "team-project"
},
}
}
}
assert ArizePhoenixLogger._get_dynamic_project_name(kwargs) == "request-project"
def test_falls_back_to_team_metadata_when_no_top_level_name(self):
"""
When there is no top-level phoenix_project_name but the team metadata
has one, the team value must be returned.
"""
kwargs = {
"standard_logging_object": {
"metadata": {
# no top-level phoenix_project_name
"user_api_key_auth_metadata": {
"phoenix_project_name": "team-project"
},
}
}
}
assert ArizePhoenixLogger._get_dynamic_project_name(kwargs) == "team-project"
def test_team_metadata_takes_priority_over_litellm_params(self):
"""
Team metadata (via standard_logging_object) must win over
litellm_params.metadata (SDK-level).
"""
kwargs = {
"standard_logging_object": {
"metadata": {
"user_api_key_auth_metadata": {
"phoenix_project_name": "team-project"
}
}
},
"litellm_params": {
"metadata": {"phoenix_project_name": "sdk-project"},
},
}
assert ArizePhoenixLogger._get_dynamic_project_name(kwargs) == "team-project"
def test_empty_user_api_key_auth_metadata_does_not_crash(self):
"""Empty auth_metadata dict must not raise and must fall through."""
kwargs = {
"standard_logging_object": {
"metadata": {
"user_api_key_auth_metadata": {}
}
}
}
assert ArizePhoenixLogger._get_dynamic_project_name(kwargs) is None
def test_none_user_api_key_auth_metadata_does_not_crash(self):
"""None auth_metadata must not raise and must fall through."""
kwargs = {
"standard_logging_object": {
"metadata": {
"user_api_key_auth_metadata": None
}
}
}
assert ArizePhoenixLogger._get_dynamic_project_name(kwargs) is None
def test_full_priority_chain_all_sources_present(self):
"""
Full priority chain:
per-request > team (user_api_key_auth_metadata) > litellm_params
"""
# Per-request wins
kwargs_request = {
"standard_logging_object": {
"metadata": {
"phoenix_project_name": "request-project",
"user_api_key_auth_metadata": {"phoenix_project_name": "team-project"},
}
},
"litellm_params": {"metadata": {"phoenix_project_name": "sdk-project"}},
}
assert ArizePhoenixLogger._get_dynamic_project_name(kwargs_request) == "request-project"
# No per-request → team wins
kwargs_team = {
"standard_logging_object": {
"metadata": {
"user_api_key_auth_metadata": {"phoenix_project_name": "team-project"},
}
},
"litellm_params": {"metadata": {"phoenix_project_name": "sdk-project"}},
}
assert ArizePhoenixLogger._get_dynamic_project_name(kwargs_team) == "team-project"
# No per-request, no team → SDK wins
kwargs_sdk = {
"standard_logging_object": {"metadata": {}},
"litellm_params": {"metadata": {"phoenix_project_name": "sdk-project"}},
}
assert ArizePhoenixLogger._get_dynamic_project_name(kwargs_sdk) == "sdk-project"
# Nothing → None
assert ArizePhoenixLogger._get_dynamic_project_name({}) is None
class TestDynamicProjectNameOnSpan:
"""set_arize_phoenix_attributes sets openinference.project.name on the span."""
@ -253,6 +386,52 @@ class TestDynamicProjectNameOnSpan:
span.set_attribute.assert_called_once_with("openinference.project.name", "env-project")
@patch.dict("os.environ", {"PHOENIX_PROJECT_NAME": "env-fallback"}, clear=False)
@patch("litellm.integrations.arize._utils.set_attributes")
def test_team_metadata_project_name_sets_span_attribute(self, _mock_set_attrs):
"""
Regression test: phoenix_project_name from team metadata (stored in
user_api_key_auth_metadata) must be applied to the span, not the env fallback.
"""
span = MagicMock()
kwargs = {
"standard_logging_object": {
"metadata": {
"user_api_key_auth_metadata": {
"phoenix_project_name": "team-project"
}
}
}
}
ArizePhoenixLogger.set_arize_phoenix_attributes(span, kwargs, response_obj=None)
span.set_attribute.assert_called_once_with("openinference.project.name", "team-project")
@patch.dict(
"os.environ",
{"PHOENIX_PROJECT_NAME": "env-fallback"},
clear=False,
)
@patch("litellm.integrations.arize._utils.set_attributes")
def test_per_request_overrides_team_metadata_on_span(self, _mock_set_attrs):
"""
Per-request phoenix_project_name must override team metadata on the span.
"""
span = MagicMock()
kwargs = {
"standard_logging_object": {
"metadata": {
"phoenix_project_name": "request-project",
"user_api_key_auth_metadata": {
"phoenix_project_name": "team-project"
},
}
}
}
ArizePhoenixLogger.set_arize_phoenix_attributes(span, kwargs, response_obj=None)
span.set_attribute.assert_called_once_with("openinference.project.name", "request-project")
if __name__ == "__main__":
unittest.main()