Skip to content

Commit 1ac65b9

Browse files
authored
feat: CLI analytics via Umami event collector (#572)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 306e562 commit 1ac65b9

4 files changed

Lines changed: 279 additions & 1 deletion

File tree

src/basic_memory/cli/analytics.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Lightweight CLI analytics via Umami event collector.
2+
3+
Sends anonymous, non-blocking usage events to help understand how the
4+
CLI-to-cloud conversion funnel performs. No PII, no fingerprinting,
5+
no cookies. Respects the same opt-out mechanisms as promo messaging.
6+
7+
Events are fire-and-forget — analytics never blocks or breaks the CLI.
8+
9+
Setup:
10+
Set these environment variables (or leave unset to disable):
11+
BASIC_MEMORY_UMAMI_HOST — Umami instance URL (e.g. https://analytics.basicmemory.com)
12+
BASIC_MEMORY_UMAMI_SITE_ID — Website ID from Umami dashboard
13+
"""
14+
15+
import json
16+
import os
17+
import threading
18+
import urllib.request
19+
from typing import Optional
20+
21+
import basic_memory
22+
23+
24+
# ---------------------------------------------------------------------------
25+
# Configuration — read from environment so nothing is hard-coded in source
26+
# ---------------------------------------------------------------------------
27+
28+
def _umami_host() -> Optional[str]:
29+
return os.getenv("BASIC_MEMORY_UMAMI_HOST", "").strip() or None
30+
31+
32+
def _umami_site_id() -> Optional[str]:
33+
return os.getenv("BASIC_MEMORY_UMAMI_SITE_ID", "").strip() or None
34+
35+
36+
def _analytics_disabled() -> bool:
37+
"""True when analytics should not fire."""
38+
value = os.getenv("BASIC_MEMORY_NO_PROMOS", "").strip().lower()
39+
return value in {"1", "true", "yes"}
40+
41+
42+
def _is_configured() -> bool:
43+
"""True when both host and site ID are available."""
44+
return _umami_host() is not None and _umami_site_id() is not None
45+
46+
47+
# ---------------------------------------------------------------------------
48+
# Public API
49+
# ---------------------------------------------------------------------------
50+
51+
# Well-known event names for the promo/cloud funnel
52+
EVENT_PROMO_SHOWN = "cli-promo-shown"
53+
EVENT_PROMO_OPTED_OUT = "cli-promo-opted-out"
54+
EVENT_CLOUD_LOGIN_STARTED = "cli-cloud-login-started"
55+
EVENT_CLOUD_LOGIN_SUCCESS = "cli-cloud-login-success"
56+
EVENT_CLOUD_LOGIN_SUB_REQUIRED = "cli-cloud-login-sub-required"
57+
58+
59+
def track(event_name: str, data: Optional[dict] = None) -> None:
60+
"""Send an analytics event to Umami. Non-blocking, silent on failure.
61+
62+
Parameters
63+
----------
64+
event_name:
65+
Short kebab-case name (e.g. "cli-promo-shown").
66+
data:
67+
Optional dict of event properties (all values should be strings/numbers).
68+
"""
69+
if _analytics_disabled() or not _is_configured():
70+
return
71+
72+
host = _umami_host()
73+
site_id = _umami_site_id()
74+
75+
payload = {
76+
"payload": {
77+
"hostname": "cli.basicmemory.com",
78+
"language": "en",
79+
"url": f"/cli/{event_name}",
80+
"website": site_id,
81+
"name": event_name,
82+
"data": {
83+
"version": basic_memory.__version__,
84+
**(data or {}),
85+
},
86+
}
87+
}
88+
89+
def _send():
90+
try:
91+
req = urllib.request.Request(
92+
f"{host}/api/send",
93+
data=json.dumps(payload).encode("utf-8"),
94+
headers={
95+
"Content-Type": "application/json",
96+
"User-Agent": f"basic-memory-cli/{basic_memory.__version__}",
97+
},
98+
)
99+
urllib.request.urlopen(req, timeout=3)
100+
except Exception:
101+
pass # Never break the CLI for analytics
102+
103+
threading.Thread(target=_send, daemon=True).start()

src/basic_memory/cli/commands/cloud/core_commands.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66
from basic_memory.cli.app import cloud_app
77
from basic_memory.cli.commands.command_utils import run_with_cleanup
88
from basic_memory.cli.auth import CLIAuth
9+
from basic_memory.cli.analytics import (
10+
track,
11+
EVENT_CLOUD_LOGIN_STARTED,
12+
EVENT_CLOUD_LOGIN_SUCCESS,
13+
EVENT_CLOUD_LOGIN_SUB_REQUIRED,
14+
EVENT_PROMO_OPTED_OUT,
15+
)
916
from basic_memory.cli.promo import OSS_DISCOUNT_CODE
1017
from basic_memory.config import ConfigManager
1118
from basic_memory.cli.commands.cloud.api_client import (
@@ -33,6 +40,7 @@ def login():
3340
"""Authenticate with WorkOS using OAuth Device Authorization flow."""
3441

3542
async def _login():
43+
track(EVENT_CLOUD_LOGIN_STARTED)
3644
client_id, domain, host_url = get_cloud_config()
3745
auth = CLIAuth(client_id=client_id, authkit_domain=domain)
3846

@@ -46,10 +54,12 @@ async def _login():
4654
console.print("[dim]Verifying subscription access...[/dim]")
4755
await make_api_request("GET", f"{host_url.rstrip('/')}/proxy/health")
4856

57+
track(EVENT_CLOUD_LOGIN_SUCCESS)
4958
console.print("[green]Cloud authentication successful[/green]")
5059
console.print(f"[dim]Cloud host ready: {host_url}[/dim]")
5160

5261
except SubscriptionRequiredError as e:
62+
track(EVENT_CLOUD_LOGIN_SUB_REQUIRED)
5363
console.print("\n[red]Subscription Required[/red]\n")
5464
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
5565
console.print(
@@ -205,6 +215,7 @@ def promo(enabled: bool = typer.Option(True, "--on/--off", help="Enable or disab
205215
if enabled:
206216
console.print("[green]Cloud promo messages enabled[/green]")
207217
else:
218+
track(EVENT_PROMO_OPTED_OUT)
208219
console.print("[yellow]Cloud promo messages disabled[/yellow]")
209220

210221

src/basic_memory/cli/promo.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@
77
from rich.panel import Panel
88

99
import basic_memory
10+
from basic_memory.cli.analytics import track, EVENT_PROMO_SHOWN, EVENT_PROMO_OPTED_OUT
1011
from basic_memory.config import ConfigManager
1112

1213
OSS_DISCOUNT_CODE = "BMFOSS"
13-
CLOUD_LEARN_MORE_URL = "https://basicmemory.com"
14+
CLOUD_LEARN_MORE_URL = (
15+
"https://basicmemory.com"
16+
"?utm_source=bm-cli&utm_medium=promo&utm_campaign=cloud-upsell"
17+
)
1418

1519

1620
def _promos_disabled_by_env() -> bool:
@@ -113,6 +117,9 @@ def maybe_show_cloud_promo(
113117
out.print(f"Learn more at [link={CLOUD_LEARN_MORE_URL}]{CLOUD_LEARN_MORE_URL}[/link]")
114118
out.print("[dim]Disable with: bm cloud promo --off[/dim]")
115119

120+
trigger = "first_run" if show_first_run else "version_bump"
121+
track(EVENT_PROMO_SHOWN, {"trigger": trigger})
122+
116123
config.cloud_promo_first_run_shown = True
117124
config.cloud_promo_last_version_shown = basic_memory.__version__
118125
manager.save_config(config)

tests/cli/test_analytics.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""Tests for CLI analytics module."""
2+
3+
import json
4+
import threading
5+
from unittest.mock import patch, MagicMock
6+
7+
import pytest
8+
9+
from basic_memory.cli.analytics import (
10+
track,
11+
_analytics_disabled,
12+
_is_configured,
13+
EVENT_PROMO_SHOWN,
14+
EVENT_CLOUD_LOGIN_STARTED,
15+
EVENT_CLOUD_LOGIN_SUCCESS,
16+
EVENT_CLOUD_LOGIN_SUB_REQUIRED,
17+
EVENT_PROMO_OPTED_OUT,
18+
)
19+
20+
21+
class TestAnalyticsDisabled:
22+
def test_disabled_when_env_set(self, monkeypatch):
23+
monkeypatch.setenv("BASIC_MEMORY_NO_PROMOS", "1")
24+
assert _analytics_disabled() is True
25+
26+
def test_disabled_when_env_true(self, monkeypatch):
27+
monkeypatch.setenv("BASIC_MEMORY_NO_PROMOS", "true")
28+
assert _analytics_disabled() is True
29+
30+
def test_not_disabled_by_default(self, monkeypatch):
31+
monkeypatch.delenv("BASIC_MEMORY_NO_PROMOS", raising=False)
32+
assert _analytics_disabled() is False
33+
34+
35+
class TestIsConfigured:
36+
def test_configured_when_both_set(self, monkeypatch):
37+
monkeypatch.setenv("BASIC_MEMORY_UMAMI_HOST", "https://analytics.example.com")
38+
monkeypatch.setenv("BASIC_MEMORY_UMAMI_SITE_ID", "abc-123")
39+
assert _is_configured() is True
40+
41+
def test_not_configured_when_host_missing(self, monkeypatch):
42+
monkeypatch.delenv("BASIC_MEMORY_UMAMI_HOST", raising=False)
43+
monkeypatch.setenv("BASIC_MEMORY_UMAMI_SITE_ID", "abc-123")
44+
assert _is_configured() is False
45+
46+
def test_not_configured_when_site_id_missing(self, monkeypatch):
47+
monkeypatch.setenv("BASIC_MEMORY_UMAMI_HOST", "https://analytics.example.com")
48+
monkeypatch.delenv("BASIC_MEMORY_UMAMI_SITE_ID", raising=False)
49+
assert _is_configured() is False
50+
51+
def test_not_configured_when_empty_strings(self, monkeypatch):
52+
monkeypatch.setenv("BASIC_MEMORY_UMAMI_HOST", "")
53+
monkeypatch.setenv("BASIC_MEMORY_UMAMI_SITE_ID", "")
54+
assert _is_configured() is False
55+
56+
57+
class TestTrack:
58+
def test_no_op_when_disabled(self, monkeypatch):
59+
monkeypatch.setenv("BASIC_MEMORY_NO_PROMOS", "1")
60+
with patch("basic_memory.cli.analytics.threading.Thread") as mock_thread:
61+
track("test-event")
62+
mock_thread.assert_not_called()
63+
64+
def test_no_op_when_not_configured(self, monkeypatch):
65+
monkeypatch.delenv("BASIC_MEMORY_NO_PROMOS", raising=False)
66+
monkeypatch.delenv("BASIC_MEMORY_UMAMI_HOST", raising=False)
67+
monkeypatch.delenv("BASIC_MEMORY_UMAMI_SITE_ID", raising=False)
68+
with patch("basic_memory.cli.analytics.threading.Thread") as mock_thread:
69+
track("test-event")
70+
mock_thread.assert_not_called()
71+
72+
def test_sends_event_when_configured(self, monkeypatch):
73+
monkeypatch.delenv("BASIC_MEMORY_NO_PROMOS", raising=False)
74+
monkeypatch.setenv("BASIC_MEMORY_UMAMI_HOST", "https://analytics.example.com")
75+
monkeypatch.setenv("BASIC_MEMORY_UMAMI_SITE_ID", "test-site-id")
76+
77+
captured_target = None
78+
79+
def fake_thread(target, daemon):
80+
nonlocal captured_target
81+
captured_target = target
82+
mock = MagicMock()
83+
return mock
84+
85+
with patch("basic_memory.cli.analytics.threading.Thread", side_effect=fake_thread):
86+
track(EVENT_PROMO_SHOWN, {"trigger": "first_run"})
87+
88+
assert captured_target is not None
89+
90+
def test_send_hits_correct_url(self, monkeypatch):
91+
monkeypatch.delenv("BASIC_MEMORY_NO_PROMOS", raising=False)
92+
monkeypatch.setenv("BASIC_MEMORY_UMAMI_HOST", "https://analytics.example.com")
93+
monkeypatch.setenv("BASIC_MEMORY_UMAMI_SITE_ID", "test-site-id")
94+
95+
captured_request = None
96+
97+
def fake_urlopen(req, timeout=None):
98+
nonlocal captured_request
99+
captured_request = req
100+
return MagicMock()
101+
102+
# Run the send function directly instead of in a thread
103+
with patch("basic_memory.cli.analytics.urllib.request.urlopen", fake_urlopen):
104+
with patch("basic_memory.cli.analytics.threading.Thread") as mock_thread:
105+
# Capture the target function and call it directly
106+
def run_target(target, daemon):
107+
target() # Execute synchronously
108+
return MagicMock()
109+
110+
mock_thread.side_effect = run_target
111+
track(EVENT_CLOUD_LOGIN_STARTED)
112+
113+
assert captured_request is not None
114+
assert captured_request.full_url == "https://analytics.example.com/api/send"
115+
body = json.loads(captured_request.data)
116+
assert body["payload"]["name"] == "cli-cloud-login-started"
117+
assert body["payload"]["website"] == "test-site-id"
118+
assert body["payload"]["hostname"] == "cli.basicmemory.com"
119+
assert "version" in body["payload"]["data"]
120+
121+
def test_send_failure_is_silent(self, monkeypatch):
122+
monkeypatch.delenv("BASIC_MEMORY_NO_PROMOS", raising=False)
123+
monkeypatch.setenv("BASIC_MEMORY_UMAMI_HOST", "https://analytics.example.com")
124+
monkeypatch.setenv("BASIC_MEMORY_UMAMI_SITE_ID", "test-site-id")
125+
126+
def fake_urlopen(req, timeout=None):
127+
raise ConnectionError("Network down")
128+
129+
with patch("basic_memory.cli.analytics.urllib.request.urlopen", fake_urlopen):
130+
with patch("basic_memory.cli.analytics.threading.Thread") as mock_thread:
131+
def run_target(target, daemon):
132+
target() # Should not raise
133+
return MagicMock()
134+
135+
mock_thread.side_effect = run_target
136+
# Should not raise
137+
track("test-event")
138+
139+
140+
class TestEventConstants:
141+
"""Verify event name constants exist and are kebab-case strings."""
142+
143+
@pytest.mark.parametrize(
144+
"event",
145+
[
146+
EVENT_PROMO_SHOWN,
147+
EVENT_PROMO_OPTED_OUT,
148+
EVENT_CLOUD_LOGIN_STARTED,
149+
EVENT_CLOUD_LOGIN_SUCCESS,
150+
EVENT_CLOUD_LOGIN_SUB_REQUIRED,
151+
],
152+
)
153+
def test_event_names_are_kebab_case(self, event):
154+
assert isinstance(event, str)
155+
assert event == event.lower()
156+
assert " " not in event
157+
assert event.startswith("cli-")

0 commit comments

Comments
 (0)