Skip to content
Merged
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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,39 @@ tail -f ~/.basic-memory/basic-memory.log
BASIC_MEMORY_CLOUD_MODE=true uvicorn basic_memory.api.app:app
```

## Telemetry

Basic Memory collects anonymous usage statistics to help improve the software. This follows the [Homebrew model](https://docs.brew.sh/Analytics) - telemetry is on by default with easy opt-out.

**What we collect:**
- App version, Python version, OS, architecture
- Feature usage (which MCP tools and CLI commands are used)
- Error types (sanitized - no file paths or personal data)

**What we NEVER collect:**
- Note content, file names, or paths
- Personal information
- IP addresses

**Opting out:**
```bash
# Disable telemetry
basic-memory telemetry disable

# Check status
basic-memory telemetry status

# Re-enable
basic-memory telemetry enable
```

Or set the environment variable:
```bash
export BASIC_MEMORY_TELEMETRY_ENABLED=false
```

For more details, see the [Telemetry documentation](https://basicmemory.com/telemetry).

## Development

### Running Tests
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ dependencies = [
"mdformat>=0.7.22",
"mdformat-gfm>=0.3.7",
"mdformat-frontmatter>=2.0.8",
"openpanel>=0.0.1", # Anonymous usage telemetry (Homebrew-style opt-out)
]


Expand Down
8 changes: 8 additions & 0 deletions src/basic_memory/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import typer # noqa: E402

from basic_memory.config import ConfigManager, init_cli_logging # noqa: E402
from basic_memory.telemetry import show_notice_if_needed, track_app_started # noqa: E402


def version_callback(value: bool) -> None:
Expand Down Expand Up @@ -46,6 +47,13 @@ def app_callback(
# Initialize logging for CLI (file only, no stdout)
init_cli_logging()

# Show telemetry notice and track CLI startup
# Skip for 'mcp' command - it handles its own telemetry in lifespan
# Skip for 'telemetry' command - avoid issues when user is managing telemetry
if ctx.invoked_subcommand not in {"mcp", "telemetry"}:
show_notice_if_needed()
track_app_started("cli")

# Run initialization for commands that don't use the API
# Skip for 'mcp' command - it has its own lifespan that handles initialization
# Skip for API-using commands (status, sync, etc.) - they handle initialization via deps.py
Expand Down
3 changes: 2 additions & 1 deletion src/basic_memory/cli/commands/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""CLI commands for basic-memory."""

from . import status, db, import_memory_json, mcp, import_claude_conversations
from . import import_claude_projects, import_chatgpt, tool, project, format
from . import import_claude_projects, import_chatgpt, tool, project, format, telemetry

__all__ = [
"status",
Expand All @@ -14,4 +14,5 @@
"tool",
"project",
"format",
"telemetry",
]
79 changes: 79 additions & 0 deletions src/basic_memory/cli/commands/telemetry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Telemetry commands for basic-memory CLI."""

import typer
from rich.console import Console
from rich.panel import Panel

from basic_memory.cli.app import app
from basic_memory.config import ConfigManager

console = Console()

# Create telemetry subcommand group
telemetry_app = typer.Typer(help="Manage anonymous telemetry settings")
app.add_typer(telemetry_app, name="telemetry")


@telemetry_app.command("enable")
def enable() -> None:
"""Enable anonymous telemetry.

Telemetry helps improve Basic Memory by collecting anonymous usage data.
No personal data, note content, or file paths are ever collected.
"""
config_manager = ConfigManager()
config = config_manager.config
config.telemetry_enabled = True
config_manager.save_config(config)
console.print("[green]Telemetry enabled[/green]")
console.print("[dim]Thank you for helping improve Basic Memory![/dim]")


@telemetry_app.command("disable")
def disable() -> None:
"""Disable anonymous telemetry.

You can re-enable telemetry anytime with: bm telemetry enable
"""
config_manager = ConfigManager()
config = config_manager.config
config.telemetry_enabled = False
config_manager.save_config(config)
console.print("[yellow]Telemetry disabled[/yellow]")


@telemetry_app.command("status")
def status() -> None:
"""Show current telemetry status and what's collected."""
from basic_memory.telemetry import get_install_id, TELEMETRY_DOCS_URL

config = ConfigManager().config

status_text = "[green]enabled[/green]" if config.telemetry_enabled else "[yellow]disabled[/yellow]"

console.print(f"\nTelemetry: {status_text}")
console.print(f"Install ID: [dim]{get_install_id()}[/dim]")
console.print()

what_we_collect = """
[bold]What we collect:[/bold]
- App version, Python version, OS, architecture
- Feature usage (which MCP tools and CLI commands)
- Sync statistics (entity count, duration)
- Error types (sanitized, no file paths)

[bold]What we NEVER collect:[/bold]
- Note content, file names, or paths
- Personal information
- IP addresses
"""

console.print(
Panel(
what_we_collect.strip(),
title="Telemetry Details",
border_style="blue",
expand=False,
)
)
console.print(f"[dim]Details: {TELEMETRY_DOCS_URL}[/dim]")
1 change: 1 addition & 0 deletions src/basic_memory/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
mcp,
project,
status,
telemetry,
tool,
)

Expand Down
11 changes: 11 additions & 0 deletions src/basic_memory/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,17 @@ class BasicMemoryConfig(BaseSettings):
description="Cloud project sync configuration mapping project names to their local paths and sync state",
)

# Telemetry configuration (Homebrew-style opt-out)
telemetry_enabled: bool = Field(
default=True,
description="Send anonymous usage statistics to help improve Basic Memory. Disable with: bm telemetry disable",
)

telemetry_notice_shown: bool = Field(
default=False,
description="Whether the one-time telemetry notice has been shown to the user",
)

@property
def cloud_mode_enabled(self) -> bool:
"""Check if cloud mode is enabled.
Expand Down
6 changes: 6 additions & 0 deletions src/basic_memory/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from basic_memory import db
from basic_memory.config import ConfigManager
from basic_memory.services.initialization import initialize_app, initialize_file_sync
from basic_memory.telemetry import show_notice_if_needed, track_app_started


@asynccontextmanager
Expand All @@ -20,12 +21,17 @@ async def lifespan(app: FastMCP):

Handles:
- Database initialization and migrations
- Telemetry notice and tracking
- File sync in background (if enabled and not in cloud mode)
- Proper cleanup on shutdown
"""
app_config = ConfigManager().config
logger.info("Starting Basic Memory MCP server")

# Show telemetry notice (first run only) and track startup
show_notice_if_needed()
track_app_started("mcp")

# Track if we created the engine (vs test fixtures providing it)
# This prevents disposing an engine provided by test fixtures when
# multiple Client connections are made in the same test
Expand Down
2 changes: 2 additions & 0 deletions src/basic_memory/mcp/tools/build_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get
from basic_memory.telemetry import track_mcp_tool
from basic_memory.schemas.base import TimeFrame
from basic_memory.schemas.memory import (
GraphContext,
Expand Down Expand Up @@ -87,6 +88,7 @@ async def build_context(
Raises:
ToolError: If project doesn't exist or depth parameter is invalid
"""
track_mcp_tool("build_context")
logger.info(f"Building context from {url} in project {project}")

# Convert string depth to integer if needed
Expand Down
2 changes: 2 additions & 0 deletions src/basic_memory/mcp/tools/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_put, call_post, resolve_entity_id
from basic_memory.telemetry import track_mcp_tool


@mcp.tool(
Expand Down Expand Up @@ -94,6 +95,7 @@ async def canvas(
Raises:
ToolError: If project doesn't exist or folder path is invalid
"""
track_mcp_tool("canvas")
async with get_client() as client:
active_project = await get_active_project(client, project, context)

Expand Down
3 changes: 3 additions & 0 deletions src/basic_memory/mcp/tools/chatgpt_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from basic_memory.mcp.tools.read_note import read_note
from basic_memory.schemas.search import SearchResponse
from basic_memory.config import ConfigManager
from basic_memory.telemetry import track_mcp_tool


def _format_search_results_for_chatgpt(results: SearchResponse) -> List[Dict[str, Any]]:
Expand Down Expand Up @@ -88,6 +89,7 @@ async def search(
List with one dict: `{ "type": "text", "text": "{...JSON...}" }`
where the JSON body contains `results`, `total_count`, and echo of `query`.
"""
track_mcp_tool("search")
logger.info(f"ChatGPT search request: query='{query}'")

try:
Expand Down Expand Up @@ -151,6 +153,7 @@ async def fetch(
List with one dict: `{ "type": "text", "text": "{...JSON...}" }`
where the JSON body includes `id`, `title`, `text`, `url`, and metadata.
"""
track_mcp_tool("fetch")
logger.info(f"ChatGPT fetch request: id='{id}'")

try:
Expand Down
2 changes: 2 additions & 0 deletions src/basic_memory/mcp/tools/delete_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from basic_memory.mcp.tools.utils import call_delete, resolve_entity_id
from basic_memory.mcp.server import mcp
from basic_memory.mcp.async_client import get_client
from basic_memory.telemetry import track_mcp_tool
from basic_memory.schemas import DeleteEntitiesResponse


Expand Down Expand Up @@ -203,6 +204,7 @@ async def delete_note(
with suggestions for finding the correct identifier, including search
commands and alternative formats to try.
"""
track_mcp_tool("delete_note")
async with get_client() as client:
active_project = await get_active_project(client, project, context)

Expand Down
2 changes: 2 additions & 0 deletions src/basic_memory/mcp/tools/edit_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from basic_memory.mcp.project_context import get_active_project, add_project_metadata
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_patch, resolve_entity_id
from basic_memory.telemetry import track_mcp_tool
from basic_memory.schemas import EntityResponse


Expand Down Expand Up @@ -214,6 +215,7 @@ async def edit_note(
search_notes() first to find the correct identifier. The tool provides detailed
error messages with suggestions if operations fail.
"""
track_mcp_tool("edit_note")
async with get_client() as client:
active_project = await get_active_project(client, project, context)

Expand Down
2 changes: 2 additions & 0 deletions src/basic_memory/mcp/tools/list_directory.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from basic_memory.mcp.project_context import get_active_project
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import call_get
from basic_memory.telemetry import track_mcp_tool


@mcp.tool(
Expand Down Expand Up @@ -63,6 +64,7 @@ async def list_directory(
Raises:
ToolError: If project doesn't exist or directory path is invalid
"""
track_mcp_tool("list_directory")
async with get_client() as client:
active_project = await get_active_project(client, project, context)

Expand Down
2 changes: 2 additions & 0 deletions src/basic_memory/mcp/tools/move_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from basic_memory.mcp.project_context import get_active_project
from basic_memory.schemas import EntityResponse
from basic_memory.schemas.project_info import ProjectList
from basic_memory.telemetry import track_mcp_tool
from basic_memory.utils import validate_project_path


Expand Down Expand Up @@ -395,6 +396,7 @@ async def move_note(
- Re-indexes the entity for search
- Maintains all observations and relations
"""
track_mcp_tool("move_note")
async with get_client() as client:
logger.debug(f"Moving note: {identifier} to {destination_path} in project: {project}")

Expand Down
4 changes: 4 additions & 0 deletions src/basic_memory/mcp/tools/project_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
ProjectStatusResponse,
ProjectInfoRequest,
)
from basic_memory.telemetry import track_mcp_tool
from basic_memory.utils import generate_permalink


Expand All @@ -40,6 +41,7 @@ async def list_memory_projects(context: Context | None = None) -> str:
Example:
list_memory_projects()
"""
track_mcp_tool("list_memory_projects")
async with get_client() as client:
if context: # pragma: no cover
await context.info("Listing all available projects")
Expand Down Expand Up @@ -92,6 +94,7 @@ async def create_memory_project(
create_memory_project("my-research", "~/Documents/research")
create_memory_project("work-notes", "/home/user/work", set_default=True)
"""
track_mcp_tool("create_memory_project")
async with get_client() as client:
# Check if server is constrained to a specific project
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
Expand Down Expand Up @@ -147,6 +150,7 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
This action cannot be undone. The project will need to be re-added
to access its content through Basic Memory again.
"""
track_mcp_tool("delete_project")
async with get_client() as client:
# Check if server is constrained to a specific project
constrained_project = os.environ.get("BASIC_MEMORY_MCP_PROJECT")
Expand Down
2 changes: 2 additions & 0 deletions src/basic_memory/mcp/tools/read_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_get, resolve_entity_id
from basic_memory.schemas.memory import memory_url_path
from basic_memory.telemetry import track_mcp_tool
from basic_memory.utils import validate_project_path


Expand Down Expand Up @@ -200,6 +201,7 @@ async def read_content(
HTTPError: If project doesn't exist or is inaccessible
SecurityError: If path attempts path traversal
"""
track_mcp_tool("read_content")
logger.info("Reading file", path=path, project=project)

async with get_client() as client:
Expand Down
Loading
Loading