mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -13,6 +13,7 @@ from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
|
||||
mcp,
|
||||
project,
|
||||
status,
|
||||
telemetry,
|
||||
tool,
|
||||
)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
Reference in New Issue
Block a user