Skip to content

Commit 4791e19

Browse files
phernandezclaude
andauthored
feat: add Logfire phased instrumentation (#692)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3684841 commit 4791e19

34 files changed

Lines changed: 3866 additions & 1357 deletions

docs/logfire-instrumentation-strategy.md

Lines changed: 499 additions & 0 deletions
Large diffs are not rendered by default.

justfile

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,51 @@ doctor:
205205
BASIC_MEMORY_CONFIG_DIR="$TMP_CONFIG" \
206206
./.venv/bin/python -m basic_memory.cli.main doctor --local
207207

208+
# Run an isolated Logfire smoke workflow for local trace inspection
209+
telemetry-smoke:
210+
#!/usr/bin/env bash
211+
set -euo pipefail
212+
TMP_HOME=$(mktemp -d)
213+
TMP_CONFIG=$(mktemp -d)
214+
TMP_PROJECT=$(mktemp -d)
215+
export HOME="$TMP_HOME"
216+
export BASIC_MEMORY_ENV="${BASIC_MEMORY_ENV:-dev}"
217+
export BASIC_MEMORY_HOME="$TMP_PROJECT/home-root"
218+
export BASIC_MEMORY_CONFIG_DIR="$TMP_CONFIG"
219+
export BASIC_MEMORY_NO_PROMOS=1
220+
export BASIC_MEMORY_LOG_LEVEL="${BASIC_MEMORY_LOG_LEVEL:-INFO}"
221+
export BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED="${BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED:-false}"
222+
export BASIC_MEMORY_LOGFIRE_ENABLED="${BASIC_MEMORY_LOGFIRE_ENABLED:-true}"
223+
export BASIC_MEMORY_LOGFIRE_ENVIRONMENT="${BASIC_MEMORY_LOGFIRE_ENVIRONMENT:-telemetry-smoke}"
224+
if [[ -z "${BASIC_MEMORY_LOGFIRE_SEND_TO_LOGFIRE:-}" ]]; then
225+
if [[ -n "${LOGFIRE_TOKEN:-}" ]]; then
226+
export BASIC_MEMORY_LOGFIRE_SEND_TO_LOGFIRE=true
227+
else
228+
export BASIC_MEMORY_LOGFIRE_SEND_TO_LOGFIRE=false
229+
fi
230+
fi
231+
mkdir -p "$BASIC_MEMORY_HOME"
232+
echo "Telemetry smoke setup:"
233+
echo " logfire_enabled=$BASIC_MEMORY_LOGFIRE_ENABLED"
234+
echo " send_to_logfire=$BASIC_MEMORY_LOGFIRE_SEND_TO_LOGFIRE"
235+
echo " log_level=$BASIC_MEMORY_LOG_LEVEL"
236+
echo " semantic_search_enabled=$BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED"
237+
echo " logfire_environment=$BASIC_MEMORY_LOGFIRE_ENVIRONMENT"
238+
echo " project_path=$TMP_PROJECT"
239+
./.venv/bin/python -m basic_memory.cli.main project add telemetry-smoke "$TMP_PROJECT" --default --local
240+
./.venv/bin/python -m basic_memory.cli.main tool write-note --title "Telemetry Smoke" --folder notes --content "hello from smoke" --project telemetry-smoke --local
241+
./.venv/bin/python -m basic_memory.cli.main tool read-note notes/telemetry-smoke --project telemetry-smoke --local
242+
./.venv/bin/python -m basic_memory.cli.main tool edit-note notes/telemetry-smoke --operation append --content $'\n\nsmoke edit line' --project telemetry-smoke --local
243+
./.venv/bin/python -m basic_memory.cli.main tool build-context notes/telemetry-smoke --project telemetry-smoke --local --page-size 5 --max-related 5
244+
./.venv/bin/python -m basic_memory.cli.main tool search-notes telemetry --project telemetry-smoke --local
245+
./.venv/bin/python -m basic_memory.cli.main doctor --local
246+
echo ""
247+
echo "Telemetry smoke complete."
248+
echo "Search Logfire for:"
249+
echo " service_name: basic-memory-cli"
250+
echo " environment: $BASIC_MEMORY_LOGFIRE_ENVIRONMENT"
251+
echo " span names: mcp.tool.write_note, mcp.tool.read_note, mcp.tool.edit_note, mcp.tool.build_context, mcp.tool.search_notes, sync.project.run"
252+
208253

209254
# Update all dependencies to latest versions
210255
update-deps:

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ Documentation = "https://github.com/basicmachines-co/basic-memory#readme"
5858
basic-memory = "basic_memory.cli.main:app"
5959
bm = "basic_memory.cli.main:app"
6060

61+
[project.optional-dependencies]
62+
telemetry = ["logfire>=4.19.0"]
63+
6164
[build-system]
6265
requires = ["hatchling", "uv-dynamic-versioning>=0.7.0"]
6366
build-backend = "hatchling.build"
@@ -83,6 +86,7 @@ target-version = "py312"
8386

8487
[dependency-groups]
8588
dev = [
89+
"logfire>=4.19.0",
8690
"gevent>=24.11.1",
8791
"icecream>=2.1.3",
8892
"pytest>=8.3.4",

src/basic_memory/api/app.py

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
list_projects,
2626
synchronize_projects,
2727
)
28+
from basic_memory import telemetry
2829
from basic_memory.config import init_api_logging
2930
from basic_memory.services.exceptions import EntityAlreadyExistsError
3031
from basic_memory.services.initialization import initialize_app
@@ -43,30 +44,39 @@ async def lifespan(app: FastAPI): # pragma: no cover
4344
set_container(container)
4445
app.state.container = container
4546

46-
logger.info(f"Starting Basic Memory API (mode={container.mode.name})")
47+
with telemetry.operation(
48+
"api.lifecycle.startup",
49+
entrypoint="api",
50+
mode=container.mode.name.lower(),
51+
):
52+
logger.info(f"Starting Basic Memory API (mode={container.mode.name})")
4753

48-
await initialize_app(container.config)
54+
await initialize_app(container.config)
4955

50-
# Cache database connections in app state for performance
51-
logger.info("Initializing database and caching connections...")
52-
engine, session_maker = await container.init_database()
53-
app.state.engine = engine
54-
app.state.session_maker = session_maker
55-
logger.info("Database connections cached in app state")
56+
# Cache database connections in app state for performance
57+
logger.info("Initializing database and caching connections...")
58+
engine, session_maker = await container.init_database()
59+
app.state.engine = engine
60+
app.state.session_maker = session_maker
61+
logger.info("Database connections cached in app state")
5662

57-
# Create and start sync coordinator (lifecycle centralized in coordinator)
58-
sync_coordinator = container.create_sync_coordinator()
59-
await sync_coordinator.start()
60-
app.state.sync_coordinator = sync_coordinator
63+
# Create and start sync coordinator (lifecycle centralized in coordinator)
64+
sync_coordinator = container.create_sync_coordinator()
65+
await sync_coordinator.start()
66+
app.state.sync_coordinator = sync_coordinator
6167

6268
# Proceed with startup
6369
yield
6470

6571
# Shutdown - coordinator handles clean task cancellation
66-
logger.info("Shutting down Basic Memory API")
67-
await sync_coordinator.stop()
68-
69-
await container.shutdown_database()
72+
with telemetry.operation(
73+
"api.lifecycle.shutdown",
74+
entrypoint="api",
75+
mode=container.mode.name.lower(),
76+
):
77+
logger.info("Shutting down Basic Memory API")
78+
await sync_coordinator.stop()
79+
await container.shutdown_database()
7080

7181

7282
# Initialize FastAPI app

src/basic_memory/api/v2/routers/search_router.py

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from fastapi import APIRouter, HTTPException, Path
88

9+
from basic_memory import telemetry
910
from basic_memory.api.v2.utils import to_search_results
1011
from basic_memory.repository.semantic_errors import (
1112
SemanticDependenciesMissingError,
@@ -47,29 +48,39 @@ async def search(
4748
Returns:
4849
SearchResponse with paginated search results
4950
"""
50-
offset = (page - 1) * page_size
51-
# Fetch one extra item to detect whether more pages exist (N+1 trick)
52-
fetch_limit = page_size + 1
53-
try:
54-
results = await search_service.search(query, limit=fetch_limit, offset=offset)
55-
except SemanticSearchDisabledError as exc:
56-
raise HTTPException(status_code=400, detail=str(exc)) from exc
57-
except SemanticDependenciesMissingError as exc:
58-
raise HTTPException(status_code=400, detail=str(exc)) from exc
59-
except ValueError as exc:
60-
raise HTTPException(status_code=400, detail=str(exc)) from exc
61-
62-
has_more = len(results) > page_size
63-
if has_more:
64-
results = results[:page_size]
65-
66-
search_results = await to_search_results(entity_service, results)
67-
return SearchResponse(
68-
results=search_results,
69-
current_page=page,
51+
with telemetry.operation(
52+
"api.request.search",
53+
entrypoint="api",
54+
page=page,
7055
page_size=page_size,
71-
has_more=has_more,
72-
)
56+
retrieval_mode=query.retrieval_mode.value,
57+
has_text_query=bool(query.text and query.text.strip()),
58+
has_title_query=bool(query.title),
59+
has_permalink_query=bool(query.permalink or query.permalink_match),
60+
):
61+
offset = (page - 1) * page_size
62+
# Fetch one extra item to detect whether more pages exist (N+1 trick)
63+
fetch_limit = page_size + 1
64+
try:
65+
results = await search_service.search(query, limit=fetch_limit, offset=offset)
66+
except SemanticSearchDisabledError as exc:
67+
raise HTTPException(status_code=400, detail=str(exc)) from exc
68+
except SemanticDependenciesMissingError as exc:
69+
raise HTTPException(status_code=400, detail=str(exc)) from exc
70+
except ValueError as exc:
71+
raise HTTPException(status_code=400, detail=str(exc)) from exc
72+
73+
has_more = len(results) > page_size
74+
if has_more:
75+
results = results[:page_size]
76+
77+
search_results = await to_search_results(entity_service, results)
78+
return SearchResponse(
79+
results=search_results,
80+
current_page=page,
81+
page_size=page_size,
82+
has_more=has_more,
83+
)
7384

7485

7586
@router.post("/search/reindex")

src/basic_memory/cli/app.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from basic_memory.cli.container import CliContainer, set_container # noqa: E402
1313
from basic_memory.cli.promo import maybe_show_cloud_promo, maybe_show_init_line # noqa: E402
1414
from basic_memory.config import init_cli_logging # noqa: E402
15+
from basic_memory import telemetry # noqa: E402
1516

1617

1718
def version_callback(value: bool) -> None:
@@ -42,6 +43,14 @@ def app_callback(
4243

4344
# Initialize logging for CLI (file only, no stdout)
4445
init_cli_logging()
46+
command_name = ctx.invoked_subcommand or "root"
47+
ctx.with_resource(
48+
telemetry.operation(
49+
f"cli.command.{command_name}",
50+
entrypoint="cli",
51+
command_name=command_name,
52+
)
53+
)
4554

4655
# --- Composition Root ---
4756
# Create container and read config (single point of config access)

src/basic_memory/config.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@
66
import shutil
77
from dataclasses import dataclass
88
from datetime import datetime
9+
from enum import Enum
910
from pathlib import Path
1011
from typing import Any, Dict, Literal, Optional, List, Tuple
11-
from enum import Enum
1212

1313
from loguru import logger
1414
from pydantic import AliasChoices, BaseModel, Field, model_validator
1515
from pydantic_settings import BaseSettings, SettingsConfigDict
1616

17+
from basic_memory import __version__
18+
from basic_memory.telemetry import configure_telemetry
1719
from basic_memory.utils import setup_logging, generate_permalink
1820

1921

@@ -140,6 +142,24 @@ class BasicMemoryConfig(BaseSettings):
140142
# overridden by ~/.basic-memory/config.json
141143
log_level: str = "INFO"
142144

145+
# Optional Logfire telemetry (disabled by default)
146+
logfire_enabled: bool = Field(
147+
default=False,
148+
description="Enable Logfire instrumentation for local development or managed deployments.",
149+
)
150+
logfire_send_to_logfire: bool = Field(
151+
default=False,
152+
description="When true, allow Logfire to export telemetry to the configured backend.",
153+
)
154+
logfire_service_name: str = Field(
155+
default="basic-memory",
156+
description="Base service name used when constructing entrypoint-specific Logfire service names.",
157+
)
158+
logfire_environment: str | None = Field(
159+
default=None,
160+
description="Optional override for Logfire environment. Defaults to env when unset.",
161+
)
162+
143163
# Database configuration
144164
database_backend: DatabaseBackend = Field(
145165
default=DatabaseBackend.SQLITE,
@@ -955,33 +975,50 @@ def save_basic_memory_config(file_path: Path, config: BasicMemoryConfig) -> None
955975
# Logging initialization functions for different entry points
956976

957977

958-
def init_cli_logging() -> None: # pragma: no cover
978+
def _configure_logfire_for_entrypoint(entrypoint: str) -> None:
979+
"""Configure optional Logfire telemetry for a specific entrypoint."""
980+
config = ConfigManager().config
981+
service_name = f"{config.logfire_service_name}-{entrypoint}"
982+
environment = config.logfire_environment or config.env
983+
configure_telemetry(
984+
service_name=service_name,
985+
environment=environment,
986+
service_version=__version__,
987+
enable_logfire=config.logfire_enabled,
988+
send_to_logfire=config.logfire_send_to_logfire,
989+
)
990+
991+
992+
def init_cli_logging() -> None:
959993
"""Initialize logging for CLI commands - file only.
960994
961995
CLI commands should not log to stdout to avoid interfering with
962996
command output and shell integration.
963997
"""
964998
log_level = os.getenv("BASIC_MEMORY_LOG_LEVEL", "INFO")
999+
_configure_logfire_for_entrypoint("cli")
9651000
setup_logging(log_level=log_level, log_to_file=True)
9661001

9671002

968-
def init_mcp_logging() -> None: # pragma: no cover
1003+
def init_mcp_logging() -> None:
9691004
"""Initialize logging for MCP server - file only.
9701005
9711006
MCP server must not log to stdout as it would corrupt the
9721007
JSON-RPC protocol communication.
9731008
"""
9741009
log_level = os.getenv("BASIC_MEMORY_LOG_LEVEL", "INFO")
1010+
_configure_logfire_for_entrypoint("mcp")
9751011
setup_logging(log_level=log_level, log_to_file=True)
9761012

9771013

978-
def init_api_logging() -> None: # pragma: no cover
1014+
def init_api_logging() -> None:
9791015
"""Initialize logging for API server.
9801016
9811017
Cloud mode (BASIC_MEMORY_CLOUD_MODE=1): stdout with structured context
9821018
Local mode: file only
9831019
"""
9841020
log_level = os.getenv("BASIC_MEMORY_LOG_LEVEL", "INFO")
1021+
_configure_logfire_for_entrypoint("api")
9851022
cloud_mode = os.getenv("BASIC_MEMORY_CLOUD_MODE", "").lower() in ("1", "true")
9861023
if cloud_mode:
9871024
setup_logging(log_level=log_level, log_to_stdout=True, structured_context=True)

src/basic_memory/mcp/async_client.py

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from httpx import ASGITransport, AsyncClient, Timeout
66
from loguru import logger
77

8+
from basic_memory import telemetry
89
from basic_memory.api.app import app as fastapi_app
910
from basic_memory.config import ConfigManager, ProjectMode
1011

@@ -43,21 +44,26 @@ def _asgi_client(timeout: Timeout) -> AsyncClient:
4344

4445
async def _resolve_cloud_token(config) -> str:
4546
"""Resolve cloud token with API key preferred, OAuth fallback."""
46-
token = config.cloud_api_key
47-
if token:
48-
return token
49-
50-
from basic_memory.cli.auth import CLIAuth
51-
52-
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
53-
token = await auth.get_valid_token()
54-
if token:
55-
return token
56-
57-
raise RuntimeError(
58-
"Cloud routing requested but no credentials found. "
59-
"Run 'bm cloud api-key save <key>' or 'bm cloud login' first."
60-
)
47+
with telemetry.span(
48+
"routing.resolve_cloud_credentials",
49+
has_api_key=bool(config.cloud_api_key),
50+
):
51+
token = config.cloud_api_key
52+
if token:
53+
return token
54+
55+
from basic_memory.cli.auth import CLIAuth
56+
57+
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
58+
token = await auth.get_valid_token()
59+
if token:
60+
return token
61+
62+
logger.error("Cloud routing requested but no credentials were available")
63+
raise RuntimeError(
64+
"Cloud routing requested but no credentials found. "
65+
"Run 'bm cloud api-key save <key>' or 'bm cloud login' first."
66+
)
6167

6268

6369
@asynccontextmanager

0 commit comments

Comments
 (0)