From 150a8175b0007f8dd54db55e6f988558ca4715fe Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 25 Dec 2025 02:54:13 +0000 Subject: [PATCH] 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 Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> --- pyproject.toml | 1 + src/basic_memory/cli/app.py | 7 + src/basic_memory/cli/commands/__init__.py | 3 +- src/basic_memory/cli/commands/telemetry.py | 99 +++++++ src/basic_memory/cli/main.py | 1 + src/basic_memory/config.py | 11 + src/basic_memory/mcp/server.py | 6 + src/basic_memory/telemetry.py | 195 +++++++++++++ tests/test_telemetry.py | 318 +++++++++++++++++++++ 9 files changed, 640 insertions(+), 1 deletion(-) create mode 100644 src/basic_memory/cli/commands/telemetry.py create mode 100644 src/basic_memory/telemetry.py create mode 100644 tests/test_telemetry.py diff --git a/pyproject.toml b/pyproject.toml index 0e82342d..d642735a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dependencies = [ "mdformat>=0.7.22", "mdformat-gfm>=0.3.7", "mdformat-frontmatter>=2.0.8", + "openpanel>=1.0.0", ] diff --git a/src/basic_memory/cli/app.py b/src/basic_memory/cli/app.py index 69dd8ee4..f6df9892 100644 --- a/src/basic_memory/cli/app.py +++ b/src/basic_memory/cli/app.py @@ -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 diff --git a/src/basic_memory/cli/commands/__init__.py b/src/basic_memory/cli/commands/__init__.py index 8b98c81c..a8537dc1 100644 --- a/src/basic_memory/cli/commands/__init__.py +++ b/src/basic_memory/cli/commands/__init__.py @@ -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", ] diff --git a/src/basic_memory/cli/commands/telemetry.py b/src/basic_memory/cli/commands/telemetry.py new file mode 100644 index 00000000..ed84e41c --- /dev/null +++ b/src/basic_memory/cli/commands/telemetry.py @@ -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) diff --git a/src/basic_memory/cli/main.py b/src/basic_memory/cli/main.py index 38f4dd03..e3906dee 100644 --- a/src/basic_memory/cli/main.py +++ b/src/basic_memory/cli/main.py @@ -13,6 +13,7 @@ from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover mcp, project, status, + telemetry, tool, ) diff --git a/src/basic_memory/config.py b/src/basic_memory/config.py index 07147518..bd9db24f 100644 --- a/src/basic_memory/config.py +++ b/src/basic_memory/config.py @@ -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. diff --git a/src/basic_memory/mcp/server.py b/src/basic_memory/mcp/server.py index 71b95519..a75b4ae6 100644 --- a/src/basic_memory/mcp/server.py +++ b/src/basic_memory/mcp/server.py @@ -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 diff --git a/src/basic_memory/telemetry.py b/src/basic_memory/telemetry.py new file mode 100644 index 00000000..763ac7c1 --- /dev/null +++ b/src/basic_memory/telemetry.py @@ -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}") diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py new file mode 100644 index 00000000..071ce5bb --- /dev/null +++ b/tests/test_telemetry.py @@ -0,0 +1,318 @@ +"""Unit tests for telemetry module.""" + +import os +import uuid +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from basic_memory.config import BasicMemoryConfig, ConfigManager +from basic_memory.telemetry import ( + get_client, + get_global_properties, + get_install_id, + is_telemetry_enabled, + show_telemetry_notice, + track, +) + + +@pytest.fixture +def mock_config_manager(tmp_path, monkeypatch): + """Mock ConfigManager for testing.""" + config_dir = tmp_path / ".basic-memory" + config_dir.mkdir(parents=True, exist_ok=True) + + # Mock the config directory + monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(config_dir)) + + # Clear the module-level cache + import basic_memory.config + + basic_memory.config._CONFIG_CACHE = None + + # Clear telemetry module state + import basic_memory.telemetry + + basic_memory.telemetry._client = None + basic_memory.telemetry._telemetry_checked = False + + yield ConfigManager() + + # Clean up + basic_memory.config._CONFIG_CACHE = None + basic_memory.telemetry._client = None + basic_memory.telemetry._telemetry_checked = False + + +@pytest.fixture +def install_id_file(tmp_path, monkeypatch): + """Create a temporary install ID file.""" + install_dir = tmp_path / ".basic-memory" + install_dir.mkdir(parents=True, exist_ok=True) + install_id_path = install_dir / ".install_id" + + # Mock Path.home() to return tmp_path + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + return install_id_path + + +def test_get_install_id_creates_new_id(install_id_file): + """Test that get_install_id creates a new ID if none exists.""" + # Ensure file doesn't exist + if install_id_file.exists(): + install_id_file.unlink() + + install_id = get_install_id() + + # Verify ID is a valid UUID + uuid.UUID(install_id) + + # Verify file was created + assert install_id_file.exists() + assert install_id_file.read_text().strip() == install_id + + +def test_get_install_id_returns_existing_id(install_id_file): + """Test that get_install_id returns existing ID if it exists.""" + # Create an existing ID + existing_id = str(uuid.uuid4()) + install_id_file.write_text(existing_id) + + install_id = get_install_id() + + assert install_id == existing_id + + +def test_is_telemetry_enabled_env_false(mock_config_manager, monkeypatch): + """Test that BASIC_MEMORY_TELEMETRY_ENABLED=false disables telemetry.""" + monkeypatch.setenv("BASIC_MEMORY_TELEMETRY_ENABLED", "false") + + assert is_telemetry_enabled() is False + + +def test_is_telemetry_enabled_env_true(mock_config_manager, monkeypatch): + """Test that BASIC_MEMORY_TELEMETRY_ENABLED=true enables telemetry.""" + monkeypatch.setenv("BASIC_MEMORY_TELEMETRY_ENABLED", "true") + + assert is_telemetry_enabled() is True + + +def test_is_telemetry_enabled_config_false(mock_config_manager, monkeypatch): + """Test that config file value is used if env var not set.""" + # Ensure env var is not set + monkeypatch.delenv("BASIC_MEMORY_TELEMETRY_ENABLED", raising=False) + + # Set config value + config = mock_config_manager.load_config() + config.telemetry_enabled = False + mock_config_manager.save_config(config) + + # Clear cache + import basic_memory.config + + basic_memory.config._CONFIG_CACHE = None + + assert is_telemetry_enabled() is False + + +def test_is_telemetry_enabled_config_true(mock_config_manager, monkeypatch): + """Test that config file value is used if env var not set.""" + # Ensure env var is not set + monkeypatch.delenv("BASIC_MEMORY_TELEMETRY_ENABLED", raising=False) + + # Set config value + config = mock_config_manager.load_config() + config.telemetry_enabled = True + mock_config_manager.save_config(config) + + # Clear cache + import basic_memory.config + + basic_memory.config._CONFIG_CACHE = None + + assert is_telemetry_enabled() is True + + +def test_is_telemetry_enabled_default_true(mock_config_manager, monkeypatch): + """Test that telemetry is enabled by default.""" + # Ensure env var is not set + monkeypatch.delenv("BASIC_MEMORY_TELEMETRY_ENABLED", raising=False) + + # Don't set config value - use default + assert is_telemetry_enabled() is True + + +def test_get_client_disabled(mock_config_manager, monkeypatch): + """Test that get_client returns None when telemetry is disabled.""" + monkeypatch.setenv("BASIC_MEMORY_TELEMETRY_ENABLED", "false") + + client = get_client() + assert client is None + + +@patch("basic_memory.telemetry.OpenPanel") +def test_get_client_import_error(mock_openpanel, mock_config_manager, monkeypatch): + """Test that get_client handles OpenPanel import errors gracefully.""" + monkeypatch.setenv("BASIC_MEMORY_TELEMETRY_ENABLED", "true") + + # Simulate ImportError + with patch("basic_memory.telemetry.OpenPanel", side_effect=ImportError): + # Reset telemetry module state + import basic_memory.telemetry + + basic_memory.telemetry._client = None + basic_memory.telemetry._telemetry_checked = False + + client = get_client() + assert client is None + + +def test_get_global_properties(install_id_file): + """Test that get_global_properties returns expected properties.""" + properties = get_global_properties() + + assert "app_version" in properties + assert "python_version" in properties + assert "os" in properties + assert "arch" in properties + assert "install_id" in properties + + # Verify install_id is a valid UUID + uuid.UUID(properties["install_id"]) + + +def test_track_disabled(mock_config_manager, monkeypatch): + """Test that track does nothing when telemetry is disabled.""" + monkeypatch.setenv("BASIC_MEMORY_TELEMETRY_ENABLED", "false") + + # Reset telemetry module state + import basic_memory.telemetry + + basic_memory.telemetry._client = None + basic_memory.telemetry._telemetry_checked = False + + # This should not raise any exceptions + track("test_event", {"key": "value"}) + + +@patch("basic_memory.telemetry.OpenPanel") +def test_track_enabled(mock_openpanel_class, mock_config_manager, monkeypatch, install_id_file): + """Test that track sends events when telemetry is enabled.""" + monkeypatch.setenv("BASIC_MEMORY_TELEMETRY_ENABLED", "true") + monkeypatch.setenv("OPENPANEL_CLIENT_ID", "test-client") + monkeypatch.setenv("OPENPANEL_CLIENT_SECRET", "test-secret") + + # Reset telemetry module state + import basic_memory.telemetry + + basic_memory.telemetry._client = None + basic_memory.telemetry._telemetry_checked = False + + # Create mock client instance + mock_client = MagicMock() + mock_openpanel_class.return_value = mock_client + + # Track an event + track("test_event", {"key": "value"}) + + # Verify OpenPanel was initialized + mock_openpanel_class.assert_called_once_with( + client_id="test-client", client_secret="test-secret" + ) + + # Verify track was called with correct arguments + mock_client.track.assert_called_once() + call_args = mock_client.track.call_args[0] + assert call_args[0] == "test_event" + assert "key" in call_args[1] + assert call_args[1]["key"] == "value" + assert "app_version" in call_args[1] + assert "install_id" in call_args[1] + + +def test_track_exception_handling(mock_config_manager, monkeypatch): + """Test that track handles exceptions gracefully.""" + monkeypatch.setenv("BASIC_MEMORY_TELEMETRY_ENABLED", "true") + + # Reset telemetry module state + import basic_memory.telemetry + + basic_memory.telemetry._client = None + basic_memory.telemetry._telemetry_checked = False + + # Mock get_client to raise an exception + with patch("basic_memory.telemetry.get_client", side_effect=Exception("Test error")): + # This should not raise any exceptions + track("test_event", {"key": "value"}) + + +def test_show_telemetry_notice_disabled(mock_config_manager, monkeypatch, capsys): + """Test that telemetry notice is not shown when disabled.""" + monkeypatch.setenv("BASIC_MEMORY_TELEMETRY_ENABLED", "false") + + show_telemetry_notice() + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_show_telemetry_notice_first_run(mock_config_manager, monkeypatch, capsys): + """Test that telemetry notice is shown on first run.""" + monkeypatch.setenv("BASIC_MEMORY_TELEMETRY_ENABLED", "true") + + # Ensure notice hasn't been shown + config = mock_config_manager.load_config() + config.telemetry_notice_shown = False + mock_config_manager.save_config(config) + + # Clear cache + import basic_memory.config + + basic_memory.config._CONFIG_CACHE = None + + show_telemetry_notice() + + captured = capsys.readouterr() + assert "Basic Memory collects anonymous usage statistics" in captured.out + assert "bm telemetry disable" in captured.out + + # Verify notice_shown flag was set + config = mock_config_manager.load_config() + assert config.telemetry_notice_shown is True + + +def test_show_telemetry_notice_already_shown(mock_config_manager, monkeypatch, capsys): + """Test that telemetry notice is only shown once.""" + monkeypatch.setenv("BASIC_MEMORY_TELEMETRY_ENABLED", "true") + + # Set notice as already shown + config = mock_config_manager.load_config() + config.telemetry_notice_shown = True + mock_config_manager.save_config(config) + + # Clear cache + import basic_memory.config + + basic_memory.config._CONFIG_CACHE = None + + show_telemetry_notice() + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_show_telemetry_notice_exception_handling(mock_config_manager, monkeypatch, capsys): + """Test that show_telemetry_notice handles exceptions gracefully.""" + monkeypatch.setenv("BASIC_MEMORY_TELEMETRY_ENABLED", "true") + + # Mock load_config to raise an exception + with patch.object(ConfigManager, "load_config", side_effect=Exception("Test error")): + # This should not raise any exceptions + show_telemetry_notice() + + captured = capsys.readouterr() + # Notice should not be shown due to exception + assert captured.out == ""