Skip to content

Commit 087c797

Browse files
phernandezclaude
andcommitted
fix: prevent telemetry from causing CLI hangs
- Defer OpenPanel import until telemetry is actually enabled - Add _is_telemetry_enabled() to check config before creating client - Add shutdown_telemetry() to properly stop OpenPanel's background thread - Call shutdown_telemetry() from run_with_cleanup() The OpenPanel library creates a background thread with an event loop that can cause hangs when the telemetry endpoint is unreachable (firewall, network issues) due to requests.post() having no timeout. By not creating the client when telemetry is disabled, we avoid the background thread entirely. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 5d33d82 commit 087c797

3 files changed

Lines changed: 102 additions & 38 deletions

File tree

src/basic_memory/cli/commands/command_utils.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from basic_memory.mcp.tools.utils import call_post, call_get
1515
from basic_memory.mcp.project_context import get_active_project
1616
from basic_memory.schemas import ProjectInfoResponse
17+
from basic_memory.telemetry import shutdown_telemetry
1718

1819
console = Console()
1920

@@ -23,8 +24,8 @@
2324
def run_with_cleanup(coro: Coroutine[Any, Any, T]) -> T:
2425
"""Run an async coroutine with proper database cleanup.
2526
26-
This helper ensures database connections are cleaned up before the event
27-
loop closes, preventing process hangs in CLI commands.
27+
This helper ensures database connections and telemetry threads are cleaned up
28+
before the event loop closes, preventing process hangs in CLI commands.
2829
2930
Args:
3031
coro: The coroutine to run
@@ -38,6 +39,9 @@ async def _with_cleanup() -> T:
3839
return await coro
3940
finally:
4041
await db.shutdown_db()
42+
# Shutdown telemetry to stop the OpenPanel background thread
43+
# This prevents hangs on Python 3.14+ during thread shutdown
44+
shutdown_telemetry()
4145

4246
return asyncio.run(_with_cleanup())
4347

src/basic_memory/telemetry.py

Lines changed: 65 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,21 @@
1919
Documentation: https://basicmemory.com/telemetry
2020
"""
2121

22+
from __future__ import annotations
23+
2224
import platform
2325
import re
2426
import uuid
2527
from pathlib import Path
26-
from typing import Any
28+
from typing import TYPE_CHECKING, Any
2729

2830
from loguru import logger
29-
from openpanel import OpenPanel
3031

3132
from basic_memory import __version__
3233

34+
if TYPE_CHECKING:
35+
from openpanel import OpenPanel
36+
3337
# --- Configuration ---
3438

3539
# OpenPanel credentials (write-only, safe to embed in client code)
@@ -51,6 +55,29 @@
5155

5256
_client: OpenPanel | None = None
5357
_initialized: bool = False
58+
_telemetry_enabled: bool | None = None # Cached to avoid repeated config reads
59+
60+
61+
# --- Telemetry State ---
62+
63+
64+
def _is_telemetry_enabled() -> bool:
65+
"""Check if telemetry is enabled (cached).
66+
67+
Returns False if:
68+
- User disabled via `bm telemetry disable`
69+
- DO_NOT_TRACK environment variable is set
70+
- Running in test environment
71+
"""
72+
global _telemetry_enabled
73+
74+
if _telemetry_enabled is None:
75+
from basic_memory.config import ConfigManager
76+
77+
config = ConfigManager().config
78+
_telemetry_enabled = config.telemetry_enabled and not config.is_test_env
79+
80+
return _telemetry_enabled
5481

5582

5683
# --- Installation ID ---
@@ -76,30 +103,31 @@ def get_install_id() -> str:
76103
# --- Client Management ---
77104

78105

79-
def _get_client() -> OpenPanel:
106+
def _get_client() -> OpenPanel | None:
80107
"""Get or create the OpenPanel client (singleton).
81108
82109
Lazily initializes the client with global properties.
110+
Returns None if telemetry is disabled (avoids creating background thread).
83111
"""
84112
global _client, _initialized
85113

86-
if _client is None:
87-
from basic_memory.config import ConfigManager
114+
# Trigger: telemetry disabled via config, env var, or test mode
115+
# Why: OpenPanel creates a background thread even when disabled=True,
116+
# which can cause hangs on Python 3.14 during thread shutdown
117+
# Outcome: return None early, no OpenPanel client or thread created
118+
if not _is_telemetry_enabled():
119+
return None
88120

89-
config = ConfigManager().config
121+
if _client is None:
122+
# Defer import to avoid creating background thread when telemetry disabled
123+
from openpanel import OpenPanel
90124

91-
# Trigger: first call to track an event
92-
# Why: lazy init avoids work if telemetry never used; disabled flag
93-
# tells OpenPanel to skip network calls when user opts out or during tests
94-
# Outcome: client ready to queue events (or silently discard if disabled)
95-
is_disabled = not config.telemetry_enabled or config.is_test_env
96125
_client = OpenPanel(
97126
client_id=OPENPANEL_CLIENT_ID,
98127
client_secret=OPENPANEL_CLIENT_SECRET,
99-
disabled=is_disabled,
100128
)
101129

102-
if config.telemetry_enabled and not config.is_test_env and not _initialized:
130+
if not _initialized:
103131
# Set global properties that go with every event
104132
_client.set_global_properties(
105133
{
@@ -118,9 +146,29 @@ def _get_client() -> OpenPanel:
118146

119147
def reset_client() -> None:
120148
"""Reset the telemetry client (for testing or after config changes)."""
121-
global _client, _initialized
149+
global _client, _initialized, _telemetry_enabled
122150
_client = None
123151
_initialized = False
152+
_telemetry_enabled = None
153+
154+
155+
def shutdown_telemetry() -> None:
156+
"""Shutdown the telemetry client, stopping its background thread.
157+
158+
Call this on application exit to ensure clean shutdown.
159+
The OpenPanel client creates a background thread with an event loop
160+
that needs to be stopped to avoid hangs on Python 3.14+.
161+
"""
162+
global _client
163+
164+
if _client is not None:
165+
try:
166+
# OpenPanel._cleanup stops the event loop and joins the thread
167+
_client._cleanup()
168+
except Exception as e:
169+
logger.opt(exception=False).debug(f"Telemetry shutdown failed: {e}")
170+
finally:
171+
_client = None
124172

125173

126174
# --- Event Tracking ---
@@ -136,7 +184,9 @@ def track(event: str, properties: dict[str, Any] | None = None) -> None:
136184
# Constraint: telemetry must never break the application
137185
# Even if OpenPanel API is down or config is corrupt, user's command must succeed
138186
try:
139-
_get_client().track(event, properties or {})
187+
client = _get_client()
188+
if client is not None:
189+
client.track(event, properties or {})
140190
except Exception as e:
141191
logger.opt(exception=False).debug(f"Telemetry failed: {e}")
142192

tests/test_telemetry.py

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -88,18 +88,20 @@ def test_track_does_not_raise_on_error(self, config_home, monkeypatch):
8888

8989
basic_memory.config._CONFIG_CACHE = None
9090
telemetry.reset_client()
91+
# Force telemetry to be enabled for this test
92+
monkeypatch.setattr(telemetry, "_is_telemetry_enabled", lambda: True)
9193

9294
# Replace OpenPanel with a stub that raises on track
9395
stub_client = _StubOpenPanel(client_id="id", client_secret="sec", disabled=False)
9496
stub_client.raise_on_track = Exception("Network error")
9597

96-
def openpanel_factory(*, client_id, client_secret, disabled=False):
98+
def openpanel_factory(*, client_id, client_secret):
9799
stub_client.client_id = client_id
98100
stub_client.client_secret = client_secret
99-
stub_client.disabled = disabled
100101
return stub_client
101102

102-
monkeypatch.setattr(telemetry, "OpenPanel", openpanel_factory)
103+
# Mock the import inside _get_client()
104+
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
103105

104106
# Should not raise
105107
telemetry.track("test_event", {"key": "value"})
@@ -115,16 +117,16 @@ def test_track_respects_disabled_config(self, config_home, monkeypatch):
115117

116118
created: list[_StubOpenPanel] = []
117119

118-
def openpanel_factory(*, client_id, client_secret, disabled=False):
119-
client = _StubOpenPanel(client_id=client_id, client_secret=client_secret, disabled=disabled)
120+
def openpanel_factory(*, client_id, client_secret):
121+
client = _StubOpenPanel(client_id=client_id, client_secret=client_secret, disabled=False)
120122
created.append(client)
121123
return client
122124

123-
monkeypatch.setattr(telemetry, "OpenPanel", openpanel_factory)
125+
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
124126

125127
telemetry.track("test_event")
126-
assert len(created) == 1
127-
assert created[0].disabled is True
128+
# With disabled config, OpenPanel client is never created (early return)
129+
assert len(created) == 0
128130

129131

130132
class TestShowNoticeIfNeeded:
@@ -181,15 +183,17 @@ def test_track_app_started(self, config_home, monkeypatch):
181183

182184
basic_memory.config._CONFIG_CACHE = None
183185
telemetry.reset_client()
186+
# Force telemetry to be enabled for this test (pytest sets PYTEST_CURRENT_TEST)
187+
monkeypatch.setattr(telemetry, "_is_telemetry_enabled", lambda: True)
184188

185189
created: list[_StubOpenPanel] = []
186190

187-
def openpanel_factory(*, client_id, client_secret, disabled=False):
188-
client = _StubOpenPanel(client_id=client_id, client_secret=client_secret, disabled=disabled)
191+
def openpanel_factory(*, client_id, client_secret):
192+
client = _StubOpenPanel(client_id=client_id, client_secret=client_secret, disabled=False)
189193
created.append(client)
190194
return client
191195

192-
monkeypatch.setattr(telemetry, "OpenPanel", openpanel_factory)
196+
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
193197

194198
telemetry.track_app_started("cli")
195199
assert created
@@ -201,15 +205,17 @@ def test_track_mcp_tool(self, config_home, monkeypatch):
201205

202206
basic_memory.config._CONFIG_CACHE = None
203207
telemetry.reset_client()
208+
# Force telemetry to be enabled for this test
209+
monkeypatch.setattr(telemetry, "_is_telemetry_enabled", lambda: True)
204210

205211
created: list[_StubOpenPanel] = []
206212

207-
def openpanel_factory(*, client_id, client_secret, disabled=False):
208-
client = _StubOpenPanel(client_id=client_id, client_secret=client_secret, disabled=disabled)
213+
def openpanel_factory(*, client_id, client_secret):
214+
client = _StubOpenPanel(client_id=client_id, client_secret=client_secret, disabled=False)
209215
created.append(client)
210216
return client
211217

212-
monkeypatch.setattr(telemetry, "OpenPanel", openpanel_factory)
218+
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
213219

214220
telemetry.track_mcp_tool("write_note")
215221
assert created
@@ -221,15 +227,17 @@ def test_track_error_truncates_message(self, config_home, monkeypatch):
221227

222228
basic_memory.config._CONFIG_CACHE = None
223229
telemetry.reset_client()
230+
# Force telemetry to be enabled for this test
231+
monkeypatch.setattr(telemetry, "_is_telemetry_enabled", lambda: True)
224232

225233
created: list[_StubOpenPanel] = []
226234

227-
def openpanel_factory(*, client_id, client_secret, disabled=False):
228-
client = _StubOpenPanel(client_id=client_id, client_secret=client_secret, disabled=disabled)
235+
def openpanel_factory(*, client_id, client_secret):
236+
client = _StubOpenPanel(client_id=client_id, client_secret=client_secret, disabled=False)
229237
created.append(client)
230238
return client
231239

232-
monkeypatch.setattr(telemetry, "OpenPanel", openpanel_factory)
240+
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
233241

234242
telemetry.track_error("ValueError", "x" * 500)
235243
_, props = created[0].events[-1]
@@ -241,15 +249,17 @@ def test_track_error_sanitizes_file_paths(self, config_home, monkeypatch):
241249

242250
basic_memory.config._CONFIG_CACHE = None
243251
telemetry.reset_client()
252+
# Force telemetry to be enabled for this test
253+
monkeypatch.setattr(telemetry, "_is_telemetry_enabled", lambda: True)
244254

245255
created: list[_StubOpenPanel] = []
246256

247-
def openpanel_factory(*, client_id, client_secret, disabled=False):
248-
client = _StubOpenPanel(client_id=client_id, client_secret=client_secret, disabled=disabled)
257+
def openpanel_factory(*, client_id, client_secret):
258+
client = _StubOpenPanel(client_id=client_id, client_secret=client_secret, disabled=False)
249259
created.append(client)
250260
return client
251261

252-
monkeypatch.setattr(telemetry, "OpenPanel", openpanel_factory)
262+
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
253263

254264
telemetry.track_error("FileNotFoundError", "No such file: /Users/john/notes/secret.md")
255265
_, props = created[0].events[-1]
@@ -259,7 +269,7 @@ def openpanel_factory(*, client_id, client_secret, disabled=False):
259269
telemetry.reset_client()
260270
created.clear()
261271

262-
monkeypatch.setattr(telemetry, "OpenPanel", openpanel_factory)
272+
monkeypatch.setattr("openpanel.OpenPanel", openpanel_factory)
263273
telemetry.track_error("FileNotFoundError", "Cannot open C:\\Users\\john\\docs\\private.txt")
264274
_, props = created[0].events[-1]
265275
assert "C:\\Users\\john" not in props["message"]

0 commit comments

Comments
 (0)