diff --git a/mem0-ts/src/client/config.ts b/mem0-ts/src/client/config.ts new file mode 100644 index 000000000..48931834c --- /dev/null +++ b/mem0-ts/src/client/config.ts @@ -0,0 +1,118 @@ +/** + * Best-effort read/write of ~/.mem0/config.json from the TS SDK. + * + * Used to stitch PostHog identities: the OSS Python SDK and the Python CLI + * each persist an anonymous distinct_id here, and the TS MemoryClient needs + * to read those on init so it can fire $identify and merge them into the + * email-based identity. + * + * Node-only. In browsers (or any environment without `process.versions.node`) + * every function returns null/no-ops without attempting `fs` access. + */ + +export interface Mem0AnonIds { + oss?: string; + cli?: string; + aliasedTo?: string; +} + +function isNode(): boolean { + try { + return ( + typeof process !== "undefined" && + !!process.versions && + !!process.versions.node + ); + } catch { + return false; + } +} + +async function loadNodeModules(): Promise<{ + fs: typeof import("fs"); + path: typeof import("path"); + os: typeof import("os"); +} | null> { + if (!isNode()) return null; + try { + const [fs, path, os] = await Promise.all([ + import("fs"), + import("path"), + import("os"), + ]); + return { + fs: fs.default ?? fs, + path: path.default ?? path, + os: os.default ?? os, + }; + } catch { + return null; + } +} + +async function configPath(): Promise<{ + path: string; + modules: NonNullable>>; +} | null> { + const modules = await loadNodeModules(); + if (!modules) return null; + try { + const dir = + process.env.MEM0_DIR || modules.path.join(modules.os.homedir(), ".mem0"); + return { path: modules.path.join(dir, "config.json"), modules }; + } catch { + return null; + } +} + +async function loadRawConfig(): Promise | null> { + const resolved = await configPath(); + if (!resolved) return null; + try { + if (!resolved.modules.fs.existsSync(resolved.path)) return null; + const raw = resolved.modules.fs.readFileSync(resolved.path, "utf8"); + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +export async function readMem0AnonIds(): Promise { + const config = await loadRawConfig(); + if (!config) return null; + const telemetry = + config.telemetry && typeof config.telemetry === "object" + ? (config.telemetry as Record) + : {}; + const oss = typeof config.user_id === "string" ? config.user_id : undefined; + const cli = + typeof telemetry.anonymous_id === "string" + ? telemetry.anonymous_id + : undefined; + const aliasedTo = + typeof telemetry.aliased_to === "string" ? telemetry.aliased_to : undefined; + return { oss, cli, aliasedTo }; +} + +export async function markMem0Aliased(email: string): Promise { + const resolved = await configPath(); + if (!resolved) return; + try { + const dirname = resolved.modules.path.dirname(resolved.path); + resolved.modules.fs.mkdirSync(dirname, { recursive: true }); + const config = (await loadRawConfig()) ?? {}; + const telemetry = + config.telemetry && typeof config.telemetry === "object" + ? (config.telemetry as Record) + : {}; + telemetry.aliased_to = email; + config.telemetry = telemetry; + resolved.modules.fs.writeFileSync( + resolved.path, + JSON.stringify(config, null, 4), + ); + } catch { + // Read-only filesystem (Lambda, container) — alias is best-effort. + } +} diff --git a/mem0-ts/src/client/mem0.ts b/mem0-ts/src/client/mem0.ts index 783d7f925..b8afce35f 100644 --- a/mem0-ts/src/client/mem0.ts +++ b/mem0-ts/src/client/mem0.ts @@ -20,7 +20,8 @@ import { CreateMemoryExportPayload, GetMemoryExportPayload, } from "./mem0.types"; -import { captureClientEvent, generateHash } from "./telemetry"; +import { captureClientEvent, generateHash, telemetry } from "./telemetry"; +import { readMem0AnonIds, markMem0Aliased } from "./config"; import { camelToSnake, camelToSnakeKeys, snakeToCamelKeys } from "./utils"; import { createExceptionFromResponse, MemoryError } from "../common/exceptions"; @@ -118,6 +119,8 @@ export default class MemoryClient { this.telemetryId = generateHash(this.apiKey); } + await this._maybeAliasAnonToEmail(); + captureClientEvent("init", this, { client_type: "MemoryClient", }).catch((error: any) => { @@ -132,6 +135,25 @@ export default class MemoryClient { } } + private async _maybeAliasAnonToEmail(): Promise { + try { + const email = this.telemetryId; + if (!email || !email.includes("@")) return; + const anonIds = await readMem0AnonIds(); + if (!anonIds) return; + if (anonIds.aliasedTo === email) return; + const candidates = [anonIds.oss, anonIds.cli].filter( + (id): id is string => !!id && id !== email, + ); + for (const anonId of candidates) { + await telemetry.captureIdentify(anonId, email); + } + await markMem0Aliased(email); + } catch (error: any) { + console.error("Failed to alias telemetry identity:", error); + } + } + private _captureEvent(methodName: string, args: any[]) { captureClientEvent(methodName, this, { success: true, diff --git a/mem0-ts/src/client/telemetry.ts b/mem0-ts/src/client/telemetry.ts index ab5887820..4b0d35c4e 100644 --- a/mem0-ts/src/client/telemetry.ts +++ b/mem0-ts/src/client/telemetry.ts @@ -67,6 +67,15 @@ class UnifiedTelemetry implements TelemetryClient { } } + async captureIdentify(anonId: string, email: string) { + if (!MEM0_TELEMETRY) return; + if (!anonId || !email || anonId === email) return; + await this.captureEvent(email, "$identify", { + $anon_distinct_id: anonId, + client_source: "typescript", + }); + } + async shutdown() { // No shutdown needed for direct API calls } diff --git a/mem0-ts/src/client/tests/telemetry-aliasing.test.ts b/mem0-ts/src/client/tests/telemetry-aliasing.test.ts new file mode 100644 index 000000000..1375b3da7 --- /dev/null +++ b/mem0-ts/src/client/tests/telemetry-aliasing.test.ts @@ -0,0 +1,323 @@ +/** + * Tests for PostHog identity stitching in the TS MemoryClient. + * + * Covers $identify firing, idempotency via aliased_to, and the node/browser + * gate. Mocks fs and fetch; never touches the real ~/.mem0/config.json. + */ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { MemoryClient } from "../mem0"; +import { telemetry } from "../telemetry"; +import { readMem0AnonIds, markMem0Aliased, Mem0AnonIds } from "../config"; +import { createMockFetch, TEST_API_KEY } from "./helpers"; +import { setupMockFetch, installConsoleSuppression } from "./setup"; + +installConsoleSuppression(); + +// ─── config.ts (node-only fs read/write) ────────────────────── + +describe("config.ts — readMem0AnonIds / markMem0Aliased", () => { + let tmpHome: string; + const originalMem0Dir = process.env.MEM0_DIR; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-ts-test-")); + process.env.MEM0_DIR = tmpHome; + }); + + afterEach(() => { + if (fs.existsSync(tmpHome)) { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + if (originalMem0Dir === undefined) { + delete process.env.MEM0_DIR; + } else { + process.env.MEM0_DIR = originalMem0Dir; + } + }); + + test("returns null when config file does not exist", async () => { + expect(await readMem0AnonIds()).toBeNull(); + }); + + test("reads OSS user_id only", async () => { + fs.writeFileSync( + path.join(tmpHome, "config.json"), + JSON.stringify({ user_id: "oss-uuid" }), + ); + const ids = await readMem0AnonIds(); + expect(ids).toEqual({ + oss: "oss-uuid", + cli: undefined, + aliasedTo: undefined, + }); + }); + + test("reads CLI anonymous_id and aliased_to", async () => { + fs.writeFileSync( + path.join(tmpHome, "config.json"), + JSON.stringify({ + telemetry: { anonymous_id: "cli-anon", aliased_to: "u@x.com" }, + }), + ); + const ids = await readMem0AnonIds(); + expect(ids).toEqual({ + oss: undefined, + cli: "cli-anon", + aliasedTo: "u@x.com", + }); + }); + + test("returns null on malformed JSON", async () => { + fs.writeFileSync(path.join(tmpHome, "config.json"), "{not json"); + expect(await readMem0AnonIds()).toBeNull(); + }); + + test("markMem0Aliased preserves other fields", async () => { + fs.writeFileSync( + path.join(tmpHome, "config.json"), + JSON.stringify({ + user_id: "oss-uuid", + telemetry: { anonymous_id: "cli-anon" }, + }), + ); + await markMem0Aliased("user@example.com"); + const written = JSON.parse( + fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"), + ); + expect(written.user_id).toBe("oss-uuid"); + expect(written.telemetry.anonymous_id).toBe("cli-anon"); + expect(written.telemetry.aliased_to).toBe("user@example.com"); + }); + + test("markMem0Aliased creates telemetry section when missing", async () => { + fs.writeFileSync( + path.join(tmpHome, "config.json"), + JSON.stringify({ user_id: "oss-uuid" }), + ); + await markMem0Aliased("user@example.com"); + const written = JSON.parse( + fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"), + ); + expect(written.telemetry.aliased_to).toBe("user@example.com"); + }); + + test("markMem0Aliased does not throw when target dir is unwritable", async () => { + // Point at a path that cannot be written to (a file-as-dir collision). + fs.writeFileSync(path.join(tmpHome, "blocker"), "x"); + process.env.MEM0_DIR = path.join(tmpHome, "blocker"); // file used as dir + await expect(markMem0Aliased("user@example.com")).resolves.toBeUndefined(); + }); +}); + +// ─── telemetry.captureIdentify ─────────────────────────────── + +describe("telemetry.captureIdentify", () => { + test("fires $identify with $anon_distinct_id", async () => { + const fetchMock = jest.fn(async () => ({ + ok: true, + status: 200, + text: async () => "ok", + })) as unknown as typeof fetch; + global.fetch = fetchMock as any; + + await telemetry.captureIdentify("anon-uuid", "user@example.com"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = (fetchMock as jest.Mock).mock.calls[0]; + const payload = JSON.parse(init.body); + expect(payload.event).toBe("$identify"); + expect(payload.distinct_id).toBe("user@example.com"); + expect(payload.properties.$anon_distinct_id).toBe("anon-uuid"); + }); + + test("skips when anon equals email", async () => { + const fetchMock = jest.fn() as unknown as typeof fetch; + global.fetch = fetchMock as any; + await telemetry.captureIdentify("user@example.com", "user@example.com"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("skips when either input is empty", async () => { + const fetchMock = jest.fn() as unknown as typeof fetch; + global.fetch = fetchMock as any; + await telemetry.captureIdentify("", "user@example.com"); + await telemetry.captureIdentify("anon", ""); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +// ─── MemoryClient init aliasing ────────────────────────────── + +describe("MemoryClient — _maybeAliasAnonToEmail", () => { + let tmpHome: string; + const originalMem0Dir = process.env.MEM0_DIR; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "mem0-ts-init-")); + process.env.MEM0_DIR = tmpHome; + }); + + afterEach(() => { + if (fs.existsSync(tmpHome)) { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + if (originalMem0Dir === undefined) { + delete process.env.MEM0_DIR; + } else { + process.env.MEM0_DIR = originalMem0Dir; + } + }); + + // Construct a non-initialised client so we can call _maybeAliasAnonToEmail + // in isolation (the real constructor's _initializeClient also fires it). + function makeStubClient(telemetryId: string): MemoryClient { + const client = Object.create(MemoryClient.prototype) as MemoryClient; + (client as any).apiKey = TEST_API_KEY; + (client as any).host = "https://api.mem0.ai"; + (client as any).telemetryId = telemetryId; + return client; + } + + test("fires $identify on first init and persists aliased_to", async () => { + fs.writeFileSync( + path.join(tmpHome, "config.json"), + JSON.stringify({ user_id: "oss-uuid" }), + ); + const fetchMock = setupMockFetch(); + + const client = makeStubClient("test@example.com"); + await (client as any)._maybeAliasAnonToEmail(); + + const identifyCalls = (fetchMock.mock.calls as any[]).filter( + ([, init]: [string, RequestInit]) => { + if (!init?.body) return false; + return JSON.parse(init.body as string).event === "$identify"; + }, + ); + expect(identifyCalls.length).toBe(1); + const body = JSON.parse(identifyCalls[0][1].body); + expect(body.distinct_id).toBe("test@example.com"); + expect(body.properties.$anon_distinct_id).toBe("oss-uuid"); + + const written = JSON.parse( + fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"), + ); + expect(written.telemetry.aliased_to).toBe("test@example.com"); + }); + + test("second init does not refire $identify", async () => { + fs.writeFileSync( + path.join(tmpHome, "config.json"), + JSON.stringify({ + user_id: "oss-uuid", + telemetry: { aliased_to: "test@example.com" }, + }), + ); + const fetchMock = setupMockFetch(); + + const client = makeStubClient("test@example.com"); + await (client as any)._maybeAliasAnonToEmail(); + + const identifyCalls = (fetchMock.mock.calls as any[]).filter( + ([, init]: [string, RequestInit]) => { + if (!init?.body) return false; + return JSON.parse(init.body as string).event === "$identify"; + }, + ); + expect(identifyCalls.length).toBe(0); + }); + + test("fires $identify for both OSS and CLI anon ids", async () => { + fs.writeFileSync( + path.join(tmpHome, "config.json"), + JSON.stringify({ + user_id: "oss-uuid", + telemetry: { anonymous_id: "cli-anon" }, + }), + ); + const fetchMock = setupMockFetch(); + + const client = makeStubClient("test@example.com"); + await (client as any)._maybeAliasAnonToEmail(); + + const identifyCalls = (fetchMock.mock.calls as any[]).filter( + ([, init]: [string, RequestInit]) => { + if (!init?.body) return false; + return JSON.parse(init.body as string).event === "$identify"; + }, + ); + expect(identifyCalls.length).toBe(2); + const anonIds = identifyCalls.map( + (c: [string, RequestInit]) => + JSON.parse(c[1].body as string).properties.$anon_distinct_id, + ); + expect(anonIds).toContain("oss-uuid"); + expect(anonIds).toContain("cli-anon"); + }); + + test("noop when telemetryId is not an email", async () => { + fs.writeFileSync( + path.join(tmpHome, "config.json"), + JSON.stringify({ user_id: "oss-uuid" }), + ); + const fetchMock = setupMockFetch(); + + const client = makeStubClient("not-an-email"); + await (client as any)._maybeAliasAnonToEmail(); + + const identifyCalls = (fetchMock.mock.calls as any[]).filter( + ([, init]: [string, RequestInit]) => { + if (!init?.body) return false; + return JSON.parse(init.body as string).event === "$identify"; + }, + ); + expect(identifyCalls.length).toBe(0); + }); + + test("does not throw when config read fails", async () => { + fs.writeFileSync(path.join(tmpHome, "config.json"), "{not json"); + setupMockFetch(); + + const client = makeStubClient("test@example.com"); + await expect( + (client as any)._maybeAliasAnonToEmail(), + ).resolves.toBeUndefined(); + }); +}); + +// ─── Type smoke test ────────────────────────────────────────── + +describe("Mem0AnonIds shape", () => { + test("interface accepts partial fields", () => { + const a: Mem0AnonIds = { oss: "x" }; + const b: Mem0AnonIds = { cli: "y", aliasedTo: "u@x.com" }; + expect(a.oss).toBe("x"); + expect(b.aliasedTo).toBe("u@x.com"); + }); +}); + +// ─── Browser env path (no process.versions.node) ───────────── + +describe("config.ts in browser-like environment", () => { + test("readMem0AnonIds returns null when not Node", async () => { + const originalProcess = global.process; + // Simulate a browser: no `process` global. + // @ts-expect-error force-undefining global + delete global.process; + try { + // Re-import to pick up the missing global. + jest.resetModules(); + const { readMem0AnonIds: browserRead } = await import("../config"); + expect(await browserRead()).toBeNull(); + } finally { + global.process = originalProcess; + jest.resetModules(); + } + }); +}); + +// ─── Suppress unused-import warnings ────────────────────────── +// Some helpers above are intentionally re-exported for clarity in tests. +void createMockFetch; diff --git a/mem0/client/main.py b/mem0/client/main.py index 34db1c782..2d2242ec9 100644 --- a/mem0/client/main.py +++ b/mem0/client/main.py @@ -19,8 +19,8 @@ from mem0.client.types import ( from mem0.client.utils import api_error_handler # Exception classes are referenced in docstrings only -from mem0.memory.setup import get_user_id, setup_config -from mem0.memory.telemetry import capture_client_event +from mem0.memory.setup import get_user_id, mark_aliased, read_anon_ids, setup_config +from mem0.memory.telemetry import capture_client_event, client_telemetry logger = logging.getLogger(__name__) @@ -33,6 +33,28 @@ setup_config() ENTITY_PARAMS = frozenset({"user_id", "agent_id", "app_id", "run_id"}) +def _maybe_alias_anon_to_email(user_email): + """Stitch prior anonymous PostHog identities to the resolved email. + + Reads ~/.mem0/config.json for both anon IDs (OSS user_id, CLI + telemetry.anonymous_id), fires one $identify per anon that hasn't been + aliased yet, then sets telemetry.aliased_to so this only happens once + per (anon_id, email) pair. Best-effort — never raises. + """ + if not user_email or "@" not in user_email: + return + try: + anon_ids = read_anon_ids() + if anon_ids.get("aliased_to") == user_email: + return + for anon_id in (anon_ids.get("oss"), anon_ids.get("cli")): + if anon_id and anon_id != user_email: + client_telemetry.capture_identify(anon_id, user_email) + mark_aliased(user_email) + except Exception as e: + logger.debug("Failed to alias anon telemetry to %r: %s", user_email, e) + + class MemoryClient: """Client for interacting with the Mem0 API. @@ -108,6 +130,7 @@ class MemoryClient: user_email=self.user_email, ) + _maybe_alias_anon_to_email(self.user_email) capture_client_event("client.init", self, {"sync_type": "sync"}) def _validate_api_key(self): @@ -985,6 +1008,7 @@ class AsyncMemoryClient: user_email=self.user_email, ) + _maybe_alias_anon_to_email(self.user_email) capture_client_event("client.init", self, {"sync_type": "async"}) def _validate_api_key(self): diff --git a/mem0/memory/setup.py b/mem0/memory/setup.py index 3dbd70667..75f120693 100644 --- a/mem0/memory/setup.py +++ b/mem0/memory/setup.py @@ -1,4 +1,5 @@ import json +import logging import os import uuid @@ -8,28 +9,85 @@ home_dir = os.path.expanduser("~") mem0_dir = os.environ.get("MEM0_DIR") or os.path.join(home_dir, ".mem0") os.makedirs(mem0_dir, exist_ok=True) +_logger = logging.getLogger(__name__) + + +def _config_path(): + return os.path.join(mem0_dir, "config.json") + + +def _load_config(): + """Load ~/.mem0/config.json, returning {} on missing/malformed file.""" + path = _config_path() + if not os.path.exists(path): + return {} + try: + with open(path, "r") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except Exception as e: + _logger.debug("Failed to load mem0 config %s: %s", path, e) + return {} + + +def _write_config(config): + """Best-effort write of ~/.mem0/config.json. Never raises.""" + path = _config_path() + try: + with open(path, "w") as f: + json.dump(config, f, indent=4) + except Exception as e: + _logger.debug("Failed to write mem0 config %s: %s", path, e) + def setup_config(): - config_path = os.path.join(mem0_dir, "config.json") - if not os.path.exists(config_path): - user_id = str(uuid.uuid4()) - config = {"user_id": user_id} - with open(config_path, "w") as config_file: - json.dump(config, config_file, indent=4) + """Ensure ~/.mem0/config.json exists with a top-level user_id. + + Idempotent: backfills user_id for users whose config was written by the + CLI (which writes telemetry.anonymous_id but no top-level user_id). + Without this, OSS Python telemetry is silently dropped because + get_user_id() returns None when user_id is missing. + """ + config = _load_config() + if config.get("user_id"): + return + config["user_id"] = str(uuid.uuid4()) + _write_config(config) def get_user_id(): - config_path = os.path.join(mem0_dir, "config.json") - if not os.path.exists(config_path): + config = _load_config() + if not config: return "anonymous_user" + return config.get("user_id") - try: - with open(config_path, "r") as config_file: - config = json.load(config_file) - user_id = config.get("user_id") - return user_id - except Exception: - return "anonymous_user" + +def read_anon_ids(): + """Return both anon IDs and the aliased_to flag from ~/.mem0/config.json. + + Returns a dict with keys "oss", "cli", "aliased_to" (any may be None). + OSS Python writes top-level "user_id"; the CLI writes + "telemetry.anonymous_id". They may coexist depending on which surface + ran first. + """ + config = _load_config() + telemetry = config.get("telemetry") if isinstance(config.get("telemetry"), dict) else {} + return { + "oss": config.get("user_id"), + "cli": telemetry.get("anonymous_id"), + "aliased_to": telemetry.get("aliased_to"), + } + + +def mark_aliased(email): + """Persist telemetry.aliased_to = email so $identify only fires once.""" + config = _load_config() + telemetry = config.get("telemetry") + if not isinstance(telemetry, dict): + telemetry = {} + telemetry["aliased_to"] = email + config["telemetry"] = telemetry + _write_config(config) def get_or_create_user_id(vector_store=None): diff --git a/mem0/memory/telemetry.py b/mem0/memory/telemetry.py index f0c46cbb2..2e3a27539 100644 --- a/mem0/memory/telemetry.py +++ b/mem0/memory/telemetry.py @@ -48,7 +48,8 @@ MEM0_TELEMETRY_SAMPLE_RATE = _parse_sample_rate(os.environ.get("MEM0_TELEMETRY_S # Events that bypass sampling and always fire. Keep this set in sync with the # event names passed to capture_event() in mem0/memory/main.py. -_LIFECYCLE_EVENTS = frozenset({"mem0.init", "mem0.reset", "mem0._create_procedural_memory"}) +# $identify is included so PostHog person-merging is never lost to sampling. +_LIFECYCLE_EVENTS = frozenset({"mem0.init", "mem0.reset", "mem0._create_procedural_memory", "$identify"}) def _sampling_before_send(msg): @@ -112,6 +113,33 @@ class AnonymousTelemetry: except Exception as e: _logger.debug("Failed to capture telemetry event %r: %s", event_name, e) + def capture_identify(self, anon_id, email): + """Send a PostHog $identify event so the anon person merges into the + identified person. Distinct_id is the new identity (email); the prior + anonymous distinct_id is passed as $anon_distinct_id per PostHog spec. + + Never raises. + """ + if self.posthog is None: + return + if not anon_id or not email or anon_id == email: + return + properties = { + "$anon_distinct_id": anon_id, + "client_source": "python", + "client_version": mem0.__version__, + "python_version": sys.version, + "os": sys.platform, + "os_version": platform.version(), + "os_release": platform.release(), + "processor": platform.processor(), + "machine": platform.machine(), + } + try: + self.posthog.capture(distinct_id=email, event="$identify", properties=properties) + except Exception as e: + _logger.debug("Failed to capture $identify for %r: %s", email, e) + def close(self): if self.posthog is not None: self.posthog.shutdown() diff --git a/tests/test_telemetry_aliasing.py b/tests/test_telemetry_aliasing.py new file mode 100644 index 000000000..d252af580 --- /dev/null +++ b/tests/test_telemetry_aliasing.py @@ -0,0 +1,373 @@ +"""Tests for PostHog identity stitching: anon → email alias on MemoryClient init. + +Covers the four matrix cases (OSS-only, CLI-only, both, already-aliased) plus +failure modes: missing config, malformed JSON, read-only filesystem, +broken posthog client. Telemetry must never raise. +""" + +import importlib +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def tmp_mem0_dir(tmp_path, monkeypatch): + """Point the mem0 setup module at a tempdir for the duration of the test.""" + monkeypatch.setenv("MEM0_DIR", str(tmp_path)) + # Reload setup so module-level mem0_dir picks up the env var. + import mem0.memory.setup as setup_module + + importlib.reload(setup_module) + yield tmp_path + # Restore default state. + monkeypatch.delenv("MEM0_DIR", raising=False) + importlib.reload(setup_module) + + +def _write_config(tmp_path: Path, payload: dict) -> Path: + config_path = tmp_path / "config.json" + config_path.write_text(json.dumps(payload)) + return config_path + + +# ─── setup_config idempotency ──────────────────────────────────────────────── + + +class TestSetupConfigIdempotent: + def test_creates_config_when_missing(self, tmp_mem0_dir): + import mem0.memory.setup as setup_module + + setup_module.setup_config() + config = json.loads((tmp_mem0_dir / "config.json").read_text()) + assert "user_id" in config and config["user_id"] + + def test_backfills_user_id_when_only_telemetry_present(self, tmp_mem0_dir): + import mem0.memory.setup as setup_module + + _write_config( + tmp_mem0_dir, + {"telemetry": {"anonymous_id": "cli-anon-abc123"}}, + ) + setup_module.setup_config() + config = json.loads((tmp_mem0_dir / "config.json").read_text()) + assert config.get("user_id"), "user_id must be backfilled for CLI-first users" + assert config["telemetry"]["anonymous_id"] == "cli-anon-abc123" + + def test_does_not_overwrite_existing_user_id(self, tmp_mem0_dir): + import mem0.memory.setup as setup_module + + _write_config(tmp_mem0_dir, {"user_id": "existing-uuid"}) + setup_module.setup_config() + config = json.loads((tmp_mem0_dir / "config.json").read_text()) + assert config["user_id"] == "existing-uuid" + + def test_handles_malformed_json(self, tmp_mem0_dir): + import mem0.memory.setup as setup_module + + (tmp_mem0_dir / "config.json").write_text("{not json") + setup_module.setup_config() # must not raise + config = json.loads((tmp_mem0_dir / "config.json").read_text()) + assert "user_id" in config + + +# ─── read_anon_ids ─────────────────────────────────────────────────────────── + + +class TestReadAnonIds: + def test_returns_oss_only(self, tmp_mem0_dir): + import mem0.memory.setup as setup_module + + _write_config(tmp_mem0_dir, {"user_id": "oss-uuid"}) + anon = setup_module.read_anon_ids() + assert anon == {"oss": "oss-uuid", "cli": None, "aliased_to": None} + + def test_returns_cli_only(self, tmp_mem0_dir): + import mem0.memory.setup as setup_module + + _write_config( + tmp_mem0_dir, + {"telemetry": {"anonymous_id": "cli-anon-123"}}, + ) + anon = setup_module.read_anon_ids() + assert anon == {"oss": None, "cli": "cli-anon-123", "aliased_to": None} + + def test_returns_both(self, tmp_mem0_dir): + import mem0.memory.setup as setup_module + + _write_config( + tmp_mem0_dir, + { + "user_id": "oss-uuid", + "telemetry": {"anonymous_id": "cli-anon-123", "aliased_to": "user@x.com"}, + }, + ) + anon = setup_module.read_anon_ids() + assert anon == { + "oss": "oss-uuid", + "cli": "cli-anon-123", + "aliased_to": "user@x.com", + } + + def test_no_config_returns_all_none(self, tmp_mem0_dir): + import mem0.memory.setup as setup_module + + anon = setup_module.read_anon_ids() + assert anon == {"oss": None, "cli": None, "aliased_to": None} + + def test_malformed_json_does_not_raise(self, tmp_mem0_dir): + import mem0.memory.setup as setup_module + + (tmp_mem0_dir / "config.json").write_text("{not json") + anon = setup_module.read_anon_ids() + assert anon == {"oss": None, "cli": None, "aliased_to": None} + + +# ─── mark_aliased ──────────────────────────────────────────────────────────── + + +class TestMarkAliased: + def test_writes_aliased_to_preserving_other_fields(self, tmp_mem0_dir): + import mem0.memory.setup as setup_module + + _write_config( + tmp_mem0_dir, + { + "user_id": "oss-uuid", + "telemetry": {"anonymous_id": "cli-anon-123"}, + }, + ) + setup_module.mark_aliased("user@example.com") + config = json.loads((tmp_mem0_dir / "config.json").read_text()) + assert config["user_id"] == "oss-uuid" + assert config["telemetry"]["anonymous_id"] == "cli-anon-123" + assert config["telemetry"]["aliased_to"] == "user@example.com" + + def test_creates_telemetry_section_when_missing(self, tmp_mem0_dir): + import mem0.memory.setup as setup_module + + _write_config(tmp_mem0_dir, {"user_id": "oss-uuid"}) + setup_module.mark_aliased("user@example.com") + config = json.loads((tmp_mem0_dir / "config.json").read_text()) + assert config["telemetry"]["aliased_to"] == "user@example.com" + + +# ─── capture_identify ──────────────────────────────────────────────────────── + + +class TestCaptureIdentify: + def test_fires_identify_with_anon_distinct_id(self): + import mem0.memory.telemetry as telemetry_module + + with patch.object(telemetry_module, "MEM0_TELEMETRY", True): + with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls: + at = telemetry_module.AnonymousTelemetry() + at.capture_identify("anon-123", "user@example.com") + mock_ph = mock_posthog_cls.return_value + mock_ph.capture.assert_called_once() + _, kwargs = mock_ph.capture.call_args + assert kwargs["distinct_id"] == "user@example.com" + assert kwargs["event"] == "$identify" + assert kwargs["properties"]["$anon_distinct_id"] == "anon-123" + + def test_skips_when_anon_equals_email(self): + import mem0.memory.telemetry as telemetry_module + + with patch.object(telemetry_module, "MEM0_TELEMETRY", True): + with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls: + at = telemetry_module.AnonymousTelemetry() + at.capture_identify("user@example.com", "user@example.com") + mock_posthog_cls.return_value.capture.assert_not_called() + + def test_skips_when_inputs_empty(self): + import mem0.memory.telemetry as telemetry_module + + with patch.object(telemetry_module, "MEM0_TELEMETRY", True): + with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls: + at = telemetry_module.AnonymousTelemetry() + at.capture_identify("", "user@example.com") + at.capture_identify("anon-123", "") + mock_posthog_cls.return_value.capture.assert_not_called() + + def test_noop_when_telemetry_disabled(self): + import mem0.memory.telemetry as telemetry_module + + with patch.object(telemetry_module, "MEM0_TELEMETRY", False): + at = telemetry_module.AnonymousTelemetry() + at.capture_identify("anon-123", "user@example.com") # must not raise + assert at.posthog is None + + def test_does_not_raise_on_posthog_error(self): + import mem0.memory.telemetry as telemetry_module + + with patch.object(telemetry_module, "MEM0_TELEMETRY", True): + with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls: + mock_posthog_cls.return_value.capture.side_effect = RuntimeError("boom") + at = telemetry_module.AnonymousTelemetry() + at.capture_identify("anon-123", "user@example.com") # must not raise + + def test_identify_is_in_lifecycle_events(self): + """$identify must bypass the 90% sampling drop.""" + import mem0.memory.telemetry as telemetry_module + + assert "$identify" in telemetry_module._LIFECYCLE_EVENTS + + +# ─── _maybe_alias_anon_to_email integration ────────────────────────────────── + + +class TestMaybeAliasAnonToEmail: + """Test the alias helper in isolation by mocking out the config readers + and the telemetry client, since module-level setup_config() side effects + make end-to-end fixturing awkward.""" + + def test_fires_identify_for_oss_uuid(self): + from mem0.client import main as client_main + + with ( + patch.object( + client_main, + "read_anon_ids", + return_value={"oss": "oss-uuid", "cli": None, "aliased_to": None}, + ), + patch.object(client_main, "mark_aliased") as mark, + patch.object(client_main, "client_telemetry") as telemetry, + ): + client_main._maybe_alias_anon_to_email("user@example.com") + telemetry.capture_identify.assert_called_once_with("oss-uuid", "user@example.com") + mark.assert_called_once_with("user@example.com") + + def test_fires_identify_for_cli_anon(self): + from mem0.client import main as client_main + + with ( + patch.object( + client_main, + "read_anon_ids", + return_value={"oss": None, "cli": "cli-anon-xyz", "aliased_to": None}, + ), + patch.object(client_main, "mark_aliased"), + patch.object(client_main, "client_telemetry") as telemetry, + ): + client_main._maybe_alias_anon_to_email("user@example.com") + telemetry.capture_identify.assert_called_once_with("cli-anon-xyz", "user@example.com") + + def test_fires_identify_for_both_anon_ids(self): + from mem0.client import main as client_main + + with ( + patch.object( + client_main, + "read_anon_ids", + return_value={"oss": "oss-uuid", "cli": "cli-anon", "aliased_to": None}, + ), + patch.object(client_main, "mark_aliased"), + patch.object(client_main, "client_telemetry") as telemetry, + ): + client_main._maybe_alias_anon_to_email("user@example.com") + assert telemetry.capture_identify.call_count == 2 + calls = {c.args for c in telemetry.capture_identify.call_args_list} + assert ("oss-uuid", "user@example.com") in calls + assert ("cli-anon", "user@example.com") in calls + + def test_skips_when_already_aliased(self): + from mem0.client import main as client_main + + with ( + patch.object( + client_main, + "read_anon_ids", + return_value={"oss": "oss-uuid", "cli": None, "aliased_to": "user@example.com"}, + ), + patch.object(client_main, "mark_aliased") as mark, + patch.object(client_main, "client_telemetry") as telemetry, + ): + client_main._maybe_alias_anon_to_email("user@example.com") + telemetry.capture_identify.assert_not_called() + mark.assert_not_called() + + def test_skips_when_email_invalid(self): + from mem0.client import main as client_main + + with patch.object(client_main, "client_telemetry") as telemetry: + client_main._maybe_alias_anon_to_email(None) + client_main._maybe_alias_anon_to_email("") + client_main._maybe_alias_anon_to_email("not-an-email") + telemetry.capture_identify.assert_not_called() + + def test_does_not_raise_on_telemetry_failure(self): + from mem0.client import main as client_main + + mock_telemetry = MagicMock() + mock_telemetry.capture_identify.side_effect = RuntimeError("boom") + with ( + patch.object( + client_main, + "read_anon_ids", + return_value={"oss": "oss-uuid", "cli": None, "aliased_to": None}, + ), + patch.object(client_main, "mark_aliased"), + patch.object(client_main, "client_telemetry", mock_telemetry), + ): + client_main._maybe_alias_anon_to_email("user@example.com") # must not raise + + def test_skips_anon_id_equal_to_email(self): + """Defensive: if the anon_id somehow already is the email, don't self-alias.""" + from mem0.client import main as client_main + + with ( + patch.object( + client_main, + "read_anon_ids", + return_value={"oss": "user@example.com", "cli": None, "aliased_to": None}, + ), + patch.object(client_main, "mark_aliased"), + patch.object(client_main, "client_telemetry") as telemetry, + ): + client_main._maybe_alias_anon_to_email("user@example.com") + telemetry.capture_identify.assert_not_called() + + def test_does_not_raise_on_read_failure(self): + """If read_anon_ids itself raises (e.g. IO error), helper must swallow it.""" + from mem0.client import main as client_main + + with ( + patch.object(client_main, "read_anon_ids", side_effect=OSError("fs broken")), + patch.object(client_main, "client_telemetry") as telemetry, + ): + client_main._maybe_alias_anon_to_email("user@example.com") # must not raise + telemetry.capture_identify.assert_not_called() + + +# ─── End-to-end idempotency through real config ────────────────────────────── + + +class TestEndToEndIdempotency: + """Verify the real config flow: two consecutive _maybe_alias_anon_to_email + calls fire $identify exactly once thanks to the persisted aliased_to flag.""" + + def test_second_call_is_noop_after_aliased_to_persisted(self, tmp_mem0_dir): + # Pre-populate config with an OSS user_id only. + _write_config(tmp_mem0_dir, {"user_id": "oss-uuid"}) + # Reload setup so it uses the tempdir, then reload client.main so it + # picks up the freshly-loaded read_anon_ids/mark_aliased bindings. + import mem0.memory.setup as setup_module + + importlib.reload(setup_module) + from mem0.client import main as client_main + + importlib.reload(client_main) + + with patch.object(client_main, "client_telemetry") as telemetry: + client_main._maybe_alias_anon_to_email("user@example.com") + first_call_count = telemetry.capture_identify.call_count + assert first_call_count >= 1 + + # Second call should hit the aliased_to short-circuit. + client_main._maybe_alias_anon_to_email("user@example.com") + assert telemetry.capture_identify.call_count == first_call_count + + config = json.loads((tmp_mem0_dir / "config.json").read_text()) + assert config["telemetry"]["aliased_to"] == "user@example.com"