Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
724 changes: 718 additions & 6 deletions poetry.lock

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ dream = "memos.dream:CommunityDreamPlugin"
# Developers install: `poetry install --extras <feature>`. e.g., `poetry install --extras general-mem`
# Users install: `pip install MemoryOS[<feature>]`. e.g., `pip install MemoryOS[general-mem]`

# OpenTelemetry instrumentation (memory-semconv v0.1.0)
otel = [
"opentelemetry-api (>=1.27.0)",
"opentelemetry-sdk (>=1.27.0)",
"opentelemetry-exporter-otlp-proto-grpc (>=1.27.0)",
"opentelemetry-exporter-otlp-proto-http (>=1.27.0)",
"opentelemetry-semantic-conventions (>=0.48b0)",
"opentelemetry-instrumentation-fastapi (>=0.48b0)",
]

# TreeTextualMemory
tree-mem = [
"neo4j (>=5.28.1,<6.0.0)", # Graph database
Expand Down Expand Up @@ -116,6 +126,12 @@ tavily = [
# Allow users to install with `pip install MemoryOS[all]`
all = [
# Exist in the above optional groups
"opentelemetry-api (>=1.27.0)",
"opentelemetry-sdk (>=1.27.0)",
"opentelemetry-exporter-otlp-proto-grpc (>=1.27.0)",
"opentelemetry-exporter-otlp-proto-http (>=1.27.0)",
"opentelemetry-semantic-conventions (>=0.48b0)",
"opentelemetry-instrumentation-fastapi (>=0.48b0)",
"neo4j (>=5.28.1,<6.0.0)",
"schedule (>=1.2.2,<2.0.0)",
"redis (>=6.2.0,<7.0.0)",
Expand Down
53 changes: 53 additions & 0 deletions src/memos/api/mcp_serve.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import logging
import os

from typing import Any
Expand All @@ -7,13 +8,62 @@
from fastmcp import FastMCP

# Assuming these are your imports
from memos import telemetry as _telemetry
from memos.mem_os.main import MOS
from memos.mem_os.utils.default_config import get_default
from memos.mem_user.user_manager import UserRole


load_dotenv()

logger = logging.getLogger(__name__)


def _bootstrap_telemetry() -> None:
"""
Bootstrap the OTel SDK for the MCP server entry-point.

The ``mos_core.add/search/update/delete`` calls invoked by the MCP tools below
are already instrumented (memos.telemetry.memory_span / instrument_op), but
those spans/metrics/logs only reach a collector when a real (exporting)
TracerProvider is installed. The REST server (server_api.py) bootstraps this,
but the MCP entry-point previously did not — so when MemOS was driven over MCP
(e.g. the memory-benchmark driver) it emitted NO telemetry and the "memos"
service never appeared in the end-to-end trace (ISI-1918 board report).

configure_from_env() is a no-op unless OTEL_EXPORTER_OTLP_ENDPOINT is set, so
local stdio runs without a collector stay silent.

We then best-effort enable server-side MCP instrumentation so an incoming
W3C traceparent (injected by an instrumented MCP client) is extracted and the
memory.* spans nest under the caller's trace instead of starting a new root.
"""
configured = _telemetry.configure_from_env()
if configured:
logger.info(
"[MCP_SERVE] OTel telemetry configured -> %s (service=%s)",
os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"),
os.getenv("OTEL_SERVICE_NAME", "memos"),
)
# Extract incoming trace context from MCP requests so memory.* spans join
# the caller's trace (end-to-end). Optional: warn loudly if the package is
# absent rather than silently losing cross-service correlation.
try:
from opentelemetry.instrumentation.mcp import MCPInstrumentor

MCPInstrumentor().instrument()
logger.info(
"[MCP_SERVE] MCP instrumentation active — trace context propagates over MCP"
)
except ImportError:
logger.warning(
"[MCP_SERVE] opentelemetry-instrumentation-mcp NOT installed: memos spans "
"will export but start a NEW trace instead of nesting under the caller. "
"Install it (client + server) for end-to-end cross-service traces."
)
else:
logger.info("[MCP_SERVE] OTel telemetry not configured (OTEL_EXPORTER_OTLP_ENDPOINT unset)")


def load_default_config(user_id="default_user"):
"""
Expand Down Expand Up @@ -126,6 +176,9 @@ class MOSMCPServer:
"""MCP Server that accepts an existing MOS instance."""

def __init__(self, mos_instance: MOS | None = None):
# Bootstrap OTel BEFORE building MOS so instrumentation binds to the real
# (exporting) TracerProvider rather than the global no-op default.
_bootstrap_telemetry()
self.mcp = FastMCP("MOS Memory System")
if mos_instance is None:
# Fall back to creating from default config
Expand Down
37 changes: 37 additions & 0 deletions src/memos/api/server_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,30 @@
from fastapi.exceptions import RequestValidationError
from starlette.staticfiles import StaticFiles

# OTel: bootstrap the SDK (TracerProvider + OTLP exporters) from the environment,
# then instrument FastAPI so agent HTTP calls produce end-to-end traces.
#
# configure_from_env() MUST run before FastAPIInstrumentor.instrument_app() so the
# instrumentor binds to the real (exporting) TracerProvider rather than the global
# no-op default. It is a no-op unless OTEL_EXPORTER_OTLP_ENDPOINT is set, so local
# runs without a collector stay silent. In the memory-benchmark cluster we set
# OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector.observability:4317.
from memos import telemetry as _telemetry
from memos.api.exceptions import APIExceptionHandler
from memos.api.lifecycle import shutdown_components
from memos.api.middleware.request_context import RequestContextMiddleware
from memos.api.routers import server_router as server_router_module
from memos.plugins.manager import plugin_manager


try:
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor as _FastAPIInstrumentor

_OTEL_FASTAPI_AVAILABLE = True
except ImportError:
_OTEL_FASTAPI_AVAILABLE = False


load_dotenv()

plugin_manager.discover()
Expand Down Expand Up @@ -46,6 +63,26 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
app.mount("/download", StaticFiles(directory=os.getenv("FILE_LOCAL_PATH")), name="static_mapping")

app.add_middleware(RequestContextMiddleware, source="server_api")

# Bootstrap the OTel SDK from env (no-op unless OTEL_EXPORTER_OTLP_ENDPOINT is set),
# then attach FastAPI instrumentation. Ordering matters: instrument_app() binds to
# whatever TracerProvider is global at call time, so configure_from_env() runs first.
_OTEL_CONFIGURED = _telemetry.configure_from_env()
if _OTEL_CONFIGURED:
logger.info(
"[SERVER_API] OTel telemetry configured -> %s (service=%s)",
os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"),
os.getenv("OTEL_SERVICE_NAME", "memos"),
)
else:
logger.info("[SERVER_API] OTel telemetry not configured (OTEL_EXPORTER_OTLP_ENDPOINT unset)")

# Attach OTel FastAPI instrumentation. This creates server-side spans for each
# request and propagates W3C traceparent headers, so downstream memory.* spans
# appear nested under the agent's root span.
if _OTEL_FASTAPI_AVAILABLE:
_FastAPIInstrumentor.instrument_app(app)

# Include routers
app.include_router(server_router_module.router)

Expand Down
Loading