-
Notifications
You must be signed in to change notification settings - Fork 168
feat: CLI analytics via Umami event collector #572
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+279
−1
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| """Lightweight CLI analytics via Umami event collector. | ||
|
|
||
| Sends anonymous, non-blocking usage events to help understand how the | ||
| CLI-to-cloud conversion funnel performs. No PII, no fingerprinting, | ||
| no cookies. Respects the same opt-out mechanisms as promo messaging. | ||
|
|
||
| Events are fire-and-forget — analytics never blocks or breaks the CLI. | ||
|
|
||
| Setup: | ||
| Set these environment variables (or leave unset to disable): | ||
| BASIC_MEMORY_UMAMI_HOST — Umami instance URL (e.g. https://analytics.basicmemory.com) | ||
| BASIC_MEMORY_UMAMI_SITE_ID — Website ID from Umami dashboard | ||
| """ | ||
|
|
||
| import json | ||
| import os | ||
| import threading | ||
| import urllib.request | ||
| from typing import Optional | ||
|
|
||
| import basic_memory | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Configuration — read from environment so nothing is hard-coded in source | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| def _umami_host() -> Optional[str]: | ||
| return os.getenv("BASIC_MEMORY_UMAMI_HOST", "").strip() or None | ||
|
|
||
|
|
||
| def _umami_site_id() -> Optional[str]: | ||
| return os.getenv("BASIC_MEMORY_UMAMI_SITE_ID", "").strip() or None | ||
|
|
||
|
|
||
| def _analytics_disabled() -> bool: | ||
| """True when analytics should not fire.""" | ||
| value = os.getenv("BASIC_MEMORY_NO_PROMOS", "").strip().lower() | ||
| return value in {"1", "true", "yes"} | ||
|
|
||
|
|
||
| def _is_configured() -> bool: | ||
| """True when both host and site ID are available.""" | ||
| return _umami_host() is not None and _umami_site_id() is not None | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Public API | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| # Well-known event names for the promo/cloud funnel | ||
| EVENT_PROMO_SHOWN = "cli-promo-shown" | ||
| EVENT_PROMO_OPTED_OUT = "cli-promo-opted-out" | ||
| EVENT_CLOUD_LOGIN_STARTED = "cli-cloud-login-started" | ||
| EVENT_CLOUD_LOGIN_SUCCESS = "cli-cloud-login-success" | ||
| EVENT_CLOUD_LOGIN_SUB_REQUIRED = "cli-cloud-login-sub-required" | ||
|
|
||
|
|
||
| def track(event_name: str, data: Optional[dict] = None) -> None: | ||
| """Send an analytics event to Umami. Non-blocking, silent on failure. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| event_name: | ||
| Short kebab-case name (e.g. "cli-promo-shown"). | ||
| data: | ||
| Optional dict of event properties (all values should be strings/numbers). | ||
| """ | ||
| if _analytics_disabled() or not _is_configured(): | ||
| return | ||
|
|
||
| host = _umami_host() | ||
| site_id = _umami_site_id() | ||
|
|
||
| payload = { | ||
| "payload": { | ||
| "hostname": "cli.basicmemory.com", | ||
| "language": "en", | ||
| "url": f"/cli/{event_name}", | ||
| "website": site_id, | ||
| "name": event_name, | ||
| "data": { | ||
| "version": basic_memory.__version__, | ||
| **(data or {}), | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| def _send(): | ||
| try: | ||
| req = urllib.request.Request( | ||
| f"{host}/api/send", | ||
| data=json.dumps(payload).encode("utf-8"), | ||
| headers={ | ||
| "Content-Type": "application/json", | ||
| "User-Agent": f"basic-memory-cli/{basic_memory.__version__}", | ||
| }, | ||
| ) | ||
| urllib.request.urlopen(req, timeout=3) | ||
| except Exception: | ||
| pass # Never break the CLI for analytics | ||
|
|
||
| threading.Thread(target=_send, daemon=True).start() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| """Tests for CLI analytics module.""" | ||
|
|
||
| import json | ||
| import threading | ||
| from unittest.mock import patch, MagicMock | ||
|
|
||
| import pytest | ||
|
|
||
| from basic_memory.cli.analytics import ( | ||
| track, | ||
| _analytics_disabled, | ||
| _is_configured, | ||
| EVENT_PROMO_SHOWN, | ||
| EVENT_CLOUD_LOGIN_STARTED, | ||
| EVENT_CLOUD_LOGIN_SUCCESS, | ||
| EVENT_CLOUD_LOGIN_SUB_REQUIRED, | ||
| EVENT_PROMO_OPTED_OUT, | ||
| ) | ||
|
|
||
|
|
||
| class TestAnalyticsDisabled: | ||
| def test_disabled_when_env_set(self, monkeypatch): | ||
| monkeypatch.setenv("BASIC_MEMORY_NO_PROMOS", "1") | ||
| assert _analytics_disabled() is True | ||
|
|
||
| def test_disabled_when_env_true(self, monkeypatch): | ||
| monkeypatch.setenv("BASIC_MEMORY_NO_PROMOS", "true") | ||
| assert _analytics_disabled() is True | ||
|
|
||
| def test_not_disabled_by_default(self, monkeypatch): | ||
| monkeypatch.delenv("BASIC_MEMORY_NO_PROMOS", raising=False) | ||
| assert _analytics_disabled() is False | ||
|
|
||
|
|
||
| class TestIsConfigured: | ||
| def test_configured_when_both_set(self, monkeypatch): | ||
| monkeypatch.setenv("BASIC_MEMORY_UMAMI_HOST", "https://analytics.example.com") | ||
| monkeypatch.setenv("BASIC_MEMORY_UMAMI_SITE_ID", "abc-123") | ||
| assert _is_configured() is True | ||
|
|
||
| def test_not_configured_when_host_missing(self, monkeypatch): | ||
| monkeypatch.delenv("BASIC_MEMORY_UMAMI_HOST", raising=False) | ||
| monkeypatch.setenv("BASIC_MEMORY_UMAMI_SITE_ID", "abc-123") | ||
| assert _is_configured() is False | ||
|
|
||
| def test_not_configured_when_site_id_missing(self, monkeypatch): | ||
| monkeypatch.setenv("BASIC_MEMORY_UMAMI_HOST", "https://analytics.example.com") | ||
| monkeypatch.delenv("BASIC_MEMORY_UMAMI_SITE_ID", raising=False) | ||
| assert _is_configured() is False | ||
|
|
||
| def test_not_configured_when_empty_strings(self, monkeypatch): | ||
| monkeypatch.setenv("BASIC_MEMORY_UMAMI_HOST", "") | ||
| monkeypatch.setenv("BASIC_MEMORY_UMAMI_SITE_ID", "") | ||
| assert _is_configured() is False | ||
|
|
||
|
|
||
| class TestTrack: | ||
| def test_no_op_when_disabled(self, monkeypatch): | ||
| monkeypatch.setenv("BASIC_MEMORY_NO_PROMOS", "1") | ||
| with patch("basic_memory.cli.analytics.threading.Thread") as mock_thread: | ||
| track("test-event") | ||
| mock_thread.assert_not_called() | ||
|
|
||
| def test_no_op_when_not_configured(self, monkeypatch): | ||
| monkeypatch.delenv("BASIC_MEMORY_NO_PROMOS", raising=False) | ||
| monkeypatch.delenv("BASIC_MEMORY_UMAMI_HOST", raising=False) | ||
| monkeypatch.delenv("BASIC_MEMORY_UMAMI_SITE_ID", raising=False) | ||
| with patch("basic_memory.cli.analytics.threading.Thread") as mock_thread: | ||
| track("test-event") | ||
| mock_thread.assert_not_called() | ||
|
|
||
| def test_sends_event_when_configured(self, monkeypatch): | ||
| monkeypatch.delenv("BASIC_MEMORY_NO_PROMOS", raising=False) | ||
| monkeypatch.setenv("BASIC_MEMORY_UMAMI_HOST", "https://analytics.example.com") | ||
| monkeypatch.setenv("BASIC_MEMORY_UMAMI_SITE_ID", "test-site-id") | ||
|
|
||
| captured_target = None | ||
|
|
||
| def fake_thread(target, daemon): | ||
| nonlocal captured_target | ||
| captured_target = target | ||
| mock = MagicMock() | ||
| return mock | ||
|
|
||
| with patch("basic_memory.cli.analytics.threading.Thread", side_effect=fake_thread): | ||
| track(EVENT_PROMO_SHOWN, {"trigger": "first_run"}) | ||
|
|
||
| assert captured_target is not None | ||
|
|
||
| def test_send_hits_correct_url(self, monkeypatch): | ||
| monkeypatch.delenv("BASIC_MEMORY_NO_PROMOS", raising=False) | ||
| monkeypatch.setenv("BASIC_MEMORY_UMAMI_HOST", "https://analytics.example.com") | ||
| monkeypatch.setenv("BASIC_MEMORY_UMAMI_SITE_ID", "test-site-id") | ||
|
|
||
| captured_request = None | ||
|
|
||
| def fake_urlopen(req, timeout=None): | ||
| nonlocal captured_request | ||
| captured_request = req | ||
| return MagicMock() | ||
|
|
||
| # Run the send function directly instead of in a thread | ||
| with patch("basic_memory.cli.analytics.urllib.request.urlopen", fake_urlopen): | ||
| with patch("basic_memory.cli.analytics.threading.Thread") as mock_thread: | ||
| # Capture the target function and call it directly | ||
| def run_target(target, daemon): | ||
| target() # Execute synchronously | ||
| return MagicMock() | ||
|
|
||
| mock_thread.side_effect = run_target | ||
| track(EVENT_CLOUD_LOGIN_STARTED) | ||
|
|
||
| assert captured_request is not None | ||
| assert captured_request.full_url == "https://analytics.example.com/api/send" | ||
| body = json.loads(captured_request.data) | ||
| assert body["payload"]["name"] == "cli-cloud-login-started" | ||
| assert body["payload"]["website"] == "test-site-id" | ||
| assert body["payload"]["hostname"] == "cli.basicmemory.com" | ||
| assert "version" in body["payload"]["data"] | ||
|
|
||
| def test_send_failure_is_silent(self, monkeypatch): | ||
| monkeypatch.delenv("BASIC_MEMORY_NO_PROMOS", raising=False) | ||
| monkeypatch.setenv("BASIC_MEMORY_UMAMI_HOST", "https://analytics.example.com") | ||
| monkeypatch.setenv("BASIC_MEMORY_UMAMI_SITE_ID", "test-site-id") | ||
|
|
||
| def fake_urlopen(req, timeout=None): | ||
| raise ConnectionError("Network down") | ||
|
|
||
| with patch("basic_memory.cli.analytics.urllib.request.urlopen", fake_urlopen): | ||
| with patch("basic_memory.cli.analytics.threading.Thread") as mock_thread: | ||
| def run_target(target, daemon): | ||
| target() # Should not raise | ||
| return MagicMock() | ||
|
|
||
| mock_thread.side_effect = run_target | ||
| # Should not raise | ||
| track("test-event") | ||
|
|
||
|
|
||
| class TestEventConstants: | ||
| """Verify event name constants exist and are kebab-case strings.""" | ||
|
|
||
| @pytest.mark.parametrize( | ||
| "event", | ||
| [ | ||
| EVENT_PROMO_SHOWN, | ||
| EVENT_PROMO_OPTED_OUT, | ||
| EVENT_CLOUD_LOGIN_STARTED, | ||
| EVENT_CLOUD_LOGIN_SUCCESS, | ||
| EVENT_CLOUD_LOGIN_SUB_REQUIRED, | ||
| ], | ||
| ) | ||
| def test_event_names_are_kebab_case(self, event): | ||
| assert isinstance(event, str) | ||
| assert event == event.lower() | ||
| assert " " not in event | ||
| assert event.startswith("cli-") |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
track()only checks_analytics_disabled()(theBASIC_MEMORY_NO_PROMOSenv var), so it ignores the persisted opt-out set bybm cloud promo --off(cloud_promo_opt_outincore_commands.promo). In that state, later commands (for examplebm cloud login) still emit analytics events, which contradicts the module’s stated “same opt-out mechanisms as promo messaging” behavior and leaks telemetry after an explicit opt-out. Add a config-based opt-out check (or reuse promo gating logic) before dispatching events.Useful? React with 👍 / 👎.