555e282cc4
ci / changelog_check (push) Waiting to run
ci / check_changes (push) Waiting to run
ci / build_mem0 (3.10) (push) Blocked by required conditions
ci / build_mem0 (3.11) (push) Blocked by required conditions
ci / build_mem0 (3.12) (push) Blocked by required conditions
CLI Node CI / lint (push) Waiting to run
CLI Node CI / test (20) (push) Waiting to run
CLI Node CI / test (22) (push) Waiting to run
CLI Node CI / build (push) Waiting to run
CLI Python CI / lint (push) Waiting to run
CLI Python CI / test (3.10) (push) Waiting to run
CLI Python CI / test (3.11) (push) Waiting to run
CLI Python CI / test (3.12) (push) Waiting to run
CLI Python CI / build (push) Waiting to run
openclaw checks / lint (push) Waiting to run
openclaw checks / test (20) (push) Waiting to run
openclaw checks / test (22) (push) Waiting to run
openclaw checks / build (push) Waiting to run
opencode-plugin checks / build (push) Waiting to run
pi-agent-plugin checks / lint (push) Waiting to run
pi-agent-plugin checks / test (20) (push) Waiting to run
pi-agent-plugin checks / test (22) (push) Waiting to run
pi-agent-plugin checks / build (push) Waiting to run
TypeScript SDK CI / check_changes (push) Waiting to run
TypeScript SDK CI / changelog_check (push) Blocked by required conditions
TypeScript SDK CI / build_ts_sdk (20) (push) Blocked by required conditions
TypeScript SDK CI / build_ts_sdk (22) (push) Blocked by required conditions
TypeScript SDK CI / integration_ts_sdk (20) (push) Blocked by required conditions
TypeScript SDK CI / integration_ts_sdk (22) (push) Blocked by required conditions
442 lines
21 KiB
Python
442 lines
21 KiB
Python
import threading
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
import mem0.memory.telemetry as telemetry_module
|
|
|
|
|
|
class TestTelemetryDisabled:
|
|
"""Verify PostHog is never instantiated when telemetry is disabled."""
|
|
|
|
def test_posthog_not_created_when_disabled(self):
|
|
"""Posthog() constructor should never be called when MEM0_TELEMETRY=False."""
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
|
|
with patch("mem0.memory.telemetry.Posthog") as mock_posthog:
|
|
at = telemetry_module.AnonymousTelemetry()
|
|
mock_posthog.assert_not_called()
|
|
assert at.posthog is None
|
|
assert at.user_id is None
|
|
|
|
def test_capture_event_noop_when_disabled(self):
|
|
"""capture_event() should return immediately without touching the singleton."""
|
|
saved = telemetry_module._oss_telemetry_instance
|
|
try:
|
|
telemetry_module._oss_telemetry_instance = None
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
|
|
telemetry_module.capture_event("test.event", MagicMock())
|
|
# Singleton must not have been initialised
|
|
assert telemetry_module._oss_telemetry_instance is None
|
|
finally:
|
|
telemetry_module._oss_telemetry_instance = saved
|
|
|
|
def test_capture_client_event_noop_when_disabled(self):
|
|
"""capture_client_event() should return immediately without calling posthog."""
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
|
|
mock_instance = MagicMock()
|
|
mock_client_telemetry = MagicMock()
|
|
with patch.object(telemetry_module, "client_telemetry", mock_client_telemetry):
|
|
telemetry_module.capture_client_event("test.event", mock_instance)
|
|
mock_client_telemetry.capture_event.assert_not_called()
|
|
|
|
def test_instance_capture_event_noop_when_posthog_is_none(self):
|
|
"""AnonymousTelemetry.capture_event() should be a no-op when posthog is None."""
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
|
|
at = telemetry_module.AnonymousTelemetry()
|
|
at.capture_event("test.event", {"key": "value"}) # should not raise
|
|
|
|
def test_close_noop_when_posthog_is_none(self):
|
|
"""close() should not raise when posthog is None."""
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
|
|
at = telemetry_module.AnonymousTelemetry()
|
|
at.close() # should not raise
|
|
|
|
def test_no_threads_spawned_when_disabled(self):
|
|
"""No consumer threads should be created when telemetry is disabled."""
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
|
|
threads_before = threading.active_count()
|
|
telemetry_module.AnonymousTelemetry()
|
|
threads_after = threading.active_count()
|
|
assert threads_after == threads_before
|
|
|
|
|
|
class TestTelemetryEnabled:
|
|
"""Verify PostHog works normally when telemetry is enabled."""
|
|
|
|
def test_posthog_created_when_enabled(self):
|
|
"""Posthog() should be instantiated when MEM0_TELEMETRY=True."""
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
|
with patch("mem0.memory.telemetry.Posthog") as mock_posthog:
|
|
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="test-user"):
|
|
at = telemetry_module.AnonymousTelemetry()
|
|
mock_posthog.assert_called_once()
|
|
assert (
|
|
mock_posthog.call_args.kwargs["feature_flags_request_timeout_seconds"]
|
|
== telemetry_module.FEATURE_FLAGS_REQUEST_TIMEOUT_SECONDS
|
|
)
|
|
assert at.posthog is not None
|
|
assert at.user_id == "test-user"
|
|
|
|
def test_capture_event_sends_when_enabled(self):
|
|
"""capture_event() should use the singleton and call capture when enabled."""
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
|
mock_at = MagicMock()
|
|
with patch.object(telemetry_module, "_oss_telemetry_instance", mock_at):
|
|
mock_memory = MagicMock()
|
|
mock_memory.config.graph_store.config = None
|
|
mock_memory.api_version = "v1"
|
|
telemetry_module.capture_event("test.event", mock_memory)
|
|
mock_at.capture_event.assert_called_once()
|
|
|
|
def test_anonymous_capture_event_passes_flags_to_posthog(self):
|
|
"""capture_event() should use PostHog's event-first API and preserve flag snapshots."""
|
|
flags = MagicMock()
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
|
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
|
|
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="test-user"):
|
|
at = telemetry_module.AnonymousTelemetry()
|
|
at.capture_event("test.event", {"key": "value"}, flags=flags)
|
|
|
|
mock_posthog_cls.return_value.capture.assert_called_once()
|
|
args, kwargs = mock_posthog_cls.return_value.capture.call_args
|
|
assert args == ("test.event",)
|
|
assert kwargs["distinct_id"] == "test-user"
|
|
assert kwargs["flags"] is flags
|
|
assert kwargs["properties"]["key"] == "value"
|
|
|
|
def test_capture_client_event_sends_when_enabled(self):
|
|
"""capture_client_event() should call client_telemetry.capture_event when enabled."""
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
|
mock_client_telemetry = MagicMock()
|
|
with patch.object(telemetry_module, "client_telemetry", mock_client_telemetry):
|
|
mock_instance = MagicMock()
|
|
mock_instance.user_email = "test@example.com"
|
|
telemetry_module.capture_client_event("test.event", mock_instance)
|
|
mock_client_telemetry.capture_event.assert_called_once()
|
|
|
|
|
|
class TestAnonymousTelemetryClose:
|
|
"""Verify AnonymousTelemetry.close() edge cases."""
|
|
|
|
def test_close_calls_posthog_shutdown(self):
|
|
"""close() should call posthog.shutdown()."""
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
|
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
|
|
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
|
|
at = telemetry_module.AnonymousTelemetry()
|
|
mock_ph = mock_posthog_cls.return_value
|
|
at.close()
|
|
mock_ph.shutdown.assert_called_once()
|
|
|
|
def test_close_sets_posthog_to_none(self):
|
|
"""close() should set posthog to None to prevent double-shutdown."""
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
|
with patch("mem0.memory.telemetry.Posthog"):
|
|
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
|
|
at = telemetry_module.AnonymousTelemetry()
|
|
at.close()
|
|
assert at.posthog is None
|
|
|
|
def test_double_close_is_safe(self):
|
|
"""Calling close() twice should not raise."""
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
|
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
|
|
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
|
|
at = telemetry_module.AnonymousTelemetry()
|
|
mock_ph = mock_posthog_cls.return_value
|
|
at.close()
|
|
at.close() # should not raise
|
|
# shutdown only called once because posthog was set to None after first close
|
|
mock_ph.shutdown.assert_called_once()
|
|
|
|
def test_capture_after_close_is_noop(self):
|
|
"""capture_event() should be a no-op after close() (posthog is None)."""
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
|
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
|
|
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
|
|
at = telemetry_module.AnonymousTelemetry()
|
|
mock_ph = mock_posthog_cls.return_value
|
|
at.close()
|
|
mock_ph.reset_mock()
|
|
at.capture_event("test.event", {"key": "value"})
|
|
mock_ph.capture.assert_not_called()
|
|
|
|
|
|
class TestTelemetrySingleton:
|
|
"""Verify the OSS telemetry singleton behaviour."""
|
|
|
|
def setup_method(self):
|
|
# Reset singleton state before each test
|
|
telemetry_module._oss_telemetry_instance = None
|
|
telemetry_module._oss_telemetry_shutting_down = False
|
|
|
|
def teardown_method(self):
|
|
telemetry_module._oss_telemetry_instance = None
|
|
telemetry_module._oss_telemetry_shutting_down = False
|
|
|
|
def test_singleton_reuses_instance(self):
|
|
"""_get_oss_telemetry() should return the same instance on repeated calls."""
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
|
with patch("mem0.memory.telemetry.Posthog"):
|
|
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
|
|
with patch("atexit.register"):
|
|
first = telemetry_module._get_oss_telemetry()
|
|
second = telemetry_module._get_oss_telemetry()
|
|
assert first is second
|
|
|
|
def test_singleton_created_only_once_across_threads(self):
|
|
"""Only one AnonymousTelemetry should be created even under concurrent access."""
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
|
with patch("mem0.memory.telemetry.Posthog") as mock_posthog:
|
|
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
|
|
with patch("atexit.register"):
|
|
instances = []
|
|
|
|
def grab():
|
|
instances.append(telemetry_module._get_oss_telemetry())
|
|
|
|
threads = [threading.Thread(target=grab) for _ in range(20)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
|
|
assert len(set(id(i) for i in instances)) == 1
|
|
assert mock_posthog.call_count == 1
|
|
|
|
def test_atexit_registered_once(self):
|
|
"""atexit.register should be called exactly once for the singleton."""
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
|
with patch("mem0.memory.telemetry.Posthog"):
|
|
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
|
|
with patch("atexit.register") as mock_atexit:
|
|
telemetry_module._get_oss_telemetry()
|
|
telemetry_module._get_oss_telemetry()
|
|
telemetry_module._get_oss_telemetry()
|
|
# Only one atexit registration despite multiple calls
|
|
mock_atexit.assert_called_once_with(telemetry_module._shutdown_oss_telemetry)
|
|
|
|
def test_capture_event_does_not_create_new_instance_each_call(self):
|
|
"""capture_event() should not create a new AnonymousTelemetry per call."""
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
|
with patch("mem0.memory.telemetry.Posthog"):
|
|
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
|
|
with patch("atexit.register"):
|
|
mock_memory = MagicMock()
|
|
mock_memory.config.graph_store.config = None
|
|
mock_memory.api_version = "v1"
|
|
|
|
telemetry_module.capture_event("e1", mock_memory)
|
|
first = telemetry_module._oss_telemetry_instance
|
|
|
|
telemetry_module.capture_event("e2", mock_memory)
|
|
second = telemetry_module._oss_telemetry_instance
|
|
|
|
assert first is second
|
|
|
|
def test_posthog_constructed_once_across_many_capture_event_calls(self):
|
|
"""The core leak fix: Posthog() should only be called once no matter how
|
|
many times capture_event() is invoked."""
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
|
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
|
|
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
|
|
with patch("atexit.register"):
|
|
mock_memory = MagicMock()
|
|
mock_memory.config.graph_store.config = None
|
|
mock_memory.api_version = "v1"
|
|
|
|
for i in range(50):
|
|
telemetry_module.capture_event(f"event_{i}", mock_memory)
|
|
|
|
# Only ONE Posthog client created, not 50
|
|
assert mock_posthog_cls.call_count == 1
|
|
|
|
def test_shutdown_clears_singleton(self):
|
|
"""_shutdown_oss_telemetry() should close and clear the singleton."""
|
|
mock_at = MagicMock()
|
|
telemetry_module._oss_telemetry_instance = mock_at
|
|
|
|
telemetry_module._shutdown_oss_telemetry()
|
|
|
|
mock_at.close.assert_called_once()
|
|
assert telemetry_module._oss_telemetry_instance is None
|
|
|
|
def test_shutdown_idempotent(self):
|
|
"""Calling _shutdown_oss_telemetry() twice should not raise."""
|
|
mock_at = MagicMock()
|
|
telemetry_module._oss_telemetry_instance = mock_at
|
|
|
|
telemetry_module._shutdown_oss_telemetry()
|
|
telemetry_module._shutdown_oss_telemetry() # should not raise
|
|
|
|
mock_at.close.assert_called_once()
|
|
|
|
def test_shutdown_noop_when_no_instance(self):
|
|
"""_shutdown_oss_telemetry() should be a no-op when singleton was never created."""
|
|
assert telemetry_module._oss_telemetry_instance is None
|
|
telemetry_module._shutdown_oss_telemetry() # should not raise
|
|
|
|
def test_capture_event_noop_when_disabled_with_singleton(self):
|
|
"""capture_event() should not initialise the singleton when telemetry is off."""
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
|
|
telemetry_module.capture_event("test.event", MagicMock())
|
|
assert telemetry_module._oss_telemetry_instance is None
|
|
|
|
def test_no_new_instance_after_shutdown(self):
|
|
"""After _shutdown_oss_telemetry(), _get_oss_telemetry() should not create a new instance."""
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
|
with patch("mem0.memory.telemetry.Posthog"):
|
|
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
|
|
with patch("atexit.register"):
|
|
# Create and then shut down the singleton
|
|
telemetry_module._get_oss_telemetry()
|
|
telemetry_module._shutdown_oss_telemetry()
|
|
|
|
# After shutdown, getting telemetry should return None, not a new instance
|
|
result = telemetry_module._get_oss_telemetry()
|
|
assert result is None
|
|
assert telemetry_module._oss_telemetry_instance is None
|
|
|
|
|
|
class TestMemoryLifecycle:
|
|
"""Verify Memory.close() and context manager support."""
|
|
|
|
def _make_mock_memory(self):
|
|
"""Create a Memory-like object with a mock db for testing close()."""
|
|
from mem0.memory.main import Memory
|
|
|
|
with patch.object(Memory, "__init__", lambda self: None):
|
|
m = Memory.__new__(Memory)
|
|
m.db = MagicMock()
|
|
return m
|
|
|
|
def test_double_close_is_safe(self):
|
|
"""close() should be safe to call twice (SQLiteManager.close sets connection=None)."""
|
|
m = self._make_mock_memory()
|
|
m.close()
|
|
# After first close, db is still the mock but SQLiteManager.close() handles
|
|
# the None-connection case internally. Simulate that by making db.close a no-op.
|
|
m.close() # should not raise
|
|
|
|
def test_close_when_db_is_none(self):
|
|
"""close() should not raise if db was already None."""
|
|
m = self._make_mock_memory()
|
|
m.db = None
|
|
m.close() # should not raise
|
|
|
|
def test_close_when_db_not_set(self):
|
|
"""close() should not raise if __init__ failed before setting db."""
|
|
from mem0.memory.main import Memory
|
|
|
|
with patch.object(Memory, "__init__", lambda self: None):
|
|
m = Memory.__new__(Memory)
|
|
# db attribute not set at all
|
|
m.close() # should not raise due to hasattr guard
|
|
|
|
|
|
class TestAsyncMemoryLifecycle:
|
|
"""Verify AsyncMemory.close() and async context manager support."""
|
|
|
|
def _make_mock_async_memory(self):
|
|
from mem0.memory.main import AsyncMemory
|
|
|
|
with patch.object(AsyncMemory, "__init__", lambda self: None):
|
|
m = AsyncMemory.__new__(AsyncMemory)
|
|
m.db = MagicMock()
|
|
return m
|
|
|
|
def test_close_when_db_is_none(self):
|
|
m = self._make_mock_async_memory()
|
|
m.db = None
|
|
m.close() # should not raise
|
|
|
|
def test_close_when_db_not_set(self):
|
|
from mem0.memory.main import AsyncMemory
|
|
|
|
with patch.object(AsyncMemory, "__init__", lambda self: None):
|
|
m = AsyncMemory.__new__(AsyncMemory)
|
|
m.close() # should not raise
|
|
|
|
|
|
class TestTelemetryEnvVar:
|
|
"""Verify the MEM0_TELEMETRY env var parsing logic."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"value,expected",
|
|
[
|
|
("true", True),
|
|
("True", True),
|
|
("TRUE", True),
|
|
("1", True),
|
|
("yes", True),
|
|
("false", False),
|
|
("False", False),
|
|
("0", False),
|
|
("no", False),
|
|
("anything_else", False),
|
|
],
|
|
)
|
|
def test_env_var_parsing(self, value, expected):
|
|
result = value.lower() in ("true", "1", "yes")
|
|
assert result == expected
|
|
|
|
|
|
class TestTelemetryNullUserIdHandling:
|
|
"""Verify telemetry doesn't crash when user_id is None.
|
|
|
|
This is a regression test for the bug where Memory.from_config() crashed
|
|
with AssertionError because distinct_id was None in PostHog.capture().
|
|
"""
|
|
|
|
def test_capture_event_skips_when_user_id_is_none(self):
|
|
"""AnonymousTelemetry.capture_event should not crash when user_id is None."""
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
|
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
|
|
at = telemetry_module.AnonymousTelemetry()
|
|
at.user_id = None # Simulate the bug condition
|
|
|
|
# This should not raise, even with user_id=None
|
|
at.capture_event("test.event", {"key": "value"})
|
|
|
|
# PostHog.capture should NOT have been called since user_id is None
|
|
mock_posthog_cls.return_value.capture.assert_not_called()
|
|
|
|
def test_capture_event_does_not_crash_on_posthog_error(self):
|
|
"""AnonymousTelemetry.capture_event should catch PostHog exceptions."""
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
|
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
|
|
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="test-user"):
|
|
at = telemetry_module.AnonymousTelemetry()
|
|
mock_posthog_cls.return_value.capture.side_effect = Exception("PostHog error")
|
|
|
|
# This should not raise, even when PostHog.capture fails
|
|
at.capture_event("test.event", {"key": "value"})
|
|
|
|
def test_oss_capture_event_does_not_crash_memory_init(self):
|
|
"""capture_event() should never raise, even if everything inside fails."""
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
|
# Make _get_oss_telemetry return a broken telemetry object
|
|
mock_at = MagicMock()
|
|
mock_at.capture_event.side_effect = Exception("Telemetry is broken")
|
|
|
|
with patch.object(telemetry_module, "_oss_telemetry_instance", mock_at):
|
|
mock_memory = MagicMock()
|
|
mock_memory.config.graph_store.config = None
|
|
mock_memory.api_version = "v1"
|
|
|
|
# This should not raise, even when telemetry fails
|
|
telemetry_module.capture_event("mem0.init", mock_memory)
|
|
|
|
def test_client_capture_event_does_not_crash(self):
|
|
"""capture_client_event() should never raise exceptions."""
|
|
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
|
|
mock_client_telemetry = MagicMock()
|
|
mock_client_telemetry.capture_event.side_effect = Exception("Client telemetry broken")
|
|
|
|
with patch.object(telemetry_module, "client_telemetry", mock_client_telemetry):
|
|
mock_instance = MagicMock()
|
|
mock_instance.user_email = "test@example.com"
|
|
|
|
# This should not raise
|
|
telemetry_module.capture_client_event("test.event", mock_instance)
|