feat: add anonymous usage telemetry (Homebrew-style opt-out)

Implements issue #477: Add anonymous usage telemetry using OpenPanel.

Core Features:
- Anonymous telemetry with random UUID installation ID
- Homebrew-style opt-out (on by default, easy to disable)
- Fire-and-forget tracking (errors never break the app)
- No personal data or note content collection

Configuration:
- Added telemetry_enabled field to BasicMemoryConfig (default: True)
- Added telemetry_notice_shown field to track first-run notice
- Environment variable override: BASIC_MEMORY_TELEMETRY_ENABLED

CLI Commands:
- bm telemetry enable - Enable telemetry
- bm telemetry disable - Disable telemetry
- bm telemetry status - Show current status and what's collected

Integration:
- CLI app startup tracking with command name
- MCP server startup tracking
- Global properties sent with every event (version, OS, arch, etc.)

Testing:
- Comprehensive unit tests for all telemetry functions
- Tests for config integration and environment variables
- Tests for first-run notice behavior
- Tests for error handling (telemetry never breaks the app)

Files Changed:
- pyproject.toml: Added openpanel dependency
- src/basic_memory/config.py: Added telemetry configuration fields
- src/basic_memory/telemetry.py: Core telemetry module
- src/basic_memory/cli/commands/telemetry.py: CLI commands
- src/basic_memory/cli/app.py: Added CLI startup tracking
- src/basic_memory/mcp/server.py: Added MCP startup tracking
- tests/test_telemetry.py: Comprehensive unit tests

Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
claude[bot]
2025-12-25 02:54:13 +00:00
parent 1fd680c3f1
commit 150a8175b0
9 changed files with 640 additions and 1 deletions
+7
View File
@@ -46,6 +46,13 @@ def app_callback(
# Initialize logging for CLI (file only, no stdout)
init_cli_logging()
# Track CLI startup and show telemetry notice if needed
if not version and ctx.invoked_subcommand is not None:
from basic_memory.telemetry import show_telemetry_notice, track
track("app_started", {"mode": "cli", "command": ctx.invoked_subcommand})
show_telemetry_notice()
# 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
+2 -1
View File
@@ -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",
@@ -14,4 +14,5 @@ __all__ = [
"tool",
"project",
"format",
"telemetry",
]
@@ -0,0 +1,99 @@
"""CLI commands for telemetry management."""
import typer
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from basic_memory.config import ConfigManager
from basic_memory.telemetry import get_install_id, is_telemetry_enabled
app = typer.Typer(help="Manage anonymous usage telemetry")
console = Console()
@app.command()
def enable():
"""Enable anonymous usage telemetry."""
try:
config_manager = ConfigManager()
config = config_manager.load_config()
config.telemetry_enabled = True
config_manager.save_config(config)
console.print("[green]✓[/green] Telemetry enabled")
console.print(
"Thank you for helping improve Basic Memory! Details: https://memory.basicmachines.co/telemetry"
)
except Exception as e:
console.print(f"[red]Error enabling telemetry:[/red] {e}")
raise typer.Exit(1)
@app.command()
def disable():
"""Disable anonymous usage telemetry."""
try:
config_manager = ConfigManager()
config = config_manager.load_config()
config.telemetry_enabled = False
config_manager.save_config(config)
console.print("[green]✓[/green] Telemetry disabled")
console.print(
"You can delete your installation ID at: ~/.basic-memory/.install_id"
)
except Exception as e:
console.print(f"[red]Error disabling telemetry:[/red] {e}")
raise typer.Exit(1)
@app.command()
def status():
"""Show current telemetry status and what data is collected."""
try:
enabled = is_telemetry_enabled()
install_id = get_install_id()
# Status table
status_table = Table(show_header=False, box=None)
status_table.add_row("Status", "[green]Enabled[/green]" if enabled else "[red]Disabled[/red]")
status_table.add_row("Installation ID", install_id)
status_table.add_row("ID File", "~/.basic-memory/.install_id")
console.print(Panel(status_table, title="Telemetry Status"))
# What we collect
collected_table = Table(title="Data Collected (when enabled)", show_header=True)
collected_table.add_column("Category", style="cyan")
collected_table.add_column("Examples", style="white")
collected_table.add_row("App Info", "version, mode (CLI/MCP)")
collected_table.add_row("System Info", "OS, Python version, architecture")
collected_table.add_row("Feature Usage", "MCP tools called, CLI commands used")
collected_table.add_row("Performance", "sync duration, entity counts")
collected_table.add_row("Errors", "error types (sanitized, no personal data)")
console.print(collected_table)
# What we never collect
never_table = Table(title="Never Collected", show_header=True)
never_table.add_column("Category", style="red")
never_table.add_row("Note content or file contents")
never_table.add_row("File names or paths")
never_table.add_row("Personal information")
never_table.add_row("IP addresses")
console.print(never_table)
# Commands
console.print("\n[bold]Commands:[/bold]")
console.print(" Enable: [cyan]bm telemetry enable[/cyan]")
console.print(" Disable: [cyan]bm telemetry disable[/cyan]")
console.print(" Delete installation ID: [cyan]rm ~/.basic-memory/.install_id[/cyan]")
console.print("\n[bold]More info:[/bold] https://memory.basicmachines.co/telemetry")
except Exception as e:
console.print(f"[red]Error getting telemetry status:[/red] {e}")
raise typer.Exit(1)
+1
View File
@@ -13,6 +13,7 @@ from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
mcp,
project,
status,
telemetry,
tool,
)
+11
View File
@@ -221,6 +221,17 @@ class BasicMemoryConfig(BaseSettings):
description="Cloud project sync configuration mapping project names to their local paths and sync state",
)
# Telemetry configuration
telemetry_enabled: bool = Field(
default=True,
description="Enable anonymous usage telemetry (Homebrew-style opt-out)",
)
telemetry_notice_shown: bool = Field(
default=False,
description="Whether the telemetry notice has been shown to the user",
)
@property
def cloud_mode_enabled(self) -> bool:
"""Check if cloud mode is enabled.
+6
View File
@@ -26,6 +26,12 @@ async def lifespan(app: FastMCP):
app_config = ConfigManager().config
logger.info("Starting Basic Memory MCP server")
# Track MCP startup and show telemetry notice if needed
from basic_memory.telemetry import show_telemetry_notice, track
track("app_started", {"mode": "mcp"})
show_telemetry_notice()
# 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
+195
View File
@@ -0,0 +1,195 @@
"""Anonymous telemetry for Basic Memory (Homebrew-style opt-out).
This module provides privacy-first telemetry following the Homebrew analytics model:
- On by default with easy opt-out
- Anonymous installation ID (random UUID, user-deletable)
- No personal data or note content collection
- Fire-and-forget (telemetry errors never break the app)
Usage:
from basic_memory.telemetry import track, show_telemetry_notice
# Track an event
track("app_started", {"mode": "cli"})
# Show first-run notice (only once)
show_telemetry_notice()
"""
import os
import platform
import uuid
from pathlib import Path
from typing import Any, Optional
from loguru import logger
from basic_memory import __version__
from basic_memory.config import ConfigManager
# --- Module State ---
_client: Optional[Any] = None
_telemetry_checked = False
def get_install_id() -> str:
"""Get or create anonymous installation ID.
The install ID is stored at ~/.basic-memory/.install_id and can be
deleted by the user at any time to generate a new ID.
Returns:
UUID string identifying this installation
"""
id_file = Path.home() / ".basic-memory" / ".install_id"
if id_file.exists():
return id_file.read_text().strip()
# Create new install ID
install_id = str(uuid.uuid4())
id_file.parent.mkdir(parents=True, exist_ok=True)
id_file.write_text(install_id)
return install_id
def is_telemetry_enabled() -> bool:
"""Check if telemetry is enabled.
Priority:
1. BASIC_MEMORY_TELEMETRY_ENABLED environment variable
2. Config file value (telemetry_enabled)
Returns:
True if telemetry is enabled, False otherwise
"""
env_value = os.environ.get("BASIC_MEMORY_TELEMETRY_ENABLED", "").lower()
if env_value in ("false", "0", "no"):
return False
elif env_value in ("true", "1", "yes"):
return True
# Fall back to config file value
try:
config_manager = ConfigManager()
config = config_manager.load_config()
return config.telemetry_enabled
except Exception:
# If config can't be loaded, default to enabled
return True
def get_client() -> Optional[Any]:
"""Get or create the OpenPanel client.
Returns None if telemetry is disabled or if OpenPanel import fails.
Lazily initializes the client on first call.
Returns:
OpenPanel client instance or None
"""
global _client, _telemetry_checked
if _telemetry_checked:
return _client
_telemetry_checked = True
if not is_telemetry_enabled():
return None
try:
from openpanel import OpenPanel
# Initialize OpenPanel with Basic Memory project details
# API key and client details will be provided via environment variables
# or config in production
_client = OpenPanel(
client_id=os.getenv("OPENPANEL_CLIENT_ID", "basic-memory"),
client_secret=os.getenv("OPENPANEL_CLIENT_SECRET", ""),
)
return _client
except ImportError:
logger.debug("OpenPanel not available, telemetry disabled")
return None
except Exception as e:
logger.debug(f"Failed to initialize telemetry client: {e}")
return None
def get_global_properties() -> dict[str, Any]:
"""Get global properties sent with every event.
Returns:
Dictionary of global properties including version, OS, architecture, etc.
"""
return {
"app_version": __version__,
"python_version": platform.python_version(),
"os": platform.system().lower(),
"arch": platform.machine(),
"install_id": get_install_id(),
}
def track(event: str, properties: Optional[dict[str, Any]] = None) -> None:
"""Track an event with optional properties.
This is a fire-and-forget operation that never raises exceptions.
If telemetry is disabled or the client is unavailable, this is a no-op.
Args:
event: Event name (e.g., "app_started", "mcp_tool_called")
properties: Optional event-specific properties
"""
try:
client = get_client()
if client is None:
return
# Merge global properties with event-specific properties
all_properties = get_global_properties()
if properties:
all_properties.update(properties)
# Track the event
client.track(event, all_properties)
except Exception as e:
# Telemetry must never break the app
logger.debug(f"Telemetry tracking failed: {e}")
def show_telemetry_notice() -> None:
"""Show the telemetry notice to the user (once per installation).
This should be called on first run of CLI or MCP server.
The notice informs users about data collection and how to opt out.
"""
try:
if not is_telemetry_enabled():
return
config_manager = ConfigManager()
config = config_manager.load_config()
# Check if notice has already been shown
if config.telemetry_notice_shown:
return
# Show the notice
notice = """
Basic Memory collects anonymous usage statistics to help improve the software.
This includes: version, OS, feature usage, and errors. No personal data or note content.
To opt out: bm telemetry disable
Details: https://memory.basicmachines.co/telemetry
"""
print(notice)
# Mark notice as shown
config.telemetry_notice_shown = True
config_manager.save_config(config)
except Exception as e:
# Telemetry must never break the app
logger.debug(f"Failed to show telemetry notice: {e}")