Skip to content

Commit 937e048

Browse files
committed
refactor(MCP): Derive span attributes from baggage
Set the client identity and tool name once, as W3C Baggage, and copy flagsmith.* entries onto spans with a span processor — replacing the separate clientInfo span annotation. The off-the-shelf opentelemetry-processor-baggage reads baggage from the span's parent context, which FastMCP builds from the request _meta without baggage, so the processor reads the current context instead. beep boop
1 parent eee6c1e commit 937e048

4 files changed

Lines changed: 87 additions & 34 deletions

File tree

mcp/src/flagsmith_mcp/telemetry.py

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Any
2+
13
import mcp.types as mt
24
from common.core.logging import setup_logging
35
from common.core.otel import (
@@ -20,33 +22,55 @@
2022
APPLICATION_LOGGERS = ["flagsmith_mcp", "fastmcp", "mcp"]
2123

2224

23-
class ClientInfoSpanProcessor(SpanProcessor):
24-
"""Annotate started spans with the MCP client's self-declared identity."""
25+
def is_flagsmith_baggage_key(key: str) -> bool:
26+
return key.startswith("flagsmith.")
27+
28+
29+
class FlagsmithBaggageSpanProcessor(SpanProcessor):
30+
"""Copy flagsmith.* baggage entries onto started spans.
31+
32+
The off-the-shelf opentelemetry-processor-baggage reads baggage from
33+
the span's parent context, but FastMCP starts its server spans from a
34+
context extracted from the request _meta, which carries no baggage.
35+
Read the current context, where middleware attached the entries.
36+
"""
2537

2638
def on_start(self, span: Span, parent_context: Context | None = None) -> None:
27-
if (client_info := get_client_info()) is not None:
28-
span.set_attribute("flagsmith.client.name", client_info.name)
29-
span.set_attribute("flagsmith.client.version", client_info.version)
39+
for key, value in baggage.get_all().items():
40+
if is_flagsmith_baggage_key(key):
41+
span.set_attribute(key, str(value))
3042

3143

3244
class BaggageMiddleware(Middleware):
33-
"""Attach the tool name and client identity as W3C Baggage, propagated
34-
to the Flagsmith API by the instrumented upstream HTTP client."""
45+
"""Attach the client identity and tool name as W3C Baggage: the single
46+
source both for span attributes (via BaggageSpanProcessor) and for
47+
propagation to the Flagsmith API by the instrumented upstream client."""
48+
49+
async def on_request(
50+
self,
51+
context: MiddlewareContext[mt.Request[Any, Any]],
52+
call_next: CallNext[mt.Request[Any, Any], Any],
53+
) -> Any:
54+
if (client_info := get_client_info()) is None:
55+
return await call_next(context)
56+
ctx = baggage.set_baggage("flagsmith.client.name", client_info.name)
57+
ctx = baggage.set_baggage(
58+
"flagsmith.client.version", client_info.version, context=ctx
59+
)
60+
token = otel_context.attach(ctx)
61+
try:
62+
return await call_next(context)
63+
finally:
64+
otel_context.detach(token)
3565

3666
async def on_call_tool(
3767
self,
3868
context: MiddlewareContext[mt.CallToolRequestParams],
3969
call_next: CallNext[mt.CallToolRequestParams, ToolResult],
4070
) -> ToolResult:
41-
ctx = baggage.set_baggage("flagsmith.tool.name", context.message.name)
42-
if (client_info := get_client_info()) is not None:
43-
ctx = baggage.set_baggage(
44-
"flagsmith.client.name", client_info.name, context=ctx
45-
)
46-
ctx = baggage.set_baggage(
47-
"flagsmith.client.version", client_info.version, context=ctx
48-
)
49-
token = otel_context.attach(ctx)
71+
token = otel_context.attach(
72+
baggage.set_baggage("flagsmith.tool.name", context.message.name)
73+
)
5074
try:
5175
return await call_next(context)
5276
finally:
@@ -73,7 +97,7 @@ def setup_telemetry(settings: config.Settings) -> None:
7397
endpoint=f"{endpoint}/v1/traces",
7498
service_name=settings.otel_service_name,
7599
)
76-
tracer_provider.add_span_processor(ClientInfoSpanProcessor())
100+
tracer_provider.add_span_processor(FlagsmithBaggageSpanProcessor())
77101
trace.set_tracer_provider(tracer_provider)
78102
setup_logging(
79103
log_level=settings.log_level,

mcp/tests/integration/conftest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
from flagsmith_mcp import config, constants
1818
from flagsmith_mcp import server as server_module
19-
from flagsmith_mcp.telemetry import ClientInfoSpanProcessor
19+
from flagsmith_mcp.telemetry import FlagsmithBaggageSpanProcessor
2020

2121
HTTPClientFactoryFixture = Callable[[FastMCP], AsyncIterator[httpx.AsyncClient]]
2222

@@ -28,7 +28,7 @@ def span_exporter() -> InMemorySpanExporter:
2828
exporter = InMemorySpanExporter()
2929
provider = TracerProvider()
3030
provider.add_span_processor(SimpleSpanProcessor(exporter))
31-
provider.add_span_processor(ClientInfoSpanProcessor())
31+
provider.add_span_processor(FlagsmithBaggageSpanProcessor())
3232
trace.set_tracer_provider(provider)
3333
return exporter
3434

mcp/tests/integration/test_spans.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ async def test_spans__tool_call__annotated_with_client_identity(
2929
assert span.attributes is not None
3030
assert {
3131
"gen_ai.tool.name": "list_environments",
32+
"flagsmith.tool.name": "list_environments",
3233
"flagsmith.client.name": "mcp",
3334
"flagsmith.client.version": "0.1.0",
3435
}.items() <= dict(span.attributes).items()
@@ -47,7 +48,7 @@ async def test_spans__tool_call__upstream_request_carries_baggage(
4748

4849
# Then the instrumented API client propagated W3C Baggage
4950
assert route.calls.last.request.headers["baggage"] == (
50-
"flagsmith.tool.name=list_environments,"
5151
"flagsmith.client.name=mcp,"
52-
"flagsmith.client.version=0.1.0"
52+
"flagsmith.client.version=0.1.0,"
53+
"flagsmith.tool.name=list_environments"
5354
)

mcp/tests/unit/test_telemetry.py

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from common.core.otel import add_otel_trace_context
44
from opentelemetry import baggage
5+
from opentelemetry import context as otel_context
56
from pytest_mock import MockerFixture
67

78
from flagsmith_mcp import config, telemetry
@@ -76,7 +77,7 @@ def test_setup_telemetry__otlp_endpoint__exports_logs_and_traces(
7677
[span_processor] = (
7778
build_tracer_provider_mock.return_value.add_span_processor.call_args.args
7879
)
79-
assert isinstance(span_processor, telemetry.ClientInfoSpanProcessor)
80+
assert isinstance(span_processor, telemetry.FlagsmithBaggageSpanProcessor)
8081
setup_logging_mock.assert_called_once_with(
8182
log_level="DEBUG",
8283
log_format="json",
@@ -88,26 +89,33 @@ def test_setup_telemetry__otlp_endpoint__exports_logs_and_traces(
8889
)
8990

9091

91-
def test_client_info_span_processor__outside_request_context__no_attributes(
92+
async def test_baggage_middleware__uninitialised_session__tool_name_baggage_only(
9293
mocker: MockerFixture,
9394
) -> None:
94-
# Given no MCP request context
95-
span = mocker.Mock()
95+
# Given a tool call outside an initialised session
96+
middleware = telemetry.BaggageMiddleware()
97+
context = mocker.Mock()
98+
context.message.name = "list_environments"
99+
seen_baggage: dict[str, object] = {}
100+
101+
async def record_baggage(ctx: object) -> None:
102+
seen_baggage.update(baggage.get_all())
103+
104+
call_next = mocker.AsyncMock(side_effect=record_baggage)
96105

97106
# When
98-
telemetry.ClientInfoSpanProcessor().on_start(span)
107+
await middleware.on_call_tool(context, call_next)
99108

100109
# Then
101-
span.set_attribute.assert_not_called()
110+
assert seen_baggage == {"flagsmith.tool.name": "list_environments"}
111+
assert baggage.get_all() == {}
102112

103113

104-
async def test_baggage_middleware__uninitialised_session__tool_name_baggage_only(
114+
async def test_baggage_middleware__uninitialised_session__request_baggage_untouched(
105115
mocker: MockerFixture,
106116
) -> None:
107-
# Given a tool call outside an initialised session
117+
# Given a request outside an initialised session
108118
middleware = telemetry.BaggageMiddleware()
109-
context = mocker.Mock()
110-
context.message.name = "list_environments"
111119
seen_baggage: dict[str, object] = {}
112120

113121
async def record_baggage(ctx: object) -> None:
@@ -116,8 +124,28 @@ async def record_baggage(ctx: object) -> None:
116124
call_next = mocker.AsyncMock(side_effect=record_baggage)
117125

118126
# When
119-
await middleware.on_call_tool(context, call_next)
127+
await middleware.on_request(mocker.Mock(), call_next)
120128

121129
# Then
122-
assert seen_baggage == {"flagsmith.tool.name": "list_environments"}
123-
assert baggage.get_all() == {}
130+
assert seen_baggage == {}
131+
132+
133+
def test_flagsmith_baggage_span_processor__foreign_baggage__not_copied(
134+
mocker: MockerFixture,
135+
) -> None:
136+
# Given baggage with flagsmith and foreign entries
137+
span = mocker.Mock()
138+
ctx = baggage.set_baggage("other.key", "x")
139+
ctx = baggage.set_baggage("flagsmith.tool.name", "list_environments", context=ctx)
140+
token = otel_context.attach(ctx)
141+
142+
# When
143+
try:
144+
telemetry.FlagsmithBaggageSpanProcessor().on_start(span)
145+
finally:
146+
otel_context.detach(token)
147+
148+
# Then
149+
span.set_attribute.assert_called_once_with(
150+
"flagsmith.tool.name", "list_environments"
151+
)

0 commit comments

Comments
 (0)