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
+14
View File
@@ -2841,6 +2841,13 @@ Existing memories written by the previous versions are not rewritten. If your me
<Tab title="Strands">
<Update label="2026-08-25" description="mem0-strands v0.1.1">
**New Features:**
- **Usage telemetry:** Anonymous usage events (`strands.store.init`, `strands.store.search`, `strands.store.add`, `strands.store.add_messages`) ride the Mem0 SDK's existing PostHog client, unsampled, with no new dependency. Events carry only counts, durations, booleans, and coarse failure kinds: never queries, memory text, message content, entity ids, metadata, or API keys. Opt out with `MEM0_TELEMETRY=false` ([#7110](https://github.com/mem0ai/mem0/pull/7110))
</Update>
<Update label="2026-08-24" description="mem0-strands v0.1.0">
**Initial release** of [`mem0-strands`](https://pypi.org/project/mem0-strands/), a native `MemoryStore` that plugs Mem0 into the [Strands Agents](https://strandsagents.com/) `MemoryManager` ([#7021](https://github.com/mem0ai/mem0/pull/7021))
@@ -2863,6 +2870,13 @@ Existing memories written by the previous versions are not rewritten. If your me
<Tab title="DeepSeek Harness">
<Update label="2026-08-25" description="deepseek-plugin v0.1.1">
**New Features:**
- **Usage telemetry:** Anonymous usage events (`deepseek.plugin.mounted`, `deepseek.tool.search_memory`, `deepseek.tool.add_memory`) are batched to PostHog over native fetch and flushed in the background. Events carry only tool names, durations, counts, and coarse failure kinds: never queries, memory text, filters, or API keys. Opt out with `MEM0_TELEMETRY=false` ([#7110](https://github.com/mem0ai/mem0/pull/7110))
</Update>
<Update label="2026-08-24" description="deepseek-plugin v0.1.0">
**Initial release** of [`deepseek-plugin`](https://www.npmjs.com/package/@mem0/deepseek-plugin), a native DeepSeek Harness (Cordis) plugin that registers Mem0 as two agent-callable tools ([#7027](https://github.com/mem0ai/mem0/pull/7027))
+2
View File
@@ -55,6 +55,8 @@ For a Mem0 Platform on-prem or dedicated deployment, point `config.host` at that
Writes are tagged `source="DEEPSEEK_HARNESS"` so Mem0's backend can attribute usage to this integration. For it to surface by name (rather than bucketing into `OTHERS`), `DEEPSEEK_HARNESS` must be present in the backend's `KNOWN_EVENT_SOURCES` allowlist, a one-line platform change matching the existing `ZAPIER` / `STRANDS` sources.
The plugin also sends anonymous usage events (which tool ran, duration, result counts, coarse failure kind) so Mem0 can tell how the plugin is used and where it breaks. Queries, memory text, and entity ids are never sent. Turn it off with `MEM0_TELEMETRY=false`.
## Status
Developer preview. Tracks the DeepSeek Harness v0.1 plugin API (`@deepseek-ai/cordis`, `@deepseek-ai/dsh-tools`), which is young and moving; pin versions once it stabilizes. Auto-capture (store turns without an explicit tool call) and auto-recall (inject memory into the prompt at assembly) are planned once the harness session/assembly event API is confirmed.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@mem0/deepseek-plugin",
"version": "0.1.0",
"version": "0.1.1",
"description": "Mem0 long-term memory as a native DeepSeek Harness (Cordis) plugin.",
"type": "module",
"license": "Apache-2.0",
+53 -1
View File
@@ -16,6 +16,7 @@ import { MemoryClient } from "mem0ai";
import { formatMemoryList, formatAddResult } from "./formatting.ts";
import { truncateOutput } from "./output.ts";
import { resolveSearchFilters, resolveAddParams } from "./scoping.ts";
import { captureEvent, errorKind } from "./telemetry.ts";
export const name = "mem0";
export const inject = ["tools"];
@@ -81,6 +82,8 @@ export function apply(ctx: Context, config: Config): void {
...(config.host ? { host: config.host } : {}),
});
captureEvent("deepseek.plugin.mounted", { has_host: Boolean(config.host) }, client);
// Recall. The platform rejects top-level entity params on search, so scope
// goes inside `filters` (unlike add below, which takes them top-level).
ctx.tools.register(
@@ -99,11 +102,36 @@ export function apply(ctx: Context, config: Config): void {
output: textOutput,
async execute({ query, limit, userId: u, agentId, runId }) {
const filters = resolveSearchFilters({ userId: u, agentId, runId }, userId);
const topK = limit && limit > 0 ? limit : DEFAULT_SEARCH_LIMIT;
const started = Date.now();
try {
const topK = limit && limit > 0 ? limit : DEFAULT_SEARCH_LIMIT;
const { results } = await client.search(query, { filters, topK });
captureEvent(
"deepseek.tool.search_memory",
{
success: true,
duration_ms: Date.now() - started,
top_k: topK,
result_count: results?.length ?? 0,
query_chars: query.length,
scope_overridden: Boolean(u && u !== userId),
has_agent_id: Boolean(agentId),
has_run_id: Boolean(runId),
},
client,
);
return truncateOutput(formatMemoryList(results ?? []));
} catch (err) {
captureEvent(
"deepseek.tool.search_memory",
{
success: false,
duration_ms: Date.now() - started,
top_k: topK,
error_kind: errorKind(err),
},
client,
);
return `search_memory failed: ${err instanceof Error ? err.message : String(err)}`;
}
},
@@ -123,13 +151,37 @@ export function apply(ctx: Context, config: Config): void {
output: textOutput,
async execute({ text, userId: u, agentId, runId }) {
const addParams = resolveAddParams({ userId: u, agentId, runId }, userId);
const started = Date.now();
try {
const result = await client.add([{ role: "user", content: text }], {
...addParams,
source: SOURCE,
});
captureEvent(
"deepseek.tool.add_memory",
{
success: true,
duration_ms: Date.now() - started,
text_chars: text.length,
memory_count: Array.isArray(result) ? result.length : 0,
scope_overridden: Boolean(u && u !== userId),
has_agent_id: Boolean(agentId),
has_run_id: Boolean(runId),
},
client,
);
return truncateOutput(formatAddResult(result));
} catch (err) {
captureEvent(
"deepseek.tool.add_memory",
{
success: false,
duration_ms: Date.now() - started,
text_chars: text.length,
error_kind: errorKind(err),
},
client,
);
return `add_memory failed: ${err instanceof Error ? err.message : String(err)}`;
}
},
@@ -0,0 +1,173 @@
/**
* Anonymous usage telemetry for the DeepSeek Harness plugin.
*
* Fire-and-forget PostHog events over native fetch, batched and flushed every
* 5 seconds, at 10 queued events, or on process exit. Never throws, never logs.
*
* Events carry only tool names, durations, counts, and coarse failure kinds:
* never queries, memory text, filters, or API keys.
*
* Disable with MEM0_TELEMETRY=false.
*/
import { randomUUID } from "node:crypto";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX";
const POSTHOG_BATCH_URL = "https://us.i.posthog.com/batch/";
const FLUSH_INTERVAL_MS = 5_000;
const FLUSH_THRESHOLD = 10;
const SEND_TIMEOUT_MS = 3_000;
const PLUGIN_VERSION = ((): string => {
try {
return JSON.parse(
fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8"),
).version;
} catch {
return "unknown";
}
})();
/** The Mem0 SDK resolves this to the account email once ping() lands, so events join other Mem0 surfaces. */
export interface TelemetryIdentity {
telemetryId?: string;
}
let queue: Record<string, unknown>[] = [];
let flushTimer: ReturnType<typeof setInterval> | undefined;
let exitHandlerInstalled = false;
let cachedAnonymousId: string | undefined;
let identified = false;
function identityPath(): string {
return path.join(os.homedir(), ".mem0", "deepseek-plugin-telemetry.json");
}
export function isTelemetryEnabled(): boolean {
const value = process.env.MEM0_TELEMETRY?.toLowerCase();
return value !== "false" && value !== "0" && value !== "no" && value !== "off";
}
function anonymousId(): string {
if (cachedAnonymousId) return cachedAnonymousId;
try {
const stored = JSON.parse(fs.readFileSync(identityPath(), "utf-8"));
if (typeof stored.anonymousId === "string" && stored.anonymousId) {
cachedAnonymousId = stored.anonymousId;
return cachedAnonymousId!;
}
} catch {
/* first run, or an unreadable identity file */
}
const created = `deepseek-anon-${randomUUID().replace(/-/g, "")}`;
try {
const target = identityPath();
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, JSON.stringify({ anonymousId: created }), "utf-8");
} catch {
/* an unwritable home directory must not break the plugin */
}
cachedAnonymousId = created;
return created;
}
/** Merge the pre-ping anonymous history into the account once the email resolves. */
function identifyEvent(distinctId: string): Record<string, unknown> | undefined {
if (identified || distinctId.startsWith("deepseek-anon-")) return undefined;
identified = true;
let storedAnonymousId: string | undefined;
try {
storedAnonymousId = JSON.parse(fs.readFileSync(identityPath(), "utf-8")).anonymousId;
fs.unlinkSync(identityPath());
} catch {
return undefined;
}
cachedAnonymousId = undefined;
if (!storedAnonymousId) return undefined;
return {
event: "$identify",
distinct_id: distinctId,
properties: { $anon_distinct_id: storedAnonymousId, $lib: "posthog-node" },
};
}
export function flushEvents(): void {
if (queue.length === 0) return;
const batch = queue;
queue = [];
fetch(POSTHOG_BATCH_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }),
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
}).catch(() => {
/* telemetry never surfaces its own failures */
});
}
function scheduleFlush(): void {
if (!flushTimer) {
flushTimer = setInterval(flushEvents, FLUSH_INTERVAL_MS);
flushTimer.unref?.();
}
if (!exitHandlerInstalled) {
exitHandlerInstalled = true;
process.on("beforeExit", flushEvents);
}
}
export function errorKind(error: unknown): string {
const text = (error instanceof Error ? error.message : String(error)).toLowerCase();
if (text.includes("timeout") || text.includes("aborted")) return "timeout";
if (text.includes("401") || text.includes("403") || text.includes("unauthor")) return "auth";
if (text.includes("429") || text.includes("rate limit")) return "rate-limited";
if (/50[0234]/.test(text)) return "server-error";
if (text.includes("400") || text.includes("422")) return "bad-request";
if (text.includes("fetch failed") || text.includes("enotfound")) return "network";
return error instanceof Error ? error.constructor.name : "other";
}
export function captureEvent(
event: string,
properties: Record<string, unknown>,
client: TelemetryIdentity,
): void {
if (!isTelemetryEnabled()) return;
try {
const distinctId = client.telemetryId || anonymousId();
const identify = identifyEvent(distinctId);
if (identify) queue.push(identify);
queue.push({
event,
distinct_id: distinctId,
properties: {
source: "DEEPSEEK_HARNESS",
language: "node",
plugin_version: PLUGIN_VERSION,
node_version: process.version,
os: process.platform,
$process_person_profile: false,
$lib: "posthog-node",
...properties,
},
});
scheduleFlush();
if (queue.length >= FLUSH_THRESHOLD) flushEvents();
} catch {
/* telemetry never breaks a tool call */
}
}
export function _queueForTesting(): Record<string, unknown>[] {
return queue;
}
export function _resetForTesting(): void {
queue = [];
clearInterval(flushTimer);
flushTimer = undefined;
cachedAnonymousId = undefined;
identified = false;
}
@@ -35,9 +35,12 @@ function applyAndCollect(config: Config): Map<string, RegisteredTool> {
}
let savedKey: string | undefined;
let savedTelemetry: string | undefined;
beforeEach(() => {
savedKey = process.env.MEM0_API_KEY;
savedTelemetry = process.env.MEM0_TELEMETRY;
process.env.MEM0_TELEMETRY = "false";
mockSearch.mockReset();
mockAdd.mockReset();
});
@@ -45,6 +48,8 @@ beforeEach(() => {
afterEach(() => {
if (savedKey === undefined) delete process.env.MEM0_API_KEY;
else process.env.MEM0_API_KEY = savedKey;
if (savedTelemetry === undefined) delete process.env.MEM0_TELEMETRY;
else process.env.MEM0_TELEMETRY = savedTelemetry;
});
describe("apply() config validation", () => {
@@ -0,0 +1,218 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
const mockSearch = vi.fn();
const mockAdd = vi.fn();
vi.mock("mem0ai", () => ({
MemoryClient: class {
telemetryId = "dev@example.com";
search = mockSearch;
add = mockAdd;
},
}));
vi.mock("@deepseek-ai/dsh-tools", () => ({ defineTool: (options: unknown) => options }));
import { apply, type Config } from "../src/index.ts";
import {
captureEvent,
errorKind,
flushEvents,
isTelemetryEnabled,
_queueForTesting,
_resetForTesting,
} from "../src/telemetry.ts";
interface RegisteredTool {
name: string;
execute(args: unknown, exec: unknown): Promise<unknown>;
}
function applyAndCollect(config: Config): Map<string, RegisteredTool> {
const tools = new Map<string, RegisteredTool>();
apply({ tools: { register: (t: RegisteredTool) => tools.set(t.name, t) } } as never, config);
return tools;
}
function queued(): Record<string, any>[] {
return _queueForTesting() as Record<string, any>[];
}
let home: string;
let savedHome: string | undefined;
let savedTelemetry: string | undefined;
beforeEach(() => {
savedHome = process.env.HOME;
savedTelemetry = process.env.MEM0_TELEMETRY;
delete process.env.MEM0_TELEMETRY;
home = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-deepseek-"));
process.env.HOME = home;
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true }));
mockSearch.mockReset();
mockAdd.mockReset();
_resetForTesting();
});
afterEach(() => {
_resetForTesting();
vi.unstubAllGlobals();
fs.rmSync(home, { recursive: true, force: true });
if (savedHome === undefined) delete process.env.HOME;
else process.env.HOME = savedHome;
if (savedTelemetry === undefined) delete process.env.MEM0_TELEMETRY;
else process.env.MEM0_TELEMETRY = savedTelemetry;
});
describe("opt-out", () => {
it("queues nothing for every documented off value", () => {
for (const value of ["false", "0", "no", "OFF"]) {
process.env.MEM0_TELEMETRY = value;
expect(isTelemetryEnabled()).toBe(false);
captureEvent("deepseek.tool.search_memory", {}, { telemetryId: "dev@example.com" });
}
expect(queued()).toHaveLength(0);
});
it("is on by default", () => {
expect(isTelemetryEnabled()).toBe(true);
});
});
describe("identity", () => {
it("keys events on the account email the SDK resolved", () => {
captureEvent("deepseek.tool.add_memory", {}, { telemetryId: "dev@example.com" });
expect(queued()[0].distinct_id).toBe("dev@example.com");
});
it("falls back to a persisted anonymous id before the SDK has pinged", () => {
captureEvent("deepseek.tool.add_memory", {}, {});
const first = queued()[0].distinct_id as string;
expect(first).toMatch(/^deepseek-anon-/);
_resetForTesting();
captureEvent("deepseek.tool.add_memory", {}, {});
expect(queued()[0].distinct_id).toBe(first);
});
it("aliases the anonymous history onto the email exactly once", () => {
captureEvent("deepseek.tool.add_memory", {}, {});
const anonymous = queued()[0].distinct_id;
_resetForTesting();
captureEvent("deepseek.tool.add_memory", {}, { telemetryId: "dev@example.com" });
const [identify, event] = queued();
expect(identify.event).toBe("$identify");
expect(identify.distinct_id).toBe("dev@example.com");
expect(identify.properties.$anon_distinct_id).toBe(anonymous);
expect(event.distinct_id).toBe("dev@example.com");
captureEvent("deepseek.tool.add_memory", {}, { telemetryId: "dev@example.com" });
expect(queued().filter((e) => e.event === "$identify")).toHaveLength(1);
});
});
describe("event shape", () => {
it("stamps every event with the integration source and runtime baseline", () => {
captureEvent("deepseek.tool.search_memory", { success: true }, { telemetryId: "d@e.com" });
const { properties } = queued()[0];
expect(properties.source).toBe("DEEPSEEK_HARNESS");
expect(properties.language).toBe("node");
expect(properties.$process_person_profile).toBe(false);
expect(properties.os).toBe(process.platform);
expect(properties.plugin_version).not.toBe("unknown");
expect(properties.success).toBe(true);
});
it("flushes one batch to PostHog and empties the queue", async () => {
captureEvent("deepseek.tool.search_memory", {}, { telemetryId: "d@e.com" });
flushEvents();
const [url, init] = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain("posthog.com");
expect(JSON.parse(init.body).batch).toHaveLength(1);
expect(queued()).toHaveLength(0);
});
it("never throws when the network is gone", () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("fetch failed")));
captureEvent("deepseek.tool.search_memory", {}, { telemetryId: "d@e.com" });
expect(() => flushEvents()).not.toThrow();
});
});
describe("errorKind", () => {
it("buckets failures without leaking the message", () => {
expect(errorKind(new Error("Request failed with status 429"))).toBe("rate-limited");
expect(errorKind(new Error("401 Unauthorized"))).toBe("auth");
expect(errorKind(new Error("503 Service Unavailable"))).toBe("server-error");
expect(errorKind(new Error("The operation was aborted due to timeout"))).toBe("timeout");
expect(errorKind(new Error("fetch failed"))).toBe("network");
expect(errorKind(new Error("token sk-abcdef is invalid"))).toBe("Error");
});
});
describe("tool instrumentation", () => {
it("records a mount, then a successful search with its shape but not its query", async () => {
mockSearch.mockResolvedValue({ results: [{ id: "m1", memory: "Likes tea" }] });
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
await tools.get("search_memory")!.execute({ query: "what drink", limit: 3 }, {});
const [mounted, search] = queued();
expect(mounted.event).toBe("deepseek.plugin.mounted");
expect(mounted.properties.has_host).toBe(false);
expect(search.event).toBe("deepseek.tool.search_memory");
expect(search.properties).toMatchObject({
success: true,
top_k: 3,
result_count: 1,
query_chars: 10,
scope_overridden: false,
has_agent_id: false,
has_run_id: false,
});
expect(typeof search.properties.duration_ms).toBe("number");
expect(JSON.stringify(search)).not.toContain("what drink");
expect(JSON.stringify(search)).not.toContain("Likes tea");
});
it("records a failed search with a coarse error kind", async () => {
mockSearch.mockRejectedValue(new Error("429 rate limit exceeded"));
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
await tools.get("search_memory")!.execute({ query: "x" }, {});
const search = queued().at(-1)!;
expect(search.properties).toMatchObject({ success: false, error_kind: "rate-limited" });
});
it("records a write with its size but not its text", async () => {
mockAdd.mockResolvedValue([{ id: "m1", memory: "Fact" }]);
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
await tools.get("add_memory")!.execute({ text: "secret fact", userId: "alice" }, {});
const add = queued().at(-1)!;
expect(add.event).toBe("deepseek.tool.add_memory");
expect(add.properties).toMatchObject({
success: true,
text_chars: 11,
memory_count: 1,
scope_overridden: true,
});
expect(JSON.stringify(add)).not.toContain("secret fact");
expect(JSON.stringify(add)).not.toContain("alice");
});
it("records a failed write and still returns the graceful failure line", async () => {
mockAdd.mockRejectedValue(new Error("boom"));
const tools = applyAndCollect({ apiKey: "k", userId: "u" });
const out = await tools.get("add_memory")!.execute({ text: "x" }, {});
expect(out).toContain("add_memory failed");
expect(queued().at(-1)!.properties).toMatchObject({ success: false, error_kind: "Error" });
});
});
+7
View File
@@ -77,6 +77,13 @@ For the model-called tool (`store` / `retrieve` / `get` / `delete`), use the
[`mem0_memory`](https://github.com/strands-agents/tools) tool from `strands-agents-tools`. The store and
the tool share one Mem0 backend and namespace.
## Telemetry
The store sends anonymous usage events (store configuration, operation, duration,
result counts, coarse failure kind) over the Mem0 SDK's existing telemetry client,
tagged `source="STRANDS"`. Queries, memory text, message content, entity ids, and
metadata are never sent. Turn it off with `MEM0_TELEMETRY=false`.
## Development
The package lives under [`python/`](python/) (monorepo-style layout matching the
@@ -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