Skip to content

Commit 918e20a

Browse files
committed
test: converge span capture on capfire to fix xdist order-dependence
The previous tests/server/conftest.py called trace.set_tracer_provider() directly, which is set-once per process and raced against logfire's capfire fixture (tests/shared/test_otel.py) under xdist — whichever ran first in a worker won, the other's tests broke. Converge on capfire as the single span-capture owner since logfire.configure() already handles repeat calls by swapping span processors instead of re-setting the provider: - tests/conftest.py: set LOGFIRE_DISTRIBUTED_TRACING=true so propagation tests don't trip logfire's 'found propagated trace context' RuntimeWarning. - tests/server/conftest.py: SpanCapture adapter over capfire.exporter — filters to the mcp-python-sdk instrumentation scope and excludes logfire's pending_span markers, so tests assert on raw ReadableSpan without importing logfire types. - tests/shared/test_otel.py: drop the now-unneeded filterwarnings decorator.
1 parent a8b669c commit 918e20a

4 files changed

Lines changed: 53 additions & 34 deletions

File tree

tests/conftest.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
1+
import os
2+
13
import pytest
24

5+
# OpenTelemetry's `set_tracer_provider` is set-once per process, so the suite
6+
# uses a single span-capture mechanism: logfire's `capfire` fixture (its
7+
# `configure()` swaps span processors on repeat calls rather than re-setting
8+
# the provider). Logfire's default `distributed_tracing=None` emits a
9+
# RuntimeWarning + diagnostic span when incoming W3C trace context is
10+
# extracted; several tests exercise that propagation deliberately, so opt in
11+
# suite-wide. Set before logfire is imported anywhere.
12+
os.environ.setdefault("LOGFIRE_DISTRIBUTED_TRACING", "true")
13+
314

415
@pytest.fixture
516
def anyio_backend():

tests/server/conftest.py

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,32 +3,43 @@
33
from collections.abc import Iterator
44

55
import pytest
6-
from opentelemetry import trace
7-
from opentelemetry.sdk.trace import TracerProvider
8-
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
9-
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
6+
from logfire.testing import CaptureLogfire, TestExporter
7+
from opentelemetry.sdk.trace import ReadableSpan
108

11-
_span_exporter = InMemorySpanExporter()
129

10+
class SpanCapture:
11+
"""Thin adapter over logfire's `TestExporter` for asserting on MCP spans.
1312
14-
@pytest.fixture(scope="session")
15-
def _tracer_provider() -> TracerProvider:
16-
"""Install a real OTel SDK tracer provider once per test session.
17-
18-
The runtime dependency is ``opentelemetry-api`` only, which yields no-op
19-
``NonRecordingSpan`` objects. Tests that need to assert on emitted spans
20-
request the `spans` fixture, which depends on this one to make the global
21-
tracer record into an in-memory exporter.
13+
`finished()` returns the raw `ReadableSpan` objects emitted by the
14+
``mcp-python-sdk`` instrumentation scope, filtered to exclude logfire's
15+
synthetic ``pending_span`` markers, so tests can assert directly on
16+
`.name`, `.kind`, `.status`, `.attributes`, `.parent`, `.events`.
2217
"""
23-
provider = TracerProvider()
24-
provider.add_span_processor(SimpleSpanProcessor(_span_exporter))
25-
trace.set_tracer_provider(provider)
26-
return provider
18+
19+
def __init__(self, exporter: TestExporter) -> None:
20+
self._exporter = exporter
21+
22+
def clear(self) -> None:
23+
self._exporter.clear()
24+
25+
def finished(self) -> list[ReadableSpan]:
26+
return [
27+
s
28+
for s in self._exporter.exported_spans
29+
if s.instrumentation_scope is not None
30+
and s.instrumentation_scope.name == "mcp-python-sdk"
31+
and not (s.attributes and s.attributes.get("logfire.span_type") == "pending_span")
32+
]
2733

2834

2935
@pytest.fixture
30-
def spans(_tracer_provider: TracerProvider) -> Iterator[InMemorySpanExporter]:
31-
"""In-memory OTel span exporter, cleared before and after each test."""
32-
_span_exporter.clear()
33-
yield _span_exporter
34-
_span_exporter.clear()
36+
def spans(capfire: CaptureLogfire) -> Iterator[SpanCapture]:
37+
"""In-memory MCP span capture, cleared before and after each test.
38+
39+
Backed by the project-level `capfire` override (see ``tests/conftest.py``)
40+
so there is a single global tracer provider for the suite.
41+
"""
42+
capture = SpanCapture(capfire.exporter)
43+
capture.clear()
44+
yield capture
45+
capture.clear()

tests/server/test_runner.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
import anyio
1414
import anyio.lowlevel
1515
import pytest
16-
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
1716
from opentelemetry.trace import SpanKind, StatusCode
1817

1918
from mcp.server.connection import Connection
@@ -36,6 +35,7 @@
3635
)
3736

3837
from ..shared.test_dispatcher import Recorder, echo_handlers
38+
from .conftest import SpanCapture
3939

4040

4141
def _initialize_params() -> dict[str, Any]:
@@ -276,7 +276,7 @@ async def test_runner_stateless_skips_init_gate(server: SrvT):
276276

277277

278278
@pytest.mark.anyio
279-
async def test_otel_middleware_emits_server_span_with_method_and_target(server: SrvT, spans: InMemorySpanExporter):
279+
async def test_otel_middleware_emits_server_span_with_method_and_target(server: SrvT, spans: SpanCapture):
280280
async def call_tool(ctx: Any, params: Any) -> dict[str, Any]:
281281
return {"content": [], "isError": False}
282282

@@ -285,7 +285,7 @@ async def call_tool(ctx: Any, params: Any) -> dict[str, Any]:
285285
spans.clear()
286286
result = await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}})
287287
assert result == {"content": [], "isError": False}
288-
[span] = spans.get_finished_spans()
288+
[span] = spans.finished()
289289
assert span.name == "MCP handle tools/call mytool"
290290
assert span.kind == SpanKind.SERVER
291291
assert span.attributes is not None
@@ -294,35 +294,35 @@ async def call_tool(ctx: Any, params: Any) -> dict[str, Any]:
294294

295295

296296
@pytest.mark.anyio
297-
async def test_otel_middleware_extracts_parent_context_from_meta(server: SrvT, spans: InMemorySpanExporter):
297+
async def test_otel_middleware_extracts_parent_context_from_meta(server: SrvT, spans: SpanCapture):
298298
parent_span_id = "b7ad6b7169203331"
299299
traceparent = f"00-0af7651916cd43dd8448eb211c80319c-{parent_span_id}-01"
300300
async with connected_runner(server, dispatch_middleware=[otel_middleware]) as (client, _):
301301
spans.clear()
302302
await client.send_raw_request("tools/list", {"_meta": {"traceparent": traceparent}})
303-
[span] = spans.get_finished_spans()
303+
[span] = spans.finished()
304304
assert span.parent is not None
305305
assert format(span.parent.span_id, "016x") == parent_span_id
306306
assert span.context is not None
307307
assert format(span.context.trace_id, "032x") == "0af7651916cd43dd8448eb211c80319c"
308308

309309

310310
@pytest.mark.anyio
311-
async def test_otel_middleware_records_error_status_on_mcp_error(server: SrvT, spans: InMemorySpanExporter):
311+
async def test_otel_middleware_records_error_status_on_mcp_error(server: SrvT, spans: SpanCapture):
312312
async with connected_runner(server, dispatch_middleware=[otel_middleware]) as (client, _):
313313
spans.clear()
314314
with pytest.raises(MCPError) as exc:
315315
await client.send_raw_request("nonexistent/method", None)
316316
assert exc.value.error.code == METHOD_NOT_FOUND
317-
[span] = spans.get_finished_spans()
317+
[span] = spans.finished()
318318
assert span.status.status_code == StatusCode.ERROR
319319
assert span.status.description == "Method not found: nonexistent/method"
320320
# MCPError is a protocol-level response, not a crash — no traceback event.
321321
assert not [e for e in span.events if e.name == "exception"]
322322

323323

324324
@pytest.mark.anyio
325-
async def test_otel_middleware_records_error_status_on_handler_exception(server: SrvT, spans: InMemorySpanExporter):
325+
async def test_otel_middleware_records_error_status_on_handler_exception(server: SrvT, spans: SpanCapture):
326326
async def failing(ctx: Any, params: Any) -> Any:
327327
raise ValueError("handler blew up")
328328

@@ -332,7 +332,7 @@ async def failing(ctx: Any, params: Any) -> Any:
332332
with pytest.raises(MCPError) as exc:
333333
await client.send_raw_request("tools/list", None)
334334
assert exc.value.error.code == INTERNAL_ERROR
335-
[span] = spans.get_finished_spans()
335+
[span] = spans.finished()
336336
assert span.status.status_code == StatusCode.ERROR
337337
assert span.status.description == "handler blew up"
338338
[event] = [e for e in span.events if e.name == "exception"]

tests/shared/test_otel.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,6 @@
1010
pytestmark = pytest.mark.anyio
1111

1212

13-
# Logfire warns about propagated trace context by default (distributed_tracing=None).
14-
# This is expected here since we're testing cross-boundary context propagation.
15-
@pytest.mark.filterwarnings("ignore::RuntimeWarning")
1613
async def test_client_and_server_spans(capfire: CaptureLogfire):
1714
"""Verify that calling a tool produces client and server spans with correct attributes."""
1815
server = MCPServer("test")

0 commit comments

Comments
 (0)