"""Tests for telemetry module (minimal mocking).
We avoid standard-library mocks and instead use small stub objects + pytest monkeypatch.
"""
from __future__ import annotations
from pathlib import Path
from basic_memory.config import BasicMemoryConfig
class _StubOpenPanel:
def __init__(self, *, client_id: str, client_secret: str, disabled: bool = False):
self.client_id = client_id
self.client_secret = client_secret
self.disabled = disabled
self.global_properties: dict | None = None
self.events: list[tuple[str, dict]] = []
self.raise_on_track: Exception | None = None
def set_global_properties(self, props: dict) -> None:
self.global_properties = props
def track(self, event: str, properties: dict) -> None:
if self.raise_on_track:
raise self.raise_on_track
self.events.append((event, properties))
class _StubConsole:
def __init__(self, *args, **kwargs):
self.print_calls: list[tuple[tuple, dict]] = []
def print(self, *args, **kwargs):
self.print_calls.append((args, kwargs))
class TestGetInstallId:
def test_creates_install_id_on_first_call(self, tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
from basic_memory.telemetry import get_install_id
install_id = get_install_id()
assert len(install_id) == 36
assert install_id.count("-") == 4
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):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
id_file = tmp_path / ".basic-memory" / ".install_id"
id_file.parent.mkdir(parents=True, exist_ok=True)
id_file.write_text("test-uuid-12345")
from basic_memory.telemetry import get_install_id
assert get_install_id() == "test-uuid-12345"
class TestTelemetryConfig:
def test_telemetry_enabled_defaults_to_true(self, config_home, monkeypatch):
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
assert BasicMemoryConfig().telemetry_enabled is True
def test_telemetry_notice_shown_defaults_to_false(self, config_home, monkeypatch):
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
assert BasicMemoryConfig().telemetry_notice_shown is False
def test_telemetry_enabled_via_env_var(self, config_home, monkeypatch):
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
monkeypatch.setenv("BASIC_MEMORY_TELEMETRY_ENABLED", "false")
assert BasicMemoryConfig().telemetry_enabled is False
class TestTrack:
def test_track_does_not_raise_on_error(self, config_home, monkeypatch):
import basic_memory.telemetry as telemetry
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
telemetry.reset_client()
# Replace OpenPanel with a stub that raises on track
stub_client = _StubOpenPanel(client_id="id", client_secret="sec", disabled=False)
stub_client.raise_on_track = Exception("Network error")
def openpanel_factory(*, client_id, client_secret, disabled=False):
stub_client.client_id = client_id
stub_client.client_secret = client_secret
stub_client.disabled = disabled
return stub_client
monkeypatch.setattr(telemetry, "OpenPanel", openpanel_factory)
# Should not raise
telemetry.track("test_event", {"key": "value"})
def test_track_respects_disabled_config(self, config_home, monkeypatch):
import basic_memory.telemetry as telemetry
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
telemetry.reset_client()
monkeypatch.setenv("BASIC_MEMORY_TELEMETRY_ENABLED", "false")
created: list[_StubOpenPanel] = []
def openpanel_factory(*, client_id, client_secret, disabled=False):
client = _StubOpenPanel(client_id=client_id, client_secret=client_secret, disabled=disabled)
created.append(client)
return client
monkeypatch.setattr(telemetry, "OpenPanel", openpanel_factory)
telemetry.track("test_event")
assert len(created) == 1
assert created[0].disabled is True
class TestShowNoticeIfNeeded:
def test_shows_notice_when_enabled_and_not_shown(self, config_manager, monkeypatch):
import basic_memory.telemetry as telemetry
telemetry.reset_client()
# Ensure config state: enabled + not yet shown
cfg = config_manager.load_config()
cfg.telemetry_enabled = True
cfg.telemetry_notice_shown = False
config_manager.save_config(cfg)
instances: list[_StubConsole] = []
def console_factory(*_args, **_kwargs):
c = _StubConsole()
instances.append(c)
return c
monkeypatch.setattr("rich.console.Console", console_factory)
telemetry.show_notice_if_needed()
assert len(instances) == 1
assert len(instances[0].print_calls) == 1
cfg2 = config_manager.load_config()
assert cfg2.telemetry_notice_shown is True
def test_does_not_show_notice_when_disabled(self, config_manager, monkeypatch):
import basic_memory.telemetry as telemetry
telemetry.reset_client()
cfg = config_manager.load_config()
cfg.telemetry_enabled = False
cfg.telemetry_notice_shown = False
config_manager.save_config(cfg)
def console_factory(*_args, **_kwargs):
raise AssertionError("Console should not be constructed when telemetry is disabled")
monkeypatch.setattr("rich.console.Console", console_factory)
telemetry.show_notice_if_needed()
class TestConvenienceFunctions:
def test_track_app_started(self, config_home, monkeypatch):
import basic_memory.telemetry as telemetry
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
telemetry.reset_client()
created: list[_StubOpenPanel] = []
def openpanel_factory(*, client_id, client_secret, disabled=False):
client = _StubOpenPanel(client_id=client_id, client_secret=client_secret, disabled=disabled)
created.append(client)
return client
monkeypatch.setattr(telemetry, "OpenPanel", openpanel_factory)
telemetry.track_app_started("cli")
assert created
assert created[0].events[-1] == ("app_started", {"mode": "cli"})
def test_track_mcp_tool(self, config_home, monkeypatch):
import basic_memory.telemetry as telemetry
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
telemetry.reset_client()
created: list[_StubOpenPanel] = []
def openpanel_factory(*, client_id, client_secret, disabled=False):
client = _StubOpenPanel(client_id=client_id, client_secret=client_secret, disabled=disabled)
created.append(client)
return client
monkeypatch.setattr(telemetry, "OpenPanel", openpanel_factory)
telemetry.track_mcp_tool("write_note")
assert created
assert created[0].events[-1] == ("mcp_tool_called", {"tool": "write_note"})
def test_track_error_truncates_message(self, config_home, monkeypatch):
import basic_memory.telemetry as telemetry
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
telemetry.reset_client()
created: list[_StubOpenPanel] = []
def openpanel_factory(*, client_id, client_secret, disabled=False):
client = _StubOpenPanel(client_id=client_id, client_secret=client_secret, disabled=disabled)
created.append(client)
return client
monkeypatch.setattr(telemetry, "OpenPanel", openpanel_factory)
telemetry.track_error("ValueError", "x" * 500)
_, props = created[0].events[-1]
assert len(props["message"]) == 200
def test_track_error_sanitizes_file_paths(self, config_home, monkeypatch):
import basic_memory.telemetry as telemetry
import basic_memory.config
basic_memory.config._CONFIG_CACHE = None
telemetry.reset_client()
created: list[_StubOpenPanel] = []
def openpanel_factory(*, client_id, client_secret, disabled=False):
client = _StubOpenPanel(client_id=client_id, client_secret=client_secret, disabled=disabled)
created.append(client)
return client
monkeypatch.setattr(telemetry, "OpenPanel", openpanel_factory)
telemetry.track_error("FileNotFoundError", "No such file: /Users/john/notes/secret.md")
_, props = created[0].events[-1]
assert "/Users/john" not in props["message"]
assert "[FILE]" in props["message"]
telemetry.reset_client()
created.clear()
monkeypatch.setattr(telemetry, "OpenPanel", openpanel_factory)
telemetry.track_error("FileNotFoundError", "Cannot open C:\\Users\\john\\docs\\private.txt")
_, props = created[0].events[-1]
assert "C:\\Users\\john" not in props["message"]
assert "[FILE]" in props["message"]