mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
150a8175b0
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>
319 lines
9.8 KiB
Python
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 == ""
|