Skip to content

Commit 85ce163

Browse files
phernandezclaude
andcommitted
refactor(mcp): drop _RequestSpan wrapper, use logfire span directly
Feedback from PR #754 review: _RequestSpan used speculative getattr to probe for set_attributes / set_attribute on its stored span, which CLAUDE.md explicitly forbids. The check existed because the span was typed as typing.Any | None, hiding the fact that logfire.span always yields a LogfireSpan with a stable .set_attributes method. This strip: - Deletes the _RequestSpan adapter class entirely. - _request_scope now yields the logfire span itself; callers hold a logfire.LogfireSpan | None and call .set_attributes directly. - Extracts two one-liner helpers (_response_span_attrs, _transport_error_span_attrs) for the attribute dicts, preserving the dedup across the 5 call_{get,put,post,patch,delete} sites. - Test fakes yield a _NoopSpan stub so patched `logfire.span` stays compatible with the new direct `.set_attributes` calls. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent aaf4fcd commit 85ce163

File tree

2 files changed

+51
-62
lines changed

2 files changed

+51
-62
lines changed

src/basic_memory/mcp/tools/utils.py

Lines changed: 40 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -41,43 +41,22 @@ def _classify_http_outcome(status_code: int) -> str:
4141
return "unknown" # pragma: no cover
4242

4343

44-
class _RequestSpan:
45-
"""Small adapter for attaching outcome metadata to a live request span."""
46-
47-
def __init__(self, active_span: typing.Any | None):
48-
self._active_span = active_span
49-
50-
def record_response(self, response: Response) -> None:
51-
self._set_attributes(
52-
{
53-
"status_code": response.status_code,
54-
"is_success": response.is_success,
55-
"outcome": _classify_http_outcome(response.status_code),
56-
}
57-
)
58-
59-
def record_transport_error(self, exc: Exception) -> None:
60-
self._set_attributes(
61-
{
62-
"is_success": False,
63-
"outcome": "transport_error",
64-
"error_type": type(exc).__name__,
65-
}
66-
)
67-
68-
def _set_attributes(self, attrs: dict[str, typing.Any]) -> None:
69-
if self._active_span is None:
70-
return
44+
def _response_span_attrs(response: Response) -> dict[str, Any]:
45+
"""Attributes to attach to a request span after a response lands."""
46+
return {
47+
"status_code": response.status_code,
48+
"is_success": response.is_success,
49+
"outcome": _classify_http_outcome(response.status_code),
50+
}
7151

72-
set_attributes = getattr(self._active_span, "set_attributes", None)
73-
if callable(set_attributes):
74-
set_attributes(attrs)
75-
return
7652

77-
set_attribute = getattr(self._active_span, "set_attribute", None)
78-
if callable(set_attribute):
79-
for key, value in attrs.items():
80-
set_attribute(key, value)
53+
def _transport_error_span_attrs(exc: Exception) -> dict[str, Any]:
54+
"""Attributes to attach when the transport layer fails before any response."""
55+
return {
56+
"is_success": False,
57+
"outcome": "transport_error",
58+
"error_type": type(exc).__name__,
59+
}
8160

8261

8362
def get_error_message(
@@ -200,17 +179,17 @@ def _request_scope(
200179
has_body: bool = False,
201180
):
202181
"""Create the shared MCP transport span used by all HTTP helpers."""
203-
attrs: dict[str, Any] = {
204-
"method": method,
205-
"client_name": client_name,
206-
"operation": operation,
207-
"path_template": path_template,
208-
"phase": "request",
209-
"has_query": bool(params),
210-
"has_body": has_body,
211-
}
212-
with logfire.span("mcp.http.request", **attrs) as active_span:
213-
yield _RequestSpan(active_span)
182+
with logfire.span(
183+
"mcp.http.request",
184+
method=method,
185+
client_name=client_name,
186+
operation=operation,
187+
path_template=path_template,
188+
phase="request",
189+
has_query=bool(params),
190+
has_body=has_body,
191+
) as active_span:
192+
yield active_span
214193

215194

216195
async def call_get(
@@ -249,7 +228,7 @@ async def call_get(
249228
"""
250229
logger.debug(f"Calling GET '{url}' params: '{params}'")
251230
error_message = None
252-
request_span: _RequestSpan | None = None
231+
request_span: logfire.LogfireSpan | None = None
253232

254233
try:
255234
with _request_scope(
@@ -269,7 +248,7 @@ async def call_get(
269248
timeout=timeout,
270249
extensions=extensions,
271250
)
272-
request_span.record_response(response)
251+
request_span.set_attributes(_response_span_attrs(response))
273252

274253
if response.is_success:
275254
return response
@@ -298,7 +277,7 @@ async def call_get(
298277
raise ToolError(error_message) from e
299278
except Exception as e:
300279
if request_span is not None:
301-
request_span.record_transport_error(e)
280+
request_span.set_attributes(_transport_error_span_attrs(e))
302281
raise
303282

304283

@@ -346,7 +325,7 @@ async def call_put(
346325
"""
347326
logger.debug(f"Calling PUT '{url}'")
348327
error_message = None
349-
request_span: _RequestSpan | None = None
328+
request_span: logfire.LogfireSpan | None = None
350329

351330
try:
352331
with _request_scope(
@@ -371,7 +350,7 @@ async def call_put(
371350
timeout=timeout,
372351
extensions=extensions,
373352
)
374-
request_span.record_response(response)
353+
request_span.set_attributes(_response_span_attrs(response))
375354

376355
if response.is_success:
377356
return response
@@ -401,7 +380,7 @@ async def call_put(
401380
raise ToolError(error_message) from e
402381
except Exception as e:
403382
if request_span is not None:
404-
request_span.record_transport_error(e)
383+
request_span.set_attributes(_transport_error_span_attrs(e))
405384
raise
406385

407386

@@ -448,7 +427,7 @@ async def call_patch(
448427
ToolError: If the request fails with an appropriate error message
449428
"""
450429
logger.debug(f"Calling PATCH '{url}'")
451-
request_span: _RequestSpan | None = None
430+
request_span: logfire.LogfireSpan | None = None
452431

453432
try:
454433
with _request_scope(
@@ -473,7 +452,7 @@ async def call_patch(
473452
timeout=timeout,
474453
extensions=extensions,
475454
)
476-
request_span.record_response(response)
455+
request_span.set_attributes(_response_span_attrs(response))
477456

478457
if response.is_success:
479458
return response
@@ -508,7 +487,7 @@ async def call_patch(
508487
raise ToolError(error_message) from e
509488
except Exception as e:
510489
if request_span is not None:
511-
request_span.record_transport_error(e)
490+
request_span.set_attributes(_transport_error_span_attrs(e))
512491
raise
513492

514493

@@ -556,7 +535,7 @@ async def call_post(
556535
"""
557536
logger.debug(f"Calling POST '{url}'")
558537
error_message = None
559-
request_span: _RequestSpan | None = None
538+
request_span: logfire.LogfireSpan | None = None
560539

561540
try:
562541
with _request_scope(
@@ -581,7 +560,7 @@ async def call_post(
581560
timeout=timeout,
582561
extensions=extensions,
583562
)
584-
request_span.record_response(response)
563+
request_span.set_attributes(_response_span_attrs(response))
585564
logger.debug(f"response: {_extract_response_data(response)}")
586565

587566
if response.is_success:
@@ -611,7 +590,7 @@ async def call_post(
611590
raise ToolError(error_message) from e
612591
except Exception as e:
613592
if request_span is not None:
614-
request_span.record_transport_error(e)
593+
request_span.set_attributes(_transport_error_span_attrs(e))
615594
raise
616595

617596

@@ -683,7 +662,7 @@ async def call_delete(
683662
"""
684663
logger.debug(f"Calling DELETE '{url}'")
685664
error_message = None
686-
request_span: _RequestSpan | None = None
665+
request_span: logfire.LogfireSpan | None = None
687666

688667
try:
689668
with _request_scope(
@@ -703,7 +682,7 @@ async def call_delete(
703682
timeout=timeout,
704683
extensions=extensions,
705684
)
706-
request_span.record_response(response)
685+
request_span.set_attributes(_response_span_attrs(response))
707686

708687
if response.is_success:
709688
return response
@@ -732,5 +711,5 @@ async def call_delete(
732711
raise ToolError(error_message) from e
733712
except Exception as e:
734713
if request_span is not None:
735-
request_span.record_transport_error(e)
714+
request_span.set_attributes(_transport_error_span_attrs(e))
736715
raise

tests/mcp/test_tool_telemetry.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,23 @@
1515
write_note_module = importlib.import_module("basic_memory.mcp.tools.write_note")
1616

1717

18+
class _NoopSpan:
19+
"""Minimal stand-in for a live logfire span during tests."""
20+
21+
def set_attributes(self, attrs: dict) -> None:
22+
pass
23+
24+
def set_attribute(self, key: str, value) -> None:
25+
pass
26+
27+
1828
def _recording_spans():
1929
spans: list[tuple[str, dict]] = []
2030

2131
@contextmanager
2232
def fake_span(name: str, **attrs):
2333
spans.append((name, attrs))
24-
yield
34+
yield _NoopSpan()
2535

2636
return spans, fake_span
2737

0 commit comments

Comments
 (0)