Skip to content

Commit 856737f

Browse files
phernandezclaude
andauthored
feat: add anonymous usage telemetry (Homebrew-style opt-out) (#478)
Signed-off-by: phernandez <[email protected]> Co-authored-by: Claude Opus 4.5 <[email protected]>
1 parent 1fd680c commit 856737f

25 files changed

+709
-2
lines changed

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,39 @@ tail -f ~/.basic-memory/basic-memory.log
466466
BASIC_MEMORY_CLOUD_MODE=true uvicorn basic_memory.api.app:app
467467
```
468468

469+
## Telemetry
470+
471+
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.
472+
473+
**What we collect:**
474+
- App version, Python version, OS, architecture
475+
- Feature usage (which MCP tools and CLI commands are used)
476+
- Error types (sanitized - no file paths or personal data)
477+
478+
**What we NEVER collect:**
479+
- Note content, file names, or paths
480+
- Personal information
481+
- IP addresses
482+
483+
**Opting out:**
484+
```bash
485+
# Disable telemetry
486+
basic-memory telemetry disable
487+
488+
# Check status
489+
basic-memory telemetry status
490+
491+
# Re-enable
492+
basic-memory telemetry enable
493+
```
494+
495+
Or set the environment variable:
496+
```bash
497+
export BASIC_MEMORY_TELEMETRY_ENABLED=false
498+
```
499+
500+
For more details, see the [Telemetry documentation](https://basicmemory.com/telemetry).
501+
469502
## Development
470503

471504
### Running Tests

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ dependencies = [
4141
"mdformat>=0.7.22",
4242
"mdformat-gfm>=0.3.7",
4343
"mdformat-frontmatter>=2.0.8",
44+
"openpanel>=0.0.1", # Anonymous usage telemetry (Homebrew-style opt-out)
4445
]
4546

4647

src/basic_memory/cli/app.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import typer # noqa: E402
1616

1717
from basic_memory.config import ConfigManager, init_cli_logging # noqa: E402
18+
from basic_memory.telemetry import show_notice_if_needed, track_app_started # noqa: E402
1819

1920

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

50+
# Show telemetry notice and track CLI startup
51+
# Skip for 'mcp' command - it handles its own telemetry in lifespan
52+
# Skip for 'telemetry' command - avoid issues when user is managing telemetry
53+
if ctx.invoked_subcommand not in {"mcp", "telemetry"}:
54+
show_notice_if_needed()
55+
track_app_started("cli")
56+
4957
# Run initialization for commands that don't use the API
5058
# Skip for 'mcp' command - it has its own lifespan that handles initialization
5159
# Skip for API-using commands (status, sync, etc.) - they handle initialization via deps.py

src/basic_memory/cli/commands/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""CLI commands for basic-memory."""
22

33
from . import status, db, import_memory_json, mcp, import_claude_conversations
4-
from . import import_claude_projects, import_chatgpt, tool, project, format
4+
from . import import_claude_projects, import_chatgpt, tool, project, format, telemetry
55

66
__all__ = [
77
"status",
@@ -14,4 +14,5 @@
1414
"tool",
1515
"project",
1616
"format",
17+
"telemetry",
1718
]
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""Telemetry commands for basic-memory CLI."""
2+
3+
import typer
4+
from rich.console import Console
5+
from rich.panel import Panel
6+
7+
from basic_memory.cli.app import app
8+
from basic_memory.config import ConfigManager
9+
10+
console = Console()
11+
12+
# Create telemetry subcommand group
13+
telemetry_app = typer.Typer(help="Manage anonymous telemetry settings")
14+
app.add_typer(telemetry_app, name="telemetry")
15+
16+
17+
@telemetry_app.command("enable")
18+
def enable() -> None:
19+
"""Enable anonymous telemetry.
20+
21+
Telemetry helps improve Basic Memory by collecting anonymous usage data.
22+
No personal data, note content, or file paths are ever collected.
23+
"""
24+
config_manager = ConfigManager()
25+
config = config_manager.config
26+
config.telemetry_enabled = True
27+
config_manager.save_config(config)
28+
console.print("[green]Telemetry enabled[/green]")
29+
console.print("[dim]Thank you for helping improve Basic Memory![/dim]")
30+
31+
32+
@telemetry_app.command("disable")
33+
def disable() -> None:
34+
"""Disable anonymous telemetry.
35+
36+
You can re-enable telemetry anytime with: bm telemetry enable
37+
"""
38+
config_manager = ConfigManager()
39+
config = config_manager.config
40+
config.telemetry_enabled = False
41+
config_manager.save_config(config)
42+
console.print("[yellow]Telemetry disabled[/yellow]")
43+
44+
45+
@telemetry_app.command("status")
46+
def status() -> None:
47+
"""Show current telemetry status and what's collected."""
48+
from basic_memory.telemetry import get_install_id, TELEMETRY_DOCS_URL
49+
50+
config = ConfigManager().config
51+
52+
status_text = "[green]enabled[/green]" if config.telemetry_enabled else "[yellow]disabled[/yellow]"
53+
54+
console.print(f"\nTelemetry: {status_text}")
55+
console.print(f"Install ID: [dim]{get_install_id()}[/dim]")
56+
console.print()
57+
58+
what_we_collect = """
59+
[bold]What we collect:[/bold]
60+
- App version, Python version, OS, architecture
61+
- Feature usage (which MCP tools and CLI commands)
62+
- Sync statistics (entity count, duration)
63+
- Error types (sanitized, no file paths)
64+
65+
[bold]What we NEVER collect:[/bold]
66+
- Note content, file names, or paths
67+
- Personal information
68+
- IP addresses
69+
"""
70+
71+
console.print(
72+
Panel(
73+
what_we_collect.strip(),
74+
title="Telemetry Details",
75+
border_style="blue",
76+
expand=False,
77+
)
78+
)
79+
console.print(f"[dim]Details: {TELEMETRY_DOCS_URL}[/dim]")

src/basic_memory/cli/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
mcp,
1414
project,
1515
status,
16+
telemetry,
1617
tool,
1718
)
1819

src/basic_memory/config.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,17 @@ class BasicMemoryConfig(BaseSettings):
221221
description="Cloud project sync configuration mapping project names to their local paths and sync state",
222222
)
223223

224+
# Telemetry configuration (Homebrew-style opt-out)
225+
telemetry_enabled: bool = Field(
226+
default=True,
227+
description="Send anonymous usage statistics to help improve Basic Memory. Disable with: bm telemetry disable",
228+
)
229+
230+
telemetry_notice_shown: bool = Field(
231+
default=False,
232+
description="Whether the one-time telemetry notice has been shown to the user",
233+
)
234+
224235
@property
225236
def cloud_mode_enabled(self) -> bool:
226237
"""Check if cloud mode is enabled.

src/basic_memory/mcp/server.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from basic_memory import db
1313
from basic_memory.config import ConfigManager
1414
from basic_memory.services.initialization import initialize_app, initialize_file_sync
15+
from basic_memory.telemetry import show_notice_if_needed, track_app_started
1516

1617

1718
@asynccontextmanager
@@ -20,12 +21,17 @@ async def lifespan(app: FastMCP):
2021
2122
Handles:
2223
- Database initialization and migrations
24+
- Telemetry notice and tracking
2325
- File sync in background (if enabled and not in cloud mode)
2426
- Proper cleanup on shutdown
2527
"""
2628
app_config = ConfigManager().config
2729
logger.info("Starting Basic Memory MCP server")
2830

31+
# Show telemetry notice (first run only) and track startup
32+
show_notice_if_needed()
33+
track_app_started("mcp")
34+
2935
# Track if we created the engine (vs test fixtures providing it)
3036
# This prevents disposing an engine provided by test fixtures when
3137
# multiple Client connections are made in the same test

src/basic_memory/mcp/tools/build_context.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from basic_memory.mcp.project_context import get_active_project
1010
from basic_memory.mcp.server import mcp
1111
from basic_memory.mcp.tools.utils import call_get
12+
from basic_memory.telemetry import track_mcp_tool
1213
from basic_memory.schemas.base import TimeFrame
1314
from basic_memory.schemas.memory import (
1415
GraphContext,
@@ -87,6 +88,7 @@ async def build_context(
8788
Raises:
8889
ToolError: If project doesn't exist or depth parameter is invalid
8990
"""
91+
track_mcp_tool("build_context")
9092
logger.info(f"Building context from {url} in project {project}")
9193

9294
# Convert string depth to integer if needed

src/basic_memory/mcp/tools/canvas.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from basic_memory.mcp.project_context import get_active_project
1414
from basic_memory.mcp.server import mcp
1515
from basic_memory.mcp.tools.utils import call_put, call_post, resolve_entity_id
16+
from basic_memory.telemetry import track_mcp_tool
1617

1718

1819
@mcp.tool(
@@ -94,6 +95,7 @@ async def canvas(
9495
Raises:
9596
ToolError: If project doesn't exist or folder path is invalid
9697
"""
98+
track_mcp_tool("canvas")
9799
async with get_client() as client:
98100
active_project = await get_active_project(client, project, context)
99101

0 commit comments

Comments
 (0)