mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
fix: restore MCP telemetry compatibility and outcomes
Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
@@ -57,7 +57,10 @@ async def search(
|
||||
page_size=page_size,
|
||||
retrieval_mode=query.retrieval_mode.value,
|
||||
has_query=bool(
|
||||
(query.text and query.text.strip()) or query.title or query.permalink or query.permalink_match
|
||||
(query.text and query.text.strip())
|
||||
or query.title
|
||||
or query.permalink
|
||||
or query.permalink_match
|
||||
),
|
||||
has_filters=bool(query.note_types or query.entity_types or query.metadata_filters),
|
||||
):
|
||||
|
||||
@@ -39,7 +39,9 @@ async def to_graph_context(
|
||||
entity_ids_needed: set[int] = set()
|
||||
for context_item in context_result.results:
|
||||
for item in (
|
||||
[context_item.primary_result] + context_item.observations + context_item.related_results
|
||||
[context_item.primary_result]
|
||||
+ context_item.observations
|
||||
+ context_item.related_results
|
||||
):
|
||||
if item.type == SearchItemType.ENTITY:
|
||||
# Entity's own ID for its external_id
|
||||
@@ -100,9 +102,7 @@ async def to_graph_context(
|
||||
created_at=item.created_at,
|
||||
)
|
||||
case SearchItemType.RELATION:
|
||||
from_title = (
|
||||
entity_title_lookup.get(item.from_id) if item.from_id else None
|
||||
) # pyright: ignore
|
||||
from_title = entity_title_lookup.get(item.from_id) if item.from_id else None # pyright: ignore
|
||||
to_title = entity_title_lookup.get(item.to_id) if item.to_id else None
|
||||
from_ext_id = (
|
||||
entity_external_id_lookup.get(item.from_id) if item.from_id else None
|
||||
@@ -154,7 +154,8 @@ async def to_graph_context(
|
||||
generated_at=context_result.metadata.generated_at,
|
||||
primary_count=context_result.metadata.primary_count,
|
||||
related_count=context_result.metadata.related_count,
|
||||
total_results=context_result.metadata.primary_count + context_result.metadata.related_count,
|
||||
total_results=context_result.metadata.primary_count
|
||||
+ context_result.metadata.related_count,
|
||||
total_relations=context_result.metadata.total_relations,
|
||||
total_observations=context_result.metadata.total_observations,
|
||||
)
|
||||
|
||||
@@ -206,7 +206,7 @@ async def build_context(
|
||||
"mcp.tool.build_context",
|
||||
entrypoint="mcp",
|
||||
tool_name="build_context",
|
||||
project_name=project,
|
||||
requested_project=project,
|
||||
workspace_id=workspace,
|
||||
depth=depth or 1,
|
||||
timeframe=timeframe,
|
||||
|
||||
@@ -275,7 +275,7 @@ async def edit_note(
|
||||
"mcp.tool.edit_note",
|
||||
entrypoint="mcp",
|
||||
tool_name="edit_note",
|
||||
project_name=project,
|
||||
requested_project=project,
|
||||
workspace_id=workspace,
|
||||
edit_operation=operation,
|
||||
output_format=output_format,
|
||||
|
||||
@@ -16,8 +16,8 @@ from basic_memory.mcp.project_context import (
|
||||
resolve_project_and_path,
|
||||
)
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.search import search_notes
|
||||
from basic_memory.schemas.memory import memory_url_path
|
||||
from basic_memory.schemas.search import SearchQuery
|
||||
from basic_memory.utils import validate_project_path
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ async def read_note(
|
||||
"mcp.tool.read_note",
|
||||
entrypoint="mcp",
|
||||
tool_name="read_note",
|
||||
project_name=project,
|
||||
requested_project=project,
|
||||
workspace_id=workspace,
|
||||
output_format=output_format,
|
||||
page=page,
|
||||
@@ -199,12 +199,11 @@ async def read_note(
|
||||
)
|
||||
|
||||
# Import here to avoid circular import
|
||||
from basic_memory.mcp.clients import KnowledgeClient, ResourceClient, SearchClient
|
||||
from basic_memory.mcp.clients import KnowledgeClient, ResourceClient
|
||||
|
||||
# Use typed clients for API calls
|
||||
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
||||
resource_client = ResourceClient(client, active_project.external_id)
|
||||
search_client = SearchClient(client, active_project.external_id)
|
||||
|
||||
async def _read_json_payload(entity_id: str) -> dict:
|
||||
with telemetry.scope(
|
||||
@@ -214,7 +213,9 @@ async def read_note(
|
||||
phase="shape_response",
|
||||
):
|
||||
entity = await knowledge_client.get_entity(entity_id)
|
||||
response = await resource_client.read(entity_id, page=page, page_size=page_size)
|
||||
response = await resource_client.read(
|
||||
entity_id, page=page, page_size=page_size
|
||||
)
|
||||
content_text = response.text
|
||||
body_content, parsed_frontmatter = _parse_opening_frontmatter(content_text)
|
||||
return {
|
||||
@@ -241,15 +242,22 @@ async def read_note(
|
||||
return results if isinstance(results, list) else []
|
||||
|
||||
async def _search_candidates(identifier_text: str, *, title_only: bool) -> dict:
|
||||
query = SearchQuery(title=identifier_text) if title_only else SearchQuery(
|
||||
text=identifier_text
|
||||
)
|
||||
response = await search_client.search(
|
||||
query.model_dump(mode="json", exclude_none=True),
|
||||
# Trigger: direct entity resolution failed for the caller's identifier.
|
||||
# Why: search_notes applies the same memory:// normalization and tool-level
|
||||
# query handling as the rest of MCP routing, which raw client calls skip.
|
||||
# Outcome: unresolved memory URLs still fall back through normalized search.
|
||||
search_type = "title" if title_only else "text"
|
||||
response = await search_notes(
|
||||
project=active_project.name,
|
||||
workspace=workspace,
|
||||
query=identifier_text,
|
||||
search_type=search_type,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
output_format="json",
|
||||
context=context,
|
||||
)
|
||||
return response.model_dump(mode="json")
|
||||
return response if isinstance(response, dict) else {}
|
||||
|
||||
def _result_title(item: dict) -> str:
|
||||
return str(item.get("title") or "")
|
||||
|
||||
@@ -528,7 +528,7 @@ async def search_notes(
|
||||
"mcp.tool.search_notes",
|
||||
entrypoint="mcp",
|
||||
tool_name="search_notes",
|
||||
project_name=project,
|
||||
requested_project=project,
|
||||
workspace_id=workspace,
|
||||
search_type=search_type or "default",
|
||||
output_format=output_format,
|
||||
@@ -537,7 +537,9 @@ async def search_notes(
|
||||
has_query=bool(query and query.strip()),
|
||||
note_type_filter_count=len(note_types),
|
||||
entity_type_filter_count=len(entity_types),
|
||||
has_filters=bool(metadata_filters or tags or status or note_types or entity_types or after_date),
|
||||
has_filters=bool(
|
||||
metadata_filters or tags or status or note_types or entity_types or after_date
|
||||
),
|
||||
has_tags_filter=bool(tags),
|
||||
has_status_filter=bool(status),
|
||||
):
|
||||
|
||||
@@ -5,6 +5,7 @@ to the Basic Memory API, with improved error handling and logging.
|
||||
"""
|
||||
|
||||
import typing
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional
|
||||
|
||||
from httpx import Response, URL, AsyncClient, HTTPStatusError
|
||||
@@ -27,6 +28,58 @@ from basic_memory import telemetry
|
||||
from basic_memory.config import ConfigManager
|
||||
|
||||
|
||||
def _classify_http_outcome(status_code: int) -> str:
|
||||
"""Map HTTP status codes to a low-cardinality outcome label."""
|
||||
if 200 <= status_code < 300:
|
||||
return "success"
|
||||
if 300 <= status_code < 400: # pragma: no cover
|
||||
return "redirect"
|
||||
if 400 <= status_code < 500:
|
||||
return "client_error"
|
||||
if 500 <= status_code < 600:
|
||||
return "server_error"
|
||||
return "unknown" # pragma: no cover
|
||||
|
||||
|
||||
class _RequestSpan:
|
||||
"""Small adapter for attaching outcome metadata to a live request span."""
|
||||
|
||||
def __init__(self, active_span: typing.Any | None):
|
||||
self._active_span = active_span
|
||||
|
||||
def record_response(self, response: Response) -> None:
|
||||
self._set_attributes(
|
||||
{
|
||||
"status_code": response.status_code,
|
||||
"is_success": response.is_success,
|
||||
"outcome": _classify_http_outcome(response.status_code),
|
||||
}
|
||||
)
|
||||
|
||||
def record_transport_error(self, exc: Exception) -> None:
|
||||
self._set_attributes(
|
||||
{
|
||||
"is_success": False,
|
||||
"outcome": "transport_error",
|
||||
"error_type": type(exc).__name__,
|
||||
}
|
||||
)
|
||||
|
||||
def _set_attributes(self, attrs: dict[str, typing.Any]) -> None:
|
||||
if self._active_span is None:
|
||||
return
|
||||
|
||||
set_attributes = getattr(self._active_span, "set_attributes", None)
|
||||
if callable(set_attributes):
|
||||
set_attributes(attrs)
|
||||
return
|
||||
|
||||
set_attribute = getattr(self._active_span, "set_attribute", None)
|
||||
if callable(set_attribute):
|
||||
for key, value in attrs.items():
|
||||
set_attribute(key, value)
|
||||
|
||||
|
||||
def get_error_message(
|
||||
status_code: int, url: URL | str, method: str, msg: Optional[str] = None
|
||||
) -> str:
|
||||
@@ -136,6 +189,7 @@ def _resolve_error_message(
|
||||
return get_error_message(status_code, url, method)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _request_scope(
|
||||
method: str,
|
||||
*,
|
||||
@@ -146,16 +200,18 @@ def _request_scope(
|
||||
has_body: bool = False,
|
||||
):
|
||||
"""Create the shared MCP transport span used by all HTTP helpers."""
|
||||
return telemetry.scope(
|
||||
"mcp.http.request",
|
||||
method=method,
|
||||
client_name=client_name,
|
||||
operation=operation,
|
||||
path_template=path_template,
|
||||
phase="request",
|
||||
has_query=bool(params),
|
||||
has_body=has_body,
|
||||
)
|
||||
attrs = {
|
||||
"method": method,
|
||||
"client_name": client_name,
|
||||
"operation": operation,
|
||||
"path_template": path_template,
|
||||
"phase": "request",
|
||||
"has_query": bool(params),
|
||||
"has_body": has_body,
|
||||
}
|
||||
with telemetry.contextualize(**attrs):
|
||||
with telemetry.started_span("mcp.http.request", **attrs) as active_span:
|
||||
yield _RequestSpan(active_span)
|
||||
|
||||
|
||||
async def call_get(
|
||||
@@ -194,6 +250,7 @@ async def call_get(
|
||||
"""
|
||||
logger.debug(f"Calling GET '{url}' params: '{params}'")
|
||||
error_message = None
|
||||
request_span: _RequestSpan | None = None
|
||||
|
||||
try:
|
||||
with _request_scope(
|
||||
@@ -202,7 +259,7 @@ async def call_get(
|
||||
operation=operation,
|
||||
path_template=path_template,
|
||||
params=params,
|
||||
):
|
||||
) as request_span:
|
||||
response = await client.get(
|
||||
url,
|
||||
params=params,
|
||||
@@ -213,6 +270,7 @@ async def call_get(
|
||||
timeout=timeout,
|
||||
extensions=extensions,
|
||||
)
|
||||
request_span.record_response(response)
|
||||
|
||||
if response.is_success:
|
||||
return response
|
||||
@@ -239,6 +297,10 @@ async def call_get(
|
||||
|
||||
except HTTPStatusError as e:
|
||||
raise ToolError(error_message) from e
|
||||
except Exception as e:
|
||||
if request_span is not None:
|
||||
request_span.record_transport_error(e)
|
||||
raise
|
||||
|
||||
|
||||
async def call_put(
|
||||
@@ -285,6 +347,7 @@ async def call_put(
|
||||
"""
|
||||
logger.debug(f"Calling PUT '{url}'")
|
||||
error_message = None
|
||||
request_span: _RequestSpan | None = None
|
||||
|
||||
try:
|
||||
with _request_scope(
|
||||
@@ -294,7 +357,7 @@ async def call_put(
|
||||
path_template=path_template,
|
||||
params=params,
|
||||
has_body=any(value is not None for value in (content, data, files, json)),
|
||||
):
|
||||
) as request_span:
|
||||
response = await client.put(
|
||||
url,
|
||||
content=content,
|
||||
@@ -309,6 +372,7 @@ async def call_put(
|
||||
timeout=timeout,
|
||||
extensions=extensions,
|
||||
)
|
||||
request_span.record_response(response)
|
||||
|
||||
if response.is_success:
|
||||
return response
|
||||
@@ -336,6 +400,10 @@ async def call_put(
|
||||
|
||||
except HTTPStatusError as e:
|
||||
raise ToolError(error_message) from e
|
||||
except Exception as e:
|
||||
if request_span is not None:
|
||||
request_span.record_transport_error(e)
|
||||
raise
|
||||
|
||||
|
||||
async def call_patch(
|
||||
@@ -381,6 +449,7 @@ async def call_patch(
|
||||
ToolError: If the request fails with an appropriate error message
|
||||
"""
|
||||
logger.debug(f"Calling PATCH '{url}'")
|
||||
request_span: _RequestSpan | None = None
|
||||
|
||||
try:
|
||||
with _request_scope(
|
||||
@@ -390,7 +459,7 @@ async def call_patch(
|
||||
path_template=path_template,
|
||||
params=params,
|
||||
has_body=any(value is not None for value in (content, data, files, json)),
|
||||
):
|
||||
) as request_span:
|
||||
response = await client.patch(
|
||||
url,
|
||||
content=content,
|
||||
@@ -405,6 +474,7 @@ async def call_patch(
|
||||
timeout=timeout,
|
||||
extensions=extensions,
|
||||
)
|
||||
request_span.record_response(response)
|
||||
|
||||
if response.is_success:
|
||||
return response
|
||||
@@ -437,6 +507,10 @@ async def call_patch(
|
||||
error_message = _resolve_error_message(status_code, url, "PATCH", response_data)
|
||||
|
||||
raise ToolError(error_message) from e
|
||||
except Exception as e:
|
||||
if request_span is not None:
|
||||
request_span.record_transport_error(e)
|
||||
raise
|
||||
|
||||
|
||||
async def call_post(
|
||||
@@ -483,6 +557,7 @@ async def call_post(
|
||||
"""
|
||||
logger.debug(f"Calling POST '{url}'")
|
||||
error_message = None
|
||||
request_span: _RequestSpan | None = None
|
||||
|
||||
try:
|
||||
with _request_scope(
|
||||
@@ -492,7 +567,7 @@ async def call_post(
|
||||
path_template=path_template,
|
||||
params=params,
|
||||
has_body=any(value is not None for value in (content, data, files, json)),
|
||||
):
|
||||
) as request_span:
|
||||
response = await client.post(
|
||||
url=url,
|
||||
content=content,
|
||||
@@ -507,7 +582,8 @@ async def call_post(
|
||||
timeout=timeout,
|
||||
extensions=extensions,
|
||||
)
|
||||
logger.debug(f"response: {response.json()}")
|
||||
request_span.record_response(response)
|
||||
logger.debug(f"response: {_extract_response_data(response)}")
|
||||
|
||||
if response.is_success:
|
||||
return response
|
||||
@@ -534,6 +610,10 @@ async def call_post(
|
||||
|
||||
except HTTPStatusError as e:
|
||||
raise ToolError(error_message) from e
|
||||
except Exception as e:
|
||||
if request_span is not None:
|
||||
request_span.record_transport_error(e)
|
||||
raise
|
||||
|
||||
|
||||
async def resolve_entity_id(client: AsyncClient, project_external_id: str, identifier: str) -> str:
|
||||
@@ -604,6 +684,7 @@ async def call_delete(
|
||||
"""
|
||||
logger.debug(f"Calling DELETE '{url}'")
|
||||
error_message = None
|
||||
request_span: _RequestSpan | None = None
|
||||
|
||||
try:
|
||||
with _request_scope(
|
||||
@@ -612,7 +693,7 @@ async def call_delete(
|
||||
operation=operation,
|
||||
path_template=path_template,
|
||||
params=params,
|
||||
):
|
||||
) as request_span:
|
||||
response = await client.delete(
|
||||
url=url,
|
||||
params=params,
|
||||
@@ -623,6 +704,7 @@ async def call_delete(
|
||||
timeout=timeout,
|
||||
extensions=extensions,
|
||||
)
|
||||
request_span.record_response(response)
|
||||
|
||||
if response.is_success:
|
||||
return response
|
||||
@@ -649,3 +731,7 @@ async def call_delete(
|
||||
|
||||
except HTTPStatusError as e:
|
||||
raise ToolError(error_message) from e
|
||||
except Exception as e:
|
||||
if request_span is not None:
|
||||
request_span.record_transport_error(e)
|
||||
raise
|
||||
|
||||
@@ -153,7 +153,7 @@ async def write_note(
|
||||
"mcp.tool.write_note",
|
||||
entrypoint="mcp",
|
||||
tool_name="write_note",
|
||||
project_name=project,
|
||||
requested_project=project,
|
||||
workspace_id=workspace,
|
||||
note_type=note_type,
|
||||
overwrite=effective_overwrite,
|
||||
|
||||
@@ -433,7 +433,9 @@ class EntityService(BaseService[EntityModel]):
|
||||
action="update",
|
||||
phase="upsert_entity",
|
||||
):
|
||||
entity = await self.upsert_entity_from_markdown(file_path, entity_markdown, is_new=False)
|
||||
entity = await self.upsert_entity_from_markdown(
|
||||
file_path, entity_markdown, is_new=False
|
||||
)
|
||||
|
||||
with telemetry.scope(
|
||||
"entity_service.update.update_checksum",
|
||||
@@ -465,7 +467,9 @@ class EntityService(BaseService[EntityModel]):
|
||||
action="fast_write",
|
||||
phase="resolve_entity",
|
||||
):
|
||||
existing = await self.repository.get_by_external_id(external_id) if external_id else None
|
||||
existing = (
|
||||
await self.repository.get_by_external_id(external_id) if external_id else None
|
||||
)
|
||||
|
||||
# Trigger: external_id already exists
|
||||
# Why: avoid duplicate entities when title-derived paths change
|
||||
@@ -698,7 +702,9 @@ class EntityService(BaseService[EntityModel]):
|
||||
action="reindex",
|
||||
phase="upsert_entity",
|
||||
):
|
||||
updated = await self.upsert_entity_from_markdown(file_path, entity_markdown, is_new=False)
|
||||
updated = await self.upsert_entity_from_markdown(
|
||||
file_path, entity_markdown, is_new=False
|
||||
)
|
||||
with telemetry.scope(
|
||||
"entity_service.reindex.update_checksum",
|
||||
domain="entity_service",
|
||||
@@ -1019,7 +1025,9 @@ class EntityService(BaseService[EntityModel]):
|
||||
action="edit",
|
||||
phase="upsert_entity",
|
||||
):
|
||||
entity = await self.upsert_entity_from_markdown(file_path, entity_markdown, is_new=False)
|
||||
entity = await self.upsert_entity_from_markdown(
|
||||
file_path, entity_markdown, is_new=False
|
||||
)
|
||||
|
||||
with telemetry.scope(
|
||||
"entity_service.edit.update_checksum",
|
||||
|
||||
@@ -174,10 +174,7 @@ class SearchService:
|
||||
retrieval_mode = query.retrieval_mode or SearchRetrievalMode.FTS
|
||||
strict_search_text = query.text
|
||||
has_query = bool(
|
||||
strict_search_text
|
||||
or query.title
|
||||
or query.permalink
|
||||
or query.permalink_match
|
||||
strict_search_text or query.title or query.permalink or query.permalink_match
|
||||
)
|
||||
has_filters = bool(
|
||||
metadata_filters
|
||||
@@ -658,7 +655,9 @@ class SearchService:
|
||||
)
|
||||
)
|
||||
if len(obs_content_stems) > MAX_CONTENT_STEMS_SIZE: # pragma: no cover
|
||||
obs_content_stems = obs_content_stems[:MAX_CONTENT_STEMS_SIZE] # pragma: no cover
|
||||
obs_content_stems = obs_content_stems[
|
||||
:MAX_CONTENT_STEMS_SIZE
|
||||
] # pragma: no cover
|
||||
rows_to_index.append(
|
||||
SearchIndexRow(
|
||||
id=obs.id,
|
||||
|
||||
@@ -159,13 +159,20 @@ operation = scope
|
||||
@contextmanager
|
||||
def span(name: str, **attrs: Any) -> Iterator[None]:
|
||||
"""Create a manual Logfire span when telemetry is enabled."""
|
||||
with started_span(name, **attrs):
|
||||
yield
|
||||
|
||||
|
||||
@contextmanager
|
||||
def started_span(name: str, **attrs: Any) -> Iterator[Any | None]:
|
||||
"""Create a manual Logfire span and expose the active span handle when available."""
|
||||
logfire = _load_logfire()
|
||||
if logfire is None or not _STATE.configured: # pragma: no cover
|
||||
yield # pragma: no cover
|
||||
return # pragma: no cover
|
||||
|
||||
with logfire.span(name, **_filter_attributes(attrs)):
|
||||
yield
|
||||
with logfire.span(name, **_filter_attributes(attrs)) as active_span:
|
||||
yield active_span
|
||||
|
||||
|
||||
__all__ = [
|
||||
@@ -177,5 +184,6 @@ __all__ = [
|
||||
"reset_telemetry_state",
|
||||
"scope",
|
||||
"span",
|
||||
"started_span",
|
||||
"telemetry_enabled",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user