Files
basicmachines-co-basic-memory/tests/test_telemetry.py
T
claude[bot] 150a8175b0 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>
2025-12-25 02:54:13 +00:00

319 lines
9.8 KiB
Python

"""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 == ""