mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Expose telemetry attributes on span start (#4487)
* Expose telemetry attributes on span start 🤖 Generated with Codex * Expose sampling attributes on span start 🤖 Generated with Codex --------- Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
This commit is contained in:
parent
7d76c9d055
commit
f018f68bbf
5 changed files with 157 additions and 47 deletions
|
|
@ -23,24 +23,25 @@ def client_span(
|
|||
|
||||
Automatically records any exception on the span and sets error status.
|
||||
"""
|
||||
attrs: dict[str, str] = {
|
||||
# MCP semantic conventions
|
||||
"mcp.method.name": method,
|
||||
# FastMCP-specific attributes
|
||||
"fastmcp.component.key": component_key,
|
||||
}
|
||||
if session_id is not None:
|
||||
attrs["mcp.session.id"] = session_id
|
||||
if resource_uri:
|
||||
attrs["mcp.resource.uri"] = resource_uri
|
||||
if tool_name is not None:
|
||||
attrs["gen_ai.tool.name"] = tool_name
|
||||
if prompt_name is not None:
|
||||
attrs["gen_ai.prompt.name"] = prompt_name
|
||||
|
||||
tracer = get_tracer()
|
||||
with tracer.start_as_current_span(name, kind=SpanKind.CLIENT) as span:
|
||||
if span.is_recording():
|
||||
attrs: dict[str, str] = {
|
||||
# MCP semantic conventions
|
||||
"mcp.method.name": method,
|
||||
# FastMCP-specific attributes
|
||||
"fastmcp.component.key": component_key,
|
||||
}
|
||||
if session_id is not None:
|
||||
attrs["mcp.session.id"] = session_id
|
||||
if resource_uri:
|
||||
attrs["mcp.resource.uri"] = resource_uri
|
||||
if tool_name is not None:
|
||||
attrs["gen_ai.tool.name"] = tool_name
|
||||
if prompt_name is not None:
|
||||
attrs["gen_ai.prompt.name"] = prompt_name
|
||||
span.set_attributes(attrs)
|
||||
with tracer.start_as_current_span(
|
||||
name, kind=SpanKind.CLIENT, attributes=attrs
|
||||
) as span:
|
||||
try:
|
||||
yield span
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -313,10 +313,11 @@ async def execute_tools(
|
|||
with tracer.start_as_current_span(
|
||||
f"sampling tool {tool_use.name}",
|
||||
kind=SpanKind.INTERNAL,
|
||||
attributes={
|
||||
"gen_ai.tool.name": tool_use.name,
|
||||
"fastmcp.tool.use_id": tool_use.id,
|
||||
},
|
||||
) as span:
|
||||
if span.is_recording():
|
||||
span.set_attribute("gen_ai.tool.name", tool_use.name)
|
||||
span.set_attribute("fastmcp.tool.use_id", tool_use.id)
|
||||
try:
|
||||
result_value = await tool.run(tool_use.input)
|
||||
return ToolResultContent(
|
||||
|
|
@ -556,12 +557,13 @@ async def sample_step_impl(
|
|||
with tracer.start_as_current_span(
|
||||
"sampling create_message",
|
||||
kind=SpanKind.CLIENT,
|
||||
attributes={
|
||||
"mcp.method.name": "sampling/createMessage",
|
||||
"fastmcp.server.name": context.fastmcp.name,
|
||||
},
|
||||
record_exception=False,
|
||||
set_status_on_exception=False,
|
||||
) as span:
|
||||
if span.is_recording():
|
||||
span.set_attribute("mcp.method.name", "sampling/createMessage")
|
||||
span.set_attribute("fastmcp.server.name", context.fastmcp.name)
|
||||
try:
|
||||
if use_fallback:
|
||||
response = await call_sampling_handler(
|
||||
|
|
|
|||
|
|
@ -135,23 +135,21 @@ def seam_span(method: str, server_name: str) -> Generator[Span, None, None]:
|
|||
rejections *before* the high-level path (auth, not-found, middleware vetoes)
|
||||
that would otherwise produce no SERVER span at all — are recorded here.
|
||||
"""
|
||||
attrs = {
|
||||
SEAM_SPAN_MARKER: True,
|
||||
"mcp.method.name": method,
|
||||
"fastmcp.server.name": server_name,
|
||||
**get_protocol_span_attributes(),
|
||||
**get_auth_span_attributes(),
|
||||
**get_session_span_attributes(),
|
||||
}
|
||||
tracer = get_tracer()
|
||||
with tracer.start_as_current_span(
|
||||
method,
|
||||
context=_get_parent_trace_context(),
|
||||
kind=SpanKind.SERVER,
|
||||
attributes=attrs,
|
||||
) as span:
|
||||
if span.is_recording():
|
||||
span.set_attribute(SEAM_SPAN_MARKER, True)
|
||||
span.set_attributes(
|
||||
{
|
||||
"mcp.method.name": method,
|
||||
"fastmcp.server.name": server_name,
|
||||
**get_protocol_span_attributes(),
|
||||
**get_auth_span_attributes(),
|
||||
**get_session_span_attributes(),
|
||||
}
|
||||
)
|
||||
token = _active_seam_span.set(span)
|
||||
try:
|
||||
yield span
|
||||
|
|
@ -218,9 +216,8 @@ def server_span(
|
|||
name,
|
||||
context=_get_parent_trace_context(),
|
||||
kind=SpanKind.SERVER,
|
||||
attributes=attrs,
|
||||
) as span:
|
||||
if span.is_recording():
|
||||
span.set_attributes(attrs)
|
||||
try:
|
||||
yield span
|
||||
except Exception as e:
|
||||
|
|
@ -240,16 +237,15 @@ def delegate_span(
|
|||
Used by FastMCPProvider when delegating to mounted servers.
|
||||
Automatically records any exception on the span and sets error status.
|
||||
"""
|
||||
attrs: dict[str, str] = {
|
||||
"fastmcp.provider.type": provider_type,
|
||||
"fastmcp.component.key": component_key,
|
||||
}
|
||||
if method is not None:
|
||||
attrs["mcp.method.name"] = method
|
||||
|
||||
tracer = get_tracer()
|
||||
with tracer.start_as_current_span(f"delegate {name}") as span:
|
||||
if span.is_recording():
|
||||
attrs: dict[str, str] = {
|
||||
"fastmcp.provider.type": provider_type,
|
||||
"fastmcp.component.key": component_key,
|
||||
}
|
||||
if method is not None:
|
||||
attrs["mcp.method.name"] = method
|
||||
span.set_attributes(attrs)
|
||||
with tracer.start_as_current_span(f"delegate {name}", attributes=attrs) as span:
|
||||
try:
|
||||
yield span
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ from __future__ import annotations
|
|||
|
||||
import pytest
|
||||
from mcp_types import TextContent
|
||||
from opentelemetry.context import Context as OTelContext
|
||||
from opentelemetry.sdk.trace import Span, SpanProcessor, TracerProvider
|
||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
||||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
||||
from opentelemetry.trace import StatusCode
|
||||
|
||||
|
|
@ -17,6 +20,28 @@ from fastmcp import Client, Context, FastMCP
|
|||
from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams
|
||||
|
||||
|
||||
class OnStartRecorder(SpanProcessor):
|
||||
def __init__(self) -> None:
|
||||
self.attributes: dict[str, dict[str, object]] = {}
|
||||
|
||||
def on_start(self, span: Span, parent_context: OTelContext | None = None) -> None:
|
||||
self.attributes[span.name] = dict(span.attributes or {})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def on_start_recorder(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
trace_exporter: InMemorySpanExporter,
|
||||
) -> OnStartRecorder:
|
||||
recorder = OnStartRecorder()
|
||||
provider = TracerProvider()
|
||||
provider.add_span_processor(recorder)
|
||||
provider.add_span_processor(SimpleSpanProcessor(trace_exporter))
|
||||
tracer = provider.get_tracer("test")
|
||||
monkeypatch.setattr("fastmcp.server.sampling.run.get_tracer", lambda: tracer)
|
||||
return recorder
|
||||
|
||||
|
||||
def _spans_named(exporter: InMemorySpanExporter, name: str):
|
||||
return [s for s in exporter.get_finished_spans() if s.name == name]
|
||||
|
||||
|
|
@ -27,7 +52,9 @@ def _exception_events(span):
|
|||
|
||||
class TestSamplingCreateMessageSpan:
|
||||
async def test_success_creates_span_with_attributes(
|
||||
self, trace_exporter: InMemorySpanExporter
|
||||
self,
|
||||
trace_exporter: InMemorySpanExporter,
|
||||
on_start_recorder: OnStartRecorder,
|
||||
):
|
||||
def sampling_handler(
|
||||
messages: list[SamplingMessage],
|
||||
|
|
@ -52,6 +79,10 @@ class TestSamplingCreateMessageSpan:
|
|||
assert span.attributes is not None
|
||||
assert span.attributes["mcp.method.name"] == "sampling/createMessage"
|
||||
assert span.attributes["fastmcp.server.name"] == "sampling-server"
|
||||
assert on_start_recorder.attributes["sampling create_message"] == {
|
||||
"mcp.method.name": "sampling/createMessage",
|
||||
"fastmcp.server.name": "sampling-server",
|
||||
}
|
||||
# Success path must not record any exception.
|
||||
assert _exception_events(span) == []
|
||||
assert span.status.status_code != StatusCode.ERROR
|
||||
|
|
@ -93,7 +124,9 @@ class TestSamplingCreateMessageSpan:
|
|||
|
||||
class TestSamplingToolSpan:
|
||||
async def test_tool_error_span_records_exception_once(
|
||||
self, trace_exporter: InMemorySpanExporter
|
||||
self,
|
||||
trace_exporter: InMemorySpanExporter,
|
||||
on_start_recorder: OnStartRecorder,
|
||||
):
|
||||
from mcp_types import CreateMessageResultWithTools, ToolUseContent
|
||||
|
||||
|
|
@ -146,6 +179,10 @@ class TestSamplingToolSpan:
|
|||
assert span.status.status_code == StatusCode.ERROR
|
||||
assert span.attributes is not None
|
||||
assert span.attributes["gen_ai.tool.name"] == "boom_tool"
|
||||
assert on_start_recorder.attributes["sampling tool boom_tool"] == {
|
||||
"gen_ai.tool.name": "boom_tool",
|
||||
"fastmcp.tool.use_id": "call_1",
|
||||
}
|
||||
assert "error.type" in span.attributes
|
||||
# Tool spans catch-and-convert (no re-raise), so OTel auto-recording
|
||||
# never fires; the manual record_exception must fire exactly once.
|
||||
|
|
|
|||
74
tests/telemetry/test_span_attributes.py
Normal file
74
tests/telemetry/test_span_attributes.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import pytest
|
||||
from opentelemetry.context import Context
|
||||
from opentelemetry.sdk.trace import Span, SpanProcessor, TracerProvider
|
||||
|
||||
from fastmcp.client.telemetry import client_span
|
||||
from fastmcp.server.telemetry import delegate_span, seam_span, server_span
|
||||
|
||||
|
||||
class OnStartRecorder(SpanProcessor):
|
||||
def __init__(self) -> None:
|
||||
self.attributes: dict[str, dict[str, object]] = {}
|
||||
|
||||
def on_start(self, span: Span, parent_context: Context | None = None) -> None:
|
||||
self.attributes[span.name] = dict(span.attributes or {})
|
||||
|
||||
|
||||
def test_known_span_attributes_are_available_on_start(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
recorder = OnStartRecorder()
|
||||
provider = TracerProvider()
|
||||
provider.add_span_processor(recorder)
|
||||
tracer = provider.get_tracer("test")
|
||||
monkeypatch.setattr("fastmcp.client.telemetry.get_tracer", lambda: tracer)
|
||||
monkeypatch.setattr("fastmcp.server.telemetry.get_tracer", lambda: tracer)
|
||||
|
||||
with client_span(
|
||||
"client test",
|
||||
method="tools/call",
|
||||
component_key="tool:echo@",
|
||||
tool_name="echo",
|
||||
):
|
||||
pass
|
||||
with server_span(
|
||||
"server test",
|
||||
method="tools/call",
|
||||
server_name="test-server",
|
||||
component_type="tool",
|
||||
component_key="tool:echo@",
|
||||
tool_name="echo",
|
||||
):
|
||||
pass
|
||||
with seam_span("initialize test", server_name="test-server"):
|
||||
pass
|
||||
with delegate_span(
|
||||
"delegate test",
|
||||
provider_type="LocalProvider",
|
||||
component_key="tool:echo@",
|
||||
method="tools/call",
|
||||
):
|
||||
pass
|
||||
|
||||
assert recorder.attributes["client test"] == {
|
||||
"mcp.method.name": "tools/call",
|
||||
"fastmcp.component.key": "tool:echo@",
|
||||
"gen_ai.tool.name": "echo",
|
||||
}
|
||||
assert recorder.attributes["server test"] == {
|
||||
"mcp.method.name": "tools/call",
|
||||
"fastmcp.server.name": "test-server",
|
||||
"fastmcp.component.type": "tool",
|
||||
"fastmcp.component.key": "tool:echo@",
|
||||
"gen_ai.tool.name": "echo",
|
||||
}
|
||||
assert recorder.attributes["initialize test"] == {
|
||||
"fastmcp.span.seam": True,
|
||||
"mcp.method.name": "initialize test",
|
||||
"fastmcp.server.name": "test-server",
|
||||
}
|
||||
assert recorder.attributes["delegate delegate test"] == {
|
||||
"fastmcp.provider.type": "LocalProvider",
|
||||
"fastmcp.component.key": "tool:echo@",
|
||||
"mcp.method.name": "tools/call",
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue