From a9c423abfccb25e74834b7d924ae42df0475b3bf Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 4 Jun 2026 11:36:30 +0100 Subject: [PATCH 1/8] feat(MCP): Emit session and tool call events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contributes to Flagsmith/flagsmith-private#152. A middleware emits structured events on the mcp domain, identifying clients by their self-declared clientInfo from the initialize handshake — User-Agents proved unreliable (most TS SDK clients send runtime defaults), and raw names beat server-side allow-lists, so classification happens downstream: - session.opened on initialize, with flagsmith.client.name/version - tool.called for every tools/call, with tool, client, status and duration A span processor annotates FastMCP's server spans with the same flagsmith.client.* attributes. The flagsmith. namespace avoids squatting on the semconv mcp. and client. namespaces; flagsmith-common already emits flagsmith.event. beep boop --- mcp/pyproject.toml | 1 + mcp/src/flagsmith_mcp/events.py | 70 +++++++++++++++++++++++++++ mcp/src/flagsmith_mcp/server.py | 2 + mcp/src/flagsmith_mcp/telemetry.py | 22 +++++++-- mcp/tests/integration/conftest.py | 25 ++++++++++ mcp/tests/integration/test_events.py | 71 ++++++++++++++++++++++++++++ mcp/tests/integration/test_spans.py | 34 +++++++++++++ mcp/tests/unit/test_event_logging.py | 44 +++++++++++++++++ mcp/tests/unit/test_telemetry.py | 17 +++++++ mcp/uv.lock | 15 ++++++ 10 files changed, 296 insertions(+), 5 deletions(-) create mode 100644 mcp/src/flagsmith_mcp/events.py create mode 100644 mcp/tests/integration/test_events.py create mode 100644 mcp/tests/integration/test_spans.py create mode 100644 mcp/tests/unit/test_event_logging.py diff --git a/mcp/pyproject.toml b/mcp/pyproject.toml index 8769cdf847a4..c7a8df0d6aa7 100644 --- a/mcp/pyproject.toml +++ b/mcp/pyproject.toml @@ -24,6 +24,7 @@ dev = [ "pytest-asyncio>=1.3.0,<2.0.0", # Run asynchronous tests "pytest-cov>=7.0.0,<8.0.0", # Measure test coverage "pytest-mock>=3.15.1,<4.0.0", # Mock via fixtures + "pytest-structlog>=1.1,<2.0.0", # Assert structlog events "respx>=0.22,<1.0", # Mock HTTP interactions "ruff>=0.15.12,<0.16.0", # Lint and format ] diff --git a/mcp/src/flagsmith_mcp/events.py b/mcp/src/flagsmith_mcp/events.py new file mode 100644 index 000000000000..66e362653531 --- /dev/null +++ b/mcp/src/flagsmith_mcp/events.py @@ -0,0 +1,70 @@ +import time + +import mcp.types as mt +import structlog +from fastmcp.server.dependencies import get_context +from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext +from fastmcp.tools.base import ToolResult + +logger = structlog.get_logger("mcp") + + +def get_client_info() -> mt.Implementation | None: + """The connected client's self-declared identity, captured by the + session during initialize.""" + try: + client_params = get_context().session.client_params + except RuntimeError: + return None + if client_params is None: + return None + return client_params.clientInfo + + +class EventLoggingMiddleware(Middleware): + """Emit structured product events for MCP sessions and tool calls.""" + + async def on_initialize( + self, + context: MiddlewareContext[mt.InitializeRequest], + call_next: CallNext[mt.InitializeRequest, mt.InitializeResult | None], + ) -> mt.InitializeResult | None: + result = await call_next(context) + client_info = context.message.params.clientInfo + logger.info( + "session.opened", + flagsmith__client__name=client_info.name, + flagsmith__client__version=client_info.version, + ) + return result + + async def on_call_tool( + self, + context: MiddlewareContext[mt.CallToolRequestParams], + call_next: CallNext[mt.CallToolRequestParams, ToolResult], + ) -> ToolResult: + client_info = get_client_info() + client_name = client_info.name if client_info else "" + client_version = client_info.version if client_info else "" + start = time.perf_counter() + try: + result = await call_next(context) + except Exception: + logger.info( + "tool.called", + tool__name=context.message.name, + flagsmith__client__name=client_name, + flagsmith__client__version=client_version, + status="error", + duration_ms=(time.perf_counter() - start) * 1000, + ) + raise + logger.info( + "tool.called", + tool__name=context.message.name, + flagsmith__client__name=client_name, + flagsmith__client__version=client_version, + status="success", + duration_ms=(time.perf_counter() - start) * 1000, + ) + return result diff --git a/mcp/src/flagsmith_mcp/server.py b/mcp/src/flagsmith_mcp/server.py index 8ea173bbcdad..069e3921d50e 100644 --- a/mcp/src/flagsmith_mcp/server.py +++ b/mcp/src/flagsmith_mcp/server.py @@ -12,6 +12,7 @@ from flagsmith_mcp import config, constants from flagsmith_mcp.auth import FlagsmithAuth +from flagsmith_mcp.events import EventLoggingMiddleware from flagsmith_mcp.metrics import PrometheusMiddleware from flagsmith_mcp.oauth import FlagsmithResourceAuth from flagsmith_mcp.telemetry import setup_telemetry @@ -70,6 +71,7 @@ def create_server(settings: config.Settings) -> FastMCP[None]: ) server.add_middleware(PrometheusMiddleware()) + server.add_middleware(EventLoggingMiddleware()) @server.custom_route("/health", methods=["GET"]) async def health(request: Request) -> PlainTextResponse: diff --git a/mcp/src/flagsmith_mcp/telemetry.py b/mcp/src/flagsmith_mcp/telemetry.py index 2b101ccaa179..b91d14a4ebf5 100644 --- a/mcp/src/flagsmith_mcp/telemetry.py +++ b/mcp/src/flagsmith_mcp/telemetry.py @@ -6,13 +6,25 @@ make_structlog_otel_processor, ) from opentelemetry import trace +from opentelemetry.context import Context +from opentelemetry.sdk.trace import Span, SpanProcessor from structlog.typing import Processor from flagsmith_mcp import config +from flagsmith_mcp.events import get_client_info APPLICATION_LOGGERS = ["flagsmith_mcp", "fastmcp", "mcp"] +class ClientInfoSpanProcessor(SpanProcessor): + """Annotate started spans with the MCP client's self-declared identity.""" + + def on_start(self, span: Span, parent_context: Context | None = None) -> None: + if (client_info := get_client_info()) is not None: + span.set_attribute("flagsmith.client.name", client_info.name) + span.set_attribute("flagsmith.client.version", client_info.version) + + def setup_telemetry(settings: config.Settings) -> None: """Set up logging, exporting structlog events and traces to OpenTelemetry when an OTLP endpoint is configured.""" @@ -29,12 +41,12 @@ def setup_telemetry(settings: config.Settings) -> None: ] # Setting a global tracer provider also activates FastMCP's built-in # per-request server spans. - trace.set_tracer_provider( - build_tracer_provider( - endpoint=f"{endpoint}/v1/traces", - service_name=settings.otel_service_name, - ) + tracer_provider = build_tracer_provider( + endpoint=f"{endpoint}/v1/traces", + service_name=settings.otel_service_name, ) + tracer_provider.add_span_processor(ClientInfoSpanProcessor()) + trace.set_tracer_provider(tracer_provider) setup_logging( log_level=settings.log_level, log_format=settings.log_format, diff --git a/mcp/tests/integration/conftest.py b/mcp/tests/integration/conftest.py index 79b25b9326b8..6ac100d53ded 100644 --- a/mcp/tests/integration/conftest.py +++ b/mcp/tests/integration/conftest.py @@ -6,14 +6,39 @@ import pytest from fastmcp import Client, FastMCP from fastmcp.client.transports import FastMCPTransport +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) from respx import MockRouter from flagsmith_mcp import config, constants from flagsmith_mcp import server as server_module +from flagsmith_mcp.telemetry import ClientInfoSpanProcessor HTTPClientFactoryFixture = Callable[[FastMCP], AsyncIterator[httpx.AsyncClient]] +@pytest.fixture(scope="session") +def span_exporter() -> InMemorySpanExporter: + # The global tracer provider can only be set once per process, hence + # the session scope. + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + provider.add_span_processor(ClientInfoSpanProcessor()) + trace.set_tracer_provider(provider) + return exporter + + +@pytest.fixture +def finished_spans(span_exporter: InMemorySpanExporter) -> InMemorySpanExporter: + span_exporter.clear() + return span_exporter + + @pytest.fixture def openapi_spec() -> openapi.OpenAPI: ok = openapi.Response(description="OK") diff --git a/mcp/tests/integration/test_events.py b/mcp/tests/integration/test_events.py new file mode 100644 index 000000000000..8ad8612c527d --- /dev/null +++ b/mcp/tests/integration/test_events.py @@ -0,0 +1,71 @@ +import pytest +from fastmcp import Client +from fastmcp.client.transports import FastMCPTransport +from fastmcp.exceptions import ToolError +from pytest_structlog import StructuredLogCapture +from respx import MockRouter + + +async def test_events__session_initialised__emits_session_opened( + log: StructuredLogCapture, + client: Client[FastMCPTransport], +) -> None: + # Given the server started via the client fixture + # When the session is initialised by the fixture + # Then the client's self-declared identity is reported + assert log.has( + "session.opened", + flagsmith__client__name="mcp", + flagsmith__client__version="0.1.0", + ) + + +async def test_events__successful_tool_call__emits_tool_called( + log: StructuredLogCapture, + client: Client[FastMCPTransport], + respx_mock: MockRouter, +) -> None: + # Given + respx_mock.get("https://api.flagsmith.com/environments/").respond( + json={"results": []} + ) + + # When + await client.call_tool("list_environments", {}) + + # Then + [event] = [e for e in log.events if e["event"] == "tool.called"] + assert event.pop("duration_ms") >= 0 + assert event == { + "event": "tool.called", + "level": "info", + "tool__name": "list_environments", + "flagsmith__client__name": "mcp", + "flagsmith__client__version": "0.1.0", + "status": "success", + } + + +async def test_events__failing_tool_call__emits_tool_called_with_error_status( + log: StructuredLogCapture, + client: Client[FastMCPTransport], + respx_mock: MockRouter, +) -> None: + # Given + respx_mock.get("https://api.flagsmith.com/environments/").respond(status_code=502) + + # When + with pytest.raises(ToolError): + await client.call_tool("list_environments", {}) + + # Then + [event] = [e for e in log.events if e["event"] == "tool.called"] + assert event.pop("duration_ms") >= 0 + assert event == { + "event": "tool.called", + "level": "info", + "tool__name": "list_environments", + "flagsmith__client__name": "mcp", + "flagsmith__client__version": "0.1.0", + "status": "error", + } diff --git a/mcp/tests/integration/test_spans.py b/mcp/tests/integration/test_spans.py new file mode 100644 index 000000000000..0faba0091dfb --- /dev/null +++ b/mcp/tests/integration/test_spans.py @@ -0,0 +1,34 @@ +from fastmcp import Client +from fastmcp.client.transports import FastMCPTransport +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) +from opentelemetry.trace import SpanKind +from respx import MockRouter + + +async def test_spans__tool_call__annotated_with_client_identity( + client: Client[FastMCPTransport], + respx_mock: MockRouter, + finished_spans: InMemorySpanExporter, +) -> None: + # Given + respx_mock.get("https://api.flagsmith.com/environments/").respond( + json={"results": []} + ) + + # When + await client.call_tool("list_environments", {}) + + # Then the FastMCP server span carries the client's identity + [span] = [ + s + for s in finished_spans.get_finished_spans() + if s.kind == SpanKind.SERVER and s.name == "tools/call list_environments" + ] + assert span.attributes is not None + assert { + "gen_ai.tool.name": "list_environments", + "flagsmith.client.name": "mcp", + "flagsmith.client.version": "0.1.0", + }.items() <= dict(span.attributes).items() diff --git a/mcp/tests/unit/test_event_logging.py b/mcp/tests/unit/test_event_logging.py new file mode 100644 index 000000000000..f76f050be98f --- /dev/null +++ b/mcp/tests/unit/test_event_logging.py @@ -0,0 +1,44 @@ +from pytest_mock import MockerFixture +from pytest_structlog import StructuredLogCapture + +from flagsmith_mcp import events + + +def test_get_client_info__outside_request_context__returns_none() -> None: + # Given no MCP request context + # When / Then + assert events.get_client_info() is None + + +def test_get_client_info__uninitialised_session__returns_none( + mocker: MockerFixture, +) -> None: + # Given a session that has not completed initialize + context_mock = mocker.patch.object(events, "get_context", autospec=True) + context_mock.return_value.session.client_params = None + + # When / Then + assert events.get_client_info() is None + + +async def test_event_logging_middleware__uninitialised_session__empty_client_identity( + mocker: MockerFixture, + log: StructuredLogCapture, +) -> None: + # Given a tool call outside an initialised session + middleware = events.EventLoggingMiddleware() + context = mocker.Mock() + context.message.name = "list_environments" + call_next = mocker.AsyncMock() + + # When + await middleware.on_call_tool(context, call_next) + + # Then + assert log.has( + "tool.called", + tool__name="list_environments", + flagsmith__client__name="", + flagsmith__client__version="", + status="success", + ) diff --git a/mcp/tests/unit/test_telemetry.py b/mcp/tests/unit/test_telemetry.py index c47f62cb9f0a..c4fdd5076981 100644 --- a/mcp/tests/unit/test_telemetry.py +++ b/mcp/tests/unit/test_telemetry.py @@ -72,6 +72,10 @@ def test_setup_telemetry__otlp_endpoint__exports_logs_and_traces( set_tracer_provider_mock.assert_called_once_with( build_tracer_provider_mock.return_value ) + [span_processor] = ( + build_tracer_provider_mock.return_value.add_span_processor.call_args.args + ) + assert isinstance(span_processor, telemetry.ClientInfoSpanProcessor) setup_logging_mock.assert_called_once_with( log_level="DEBUG", log_format="json", @@ -81,3 +85,16 @@ def test_setup_telemetry__otlp_endpoint__exports_logs_and_traces( make_structlog_otel_processor_mock.return_value, ], ) + + +def test_client_info_span_processor__outside_request_context__no_attributes( + mocker: MockerFixture, +) -> None: + # Given no MCP request context + span = mocker.Mock() + + # When + telemetry.ClientInfoSpanProcessor().on_start(span) + + # Then + span.set_attribute.assert_not_called() diff --git a/mcp/uv.lock b/mcp/uv.lock index 0d7418ecc4c6..a5a8f8f3d41c 100644 --- a/mcp/uv.lock +++ b/mcp/uv.lock @@ -668,6 +668,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-mock" }, + { name = "pytest-structlog" }, { name = "respx" }, { name = "ruff" }, ] @@ -689,6 +690,7 @@ dev = [ { name = "pytest-asyncio", specifier = ">=1.3.0,<2.0.0" }, { name = "pytest-cov", specifier = ">=7.0.0,<8.0.0" }, { name = "pytest-mock", specifier = ">=3.15.1,<4.0.0" }, + { name = "pytest-structlog", specifier = ">=1.1,<2.0.0" }, { name = "respx", specifier = ">=0.22,<1.0" }, { name = "ruff", specifier = ">=0.15.12,<0.16.0" }, ] @@ -1553,6 +1555,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, ] +[[package]] +name = "pytest-structlog" +version = "1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "structlog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/71/177ef678fde8257c10ae822080811467c0fc893c17daac4c065e0836cd63/pytest_structlog-1.2.tar.gz", hash = "sha256:11b37487663a990bcd490da2e68e2f96b0ecd6a1704f5e82561d5840bd321a60", size = 13285, upload-time = "2025-09-10T02:32:35.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/3f/cce51dfeb5f1956a30f480611921e9a899ae5cc5f41bb24858ac99401b17/pytest_structlog-1.2-py3-none-any.whl", hash = "sha256:a5659f6b7d43aa53cf8090075deadad35a6bf1899073ea18118e299232d3d6f0", size = 8636, upload-time = "2025-09-10T02:32:34.952Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" From 1aaff8c40df5b1259bc50f37b7999fd6982c7229 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 4 Jun 2026 15:22:19 +0100 Subject: [PATCH 2/8] feat(MCP): Propagate client identity and tool name to the API Instrument the Flagsmith API httpx client (instance-only, via opentelemetry-instrumentation-httpx) so every upstream call emits a client span and carries W3C trace context and baggage. A middleware sets flagsmith.tool.name and flagsmith.client.name/version as baggage per tool call; the API's existing OTel pipeline extracts baggage and copies it onto its own events, giving per-client, per-tool visibility on both sides of the boundary. beep boop --- mcp/pyproject.toml | 1 + mcp/src/flagsmith_mcp/server.py | 16 ++-- mcp/src/flagsmith_mcp/telemetry.py | 30 ++++++- mcp/tests/integration/test_spans.py | 19 +++++ mcp/tests/unit/test_telemetry.py | 23 ++++++ mcp/uv.lock | 117 ++++++++++++++++++++++++++++ 6 files changed, 200 insertions(+), 6 deletions(-) diff --git a/mcp/pyproject.toml b/mcp/pyproject.toml index c7a8df0d6aa7..6f3a011d4e1b 100644 --- a/mcp/pyproject.toml +++ b/mcp/pyproject.toml @@ -8,6 +8,7 @@ requires-python = ">=3.11" dependencies = [ "fastmcp>=3.3.1,<4.0.0", # Base MCP functionality "flagsmith-common[otel]>=3.10.0,<4.0.0", # Logging and OTel export + "opentelemetry-instrumentation-httpx>=0.46b0,<1.0.0", # Trace upstream API calls "prometheus-client>=0.21.0,<1.0.0", # Export Prometheus metrics "pydantic-settings>=2.0.0,<3.0.0", # Environment-driven configuration ] diff --git a/mcp/src/flagsmith_mcp/server.py b/mcp/src/flagsmith_mcp/server.py index 069e3921d50e..b0783f77752f 100644 --- a/mcp/src/flagsmith_mcp/server.py +++ b/mcp/src/flagsmith_mcp/server.py @@ -6,6 +6,7 @@ from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.openapi.models import HttpMethod, HTTPRoute from mcp.types import ToolAnnotations +from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor from prometheus_client import start_http_server from starlette.requests import Request from starlette.responses import PlainTextResponse @@ -15,7 +16,7 @@ from flagsmith_mcp.events import EventLoggingMiddleware from flagsmith_mcp.metrics import PrometheusMiddleware from flagsmith_mcp.oauth import FlagsmithResourceAuth -from flagsmith_mcp.telemetry import setup_telemetry +from flagsmith_mcp.telemetry import BaggageMiddleware, setup_telemetry ROUTE_MAPS = [ RouteMap(tags={"mcp"}, mcp_type=MCPType.TOOL), @@ -57,12 +58,16 @@ def create_server(settings: config.Settings) -> FastMCP[None]: resource_url=settings.mcp_server_url, authorization_server=settings.flagsmith_api_url, ) + api_client = httpx.AsyncClient( + base_url=settings.flagsmith_api_url, + auth=FlagsmithAuth(settings.flagsmith_api_token), + ) + # Instrument only the Flagsmith API client: emit a span per upstream + # call and propagate W3C trace context and baggage to the API. + HTTPXClientInstrumentor().instrument_client(api_client) server = FastMCP.from_openapi( openapi_spec=_fetch_spec(), - client=httpx.AsyncClient( - base_url=settings.flagsmith_api_url, - auth=FlagsmithAuth(settings.flagsmith_api_token), - ), + client=api_client, name="Flagsmith", route_maps=ROUTE_MAPS, mcp_component_fn=_customise, @@ -72,6 +77,7 @@ def create_server(settings: config.Settings) -> FastMCP[None]: server.add_middleware(PrometheusMiddleware()) server.add_middleware(EventLoggingMiddleware()) + server.add_middleware(BaggageMiddleware()) @server.custom_route("/health", methods=["GET"]) async def health(request: Request) -> PlainTextResponse: diff --git a/mcp/src/flagsmith_mcp/telemetry.py b/mcp/src/flagsmith_mcp/telemetry.py index b91d14a4ebf5..0cae87f756d7 100644 --- a/mcp/src/flagsmith_mcp/telemetry.py +++ b/mcp/src/flagsmith_mcp/telemetry.py @@ -1,3 +1,4 @@ +import mcp.types as mt from common.core.logging import setup_logging from common.core.otel import ( add_otel_trace_context, @@ -5,7 +6,10 @@ build_tracer_provider, make_structlog_otel_processor, ) -from opentelemetry import trace +from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext +from fastmcp.tools.base import ToolResult +from opentelemetry import baggage, trace +from opentelemetry import context as otel_context from opentelemetry.context import Context from opentelemetry.sdk.trace import Span, SpanProcessor from structlog.typing import Processor @@ -25,6 +29,30 @@ def on_start(self, span: Span, parent_context: Context | None = None) -> None: span.set_attribute("flagsmith.client.version", client_info.version) +class BaggageMiddleware(Middleware): + """Attach the tool name and client identity as W3C Baggage, propagated + to the Flagsmith API by the instrumented upstream HTTP client.""" + + async def on_call_tool( + self, + context: MiddlewareContext[mt.CallToolRequestParams], + call_next: CallNext[mt.CallToolRequestParams, ToolResult], + ) -> ToolResult: + ctx = baggage.set_baggage("flagsmith.tool.name", context.message.name) + if (client_info := get_client_info()) is not None: + ctx = baggage.set_baggage( + "flagsmith.client.name", client_info.name, context=ctx + ) + ctx = baggage.set_baggage( + "flagsmith.client.version", client_info.version, context=ctx + ) + token = otel_context.attach(ctx) + try: + return await call_next(context) + finally: + otel_context.detach(token) + + def setup_telemetry(settings: config.Settings) -> None: """Set up logging, exporting structlog events and traces to OpenTelemetry when an OTLP endpoint is configured.""" diff --git a/mcp/tests/integration/test_spans.py b/mcp/tests/integration/test_spans.py index 0faba0091dfb..7714cd297d16 100644 --- a/mcp/tests/integration/test_spans.py +++ b/mcp/tests/integration/test_spans.py @@ -32,3 +32,22 @@ async def test_spans__tool_call__annotated_with_client_identity( "flagsmith.client.name": "mcp", "flagsmith.client.version": "0.1.0", }.items() <= dict(span.attributes).items() + + +async def test_spans__tool_call__upstream_request_carries_baggage( + client: Client[FastMCPTransport], + respx_mock: MockRouter, +) -> None: + # Given + route = respx_mock.get("https://api.flagsmith.com/environments/") + route.respond(json={"results": []}) + + # When + await client.call_tool("list_environments", {}) + + # Then the instrumented API client propagated W3C Baggage + assert route.calls.last.request.headers["baggage"] == ( + "flagsmith.tool.name=list_environments," + "flagsmith.client.name=mcp," + "flagsmith.client.version=0.1.0" + ) diff --git a/mcp/tests/unit/test_telemetry.py b/mcp/tests/unit/test_telemetry.py index c4fdd5076981..f73f98b669ca 100644 --- a/mcp/tests/unit/test_telemetry.py +++ b/mcp/tests/unit/test_telemetry.py @@ -1,6 +1,7 @@ import os from common.core.otel import add_otel_trace_context +from opentelemetry import baggage from pytest_mock import MockerFixture from flagsmith_mcp import config, telemetry @@ -98,3 +99,25 @@ def test_client_info_span_processor__outside_request_context__no_attributes( # Then span.set_attribute.assert_not_called() + + +async def test_baggage_middleware__uninitialised_session__tool_name_baggage_only( + mocker: MockerFixture, +) -> None: + # Given a tool call outside an initialised session + middleware = telemetry.BaggageMiddleware() + context = mocker.Mock() + context.message.name = "list_environments" + seen_baggage: dict[str, object] = {} + + async def record_baggage(ctx: object) -> None: + seen_baggage.update(baggage.get_all()) + + call_next = mocker.AsyncMock(side_effect=record_baggage) + + # When + await middleware.on_call_tool(context, call_next) + + # Then + assert seen_baggage == {"flagsmith.tool.name": "list_environments"} + assert baggage.get_all() == {} diff --git a/mcp/uv.lock b/mcp/uv.lock index a5a8f8f3d41c..d4087a9594e5 100644 --- a/mcp/uv.lock +++ b/mcp/uv.lock @@ -655,6 +655,7 @@ source = { editable = "." } dependencies = [ { name = "fastmcp" }, { name = "flagsmith-common", extra = ["otel"] }, + { name = "opentelemetry-instrumentation-httpx" }, { name = "prometheus-client" }, { name = "pydantic-settings" }, ] @@ -677,6 +678,7 @@ dev = [ requires-dist = [ { name = "fastmcp", specifier = ">=3.3.1,<4.0.0" }, { name = "flagsmith-common", extras = ["otel"], specifier = ">=3.10.0,<4.0.0" }, + { name = "opentelemetry-instrumentation-httpx", specifier = ">=0.46b0,<1.0.0" }, { name = "prometheus-client", specifier = ">=0.21.0,<1.0.0" }, { name = "pydantic-settings", specifier = ">=2.0.0,<3.0.0" }, ] @@ -1169,6 +1171,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/96/82cb223a1502f0787d4bbff12907f5f8d870a50731febcd5818d93ef9555/opentelemetry_exporter_otlp_proto_http-1.42.1-py3-none-any.whl", hash = "sha256:00a16da1b312a1d6c7233d600d557c91df71125af73020f3b9a7765bd699d59d", size = 21793, upload-time = "2026-05-21T16:32:35.277Z" }, ] +[[package]] +name = "opentelemetry-instrumentation" +version = "0.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/6d/4de72d97ff54db1ed270c7a59c9b904b917c0ac7af429c086c388b824ddb/opentelemetry_instrumentation-0.63b1.tar.gz", hash = "sha256:32368d6ae52c8de20aa790a6ad86b10a76f09956092337ae37d675773990e541", size = 41081, upload-time = "2026-05-21T16:36:14.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/a1/9314e621c143e4d82a5bf7a43c2ff7a745d31023506336857607c8c543cc/opentelemetry_instrumentation-0.63b1-py3-none-any.whl", hash = "sha256:f1986716d52cc316ea5f60189098726a9071d8ecc0eee96c9ed110be08bade9c", size = 35577, upload-time = "2026-05-21T16:34:56.818Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-httpx" +version = "0.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/27/c2b4335bca030e893acbe5ff2b4f434868773bf94508be7e6bf5af981b24/opentelemetry_instrumentation_httpx-0.63b1.tar.gz", hash = "sha256:f41ec82f25c3abcdada621052db3e5fd648e3b43d55eec4b9c0c5d3ecb7b4ff4", size = 23557, upload-time = "2026-05-21T16:36:34.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/b8/f536780996195c3b9f2354998554671e05a7a262df8c043f63fe9e5a6f0b/opentelemetry_instrumentation_httpx-0.63b1-py3-none-any.whl", hash = "sha256:14df6e99d81be9a8cd238f6639b6fa52404c4d3ce219058fcb5dc8c0f2211f86", size = 16336, upload-time = "2026-05-21T16:35:32.221Z" }, +] + [[package]] name = "opentelemetry-proto" version = "1.42.1" @@ -1208,6 +1241,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" }, ] +[[package]] +name = "opentelemetry-util-http" +version = "0.63b1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/d8/7bf5e4cec0578ac3c28c18eb7b88f34279139cbc8c568d6aa02b9c5ae53e/opentelemetry_util_http-0.63b1.tar.gz", hash = "sha256:ba1268f00922ee522dba2ae38458060f99486e7385a8056985901ca9685adfff", size = 11102, upload-time = "2026-05-21T16:36:56.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/f1/34e047e8f6a3c67e5220acf1af7b9f62868c25d77791bca74457bd2180a6/opentelemetry_util_http-0.63b1-py3-none-any.whl", hash = "sha256:6284194028c59cd439f8acfe388145069a6127f11dc077e1344a2094adacc3f8", size = 8205, upload-time = "2026-05-21T16:36:09.736Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -2228,6 +2270,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] +[[package]] +name = "wrapt" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ac/4370bde262c0e633e6c4f0e56d55095710024cf9a5cecc20c59a10de483c/wrapt-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dd57607acc85678925940bd5df0385ff8332083a32fa8d7a43f8767f4997263c", size = 80321, upload-time = "2026-05-22T14:47:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/eb/79/b8ff3a61e71babf58a8cf4c0d63358e8bad383e15bf7f35e62d2f6b6e4a4/wrapt-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ae574d65c9fa8e86f64f6a7c2668f9fcd507b183e0e577619f504b883cb0a6c", size = 81216, upload-time = "2026-05-22T14:47:45.243Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fd/c0cac1f77c9c4f6fe58a920ca632ce379bb8be928720e11e8d73de28a5e9/wrapt-2.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9a04c28c10ba7fd12842b109d2edb0678872a2fe65277ca4ff06a0d61edee245", size = 159208, upload-time = "2026-05-22T14:47:47.176Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4f/744132a7b2fbefa6b81118ec5942eca5fc2e9a129f9055a0c5e46885a549/wrapt-2.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e2f02472a1cbbf3884b365714a810b5947134a95ad6952b554cb8cce9d492b0", size = 160322, upload-time = "2026-05-22T14:47:49.04Z" }, + { url = "https://files.pythonhosted.org/packages/d6/95/b7cd9a22a06cf93e6482904ee6afc956248983553593fd1009296d1b3b31/wrapt-2.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac2745950b2bff80219c15ebf2fa9d8427eba7e249739f97e55c9d169e47e9e1", size = 153243, upload-time = "2026-05-22T14:47:50.386Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4a/eb79423192015f46f0db2872e7e04a3dde8d359b83411e8959e7c9287eaa/wrapt-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67a97e5b6c457f0cd3cfc19ebb2d84463e60c3ece754cc831e4281a3ca29bb18", size = 159231, upload-time = "2026-05-22T14:47:51.753Z" }, + { url = "https://files.pythonhosted.org/packages/ec/dc/435015b58ce33c6fc4104158fa91ddb0e809ab03a5751fb7465d1d461456/wrapt-2.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c803a3d331796255af51ba2c79ed0ac8275865b516c09e61f248d1e7aff31ce9", size = 152351, upload-time = "2026-05-22T14:47:53.214Z" }, + { url = "https://files.pythonhosted.org/packages/77/ac/5d203f98df8fd136b95c5227139aea02d34505e18baf812d0c005df61963/wrapt-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9b984d1eb252145d6302c1dbd5e87fc6d404d45531447c84eadec04bf1fcb027", size = 158347, upload-time = "2026-05-22T14:47:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/a92427dbdc74e54c1674abbed27e61b2cb5e7a94441b8c1270c70671d928/wrapt-2.2.1-cp311-cp311-win32.whl", hash = "sha256:8a983a603a18c8708f024f7f6991b2e66159219abbf894634c5056243c55f3cd", size = 77562, upload-time = "2026-05-22T14:47:56.275Z" }, + { url = "https://files.pythonhosted.org/packages/c8/56/987b9c13b3e1c1a3c6de71284076f996b79caec90e75a87c044a40c23db9/wrapt-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:9c210a6994b21aa9b29e81c8d11560e8fdab54c117e9cff37870d0a27bde1343", size = 80616, upload-time = "2026-05-22T14:47:57.854Z" }, + { url = "https://files.pythonhosted.org/packages/7e/25/d01f560888d99d94a959c85533de349ce68d71ace3f2591d6ea8f632cfed/wrapt-2.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:401229e9d63ca09f9b8891ecf83798d26c11bbb445d11ed9f1836b6d4585b38a", size = 79025, upload-time = "2026-05-22T14:47:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/89/0c/bfae7b9401583b6d05938cd16dedc43857d96da2f8a3d50d78cc515bf6ff/wrapt-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ffad790d9d11d8ecf9f17c4bb671a5b4089e4d8b575c46c5129597f41f836b0", size = 81021, upload-time = "2026-05-22T14:48:00.313Z" }, + { url = "https://files.pythonhosted.org/packages/26/58/80f6a6599f933f4caecc1cb3ee88a04faf81e8b9bddbd6109c688dd63e0f/wrapt-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:628f5220c7a904d5fc78f7075c8d7871433eb6d035c94728a22fdf85f193d2a8", size = 81692, upload-time = "2026-05-22T14:48:01.49Z" }, + { url = "https://files.pythonhosted.org/packages/17/93/fb357cc7847c58a8ae790be718903afa81a28d23e642c843dc4129e8a0b2/wrapt-2.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:61acce4257a9883669703c525447c5b4c392edf0f987ae77ec32668440158f0e", size = 169364, upload-time = "2026-05-22T14:48:02.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0b/76b601ee309a8bd556af0eecb184394c20b3c49aa9c8e085aa1ffacc2568/wrapt-2.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727ab4244622cd6ad2390f322642090c877d2e83a608d2653a7643ae5368d926", size = 171079, upload-time = "2026-05-22T14:48:04.22Z" }, + { url = "https://files.pythonhosted.org/packages/cd/87/ee3f32d5658e3e26d3e0e457922b47a36dd3bfbdfee7f97bb3e802344a66/wrapt-2.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03df9ebed4c73ab93fa8c07e3d41d818dfca1852b15731a3de59457b27814624", size = 160205, upload-time = "2026-05-22T14:48:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d0/ae2fd64277a67f5d7bffcf2d05eea1e476263fb2a072baf0b0129ab85984/wrapt-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9ff006f420b2ec8296aa56ade43ea7da3e997e85769f0aafc5e0661aacb710", size = 168922, upload-time = "2026-05-22T14:48:07.132Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f3/2d541a060c5bbafb9400bca4917e4d78bfd1f239f404782c86831a8f6b29/wrapt-2.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:844c858fc3bb7eacc0ba8efa904935d16aac6a4470948ad1e7e55c9f5a2a665f", size = 158388, upload-time = "2026-05-22T14:48:08.629Z" }, + { url = "https://files.pythonhosted.org/packages/1d/68/8d92c8800c57e93cb116ae9e9d6cbafc34fade5ee9f9107b6f203fb4dc35/wrapt-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87bacdaf225117a342a20d9c03438d701c02112f6e3f351ce9b7f32354f14797", size = 167682, upload-time = "2026-05-22T14:48:10.042Z" }, + { url = "https://files.pythonhosted.org/packages/30/72/83ea3790ea352439442349388e29ff07b76e0686265f9088bbb505d1608d/wrapt-2.2.1-cp312-cp312-win32.whl", hash = "sha256:2f8c90c8afde51969487be4e1343ae049b268854877d415c2510baf833775052", size = 77857, upload-time = "2026-05-22T14:48:11.782Z" }, + { url = "https://files.pythonhosted.org/packages/ef/cb/99450668dd3502d62a54a1c8aa56e44f34cb8c1261b381cfe2e7926c3b75/wrapt-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ce32763ac31ce94fe9aada947e479b1975012bff166da409b4b9e4e376cf7e5", size = 80825, upload-time = "2026-05-22T14:48:13.046Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/87512881be64e743f9ee4c66f4cbe8e884974bef2a5989af71f999653ac7/wrapt-2.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d1b4d0e0c2119587a31f5c029abd547e0c81d93b89d394566fe1588659eb579", size = 79087, upload-time = "2026-05-22T14:48:14.323Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/a1b08f8f4fac8cbb156fa51cf64ee2c7f7f74f9875ba3cf70b3c58368694/wrapt-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d2beb1c7cab10603aecdc42f8edd6ff013f9a32e4543474e38e6b77ce9975aeb", size = 80831, upload-time = "2026-05-22T14:48:15.598Z" }, + { url = "https://files.pythonhosted.org/packages/54/ce/57890814991446a845e09b3445ce8b694f27eb0577004f2c2a36a9772ed4/wrapt-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0cb7e4dd71f4c32e5e84843cd3c4cd65dda034314004bbe1d7f99af2426ab80", size = 81375, upload-time = "2026-05-22T14:48:17.071Z" }, + { url = "https://files.pythonhosted.org/packages/38/65/08d7a6c76ac4493bdb668205ee9c1de1bd5daca61717c3e9aa49b4c01499/wrapt-2.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95821352042722cd9f1108874579a47989d0a7e12a37d87d2fc4af20fd99ab8a", size = 167417, upload-time = "2026-05-22T14:48:18.303Z" }, + { url = "https://files.pythonhosted.org/packages/62/ce/f1ccbee7a1bfe5cdc6b3da6bab4b45713d628b9294da32a39f563d648140/wrapt-2.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abd621552ede77c4c69be7fac44ba911225b0c812b6ba604e5964cf98085b474", size = 166948, upload-time = "2026-05-22T14:48:19.768Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/f85d48d1cd4869aee6704028d257d740a47c1c467b457ce396b4b5b55d07/wrapt-2.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3677c7146ce694874941ba82b57092cc4875445aadf29d72807351023105143", size = 158148, upload-time = "2026-05-22T14:48:21.96Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5c/93939ad11d4a12358ab1aab219a2ef5efa5612e0db6b9fc65af8af1a891b/wrapt-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9a5934eaea872e17936b5f45501eba5ab0bce9a74122e172b663d7c28c459c4a", size = 165905, upload-time = "2026-05-22T14:48:23.373Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/b8c2aa89862ff58605934d7abf4b70e6a5a1c33df96656f49035ccdf1c8a/wrapt-2.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f5b9daf6b629fce418e0cc3dd0436eac045188fa35deadb7a7f3941d5b8203f9", size = 156712, upload-time = "2026-05-22T14:48:24.767Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/bf00a7b02239c12bb02ddcc3c0b971bfcc36e578c5a44f1ccfef5b458545/wrapt-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f53ac9f3ef573326d009ed809beff4efcac6451931c2b8132586da4b9e53ff31", size = 166560, upload-time = "2026-05-22T14:48:26.83Z" }, + { url = "https://files.pythonhosted.org/packages/fe/93/6390ca9c5b787683cef588d04f57c8d41b9a2323b5597a65f18638c90ef2/wrapt-2.2.1-cp313-cp313-win32.whl", hash = "sha256:1ffa9cfd4bdb581539951b14ae661ff20ed0c3599b3e911a131ee0ec5ac11337", size = 77817, upload-time = "2026-05-22T14:48:28.221Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/ce10f0e71c0cfaa1a65faadb8efd4852028b3bb9ba28932b8889df769d38/wrapt-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:368eac1e20fd0bb03dd3cc42bf9887154c3861b60989389ccb5fac032617d215", size = 80736, upload-time = "2026-05-22T14:48:30.139Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4c/89f4a6818fafbbd840330e4fa3873073e1bfc166133a64cac7f8fde7a5e3/wrapt-2.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:c754dafdf5aaf0b401b644a90a30046929a0dd1a536e0ff0ec959a59155d9c7f", size = 79099, upload-time = "2026-05-22T14:48:31.405Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f2/9a8741c46f8c208ac0a45b25ba170bcb4fb72a2781d5fb97dbd7b6be73cb/wrapt-2.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ed928d0fda15fc0adc8d13305c8b3c0f2fba5b0669950c9e6d019d9162a3b3e8", size = 82802, upload-time = "2026-05-22T14:48:33.307Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0d/e9c855716a3705eef1416456bdf062b60620726fdc59428ff670fc3c60dc/wrapt-2.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fafb4e739e43544d12cb4abd1605fd4683b6ca6a9ad682b7fd8f4d21973eafa8", size = 83329, upload-time = "2026-05-22T14:48:34.593Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d6/a88f1c13112b7831adac75cea65d8310e0d696d570c8961844c90a57b865/wrapt-2.2.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:74d6a0c31472fe5d814917266b9f46495d7c61ed890af08b468acea92fb89a8d", size = 202937, upload-time = "2026-05-22T14:48:35.859Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/e29d54aef06a4d898a5b8a25589a0b3769bde454f922fad8f6f89fbfb650/wrapt-2.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab5be648d5a0b86b7438864f8df3c705a65cef35a2fd3e5561e3e203167e0f27", size = 209997, upload-time = "2026-05-22T14:48:38.153Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/e4454263516cf0e12640912fbca9a83654e424f0a6ddb79f5cd7ce14bf33/wrapt-2.2.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d8f204c8e3a8bf9ece17e0a83d137fd807440977f8a5e762d59306795011440", size = 194856, upload-time = "2026-05-22T14:48:39.69Z" }, + { url = "https://files.pythonhosted.org/packages/de/d0/fe0ee202286afdf4a7f77dd29f195703145764d572aec209c5086e57d924/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d047f6498c973874ba08ac3f97c69a2c4b2211c8de6f4c205f75cb1c9522596e", size = 205654, upload-time = "2026-05-22T14:48:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/23/b6/87d860dfc6460c246af70b1fd5c8b76df77571b42a493459423ded94fd7d/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:7a4fdb9326aab4a5a477a1640e5ad786a8495901009d7e7b038371edd23a9d2b", size = 192206, upload-time = "2026-05-22T14:48:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/46/3eea8cde077d985f239a38c0257087b8064fd9ee9b1a99e282d2c86da4ef/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8cc5094b08abeae52da9c73c8a32003623be691a5193df2f4e3eac3d557c394", size = 198428, upload-time = "2026-05-22T14:48:46.319Z" }, + { url = "https://files.pythonhosted.org/packages/18/dc/b927ee9c7fc67adc3a5658f246a0d275425eb840ba36e7b702e70f18bde8/wrapt-2.2.1-cp313-cp313t-win32.whl", hash = "sha256:9907a4402ab6db12b7077a0ea5d7a4d028ecb22c8eee2b53527080d347cd1562", size = 79448, upload-time = "2026-05-22T14:48:47.901Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b3/fd30b473fe498c70e6b9a5f328b8d3fbaf1b8c3c481465f59724bba8eb70/wrapt-2.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:5590d63f5243251641cf543009b4c9314a79d0598fdb8a8e4cfc918494536c53", size = 83021, upload-time = "2026-05-22T14:48:49.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/96c39153a8737a6e9aa85adef254ac4195bea3f2d24efc60472ccc3c9e2e/wrapt-2.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:c318a64b53d97b841d7b5e637517e50a27be64bc695128422953d4b21710954e", size = 80295, upload-time = "2026-05-22T14:48:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a3/11d7f34ebbf3231bc907a3e6d5ee051b14d034c1bc7b65a97d5cc00516df/wrapt-2.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f56a647e4eaf5f0ca40330fb070f566bdf9f7b0db89a1af20d71c28dcd7a0ab", size = 80879, upload-time = "2026-05-22T14:48:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/b74cfd984cef560b900fb1a727af20352d89e1f06bf2e1114dd3f00f5f5a/wrapt-2.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:64b7deeda4b70408e382328d8bbe52a256fe9bc63ae3db86d804608367e5422c", size = 81462, upload-time = "2026-05-22T14:48:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/15/a3/7c8f704b8dc07dfe0a5d01c2edbfd88317aa8e5e3fa7c743eb7a085ae767/wrapt-2.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9cf53ba90717db2e292401de290776c498d4bbfb0d4a559ca2895db8b9dcb5c", size = 167251, upload-time = "2026-05-22T14:48:54.562Z" }, + { url = "https://files.pythonhosted.org/packages/80/85/a34d1888d97247da6c2ff6118c3a721c73ed8cc4dd198c00208bb73b6f80/wrapt-2.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf3638274ab9d9b724c9baa0b4c04e132cd6faefb78b4dd3dd1a02a4bdaad41e", size = 166316, upload-time = "2026-05-22T14:48:56.065Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d7/72ffaeb01eebc704afe3fb99e840480f4bda45f0fa66e3381b6a39251c8f/wrapt-2.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aed9658797d0b45d6c49adcfc6b41f66e6f2d0c6de3ec79e16cf4b1855df240f", size = 157952, upload-time = "2026-05-22T14:48:57.924Z" }, + { url = "https://files.pythonhosted.org/packages/24/5b/36f5d6b024e4edfdd90b140742d11ebcf7836daf5c9daf326c55c24db412/wrapt-2.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d676ee388bc42a04d56dd7deb5605244dac2e35cc2fadbb43c9fa25bbd93508", size = 166130, upload-time = "2026-05-22T14:48:59.384Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/9296d9e97bfdef5483dfcc859d57b095b257144b2bc5300ab521e06f4bc7/wrapt-2.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e395f7bc31851ef9b612050368cb446e9bc14cd7454b025018980349caf25ae5", size = 156604, upload-time = "2026-05-22T14:49:00.921Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/16953929ed6776175720e58fc966e779926d8d71e2c7b2273230590ca71f/wrapt-2.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f1845c2a8cc1180ccccfa45785dd06f562730d19ef75be180334254012b6283", size = 166007, upload-time = "2026-05-22T14:49:02.332Z" }, + { url = "https://files.pythonhosted.org/packages/b9/73/20ee58c0612dae7c31131a7095345812ed2c7b389019e175f68cde34e5b4/wrapt-2.2.1-cp314-cp314-win32.whl", hash = "sha256:436addbc4bb4fc0a88c702577f51195d7d73683a7f3e0e5b253d8404d7847243", size = 78327, upload-time = "2026-05-22T14:49:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/22/b3/ef7c3295d02e0448a71c639a36a057f46d524d057c9486291a7a3039e65c/wrapt-2.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:50972a1d974ea07725a7f6b1cec5f8759008afd030a0024843ebe7d52de47f2b", size = 81144, upload-time = "2026-05-22T14:49:05.093Z" }, + { url = "https://files.pythonhosted.org/packages/ac/dc/7bdf336953f99f4ceb0a584bb8870e42c8f26f93ea10c87834dad62f1668/wrapt-2.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1c9934ea5d92957e3cd0adbc0845539dccfd62710ebe16195a8c66c53954db36", size = 79569, upload-time = "2026-05-22T14:49:06.413Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6d/6dfae80150ff1919c356d1dd528f049bcdfaae29b4d284bc957e022caef4/wrapt-2.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17de18fc12cea55b8a9587314cb830573e37fb33b247a7515696350863714188", size = 82892, upload-time = "2026-05-22T14:49:07.925Z" }, + { url = "https://files.pythonhosted.org/packages/82/7b/4e34766a7d7804ffce9e71befe47e9b3225dc350c49c94493c4ab39fd3a5/wrapt-2.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9dec1aca52dddde7df94818310fa2fe79739c8f385b2014c4cb1035f5508199", size = 83333, upload-time = "2026-05-22T14:49:09.257Z" }, + { url = "https://files.pythonhosted.org/packages/9d/57/0b34db3e8de44ccfece62d7b337abd1631dd810f5adc5f3db571727836b5/wrapt-2.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69f2e9244542cb34dd59c7f073445b9e54ad9f3fce8d93606c368a1b499fc413", size = 202899, upload-time = "2026-05-22T14:49:10.572Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/ac0c459f154b99d92789a6cba7ca727185b83513b986f8ec7fe2aacddcbf/wrapt-2.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d83966dc7f4f45e8b97b5933685ac2e6e67fc0e19246ea314bceb9a8970c956", size = 209986, upload-time = "2026-05-22T14:49:12.229Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/77e37ff33ad018fa81ade52c25fa327b80b56f81d734279a63614fcb4cbc/wrapt-2.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78b0aa6bfb7be8deed0ab23e7aa028cc5210c29bc2d32a04d52b50e517a7307e", size = 194893, upload-time = "2026-05-22T14:49:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9d/7ea651d1ab032fc5fa222fbec91d0f8a1397f6ae04ebb93fa7219aa921d7/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:05d5cb74d1b232ec8cfa130a8f900708699ff2491d97b8f85a4cdc5996294b85", size = 205636, upload-time = "2026-05-22T14:49:15.714Z" }, + { url = "https://files.pythonhosted.org/packages/09/af/8e88031a701275b9085c54e64bc88c0b1cd55c77eadd400691c371cd76c4/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f6518b94edb9150452e9aba08027d4cc293433753ec1fbefb4629a21cbc74181", size = 192267, upload-time = "2026-05-22T14:49:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a8/e657ca876b06710194f243d81c4b0896ade646e244bdbec2d87c8c56a8bd/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed55af48b3eb28f43228ca2306788892bcb629eb2b5c4876e2a3659872c2f17a", size = 198378, upload-time = "2026-05-22T14:49:18.785Z" }, + { url = "https://files.pythonhosted.org/packages/c8/59/822efe4ea722a3961331bfa35b7d90937790d2c20f0616de1997ccc3aebd/wrapt-2.2.1-cp314-cp314t-win32.whl", hash = "sha256:2e08688ab16525897da6589d56d0aebaf417bbe91c2d8e3b96203b1efa596e85", size = 80226, upload-time = "2026-05-22T14:49:20.264Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/2a7dc5f6abb2fca0b6e1610e120419f603650aceb4f1d3ac4cae0354e162/wrapt-2.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd0135d34387f5fd087d9be368ea77ea89cf2451dc1cd1c622d35021bcb3ab50", size = 83835, upload-time = "2026-05-22T14:49:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c0/782b86e28d1ceebeb74cccea12d2cd3d2ba0bd68e3dec20b1bc5873f6127/wrapt-2.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f70db64e8266d7c45d3b735f2e08eeb434b5e03da9a479ae42b2e2e486a21a00", size = 80722, upload-time = "2026-05-22T14:49:23.59Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, +] + [[package]] name = "zipp" version = "4.1.0" From 270d68901c335a7e0b2603c7bd9a93b1155619f6 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 4 Jun 2026 17:23:41 +0100 Subject: [PATCH 3/8] refactor(MCP): Derive span attributes from baggage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- mcp/src/flagsmith_mcp/telemetry.py | 58 ++++++++++++++++++++--------- mcp/tests/integration/conftest.py | 4 +- mcp/tests/integration/test_spans.py | 5 ++- mcp/tests/unit/test_telemetry.py | 54 ++++++++++++++++++++------- 4 files changed, 87 insertions(+), 34 deletions(-) diff --git a/mcp/src/flagsmith_mcp/telemetry.py b/mcp/src/flagsmith_mcp/telemetry.py index 0cae87f756d7..8b7cd8bdcca1 100644 --- a/mcp/src/flagsmith_mcp/telemetry.py +++ b/mcp/src/flagsmith_mcp/telemetry.py @@ -1,3 +1,5 @@ +from typing import Any + import mcp.types as mt from common.core.logging import setup_logging from common.core.otel import ( @@ -20,33 +22,55 @@ APPLICATION_LOGGERS = ["flagsmith_mcp", "fastmcp", "mcp"] -class ClientInfoSpanProcessor(SpanProcessor): - """Annotate started spans with the MCP client's self-declared identity.""" +def is_flagsmith_baggage_key(key: str) -> bool: + return key.startswith("flagsmith.") + + +class FlagsmithBaggageSpanProcessor(SpanProcessor): + """Copy flagsmith.* baggage entries onto started spans. + + The off-the-shelf opentelemetry-processor-baggage reads baggage from + the span's parent context, but FastMCP starts its server spans from a + context extracted from the request _meta, which carries no baggage. + Read the current context, where middleware attached the entries. + """ def on_start(self, span: Span, parent_context: Context | None = None) -> None: - if (client_info := get_client_info()) is not None: - span.set_attribute("flagsmith.client.name", client_info.name) - span.set_attribute("flagsmith.client.version", client_info.version) + for key, value in baggage.get_all().items(): + if is_flagsmith_baggage_key(key): + span.set_attribute(key, str(value)) class BaggageMiddleware(Middleware): - """Attach the tool name and client identity as W3C Baggage, propagated - to the Flagsmith API by the instrumented upstream HTTP client.""" + """Attach the client identity and tool name as W3C Baggage: the single + source both for span attributes (via BaggageSpanProcessor) and for + propagation to the Flagsmith API by the instrumented upstream client.""" + + async def on_request( + self, + context: MiddlewareContext[mt.Request[Any, Any]], + call_next: CallNext[mt.Request[Any, Any], Any], + ) -> Any: + if (client_info := get_client_info()) is None: + return await call_next(context) + ctx = baggage.set_baggage("flagsmith.client.name", client_info.name) + ctx = baggage.set_baggage( + "flagsmith.client.version", client_info.version, context=ctx + ) + token = otel_context.attach(ctx) + try: + return await call_next(context) + finally: + otel_context.detach(token) async def on_call_tool( self, context: MiddlewareContext[mt.CallToolRequestParams], call_next: CallNext[mt.CallToolRequestParams, ToolResult], ) -> ToolResult: - ctx = baggage.set_baggage("flagsmith.tool.name", context.message.name) - if (client_info := get_client_info()) is not None: - ctx = baggage.set_baggage( - "flagsmith.client.name", client_info.name, context=ctx - ) - ctx = baggage.set_baggage( - "flagsmith.client.version", client_info.version, context=ctx - ) - token = otel_context.attach(ctx) + token = otel_context.attach( + baggage.set_baggage("flagsmith.tool.name", context.message.name) + ) try: return await call_next(context) finally: @@ -73,7 +97,7 @@ def setup_telemetry(settings: config.Settings) -> None: endpoint=f"{endpoint}/v1/traces", service_name=settings.otel_service_name, ) - tracer_provider.add_span_processor(ClientInfoSpanProcessor()) + tracer_provider.add_span_processor(FlagsmithBaggageSpanProcessor()) trace.set_tracer_provider(tracer_provider) setup_logging( log_level=settings.log_level, diff --git a/mcp/tests/integration/conftest.py b/mcp/tests/integration/conftest.py index 6ac100d53ded..fb04e84ea10d 100644 --- a/mcp/tests/integration/conftest.py +++ b/mcp/tests/integration/conftest.py @@ -16,7 +16,7 @@ from flagsmith_mcp import config, constants from flagsmith_mcp import server as server_module -from flagsmith_mcp.telemetry import ClientInfoSpanProcessor +from flagsmith_mcp.telemetry import FlagsmithBaggageSpanProcessor HTTPClientFactoryFixture = Callable[[FastMCP], AsyncIterator[httpx.AsyncClient]] @@ -28,7 +28,7 @@ def span_exporter() -> InMemorySpanExporter: exporter = InMemorySpanExporter() provider = TracerProvider() provider.add_span_processor(SimpleSpanProcessor(exporter)) - provider.add_span_processor(ClientInfoSpanProcessor()) + provider.add_span_processor(FlagsmithBaggageSpanProcessor()) trace.set_tracer_provider(provider) return exporter diff --git a/mcp/tests/integration/test_spans.py b/mcp/tests/integration/test_spans.py index 7714cd297d16..557856114fb0 100644 --- a/mcp/tests/integration/test_spans.py +++ b/mcp/tests/integration/test_spans.py @@ -29,6 +29,7 @@ async def test_spans__tool_call__annotated_with_client_identity( assert span.attributes is not None assert { "gen_ai.tool.name": "list_environments", + "flagsmith.tool.name": "list_environments", "flagsmith.client.name": "mcp", "flagsmith.client.version": "0.1.0", }.items() <= dict(span.attributes).items() @@ -47,7 +48,7 @@ async def test_spans__tool_call__upstream_request_carries_baggage( # Then the instrumented API client propagated W3C Baggage assert route.calls.last.request.headers["baggage"] == ( - "flagsmith.tool.name=list_environments," "flagsmith.client.name=mcp," - "flagsmith.client.version=0.1.0" + "flagsmith.client.version=0.1.0," + "flagsmith.tool.name=list_environments" ) diff --git a/mcp/tests/unit/test_telemetry.py b/mcp/tests/unit/test_telemetry.py index f73f98b669ca..74347004c6f5 100644 --- a/mcp/tests/unit/test_telemetry.py +++ b/mcp/tests/unit/test_telemetry.py @@ -2,6 +2,7 @@ from common.core.otel import add_otel_trace_context from opentelemetry import baggage +from opentelemetry import context as otel_context from pytest_mock import MockerFixture from flagsmith_mcp import config, telemetry @@ -76,7 +77,7 @@ def test_setup_telemetry__otlp_endpoint__exports_logs_and_traces( [span_processor] = ( build_tracer_provider_mock.return_value.add_span_processor.call_args.args ) - assert isinstance(span_processor, telemetry.ClientInfoSpanProcessor) + assert isinstance(span_processor, telemetry.FlagsmithBaggageSpanProcessor) setup_logging_mock.assert_called_once_with( log_level="DEBUG", log_format="json", @@ -88,26 +89,33 @@ def test_setup_telemetry__otlp_endpoint__exports_logs_and_traces( ) -def test_client_info_span_processor__outside_request_context__no_attributes( +async def test_baggage_middleware__uninitialised_session__tool_name_baggage_only( mocker: MockerFixture, ) -> None: - # Given no MCP request context - span = mocker.Mock() + # Given a tool call outside an initialised session + middleware = telemetry.BaggageMiddleware() + context = mocker.Mock() + context.message.name = "list_environments" + seen_baggage: dict[str, object] = {} + + async def record_baggage(ctx: object) -> None: + seen_baggage.update(baggage.get_all()) + + call_next = mocker.AsyncMock(side_effect=record_baggage) # When - telemetry.ClientInfoSpanProcessor().on_start(span) + await middleware.on_call_tool(context, call_next) # Then - span.set_attribute.assert_not_called() + assert seen_baggage == {"flagsmith.tool.name": "list_environments"} + assert baggage.get_all() == {} -async def test_baggage_middleware__uninitialised_session__tool_name_baggage_only( +async def test_baggage_middleware__uninitialised_session__request_baggage_untouched( mocker: MockerFixture, ) -> None: - # Given a tool call outside an initialised session + # Given a request outside an initialised session middleware = telemetry.BaggageMiddleware() - context = mocker.Mock() - context.message.name = "list_environments" seen_baggage: dict[str, object] = {} async def record_baggage(ctx: object) -> None: @@ -116,8 +124,28 @@ async def record_baggage(ctx: object) -> None: call_next = mocker.AsyncMock(side_effect=record_baggage) # When - await middleware.on_call_tool(context, call_next) + await middleware.on_request(mocker.Mock(), call_next) # Then - assert seen_baggage == {"flagsmith.tool.name": "list_environments"} - assert baggage.get_all() == {} + assert seen_baggage == {} + + +def test_flagsmith_baggage_span_processor__foreign_baggage__not_copied( + mocker: MockerFixture, +) -> None: + # Given baggage with flagsmith and foreign entries + span = mocker.Mock() + ctx = baggage.set_baggage("other.key", "x") + ctx = baggage.set_baggage("flagsmith.tool.name", "list_environments", context=ctx) + token = otel_context.attach(ctx) + + # When + try: + telemetry.FlagsmithBaggageSpanProcessor().on_start(span) + finally: + otel_context.detach(token) + + # Then + span.set_attribute.assert_called_once_with( + "flagsmith.tool.name", "list_environments" + ) From 073eb9322e52cf808002f1480ce6947cab3471a5 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 4 Jun 2026 18:09:24 +0100 Subject: [PATCH 4/8] fix docgen --- .pre-commit-config.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8e55504cf497..da72d4d379d5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,8 @@ repos: - id: ruff name: mcp-lint files: ^mcp/ - args: [--config, mcp/pyproject.toml, --config, "src = ['mcp/src']", --fix] + args: + [--config, mcp/pyproject.toml, --config, "src = ['mcp/src']", --fix] - id: ruff-format name: mcp-format files: ^mcp/ @@ -43,6 +44,7 @@ repos: language: system entry: make -C api generate-docs pass_filenames: false + files: ^api/ types_or: [python, toml] - id: api-typecheck name: api-typecheck @@ -69,7 +71,7 @@ repos: - id: flagsmith-lint-tests - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.11.18 # Ensure this matches the version in api/pyproject.toml + rev: 0.11.18 # Ensure this matches the version in api/pyproject.toml hooks: - id: uv-lock name: api-lockcheck From 643c32563769081e013d3111f9afadec3d956819 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 4 Jun 2026 18:09:39 +0100 Subject: [PATCH 5/8] remove duration_ms --- mcp/src/flagsmith_mcp/events.py | 5 ----- mcp/tests/integration/test_events.py | 2 -- 2 files changed, 7 deletions(-) diff --git a/mcp/src/flagsmith_mcp/events.py b/mcp/src/flagsmith_mcp/events.py index 66e362653531..a066c4acf169 100644 --- a/mcp/src/flagsmith_mcp/events.py +++ b/mcp/src/flagsmith_mcp/events.py @@ -1,5 +1,3 @@ -import time - import mcp.types as mt import structlog from fastmcp.server.dependencies import get_context @@ -46,7 +44,6 @@ async def on_call_tool( client_info = get_client_info() client_name = client_info.name if client_info else "" client_version = client_info.version if client_info else "" - start = time.perf_counter() try: result = await call_next(context) except Exception: @@ -56,7 +53,6 @@ async def on_call_tool( flagsmith__client__name=client_name, flagsmith__client__version=client_version, status="error", - duration_ms=(time.perf_counter() - start) * 1000, ) raise logger.info( @@ -65,6 +61,5 @@ async def on_call_tool( flagsmith__client__name=client_name, flagsmith__client__version=client_version, status="success", - duration_ms=(time.perf_counter() - start) * 1000, ) return result diff --git a/mcp/tests/integration/test_events.py b/mcp/tests/integration/test_events.py index 8ad8612c527d..a8e62976d36a 100644 --- a/mcp/tests/integration/test_events.py +++ b/mcp/tests/integration/test_events.py @@ -35,7 +35,6 @@ async def test_events__successful_tool_call__emits_tool_called( # Then [event] = [e for e in log.events if e["event"] == "tool.called"] - assert event.pop("duration_ms") >= 0 assert event == { "event": "tool.called", "level": "info", @@ -60,7 +59,6 @@ async def test_events__failing_tool_call__emits_tool_called_with_error_status( # Then [event] = [e for e in log.events if e["event"] == "tool.called"] - assert event.pop("duration_ms") >= 0 assert event == { "event": "tool.called", "level": "info", From a484ef995b816cda07835c862035ef8e16a5eb08 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 4 Jun 2026 18:46:11 +0100 Subject: [PATCH 6/8] refactor(MCP): Pass MCP call context to the API as W3C Baggage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Write flagsmith.client.name/version as span attributes directly, and pass all of the current span's attributes — MCP method, tool, session and client identity — as W3C Baggage on outbound Flagsmith API requests via an httpx event hook scoped to the API client only. Replaces the baggage-attaching middleware: process context is never touched, and the API receives the full call context whenever tracing is enabled. beep boop --- mcp/src/flagsmith_mcp/server.py | 7 +-- mcp/src/flagsmith_mcp/telemetry.py | 75 +++++++--------------------- mcp/tests/integration/conftest.py | 4 +- mcp/tests/integration/test_spans.py | 26 +++++++--- mcp/tests/unit/test_event_logging.py | 8 +-- mcp/tests/unit/test_telemetry.py | 67 +++++++++---------------- 6 files changed, 70 insertions(+), 117 deletions(-) diff --git a/mcp/src/flagsmith_mcp/server.py b/mcp/src/flagsmith_mcp/server.py index b0783f77752f..d8920fd6c1a0 100644 --- a/mcp/src/flagsmith_mcp/server.py +++ b/mcp/src/flagsmith_mcp/server.py @@ -16,7 +16,7 @@ from flagsmith_mcp.events import EventLoggingMiddleware from flagsmith_mcp.metrics import PrometheusMiddleware from flagsmith_mcp.oauth import FlagsmithResourceAuth -from flagsmith_mcp.telemetry import BaggageMiddleware, setup_telemetry +from flagsmith_mcp.telemetry import propagate_span_attributes, setup_telemetry ROUTE_MAPS = [ RouteMap(tags={"mcp"}, mcp_type=MCPType.TOOL), @@ -61,9 +61,11 @@ def create_server(settings: config.Settings) -> FastMCP[None]: api_client = httpx.AsyncClient( base_url=settings.flagsmith_api_url, auth=FlagsmithAuth(settings.flagsmith_api_token), + event_hooks={"request": [propagate_span_attributes]}, ) # Instrument only the Flagsmith API client: emit a span per upstream - # call and propagate W3C trace context and baggage to the API. + # call and propagate W3C trace context; the event hook passes the MCP + # call context to the API as W3C Baggage. HTTPXClientInstrumentor().instrument_client(api_client) server = FastMCP.from_openapi( openapi_spec=_fetch_spec(), @@ -77,7 +79,6 @@ def create_server(settings: config.Settings) -> FastMCP[None]: server.add_middleware(PrometheusMiddleware()) server.add_middleware(EventLoggingMiddleware()) - server.add_middleware(BaggageMiddleware()) @server.custom_route("/health", methods=["GET"]) async def health(request: Request) -> PlainTextResponse: diff --git a/mcp/src/flagsmith_mcp/telemetry.py b/mcp/src/flagsmith_mcp/telemetry.py index 8b7cd8bdcca1..b235dda7781a 100644 --- a/mcp/src/flagsmith_mcp/telemetry.py +++ b/mcp/src/flagsmith_mcp/telemetry.py @@ -1,6 +1,4 @@ -from typing import Any - -import mcp.types as mt +import httpx from common.core.logging import setup_logging from common.core.otel import ( add_otel_trace_context, @@ -8,12 +6,10 @@ build_tracer_provider, make_structlog_otel_processor, ) -from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext -from fastmcp.tools.base import ToolResult from opentelemetry import baggage, trace -from opentelemetry import context as otel_context +from opentelemetry.baggage.propagation import W3CBaggagePropagator from opentelemetry.context import Context -from opentelemetry.sdk.trace import Span, SpanProcessor +from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor from structlog.typing import Processor from flagsmith_mcp import config @@ -22,59 +18,24 @@ APPLICATION_LOGGERS = ["flagsmith_mcp", "fastmcp", "mcp"] -def is_flagsmith_baggage_key(key: str) -> bool: - return key.startswith("flagsmith.") - - -class FlagsmithBaggageSpanProcessor(SpanProcessor): - """Copy flagsmith.* baggage entries onto started spans. - - The off-the-shelf opentelemetry-processor-baggage reads baggage from - the span's parent context, but FastMCP starts its server spans from a - context extracted from the request _meta, which carries no baggage. - Read the current context, where middleware attached the entries. - """ +class ClientInfoSpanProcessor(SpanProcessor): + """Annotate started spans with the MCP client identity.""" def on_start(self, span: Span, parent_context: Context | None = None) -> None: - for key, value in baggage.get_all().items(): - if is_flagsmith_baggage_key(key): - span.set_attribute(key, str(value)) + if (client_info := get_client_info()) is not None: + span.set_attribute("flagsmith.client.name", client_info.name) + span.set_attribute("flagsmith.client.version", client_info.version) -class BaggageMiddleware(Middleware): - """Attach the client identity and tool name as W3C Baggage: the single - source both for span attributes (via BaggageSpanProcessor) and for - propagation to the Flagsmith API by the instrumented upstream client.""" - - async def on_request( - self, - context: MiddlewareContext[mt.Request[Any, Any]], - call_next: CallNext[mt.Request[Any, Any], Any], - ) -> Any: - if (client_info := get_client_info()) is None: - return await call_next(context) - ctx = baggage.set_baggage("flagsmith.client.name", client_info.name) - ctx = baggage.set_baggage( - "flagsmith.client.version", client_info.version, context=ctx - ) - token = otel_context.attach(ctx) - try: - return await call_next(context) - finally: - otel_context.detach(token) - - async def on_call_tool( - self, - context: MiddlewareContext[mt.CallToolRequestParams], - call_next: CallNext[mt.CallToolRequestParams, ToolResult], - ) -> ToolResult: - token = otel_context.attach( - baggage.set_baggage("flagsmith.tool.name", context.message.name) - ) - try: - return await call_next(context) - finally: - otel_context.detach(token) +async def propagate_span_attributes(request: httpx.Request) -> None: + span = trace.get_current_span() + if not isinstance(span, ReadableSpan): + return + ctx: Context | None = None + for key, value in (span.attributes or {}).items(): + ctx = baggage.set_baggage(key, str(value), context=ctx) + if ctx is not None: + W3CBaggagePropagator().inject(request.headers, context=ctx) def setup_telemetry(settings: config.Settings) -> None: @@ -97,7 +58,7 @@ def setup_telemetry(settings: config.Settings) -> None: endpoint=f"{endpoint}/v1/traces", service_name=settings.otel_service_name, ) - tracer_provider.add_span_processor(FlagsmithBaggageSpanProcessor()) + tracer_provider.add_span_processor(ClientInfoSpanProcessor()) trace.set_tracer_provider(tracer_provider) setup_logging( log_level=settings.log_level, diff --git a/mcp/tests/integration/conftest.py b/mcp/tests/integration/conftest.py index fb04e84ea10d..6ac100d53ded 100644 --- a/mcp/tests/integration/conftest.py +++ b/mcp/tests/integration/conftest.py @@ -16,7 +16,7 @@ from flagsmith_mcp import config, constants from flagsmith_mcp import server as server_module -from flagsmith_mcp.telemetry import FlagsmithBaggageSpanProcessor +from flagsmith_mcp.telemetry import ClientInfoSpanProcessor HTTPClientFactoryFixture = Callable[[FastMCP], AsyncIterator[httpx.AsyncClient]] @@ -28,7 +28,7 @@ def span_exporter() -> InMemorySpanExporter: exporter = InMemorySpanExporter() provider = TracerProvider() provider.add_span_processor(SimpleSpanProcessor(exporter)) - provider.add_span_processor(FlagsmithBaggageSpanProcessor()) + provider.add_span_processor(ClientInfoSpanProcessor()) trace.set_tracer_provider(provider) return exporter diff --git a/mcp/tests/integration/test_spans.py b/mcp/tests/integration/test_spans.py index 557856114fb0..7f2ff5abddb8 100644 --- a/mcp/tests/integration/test_spans.py +++ b/mcp/tests/integration/test_spans.py @@ -1,3 +1,5 @@ +from urllib.parse import unquote + from fastmcp import Client from fastmcp.client.transports import FastMCPTransport from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( @@ -29,15 +31,15 @@ async def test_spans__tool_call__annotated_with_client_identity( assert span.attributes is not None assert { "gen_ai.tool.name": "list_environments", - "flagsmith.tool.name": "list_environments", "flagsmith.client.name": "mcp", "flagsmith.client.version": "0.1.0", }.items() <= dict(span.attributes).items() -async def test_spans__tool_call__upstream_request_carries_baggage( +async def test_spans__tool_call__upstream_request_carries_span_attribute_baggage( client: Client[FastMCPTransport], respx_mock: MockRouter, + finished_spans: InMemorySpanExporter, ) -> None: # Given route = respx_mock.get("https://api.flagsmith.com/environments/") @@ -46,9 +48,17 @@ async def test_spans__tool_call__upstream_request_carries_baggage( # When await client.call_tool("list_environments", {}) - # Then the instrumented API client propagated W3C Baggage - assert route.calls.last.request.headers["baggage"] == ( - "flagsmith.client.name=mcp," - "flagsmith.client.version=0.1.0," - "flagsmith.tool.name=list_environments" - ) + # Then the server span's attributes reached the API as W3C Baggage + entries = { + key: unquote(value) + for key, value in ( + entry.split("=", 1) + for entry in route.calls.last.request.headers["baggage"].split(",") + ) + } + assert { + "mcp.method.name": "tools/call", + "gen_ai.tool.name": "list_environments", + "flagsmith.client.name": "mcp", + "flagsmith.client.version": "0.1.0", + }.items() <= entries.items() diff --git a/mcp/tests/unit/test_event_logging.py b/mcp/tests/unit/test_event_logging.py index f76f050be98f..bf64bf600d78 100644 --- a/mcp/tests/unit/test_event_logging.py +++ b/mcp/tests/unit/test_event_logging.py @@ -10,10 +10,10 @@ def test_get_client_info__outside_request_context__returns_none() -> None: assert events.get_client_info() is None -def test_get_client_info__uninitialised_session__returns_none( +def test_get_client_info__no_client_params__returns_none( mocker: MockerFixture, ) -> None: - # Given a session that has not completed initialize + # Given a session that has not yet completed initialize context_mock = mocker.patch.object(events, "get_context", autospec=True) context_mock.return_value.session.client_params = None @@ -21,11 +21,11 @@ def test_get_client_info__uninitialised_session__returns_none( assert events.get_client_info() is None -async def test_event_logging_middleware__uninitialised_session__empty_client_identity( +async def test_event_logging_middleware__no_client_info__empty_client_identity( mocker: MockerFixture, log: StructuredLogCapture, ) -> None: - # Given a tool call outside an initialised session + # Given middleware = events.EventLoggingMiddleware() context = mocker.Mock() context.message.name = "list_environments" diff --git a/mcp/tests/unit/test_telemetry.py b/mcp/tests/unit/test_telemetry.py index 74347004c6f5..47707650b02f 100644 --- a/mcp/tests/unit/test_telemetry.py +++ b/mcp/tests/unit/test_telemetry.py @@ -1,8 +1,9 @@ import os +import httpx from common.core.otel import add_otel_trace_context -from opentelemetry import baggage -from opentelemetry import context as otel_context +from opentelemetry import trace +from opentelemetry.sdk.trace import ReadableSpan from pytest_mock import MockerFixture from flagsmith_mcp import config, telemetry @@ -77,7 +78,7 @@ def test_setup_telemetry__otlp_endpoint__exports_logs_and_traces( [span_processor] = ( build_tracer_provider_mock.return_value.add_span_processor.call_args.args ) - assert isinstance(span_processor, telemetry.FlagsmithBaggageSpanProcessor) + assert isinstance(span_processor, telemetry.ClientInfoSpanProcessor) setup_logging_mock.assert_called_once_with( log_level="DEBUG", log_format="json", @@ -89,63 +90,43 @@ def test_setup_telemetry__otlp_endpoint__exports_logs_and_traces( ) -async def test_baggage_middleware__uninitialised_session__tool_name_baggage_only( +def test_client_info_span_processor__outside_request_context__no_attributes( mocker: MockerFixture, ) -> None: - # Given a tool call outside an initialised session - middleware = telemetry.BaggageMiddleware() - context = mocker.Mock() - context.message.name = "list_environments" - seen_baggage: dict[str, object] = {} - - async def record_baggage(ctx: object) -> None: - seen_baggage.update(baggage.get_all()) - - call_next = mocker.AsyncMock(side_effect=record_baggage) + # Given no MCP request context + span = mocker.Mock() # When - await middleware.on_call_tool(context, call_next) + telemetry.ClientInfoSpanProcessor().on_start(span) # Then - assert seen_baggage == {"flagsmith.tool.name": "list_environments"} - assert baggage.get_all() == {} + span.set_attribute.assert_not_called() -async def test_baggage_middleware__uninitialised_session__request_baggage_untouched( - mocker: MockerFixture, -) -> None: - # Given a request outside an initialised session - middleware = telemetry.BaggageMiddleware() - seen_baggage: dict[str, object] = {} - - async def record_baggage(ctx: object) -> None: - seen_baggage.update(baggage.get_all()) - - call_next = mocker.AsyncMock(side_effect=record_baggage) +async def test_propagate_span_attributes__no_recording_span__headers_untouched() -> ( + None +): + # Given no SDK span in the current context + request = httpx.Request("GET", "https://api.flagsmith.com/") # When - await middleware.on_request(mocker.Mock(), call_next) + await telemetry.propagate_span_attributes(request) # Then - assert seen_baggage == {} + assert "baggage" not in request.headers -def test_flagsmith_baggage_span_processor__foreign_baggage__not_copied( +async def test_propagate_span_attributes__span_without_attributes__headers_untouched( mocker: MockerFixture, ) -> None: - # Given baggage with flagsmith and foreign entries - span = mocker.Mock() - ctx = baggage.set_baggage("other.key", "x") - ctx = baggage.set_baggage("flagsmith.tool.name", "list_environments", context=ctx) - token = otel_context.attach(ctx) + # Given a recording span with no attributes + span = mocker.Mock(spec=ReadableSpan) + span.attributes = {} + mocker.patch.object(trace, "get_current_span", return_value=span) # When - try: - telemetry.FlagsmithBaggageSpanProcessor().on_start(span) - finally: - otel_context.detach(token) + request = httpx.Request("GET", "https://api.flagsmith.com/") + await telemetry.propagate_span_attributes(request) # Then - span.set_attribute.assert_called_once_with( - "flagsmith.tool.name", "list_environments" - ) + assert "baggage" not in request.headers From dbbaf4b1196cfba7f72dfc385227c7bc62ce145d Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 4 Jun 2026 19:07:17 +0100 Subject: [PATCH 7/8] keep baggage working --- mcp/src/flagsmith_mcp/telemetry.py | 12 +++++++----- mcp/tests/unit/test_telemetry.py | 18 +++++++++++++++--- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/mcp/src/flagsmith_mcp/telemetry.py b/mcp/src/flagsmith_mcp/telemetry.py index b235dda7781a..413545b19b76 100644 --- a/mcp/src/flagsmith_mcp/telemetry.py +++ b/mcp/src/flagsmith_mcp/telemetry.py @@ -9,7 +9,7 @@ from opentelemetry import baggage, trace from opentelemetry.baggage.propagation import W3CBaggagePropagator from opentelemetry.context import Context -from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor +from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor, TracerProvider from structlog.typing import Processor from flagsmith_mcp import config @@ -52,14 +52,16 @@ def setup_telemetry(settings: config.Settings) -> None: add_otel_trace_context, make_structlog_otel_processor(log_provider), ] - # Setting a global tracer provider also activates FastMCP's built-in - # per-request server spans. tracer_provider = build_tracer_provider( endpoint=f"{endpoint}/v1/traces", service_name=settings.otel_service_name, ) - tracer_provider.add_span_processor(ClientInfoSpanProcessor()) - trace.set_tracer_provider(tracer_provider) + else: + # No exporter: spans stay in-process, but still feed the API + # baggage propagation. + tracer_provider = TracerProvider() + tracer_provider.add_span_processor(ClientInfoSpanProcessor()) + trace.set_tracer_provider(tracer_provider) setup_logging( log_level=settings.log_level, log_format=settings.log_format, diff --git a/mcp/tests/unit/test_telemetry.py b/mcp/tests/unit/test_telemetry.py index 47707650b02f..6c4d865b145d 100644 --- a/mcp/tests/unit/test_telemetry.py +++ b/mcp/tests/unit/test_telemetry.py @@ -9,7 +9,7 @@ from flagsmith_mcp import config, telemetry -def test_setup_telemetry__no_otlp_endpoint__configures_logging_only( +def test_setup_telemetry__no_otlp_endpoint__spans_in_process_only( mocker: MockerFixture, ) -> None: # Given @@ -18,18 +18,30 @@ def test_setup_telemetry__no_otlp_endpoint__configures_logging_only( set_tracer_provider_mock = mocker.patch( "flagsmith_mcp.telemetry.trace.set_tracer_provider", autospec=True ) + build_tracer_provider_mock = mocker.patch.object( + telemetry, "build_tracer_provider", autospec=True + ) + tracer_provider_mock = mocker.patch.object( + telemetry, "TracerProvider", autospec=True + ) # When telemetry.setup_telemetry(config.Settings()) - # Then + # Then logging is configured without OTel export... setup_logging_mock.assert_called_once_with( log_level="INFO", log_format="generic", application_loggers=telemetry.APPLICATION_LOGGERS, otel_processors=None, ) - set_tracer_provider_mock.assert_not_called() + # ...but spans still record in-process + build_tracer_provider_mock.assert_not_called() + set_tracer_provider_mock.assert_called_once_with(tracer_provider_mock.return_value) + [span_processor] = ( + tracer_provider_mock.return_value.add_span_processor.call_args.args + ) + assert isinstance(span_processor, telemetry.ClientInfoSpanProcessor) def test_setup_telemetry__otlp_endpoint__exports_logs_and_traces( From 78e8ca67e9f494ffafb44d26ba239bd34c749c8a Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 4 Jun 2026 19:34:24 +0100 Subject: [PATCH 8/8] feat(MCP): Identify this service and the MCP client separately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Send flagsmith.client.name=flagsmith-mcp on every span and API-bound baggage header — the API's immediate caller — and move the MCP client's self-declared identity to flagsmith.mcp.client.name/version across spans, baggage and events. Spans now record without an OTLP endpoint too (in-process only), so the API receives baggage regardless of whether telemetry export is enabled. beep boop --- mcp/src/flagsmith_mcp/constants.py | 3 +++ mcp/src/flagsmith_mcp/events.py | 12 ++++++------ mcp/src/flagsmith_mcp/telemetry.py | 10 ++++++---- mcp/tests/integration/test_events.py | 12 ++++++------ mcp/tests/integration/test_spans.py | 10 ++++++---- mcp/tests/unit/test_event_logging.py | 4 ++-- mcp/tests/unit/test_telemetry.py | 8 +++++--- 7 files changed, 34 insertions(+), 25 deletions(-) diff --git a/mcp/src/flagsmith_mcp/constants.py b/mcp/src/flagsmith_mcp/constants.py index 6ec4fc869c5a..b8c1e1409c2c 100644 --- a/mcp/src/flagsmith_mcp/constants.py +++ b/mcp/src/flagsmith_mcp/constants.py @@ -1,3 +1,6 @@ # TODO: consume a version-controlled schema — https://github.com/Flagsmith/flagsmith/issues/7669 OPENAPI_SPEC_URL = "https://api.flagsmith.com/api/v1/swagger.json" OAUTH_SCOPES = ["mcp"] + +# How this service identifies itself as a client of the Flagsmith API. +FLAGSMITH_CLIENT_NAME = "flagsmith-mcp" diff --git a/mcp/src/flagsmith_mcp/events.py b/mcp/src/flagsmith_mcp/events.py index a066c4acf169..bd268e920a4a 100644 --- a/mcp/src/flagsmith_mcp/events.py +++ b/mcp/src/flagsmith_mcp/events.py @@ -31,8 +31,8 @@ async def on_initialize( client_info = context.message.params.clientInfo logger.info( "session.opened", - flagsmith__client__name=client_info.name, - flagsmith__client__version=client_info.version, + flagsmith__mcp__client__name=client_info.name, + flagsmith__mcp__client__version=client_info.version, ) return result @@ -50,16 +50,16 @@ async def on_call_tool( logger.info( "tool.called", tool__name=context.message.name, - flagsmith__client__name=client_name, - flagsmith__client__version=client_version, + flagsmith__mcp__client__name=client_name, + flagsmith__mcp__client__version=client_version, status="error", ) raise logger.info( "tool.called", tool__name=context.message.name, - flagsmith__client__name=client_name, - flagsmith__client__version=client_version, + flagsmith__mcp__client__name=client_name, + flagsmith__mcp__client__version=client_version, status="success", ) return result diff --git a/mcp/src/flagsmith_mcp/telemetry.py b/mcp/src/flagsmith_mcp/telemetry.py index 413545b19b76..99039c9891ed 100644 --- a/mcp/src/flagsmith_mcp/telemetry.py +++ b/mcp/src/flagsmith_mcp/telemetry.py @@ -12,19 +12,21 @@ from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor, TracerProvider from structlog.typing import Processor -from flagsmith_mcp import config +from flagsmith_mcp import config, constants from flagsmith_mcp.events import get_client_info APPLICATION_LOGGERS = ["flagsmith_mcp", "fastmcp", "mcp"] class ClientInfoSpanProcessor(SpanProcessor): - """Annotate started spans with the MCP client identity.""" + """Annotate started spans with this service's identity and the MCP + client identity.""" def on_start(self, span: Span, parent_context: Context | None = None) -> None: + span.set_attribute("flagsmith.client.name", constants.FLAGSMITH_CLIENT_NAME) if (client_info := get_client_info()) is not None: - span.set_attribute("flagsmith.client.name", client_info.name) - span.set_attribute("flagsmith.client.version", client_info.version) + span.set_attribute("flagsmith.mcp.client.name", client_info.name) + span.set_attribute("flagsmith.mcp.client.version", client_info.version) async def propagate_span_attributes(request: httpx.Request) -> None: diff --git a/mcp/tests/integration/test_events.py b/mcp/tests/integration/test_events.py index a8e62976d36a..5d0fcab717be 100644 --- a/mcp/tests/integration/test_events.py +++ b/mcp/tests/integration/test_events.py @@ -15,8 +15,8 @@ async def test_events__session_initialised__emits_session_opened( # Then the client's self-declared identity is reported assert log.has( "session.opened", - flagsmith__client__name="mcp", - flagsmith__client__version="0.1.0", + flagsmith__mcp__client__name="mcp", + flagsmith__mcp__client__version="0.1.0", ) @@ -39,8 +39,8 @@ async def test_events__successful_tool_call__emits_tool_called( "event": "tool.called", "level": "info", "tool__name": "list_environments", - "flagsmith__client__name": "mcp", - "flagsmith__client__version": "0.1.0", + "flagsmith__mcp__client__name": "mcp", + "flagsmith__mcp__client__version": "0.1.0", "status": "success", } @@ -63,7 +63,7 @@ async def test_events__failing_tool_call__emits_tool_called_with_error_status( "event": "tool.called", "level": "info", "tool__name": "list_environments", - "flagsmith__client__name": "mcp", - "flagsmith__client__version": "0.1.0", + "flagsmith__mcp__client__name": "mcp", + "flagsmith__mcp__client__version": "0.1.0", "status": "error", } diff --git a/mcp/tests/integration/test_spans.py b/mcp/tests/integration/test_spans.py index 7f2ff5abddb8..a9b3870bc43c 100644 --- a/mcp/tests/integration/test_spans.py +++ b/mcp/tests/integration/test_spans.py @@ -31,8 +31,9 @@ async def test_spans__tool_call__annotated_with_client_identity( assert span.attributes is not None assert { "gen_ai.tool.name": "list_environments", - "flagsmith.client.name": "mcp", - "flagsmith.client.version": "0.1.0", + "flagsmith.client.name": "flagsmith-mcp", + "flagsmith.mcp.client.name": "mcp", + "flagsmith.mcp.client.version": "0.1.0", }.items() <= dict(span.attributes).items() @@ -59,6 +60,7 @@ async def test_spans__tool_call__upstream_request_carries_span_attribute_baggage assert { "mcp.method.name": "tools/call", "gen_ai.tool.name": "list_environments", - "flagsmith.client.name": "mcp", - "flagsmith.client.version": "0.1.0", + "flagsmith.client.name": "flagsmith-mcp", + "flagsmith.mcp.client.name": "mcp", + "flagsmith.mcp.client.version": "0.1.0", }.items() <= entries.items() diff --git a/mcp/tests/unit/test_event_logging.py b/mcp/tests/unit/test_event_logging.py index bf64bf600d78..f20908f9cdfa 100644 --- a/mcp/tests/unit/test_event_logging.py +++ b/mcp/tests/unit/test_event_logging.py @@ -38,7 +38,7 @@ async def test_event_logging_middleware__no_client_info__empty_client_identity( assert log.has( "tool.called", tool__name="list_environments", - flagsmith__client__name="", - flagsmith__client__version="", + flagsmith__mcp__client__name="", + flagsmith__mcp__client__version="", status="success", ) diff --git a/mcp/tests/unit/test_telemetry.py b/mcp/tests/unit/test_telemetry.py index 6c4d865b145d..25138c50fdc7 100644 --- a/mcp/tests/unit/test_telemetry.py +++ b/mcp/tests/unit/test_telemetry.py @@ -6,7 +6,7 @@ from opentelemetry.sdk.trace import ReadableSpan from pytest_mock import MockerFixture -from flagsmith_mcp import config, telemetry +from flagsmith_mcp import config, constants, telemetry def test_setup_telemetry__no_otlp_endpoint__spans_in_process_only( @@ -102,7 +102,7 @@ def test_setup_telemetry__otlp_endpoint__exports_logs_and_traces( ) -def test_client_info_span_processor__outside_request_context__no_attributes( +def test_client_info_span_processor__outside_request_context__service_identity_only( mocker: MockerFixture, ) -> None: # Given no MCP request context @@ -112,7 +112,9 @@ def test_client_info_span_processor__outside_request_context__no_attributes( telemetry.ClientInfoSpanProcessor().on_start(span) # Then - span.set_attribute.assert_not_called() + span.set_attribute.assert_called_once_with( + "flagsmith.client.name", constants.FLAGSMITH_CLIENT_NAME + ) async def test_propagate_span_attributes__no_recording_span__headers_untouched() -> (