diff --git a/README.md b/README.md index f3e1c859..d4e8ee9a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 0e82342d..475b2d65 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>=0.0.1", # Anonymous usage telemetry (Homebrew-style opt-out) ] diff --git a/src/basic_memory/cli/app.py b/src/basic_memory/cli/app.py index 69dd8ee4..f297878c 100644 --- a/src/basic_memory/cli/app.py +++ b/src/basic_memory/cli/app.py @@ -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: @@ -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 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 @@ "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..634a7008 --- /dev/null +++ b/src/basic_memory/cli/commands/telemetry.py @@ -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]") 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 @@ mcp, project, status, + telemetry, tool, ) diff --git a/src/basic_memory/config.py b/src/basic_memory/config.py index 07147518..3e3cf8eb 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 (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. diff --git a/src/basic_memory/mcp/server.py b/src/basic_memory/mcp/server.py index 71b95519..0d373e47 100644 --- a/src/basic_memory/mcp/server.py +++ b/src/basic_memory/mcp/server.py @@ -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 @@ -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 diff --git a/src/basic_memory/mcp/tools/build_context.py b/src/basic_memory/mcp/tools/build_context.py index c3accd6b..5a5057d7 100644 --- a/src/basic_memory/mcp/tools/build_context.py +++ b/src/basic_memory/mcp/tools/build_context.py @@ -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, @@ -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 diff --git a/src/basic_memory/mcp/tools/canvas.py b/src/basic_memory/mcp/tools/canvas.py index dac8c6e0..4c9f5083 100644 --- a/src/basic_memory/mcp/tools/canvas.py +++ b/src/basic_memory/mcp/tools/canvas.py @@ -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( @@ -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) diff --git a/src/basic_memory/mcp/tools/chatgpt_tools.py b/src/basic_memory/mcp/tools/chatgpt_tools.py index 58e88066..cb08a19b 100644 --- a/src/basic_memory/mcp/tools/chatgpt_tools.py +++ b/src/basic_memory/mcp/tools/chatgpt_tools.py @@ -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]]: @@ -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: @@ -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: diff --git a/src/basic_memory/mcp/tools/delete_note.py b/src/basic_memory/mcp/tools/delete_note.py index 5209c1b8..9c44f1f2 100644 --- a/src/basic_memory/mcp/tools/delete_note.py +++ b/src/basic_memory/mcp/tools/delete_note.py @@ -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 @@ -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) diff --git a/src/basic_memory/mcp/tools/edit_note.py b/src/basic_memory/mcp/tools/edit_note.py index a615370a..5da7014a 100644 --- a/src/basic_memory/mcp/tools/edit_note.py +++ b/src/basic_memory/mcp/tools/edit_note.py @@ -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 @@ -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) diff --git a/src/basic_memory/mcp/tools/list_directory.py b/src/basic_memory/mcp/tools/list_directory.py index e26f92e1..8dbfef44 100644 --- a/src/basic_memory/mcp/tools/list_directory.py +++ b/src/basic_memory/mcp/tools/list_directory.py @@ -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( @@ -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) diff --git a/src/basic_memory/mcp/tools/move_note.py b/src/basic_memory/mcp/tools/move_note.py index 79cb7390..ea1d2347 100644 --- a/src/basic_memory/mcp/tools/move_note.py +++ b/src/basic_memory/mcp/tools/move_note.py @@ -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 @@ -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}") diff --git a/src/basic_memory/mcp/tools/project_management.py b/src/basic_memory/mcp/tools/project_management.py index b3344b27..f583a335 100644 --- a/src/basic_memory/mcp/tools/project_management.py +++ b/src/basic_memory/mcp/tools/project_management.py @@ -15,6 +15,7 @@ ProjectStatusResponse, ProjectInfoRequest, ) +from basic_memory.telemetry import track_mcp_tool from basic_memory.utils import generate_permalink @@ -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") @@ -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") @@ -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") diff --git a/src/basic_memory/mcp/tools/read_content.py b/src/basic_memory/mcp/tools/read_content.py index e48124de..65a57c6b 100644 --- a/src/basic_memory/mcp/tools/read_content.py +++ b/src/basic_memory/mcp/tools/read_content.py @@ -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 @@ -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: diff --git a/src/basic_memory/mcp/tools/read_note.py b/src/basic_memory/mcp/tools/read_note.py index d36db506..441a4ae2 100644 --- a/src/basic_memory/mcp/tools/read_note.py +++ b/src/basic_memory/mcp/tools/read_note.py @@ -11,6 +11,7 @@ from basic_memory.mcp.server import mcp from basic_memory.mcp.tools.search import search_notes from basic_memory.mcp.tools.utils import call_get, resolve_entity_id +from basic_memory.telemetry import track_mcp_tool from basic_memory.schemas.memory import memory_url_path from basic_memory.utils import validate_project_path @@ -77,6 +78,7 @@ async def read_note( If the exact note isn't found, this tool provides helpful suggestions including related notes, search commands, and note creation templates. """ + track_mcp_tool("read_note") async with get_client() as client: # Get and validate the project active_project = await get_active_project(client, project, context) diff --git a/src/basic_memory/mcp/tools/recent_activity.py b/src/basic_memory/mcp/tools/recent_activity.py index b6b54c39..9049f5dc 100644 --- a/src/basic_memory/mcp/tools/recent_activity.py +++ b/src/basic_memory/mcp/tools/recent_activity.py @@ -9,6 +9,7 @@ from basic_memory.mcp.project_context import get_active_project, resolve_project_parameter 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, @@ -98,6 +99,7 @@ async def recent_activity( - For focused queries, consider using build_context with a specific URI - Max timeframe is 1 year in the past """ + track_mcp_tool("recent_activity") async with get_client() as client: # Build common parameters for API calls params = { diff --git a/src/basic_memory/mcp/tools/search.py b/src/basic_memory/mcp/tools/search.py index 5e99b4a2..01f6298e 100644 --- a/src/basic_memory/mcp/tools/search.py +++ b/src/basic_memory/mcp/tools/search.py @@ -10,6 +10,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_post +from basic_memory.telemetry import track_mcp_tool from basic_memory.schemas.search import SearchItemType, SearchQuery, SearchResponse @@ -330,6 +331,7 @@ async def search_notes( # Explicit project specification results = await search_notes("project planning", project="my-project") """ + track_mcp_tool("search_notes") # Create a SearchQuery object based on the parameters search_query = SearchQuery() diff --git a/src/basic_memory/mcp/tools/view_note.py b/src/basic_memory/mcp/tools/view_note.py index 1a305ad4..c75f0a0e 100644 --- a/src/basic_memory/mcp/tools/view_note.py +++ b/src/basic_memory/mcp/tools/view_note.py @@ -8,6 +8,7 @@ from basic_memory.mcp.server import mcp from basic_memory.mcp.tools.read_note import read_note +from basic_memory.telemetry import track_mcp_tool @mcp.tool( @@ -54,7 +55,7 @@ async def view_note( HTTPError: If project doesn't exist or is inaccessible SecurityError: If identifier attempts path traversal """ - + track_mcp_tool("view_note") logger.info(f"Viewing note: {identifier} in project: {project}") # Call the existing read_note logic diff --git a/src/basic_memory/mcp/tools/write_note.py b/src/basic_memory/mcp/tools/write_note.py index f0e5fd17..8b297b2f 100644 --- a/src/basic_memory/mcp/tools/write_note.py +++ b/src/basic_memory/mcp/tools/write_note.py @@ -8,6 +8,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_put, call_post, resolve_entity_id +from basic_memory.telemetry import track_mcp_tool from basic_memory.schemas import EntityResponse from fastmcp import Context from basic_memory.schemas.base import Entity @@ -116,6 +117,7 @@ async def write_note( HTTPError: If project doesn't exist or is inaccessible SecurityError: If folder path attempts path traversal """ + track_mcp_tool("write_note") async with get_client() as client: logger.info( f"MCP tool call tool=write_note project={project} folder={folder}, title={title}, tags={tags}" diff --git a/src/basic_memory/telemetry.py b/src/basic_memory/telemetry.py new file mode 100644 index 00000000..b52835f3 --- /dev/null +++ b/src/basic_memory/telemetry.py @@ -0,0 +1,247 @@ +"""Anonymous telemetry for Basic Memory (Homebrew-style opt-out). + +This module implements privacy-respecting usage analytics following the Homebrew model: +- Telemetry is ON by default +- Users can easily opt out: `bm telemetry disable` +- First run shows a one-time notice (not a prompt) +- Only anonymous data is collected (random UUID, no personal info) + +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 (OpenPanel doesn't store these) + +Documentation: https://basicmemory.com/telemetry +""" + +import platform +import re +import uuid +from pathlib import Path +from typing import Any + +from loguru import logger +from openpanel import OpenPanel + +from basic_memory import __version__ + +# --- Configuration --- + +# OpenPanel credentials (write-only, safe to embed in client code) +# These can only send events to our dashboard, not read any data +OPENPANEL_CLIENT_ID = "2e7b036d-c6e5-40aa-91eb-5c70a8ef21a3" +OPENPANEL_CLIENT_SECRET = "sec_92f7f8328bd0368ff4c2" + +TELEMETRY_DOCS_URL = "https://basicmemory.com/telemetry" + +TELEMETRY_NOTICE = f""" +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: {TELEMETRY_DOCS_URL} +""" + +# --- Module State --- + +_client: OpenPanel | None = None +_initialized: bool = False + + +# --- Installation ID --- + + +def get_install_id() -> str: + """Get or create anonymous installation ID. + + Creates a random UUID on first run and stores it locally. + User can delete ~/.basic-memory/.install_id to reset. + """ + id_file = Path.home() / ".basic-memory" / ".install_id" + + if id_file.exists(): + return id_file.read_text().strip() + + install_id = str(uuid.uuid4()) + id_file.parent.mkdir(parents=True, exist_ok=True) + id_file.write_text(install_id) + return install_id + + +# --- Client Management --- + + +def _get_client() -> OpenPanel: + """Get or create the OpenPanel client (singleton). + + Lazily initializes the client with global properties. + """ + global _client, _initialized + + if _client is None: + from basic_memory.config import ConfigManager + + config = ConfigManager().config + + # Trigger: first call to track an event + # Why: lazy init avoids work if telemetry never used; disabled flag + # tells OpenPanel to skip network calls when user opts out + # Outcome: client ready to queue events (or silently discard if disabled) + _client = OpenPanel( + client_id=OPENPANEL_CLIENT_ID, + client_secret=OPENPANEL_CLIENT_SECRET, + disabled=not config.telemetry_enabled, + ) + + if config.telemetry_enabled and not _initialized: + # Set global properties that go with every event + _client.set_global_properties( + { + "app_version": __version__, + "python_version": platform.python_version(), + "os": platform.system().lower(), + "arch": platform.machine(), + "install_id": get_install_id(), + } + ) + _initialized = True + + return _client + + +def reset_client() -> None: + """Reset the telemetry client (for testing or after config changes).""" + global _client, _initialized + _client = None + _initialized = False + + +# --- Event Tracking --- + + +def track(event: str, properties: dict[str, Any] | None = None) -> None: + """Track an event. Fire-and-forget, never raises. + + Args: + event: Event name (e.g., "app_started", "mcp_tool_called") + properties: Optional event properties + """ + # Constraint: telemetry must never break the application + # Even if OpenPanel API is down or config is corrupt, user's command must succeed + try: + _get_client().track(event, properties or {}) + except Exception as e: + logger.opt(exception=False).debug(f"Telemetry failed: {e}") + + +# --- First-Run Notice --- + + +def show_notice_if_needed() -> None: + """Show one-time telemetry notice (Homebrew style). + + Only shows if: + - Telemetry is enabled + - Notice hasn't been shown before + + After showing, marks the notice as shown in config. + """ + from basic_memory.config import ConfigManager + + config_manager = ConfigManager() + config = config_manager.config + + if config.telemetry_enabled and not config.telemetry_notice_shown: + from rich.console import Console + from rich.panel import Panel + + # Print to stderr so it doesn't interfere with command output + console = Console(stderr=True) + console.print( + Panel( + TELEMETRY_NOTICE.strip(), + title="[dim]Telemetry Notice[/dim]", + border_style="dim", + expand=False, + ) + ) + + # Mark as shown so we don't show again + config.telemetry_notice_shown = True + config_manager.save_config(config) + + +# --- Convenience Functions --- + + +def track_app_started(mode: str) -> None: + """Track app startup. + + Args: + mode: "cli" or "mcp" + """ + track("app_started", {"mode": mode}) + + +def track_mcp_tool(tool_name: str) -> None: + """Track MCP tool usage. + + Args: + tool_name: Name of the tool (e.g., "write_note", "search_notes") + """ + track("mcp_tool_called", {"tool": tool_name}) + + +def track_cli_command(command: str) -> None: + """Track CLI command usage. + + Args: + command: Command name (e.g., "sync", "import claude") + """ + track("cli_command", {"command": command}) + + +def track_sync_completed(entity_count: int, duration_ms: int) -> None: + """Track sync completion. + + Args: + entity_count: Number of entities synced + duration_ms: Duration in milliseconds + """ + track("sync_completed", {"entity_count": entity_count, "duration_ms": duration_ms}) + + +def track_import_completed(source: str, count: int) -> None: + """Track import completion. + + Args: + source: Import source (e.g., "claude", "chatgpt") + count: Number of items imported + """ + track("import_completed", {"source": source, "count": count}) + + +def track_error(error_type: str, message: str) -> None: + """Track an error (sanitized). + + Args: + error_type: Exception class name + message: Error message (will be sanitized to remove file paths) + """ + if not message: + track("error", {"type": error_type, "message": ""}) + return + + # Sanitize file paths to prevent leaking user directory structure + # Unix paths: /Users/name/file.py, /home/user/notes/doc.md + sanitized = re.sub(r"/[\w/.+-]+\.\w+", "[FILE]", message) + # Windows paths: C:\Users\name\file.py, D:\projects\doc.md + sanitized = re.sub(r"[A-Z]:\\[\w\\.+-]+\.\w+", "[FILE]", sanitized, flags=re.IGNORECASE) + + # Truncate to avoid sending too much data + track("error", {"type": error_type, "message": sanitized[:200]}) diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py new file mode 100644 index 00000000..0d467789 --- /dev/null +++ b/tests/test_telemetry.py @@ -0,0 +1,276 @@ +"""Tests for telemetry module.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from basic_memory.config import BasicMemoryConfig + + +class TestGetInstallId: + """Tests for get_install_id function.""" + + def test_creates_install_id_on_first_call(self, tmp_path, monkeypatch): + """Test that a new install ID is created on first call.""" + # Mock Path.home() to return tmp_path (works cross-platform) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + from basic_memory.telemetry import get_install_id + + install_id = get_install_id() + + # Should be a valid UUID format (36 chars with hyphens) + assert len(install_id) == 36 + assert install_id.count("-") == 4 + + # File should exist + id_file = tmp_path / ".basic-memory" / ".install_id" + assert id_file.exists() + assert id_file.read_text().strip() == install_id + + def test_returns_existing_install_id(self, tmp_path, monkeypatch): + """Test that existing install ID is returned on subsequent calls.""" + # Mock Path.home() to return tmp_path (works cross-platform) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + # Create the ID file first + id_file = tmp_path / ".basic-memory" / ".install_id" + id_file.parent.mkdir(parents=True, exist_ok=True) + existing_id = "test-uuid-12345" + id_file.write_text(existing_id) + + from basic_memory.telemetry import get_install_id + + install_id = get_install_id() + + assert install_id == existing_id + + +class TestTelemetryConfig: + """Tests for telemetry configuration fields.""" + + def test_telemetry_enabled_defaults_to_true(self, config_home, monkeypatch): + """Test that telemetry is enabled by default (Homebrew model).""" + # Clear config cache + import basic_memory.config + + basic_memory.config._CONFIG_CACHE = None + + config = BasicMemoryConfig() + assert config.telemetry_enabled is True + + def test_telemetry_notice_shown_defaults_to_false(self, config_home, monkeypatch): + """Test that telemetry notice starts as not shown.""" + # Clear config cache + import basic_memory.config + + basic_memory.config._CONFIG_CACHE = None + + config = BasicMemoryConfig() + assert config.telemetry_notice_shown is False + + def test_telemetry_enabled_via_env_var(self, config_home, monkeypatch): + """Test that telemetry can be disabled via environment variable.""" + import basic_memory.config + + basic_memory.config._CONFIG_CACHE = None + + monkeypatch.setenv("BASIC_MEMORY_TELEMETRY_ENABLED", "false") + + config = BasicMemoryConfig() + assert config.telemetry_enabled is False + + +class TestTrack: + """Tests for the track function.""" + + def test_track_does_not_raise_on_error(self, config_home, monkeypatch): + """Test that track never raises exceptions.""" + import basic_memory.config + import basic_memory.telemetry + + basic_memory.config._CONFIG_CACHE = None + basic_memory.telemetry._client = None + basic_memory.telemetry._initialized = False + + # Mock OpenPanel to raise an exception + with patch("basic_memory.telemetry.OpenPanel") as mock_openpanel: + mock_client = MagicMock() + mock_client.track.side_effect = Exception("Network error") + mock_openpanel.return_value = mock_client + + from basic_memory.telemetry import track + + # Should not raise + track("test_event", {"key": "value"}) + + def test_track_respects_disabled_config(self, config_home, monkeypatch): + """Test that track does nothing when telemetry is disabled.""" + import basic_memory.config + import basic_memory.telemetry + + basic_memory.config._CONFIG_CACHE = None + basic_memory.telemetry._client = None + basic_memory.telemetry._initialized = False + + monkeypatch.setenv("BASIC_MEMORY_TELEMETRY_ENABLED", "false") + + with patch("basic_memory.telemetry.OpenPanel") as mock_openpanel: + mock_client = MagicMock() + mock_openpanel.return_value = mock_client + + from basic_memory.telemetry import track, reset_client + + reset_client() + track("test_event") + + # OpenPanel should have been initialized with disabled=True + mock_openpanel.assert_called_once() + call_kwargs = mock_openpanel.call_args[1] + assert call_kwargs["disabled"] is True + + +class TestShowNoticeIfNeeded: + """Tests for show_notice_if_needed function.""" + + def test_shows_notice_when_enabled_and_not_shown(self, config_home, tmp_path, monkeypatch): + """Test that notice is shown on first run with telemetry enabled.""" + import basic_memory.config + import basic_memory.telemetry + + # Reset state + basic_memory.config._CONFIG_CACHE = None + basic_memory.telemetry._client = None + basic_memory.telemetry._initialized = False + + # Set up config directory + config_dir = tmp_path / ".basic-memory" + config_dir.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(config_dir)) + + # Create config with telemetry enabled but notice not shown + from basic_memory.telemetry import show_notice_if_needed + + with patch("rich.console.Console") as mock_console_class: + mock_console = MagicMock() + mock_console_class.return_value = mock_console + + show_notice_if_needed() + + # Console should have been called to print the notice + mock_console.print.assert_called_once() + + def test_does_not_show_notice_when_disabled(self, config_home, tmp_path, monkeypatch): + """Test that notice is not shown when telemetry is disabled.""" + import basic_memory.config + import basic_memory.telemetry + + # Reset state + basic_memory.config._CONFIG_CACHE = None + basic_memory.telemetry._client = None + basic_memory.telemetry._initialized = False + + monkeypatch.setenv("BASIC_MEMORY_TELEMETRY_ENABLED", "false") + + config_dir = tmp_path / ".basic-memory" + config_dir.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("BASIC_MEMORY_CONFIG_DIR", str(config_dir)) + + from basic_memory.telemetry import show_notice_if_needed + + with patch("rich.console.Console") as mock_console_class: + show_notice_if_needed() + + # Console should not have been instantiated + mock_console_class.assert_not_called() + + +class TestConvenienceFunctions: + """Tests for convenience tracking functions.""" + + def test_track_app_started(self, config_home, monkeypatch): + """Test track_app_started function.""" + import basic_memory.config + import basic_memory.telemetry + + basic_memory.config._CONFIG_CACHE = None + basic_memory.telemetry._client = None + basic_memory.telemetry._initialized = False + + with patch("basic_memory.telemetry.OpenPanel") as mock_openpanel: + mock_client = MagicMock() + mock_openpanel.return_value = mock_client + + from basic_memory.telemetry import track_app_started + + track_app_started("cli") + + mock_client.track.assert_called_once_with("app_started", {"mode": "cli"}) + + def test_track_mcp_tool(self, config_home, monkeypatch): + """Test track_mcp_tool function.""" + import basic_memory.config + import basic_memory.telemetry + + basic_memory.config._CONFIG_CACHE = None + basic_memory.telemetry._client = None + basic_memory.telemetry._initialized = False + + with patch("basic_memory.telemetry.OpenPanel") as mock_openpanel: + mock_client = MagicMock() + mock_openpanel.return_value = mock_client + + from basic_memory.telemetry import track_mcp_tool + + track_mcp_tool("write_note") + + mock_client.track.assert_called_once_with("mcp_tool_called", {"tool": "write_note"}) + + def test_track_error_truncates_message(self, config_home, monkeypatch): + """Test that track_error truncates long messages.""" + import basic_memory.config + import basic_memory.telemetry + + basic_memory.config._CONFIG_CACHE = None + basic_memory.telemetry._client = None + basic_memory.telemetry._initialized = False + + with patch("basic_memory.telemetry.OpenPanel") as mock_openpanel: + mock_client = MagicMock() + mock_openpanel.return_value = mock_client + + from basic_memory.telemetry import track_error + + long_message = "x" * 500 + track_error("ValueError", long_message) + + call_args = mock_client.track.call_args + assert call_args[0][0] == "error" + assert len(call_args[0][1]["message"]) == 200 # Truncated to 200 chars + + def test_track_error_sanitizes_file_paths(self, config_home, monkeypatch): + """Test that track_error sanitizes file paths from messages.""" + import basic_memory.config + import basic_memory.telemetry + + basic_memory.config._CONFIG_CACHE = None + basic_memory.telemetry._client = None + basic_memory.telemetry._initialized = False + + with patch("basic_memory.telemetry.OpenPanel") as mock_openpanel: + mock_client = MagicMock() + mock_openpanel.return_value = mock_client + + from basic_memory.telemetry import track_error + + # Test Unix path sanitization + track_error("FileNotFoundError", "No such file: /Users/john/notes/secret.md") + call_args = mock_client.track.call_args + assert "/Users/john" not in call_args[0][1]["message"] + assert "[FILE]" in call_args[0][1]["message"] + + # Test Windows path sanitization + mock_client.reset_mock() + track_error("FileNotFoundError", "Cannot open C:\\Users\\john\\docs\\private.txt") + call_args = mock_client.track.call_args + assert "C:\\Users\\john" not in call_args[0][1]["message"] + assert "[FILE]" in call_args[0][1]["message"] diff --git a/uv.lock b/uv.lock index 203428d5..9930df89 100644 --- a/uv.lock +++ b/uv.lock @@ -133,6 +133,7 @@ dependencies = [ { name = "mdformat-frontmatter" }, { name = "mdformat-gfm" }, { name = "nest-asyncio" }, + { name = "openpanel" }, { name = "pillow" }, { name = "psycopg" }, { name = "pybars3" }, @@ -184,6 +185,7 @@ requires-dist = [ { name = "mdformat-frontmatter", specifier = ">=2.0.8" }, { name = "mdformat-gfm", specifier = ">=0.3.7" }, { name = "nest-asyncio", specifier = ">=1.6.0" }, + { name = "openpanel", specifier = ">=0.0.1" }, { name = "pillow", specifier = ">=11.1.0" }, { name = "psycopg", specifier = "==3.3.1" }, { name = "pybars3", specifier = ">=0.9.7" }, @@ -1128,6 +1130,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/dd/b3fd642260cb17532f66cc1e8250f3507d1e580483e209dc1e9d13bd980d/openapi_spec_validator-0.7.2-py3-none-any.whl", hash = "sha256:4bbdc0894ec85f1d1bea1d6d9c8b2c3c8d7ccaa13577ef40da9c006c9fd0eb60", size = 39713, upload-time = "2025-06-07T14:48:54.077Z" }, ] +[[package]] +name = "openpanel" +version = "0.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/d2/1ca167988225113a2162fcc528ef309715f348e1ee2bcaa8405222fea08c/openpanel-0.0.1.tar.gz", hash = "sha256:96a27848d670218c03a75528b95b8e3efbd4898665d57b3ee9c81c4b3c06f922", size = 3721, upload-time = "2024-10-16T14:19:41.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/73/44ad513438c56d3e9c8dbdbea647a2f79e8be03a80967eeaa873f29a0527/openpanel-0.0.1-py3-none-any.whl", hash = "sha256:c4d5a31694d4307975bbf70bbb2fa9fae920df0fb4b8ff97fa7b78ee8fe667b2", size = 15865, upload-time = "2024-10-22T19:27:53.139Z" }, +] + [[package]] name = "packaging" version = "25.0"