feat(deepseek-plugin,mem0-strands): add usage telemetry (#7110)

This commit is contained in:
Kartik
2026-08-27 13:48:30 +05:30
committed by GitHub
parent 39bc023305
commit 0070e08e01
13 changed files with 785 additions and 6 deletions
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "mem0-strands"
version = "0.1.0"
version = "0.1.1"
description = "Persistent long-term memory for Strands agents, backed by Mem0."
readme = "README.md"
requires-python = ">=3.10"
@@ -36,11 +36,13 @@ environment variable, or pass a Mem0 OSS ``config`` dict for a self-hosted backe
from __future__ import annotations
import asyncio
import time
from typing import Any
from strands.memory import AddMessagesContext, MemoryEntry, MemoryStore, SearchOptions
from strands.types.content import Message
from mem0_strands import telemetry
from mem0_strands.client import Mem0ServiceClient
DEFAULT_MAX_SEARCH_RESULTS = 5
@@ -134,6 +136,7 @@ class Mem0MemoryStore(MemoryStore):
self._host = host
self._config = config
self._client = client
self._recorded_init = False
@property
def client(self) -> Mem0ServiceClient:
@@ -144,8 +147,22 @@ class Mem0MemoryStore(MemoryStore):
stores), so first use is deferred and always happens inside a worker thread
via :func:`asyncio.to_thread`, never on the event loop.
"""
client_injected = self._client is not None
if self._client is None:
self._client = Mem0ServiceClient(api_key=self._api_key, host=self._host, config=self._config)
if not self._recorded_init:
self._recorded_init = True
telemetry.record(
"store.init",
self._client,
scopes=sorted(self.scope),
writable=self.writable,
extraction_enabled=bool(self.extraction),
has_default_metadata=bool(self.metadata),
max_search_results=self.max_search_results,
host_overridden=bool(self._host),
client_injected=client_injected,
)
return self._client
async def search(self, query: str, options: SearchOptions | None = None) -> list[MemoryEntry]:
@@ -158,7 +175,13 @@ class Mem0MemoryStore(MemoryStore):
# ``self.client`` is resolved inside the thread so lazy construction (a
# blocking call) does not run on the event loop.
memories = await asyncio.to_thread(lambda: self.client.search_memories(query, self.scope, top_k))
started = time.perf_counter()
try:
memories = await asyncio.to_thread(lambda: self.client.search_memories(query, self.scope, top_k))
except Exception as exc:
self._record("store.search", started, False, top_k=top_k, error_kind=telemetry.error_kind(exc))
raise
self._record("store.search", started, True, top_k=top_k, result_count=len(memories))
return [self._to_entry(memory) for memory in memories]
async def add(self, content: str, metadata: dict[str, Any] | None = None) -> Any:
@@ -168,7 +191,14 @@ class Mem0MemoryStore(MemoryStore):
Mem0 de-duplicates on the server.
"""
merged = self._merge_metadata(metadata)
return await asyncio.to_thread(lambda: self.client.store_memory(content, self.scope, merged))
started = time.perf_counter()
try:
result = await asyncio.to_thread(lambda: self.client.store_memory(content, self.scope, merged))
except Exception as exc:
self._record("store.add", started, False, error_kind=telemetry.error_kind(exc))
raise
self._record("store.add", started, True, content_chars=len(content), has_metadata=bool(merged))
return result
async def add_messages(self, messages: list[Message], context: AddMessagesContext | None = None) -> Any:
"""Ingest raw conversation turns for Mem0 server-side extraction (``infer=True``).
@@ -186,7 +216,33 @@ class Mem0MemoryStore(MemoryStore):
payload.append({"role": message["role"], "content": text})
if not payload:
return None
return await asyncio.to_thread(lambda: self.client.store_messages(payload, self.scope))
started = time.perf_counter()
try:
result = await asyncio.to_thread(lambda: self.client.store_messages(payload, self.scope))
except Exception as exc:
self._record("store.add_messages", started, False, error_kind=telemetry.error_kind(exc))
raise
self._record(
"store.add_messages",
started,
True,
message_count=len(messages),
rendered_count=len(payload),
total_chars=sum(len(turn["content"]) for turn in payload),
)
return result
def _record(self, event: str, started: float, success: bool, **properties: Any) -> None:
"""Send one store telemetry event, timed from ``started``."""
if self._client is None:
return
telemetry.record(
event,
self._client,
success=success,
duration_ms=round((time.perf_counter() - started) * 1000, 2),
**properties,
)
@staticmethod
def _render_content(content: Any) -> str:
@@ -0,0 +1,70 @@
"""Anonymous usage telemetry for the Strands memory store.
Events ride the Mem0 SDK's own PostHog client, so they need no extra dependency
and join the account's other Mem0 usage: on the hosted platform the distinct id
is the account email, and on a self-hosted OSS backend it is the machine-local
anonymous id the SDK already keeps. Unlike the SDK's OSS hot-path events these
are never sampled, because a store call is an agent-level action, not a loop.
Only names, counts, durations, and coarse failure kinds are sent: never queries,
memory text, message content, entity ids, metadata, or API keys.
Opt out with MEM0_TELEMETRY=false.
"""
from __future__ import annotations
from importlib.metadata import PackageNotFoundError, version
from typing import TYPE_CHECKING, Any
from mem0.memory.telemetry import client_telemetry
if TYPE_CHECKING:
from mem0_strands.client import Mem0ServiceClient
SOURCE = "STRANDS"
try:
_VERSION = version("mem0-strands")
except PackageNotFoundError: # pragma: no cover - only when running from a source tree
_VERSION = "0.0.0+unknown"
def error_kind(exc: BaseException) -> str:
"""Coarse, content-free label for a failure, safe to send."""
text = f"{type(exc).__name__}: {exc}".lower()
if "timeout" in text or "timed out" in text:
return "timeout"
if "401" in text or "403" in text or "unauthor" in text or "forbidden" in text:
return "auth"
if "429" in text or "rate limit" in text:
return "rate-limited"
if any(code in text for code in ("500", "502", "503", "504")):
return "server-error"
if "400" in text or "422" in text:
return "bad-request"
return type(exc).__name__
def _distinct_id(client: Mem0ServiceClient) -> str | None:
"""The account email to attribute events to, or None to fall back to the SDK's anonymous id."""
email = getattr(client.mem0, "user_email", None)
return email if isinstance(email, str) and email else None
def record(event: str, client: Mem0ServiceClient, **properties: Any) -> None:
"""Send one strands.* usage event. Never raises."""
try:
client_telemetry.capture_event(
f"strands.{event}",
{
"source": SOURCE,
"language": "python",
"strands_store_version": _VERSION,
"backend": "platform" if client.is_platform else "oss",
**properties,
},
_distinct_id(client),
)
except Exception:
pass
@@ -0,0 +1,10 @@
"""Keep the test suite from sending usage telemetry to the live PostHog project.
Set before ``mem0`` is imported: the SDK reads ``MEM0_TELEMETRY`` once, at import.
"""
from __future__ import annotations
import os
os.environ["MEM0_TELEMETRY"] = "false"
@@ -0,0 +1,172 @@
"""Tests for the store's anonymous usage telemetry."""
from unittest.mock import MagicMock, patch
import pytest
from mem0_strands import Mem0MemoryStore, telemetry
@pytest.fixture
def mock_client():
"""A mocked Mem0ServiceClient that looks like the hosted platform."""
client = MagicMock()
client.is_platform = True
client.mem0.user_email = "dev@example.com"
return client
@pytest.fixture
def captured():
"""Intercept the SDK PostHog client and collect (event, properties, distinct_id)."""
with patch.object(telemetry, "client_telemetry") as posthog:
events = []
posthog.capture_event.side_effect = lambda event, properties, distinct_id=None: events.append(
(event, properties, distinct_id)
)
yield events
def make_store(mock_client, **kwargs):
kwargs.setdefault("user_id", "alex")
return Mem0MemoryStore(client=mock_client, **kwargs)
# ---------------------------------------------------------------------------
# Event shape
# ---------------------------------------------------------------------------
def test_every_event_carries_source_and_backend(mock_client, captured):
assert make_store(mock_client).client is mock_client
event, properties, distinct_id = captured[0]
assert event == "strands.store.init"
assert properties["source"] == "STRANDS"
assert properties["language"] == "python"
assert properties["backend"] == "platform"
assert properties["strands_store_version"]
assert distinct_id == "dev@example.com"
def test_oss_backend_is_labelled_and_falls_back_to_the_sdk_anonymous_id(captured):
client = MagicMock()
client.is_platform = False
del client.mem0.user_email
assert make_store(client).client is client
_, properties, distinct_id = captured[0]
assert properties["backend"] == "oss"
assert distinct_id is None
def test_init_is_recorded_once_per_store(mock_client, captured):
store = make_store(mock_client)
assert store.client is store.client
assert [event for event, _, _ in captured] == ["strands.store.init"]
def test_init_describes_configuration_without_scope_values(mock_client, captured):
store = make_store(mock_client, agent_id="assistant", writable=False, extraction=True, metadata={"team": "core"})
assert store.client is mock_client
_, properties, _ = captured[0]
assert properties["scopes"] == ["agent_id", "user_id"]
assert properties["writable"] is False
assert properties["extraction_enabled"] is True
assert properties["has_default_metadata"] is True
assert "alex" not in str(properties)
assert "assistant" not in str(properties)
assert "core" not in str(properties)
# ---------------------------------------------------------------------------
# Per-operation events
# ---------------------------------------------------------------------------
async def test_search_records_counts_but_never_the_query(mock_client, captured):
mock_client.search_memories.return_value = [{"id": "m1", "memory": "Likes tea"}]
await make_store(mock_client).search("what does alex drink")
event, properties, _ = captured[-1]
assert event == "strands.store.search"
assert properties["success"] is True
assert properties["result_count"] == 1
assert properties["top_k"] == 5
assert properties["duration_ms"] >= 0
assert "drink" not in str(properties)
assert "tea" not in str(properties)
async def test_add_records_size_but_never_the_content(mock_client, captured):
await make_store(mock_client).add("Alex prefers dark roast", metadata={"team": "core"})
event, properties, _ = captured[-1]
assert event == "strands.store.add"
assert properties["success"] is True
assert properties["content_chars"] == len("Alex prefers dark roast")
assert properties["has_metadata"] is True
assert "roast" not in str(properties)
async def test_add_messages_records_turn_counts_but_never_the_turns(mock_client, captured):
messages = [
{"role": "user", "content": [{"text": "hello"}]},
{"role": "assistant", "content": [{"text": "hi there"}]},
]
await make_store(mock_client).add_messages(messages)
event, properties, _ = captured[-1]
assert event == "strands.store.add_messages"
assert properties["message_count"] == 2
assert properties["rendered_count"] == 2
assert properties["total_chars"] == len("hello") + len("hi there")
assert "hello" not in str(properties)
async def test_empty_add_messages_records_nothing(mock_client, captured):
assert await make_store(mock_client).add_messages([]) is None
assert [event for event, _, _ in captured] == []
# ---------------------------------------------------------------------------
# Failures
# ---------------------------------------------------------------------------
async def test_a_failed_search_records_a_coarse_error_kind_and_still_raises(mock_client, captured):
mock_client.search_memories.side_effect = RuntimeError("HTTP 429 rate limit exceeded for key sk-secret")
with pytest.raises(RuntimeError):
await make_store(mock_client).search("anything")
event, properties, _ = captured[-1]
assert event == "strands.store.search"
assert properties["success"] is False
assert properties["error_kind"] == "rate-limited"
assert "sk-secret" not in str(properties)
@pytest.mark.parametrize(
("message", "expected"),
[
("request timed out", "timeout"),
("401 Unauthorized", "auth"),
("429 Too Many Requests", "rate-limited"),
("503 Service Unavailable", "server-error"),
("422 Unprocessable Entity", "bad-request"),
("something else entirely", "ValueError"),
],
)
def test_error_kind_buckets_failures(message, expected):
assert telemetry.error_kind(ValueError(message)) == expected
def test_a_broken_telemetry_backend_never_breaks_the_store(mock_client):
with patch.object(telemetry, "client_telemetry") as posthog:
posthog.capture_event.side_effect = RuntimeError("posthog is down")
assert make_store(mock_client).client is mock_client