fix(proxy): fall back to live identifier for backend_* span attributes (#4109)

🤖 Generated with Claude Code (Opus 4.7) on behalf of @ringerc

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Craig Ringer 2026-05-11 02:07:57 +12:00 committed by GitHub
commit 89b99ecfb9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 72 additions and 4 deletions

View file

@ -178,7 +178,7 @@ class ProxyTool(Tool):
def get_span_attributes(self) -> dict[str, Any]:
return super().get_span_attributes() | {
"fastmcp.provider.type": "ProxyProvider",
"fastmcp.proxy.backend_name": self._backend_name,
"fastmcp.proxy.backend_name": self._backend_name or self.name,
}
@ -285,7 +285,7 @@ class ProxyResource(Resource):
def get_span_attributes(self) -> dict[str, Any]:
return super().get_span_attributes() | {
"fastmcp.provider.type": "ProxyProvider",
"fastmcp.proxy.backend_uri": self._backend_uri,
"fastmcp.proxy.backend_uri": self._backend_uri or str(self.uri),
}
@ -400,7 +400,9 @@ class ProxyTemplate(ResourceTemplate):
def get_span_attributes(self) -> dict[str, Any]:
return super().get_span_attributes() | {
"fastmcp.provider.type": "ProxyProvider",
"fastmcp.proxy.backend_uri_template": self._backend_uri_template,
"fastmcp.proxy.backend_uri_template": (
self._backend_uri_template or self.uri_template
),
}
@ -483,7 +485,7 @@ class ProxyPrompt(Prompt):
def get_span_attributes(self) -> dict[str, Any]:
return super().get_span_attributes() | {
"fastmcp.provider.type": "ProxyProvider",
"fastmcp.proxy.backend_name": self._backend_name,
"fastmcp.proxy.backend_name": self._backend_name or self.name,
}

View file

@ -915,3 +915,69 @@ class TestProxyProviderCache:
result = await proxy.call_tool("greet", {"name": "Alice"})
mock_list.assert_not_called()
assert result.content[0].text == "Hello, Alice!" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
class TestProxySpanAttributes:
"""Regression tests for span attributes on un-renamed proxy components.
A proxy component's private ``_backend_*`` field is only populated when
the component is renamed via ``model_copy``. For un-renamed components it
stays ``None``, so ``get_span_attributes()`` would emit ``None`` for the
``fastmcp.proxy.backend_*`` keys which OpenTelemetry rejects with
``Invalid type NoneType for attribute ... value`` and drops, producing
log spam on every proxied call.
"""
async def test_proxy_tool_span_attributes_fall_back_to_name(self, proxy_server):
proxy_provider = next(
p for p in proxy_server.providers if isinstance(p, ProxyProvider)
)
tools = await proxy_provider._list_tools()
assert tools, "expected the fixture to expose at least one tool"
for tool in tools:
attrs = tool.get_span_attributes()
assert attrs["fastmcp.proxy.backend_name"] == tool.name
assert all(v is not None for v in attrs.values()), (
f"OpenTelemetry rejects None attribute values; got {attrs!r}"
)
async def test_proxy_resource_span_attributes_fall_back_to_uri(self, proxy_server):
proxy_provider = next(
p for p in proxy_server.providers if isinstance(p, ProxyProvider)
)
resources = await proxy_provider._list_resources()
assert resources, "expected the fixture to expose at least one resource"
for resource in resources:
attrs = resource.get_span_attributes()
assert attrs["fastmcp.proxy.backend_uri"] == str(resource.uri)
assert all(v is not None for v in attrs.values()), (
f"OpenTelemetry rejects None attribute values; got {attrs!r}"
)
async def test_proxy_template_span_attributes_fall_back_to_uri_template(
self, proxy_server
):
proxy_provider = next(
p for p in proxy_server.providers if isinstance(p, ProxyProvider)
)
templates = await proxy_provider._list_resource_templates()
assert templates, "expected the fixture to expose at least one template"
for template in templates:
attrs = template.get_span_attributes()
assert attrs["fastmcp.proxy.backend_uri_template"] == template.uri_template
assert all(v is not None for v in attrs.values()), (
f"OpenTelemetry rejects None attribute values; got {attrs!r}"
)
async def test_proxy_prompt_span_attributes_fall_back_to_name(self, proxy_server):
proxy_provider = next(
p for p in proxy_server.providers if isinstance(p, ProxyProvider)
)
prompts = await proxy_provider._list_prompts()
assert prompts, "expected the fixture to expose at least one prompt"
for prompt in prompts:
attrs = prompt.get_span_attributes()
assert attrs["fastmcp.proxy.backend_name"] == prompt.name
assert all(v is not None for v in attrs.values()), (
f"OpenTelemetry rejects None attribute values; got {attrs!r}"
)