diff --git a/mem0-ts/src/client/config.ts b/mem0-ts/src/client/config.ts index 4fb409e81..646360661 100644 --- a/mem0-ts/src/client/config.ts +++ b/mem0-ts/src/client/config.ts @@ -1,9 +1,9 @@ /** * 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 reads - * those on init to fire $identify and merge them into the email identity. + * Used to stitch PostHog identities: SDKs and CLIs persist anonymous + * distinct_id values here, and the TS MemoryClient reads those on init to + * fire $identify and merge them into the email identity. * * Node-only. Browsers (no `process.versions.node`) no-op. */ @@ -11,30 +11,34 @@ export interface Mem0AnonIds { oss?: string; cli?: string; - aliasedTo?: string; + aliasedPairs: string[]; } interface NodeFs { fs: typeof import("fs"); path: typeof import("path"); + crypto: typeof import("crypto"); configPath: string; } async function getNodeFs(): Promise { if (typeof process === "undefined" || !process.versions?.node) return null; try { - const [fs, path, os] = await Promise.all([ + const [fs, path, os, crypto] = await Promise.all([ import("fs"), import("path"), import("os"), + import("crypto"), ]); const fsMod = (fs as any).default ?? fs; const pathMod = (path as any).default ?? path; const osMod = (os as any).default ?? os; + const cryptoMod = (crypto as any).default ?? crypto; const dir = process.env.MEM0_DIR || pathMod.join(osMod.homedir(), ".mem0"); return { fs: fsMod, path: pathMod, + crypto: cryptoMod, configPath: pathMod.join(dir, "config.json"), }; } catch { @@ -52,6 +56,45 @@ function loadConfig(node: NodeFs): Record | null { } } +function writeConfig(node: NodeFs, config: Record): void { + node.fs.mkdirSync(node.path.dirname(node.configPath), { recursive: true }); + node.fs.writeFileSync(node.configPath, JSON.stringify(config, null, 4)); +} + +function aliasPairMarker(node: NodeFs, anonId: string, email: string): string { + return node.crypto + .createHash("sha256") + .update(`${anonId}\0${email}`, "utf8") + .digest("hex"); +} + +function randomUserId(node: NodeFs): string { + if (typeof node.crypto.randomUUID === "function") { + return node.crypto.randomUUID(); + } + return ( + Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15) + ); +} + +export async function getOrCreateMem0UserId(): Promise { + const node = await getNodeFs(); + if (!node) return null; + try { + const config = loadConfig(node) ?? {}; + if (typeof config.user_id === "string" && config.user_id) { + return config.user_id; + } + const userId = randomUserId(node); + config.user_id = userId; + writeConfig(node, config); + return userId; + } catch { + return null; + } +} + export async function readMem0AnonIds(): Promise { const node = await getNodeFs(); if (!node) return null; @@ -67,26 +110,55 @@ export async function readMem0AnonIds(): Promise { typeof telemetry.anonymous_id === "string" ? telemetry.anonymous_id : undefined, - aliasedTo: - typeof telemetry.aliased_to === "string" - ? telemetry.aliased_to - : undefined, + aliasedPairs: Array.isArray(telemetry.aliased_pairs) + ? telemetry.aliased_pairs.filter( + (item: unknown) => typeof item === "string", + ) + : [], }; } -export async function markMem0Aliased(email: string): Promise { +export async function isMem0Aliased( + anonId: string, + email: string, +): Promise { + if (!anonId || !email) return false; + const node = await getNodeFs(); + if (!node) return false; + const config = loadConfig(node); + if (!config) return false; + const telemetry = + config.telemetry && typeof config.telemetry === "object" + ? config.telemetry + : {}; + const aliasedPairs = Array.isArray(telemetry.aliased_pairs) + ? telemetry.aliased_pairs + : []; + return aliasedPairs.includes(aliasPairMarker(node, anonId, email)); +} + +export async function markMem0Aliased( + anonId: string, + email: string, +): Promise { const node = await getNodeFs(); if (!node) return; try { - node.fs.mkdirSync(node.path.dirname(node.configPath), { recursive: true }); const config = loadConfig(node) ?? {}; const telemetry = config.telemetry && typeof config.telemetry === "object" ? config.telemetry : {}; - telemetry.aliased_to = email; + const aliasedPairs = Array.isArray(telemetry.aliased_pairs) + ? telemetry.aliased_pairs + : []; + const marker = aliasPairMarker(node, anonId, email); + if (!aliasedPairs.includes(marker)) { + aliasedPairs.push(marker); + } + telemetry.aliased_pairs = aliasedPairs; config.telemetry = telemetry; - node.fs.writeFileSync(node.configPath, JSON.stringify(config, null, 4)); + writeConfig(node, config); } catch { // Best-effort: read-only filesystems and unwritable paths just skip. } diff --git a/mem0-ts/src/client/mem0.ts b/mem0-ts/src/client/mem0.ts index f1ea80694..cac2fe36c 100644 --- a/mem0-ts/src/client/mem0.ts +++ b/mem0-ts/src/client/mem0.ts @@ -26,7 +26,12 @@ import { isTelemetryEnabled, telemetry, } from "./telemetry"; -import { readMem0AnonIds, markMem0Aliased } from "./config"; +import { + getOrCreateMem0UserId, + isMem0Aliased, + markMem0Aliased, + readMem0AnonIds, +} from "./config"; import { camelToSnake, camelToSnakeKeys, snakeToCamelKeys } from "./utils"; import { createExceptionFromResponse, MemoryError } from "../common/exceptions"; @@ -145,16 +150,20 @@ export default class MemoryClient { try { const email = this.telemetryId; if (!email || !email.includes("@")) return; + const sharedAnonId = await getOrCreateMem0UserId(); const anonIds = await readMem0AnonIds(); - if (!anonIds) return; - if (anonIds.aliasedTo === email) return; - const candidates = [anonIds.oss, anonIds.cli].filter( + if (!anonIds && !sharedAnonId) return; + const candidates = [anonIds?.oss || sharedAnonId, anonIds?.cli].filter( (id): id is string => !!id && id !== email, ); + const seen = new Set(); for (const anonId of candidates) { - await telemetry.captureIdentify(anonId, email); + if (seen.has(anonId) || (await isMem0Aliased(anonId, email))) continue; + seen.add(anonId); + if (await telemetry.captureIdentify(anonId, email)) { + await markMem0Aliased(anonId, email); + } } - await markMem0Aliased(email); } catch (error: any) { console.error("Failed to alias telemetry identity:", error); } diff --git a/mem0-ts/src/client/telemetry.ts b/mem0-ts/src/client/telemetry.ts index 19e77c7aa..df3a722d4 100644 --- a/mem0-ts/src/client/telemetry.ts +++ b/mem0-ts/src/client/telemetry.ts @@ -32,8 +32,12 @@ class UnifiedTelemetry implements TelemetryClient { this.host = host; } - async captureEvent(distinctId: string, eventName: string, properties = {}) { - if (!MEM0_TELEMETRY) return; + async captureEvent( + distinctId: string, + eventName: string, + properties = {}, + ): Promise { + if (!MEM0_TELEMETRY) return false; const eventProperties = { client_version: version, @@ -61,19 +65,51 @@ class UnifiedTelemetry implements TelemetryClient { if (!response.ok) { console.error("Telemetry event capture failed:", await response.text()); + return false; } + return true; } catch (error) { console.error("Telemetry event capture failed:", error); + return false; } } - 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 captureIdentify(anonId: string, email: string): Promise { + if (!MEM0_TELEMETRY) return false; + if (!anonId || !email || anonId === email) return false; + + const payload = { + api_key: this.apiKey, + distinct_id: email, + event: "$identify", + properties: { + $anon_distinct_id: anonId, + client_source: "typescript", + $lib: "posthog-node", + }, + }; + + try { + const response = await fetch(this.host, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + console.error( + "Telemetry identify capture failed:", + await response.text(), + ); + return false; + } + return true; + } catch (error) { + console.error("Telemetry identify capture failed:", error); + return false; + } } async shutdown() { diff --git a/mem0-ts/src/client/telemetry.types.ts b/mem0-ts/src/client/telemetry.types.ts index 5b307d99d..cca3a58f4 100644 --- a/mem0-ts/src/client/telemetry.types.ts +++ b/mem0-ts/src/client/telemetry.types.ts @@ -3,7 +3,7 @@ export interface TelemetryClient { distinctId: string, eventName: string, properties?: Record, - ): Promise; + ): Promise; shutdown(): Promise; } diff --git a/mem0-ts/src/client/tests/telemetry-aliasing.test.ts b/mem0-ts/src/client/tests/telemetry-aliasing.test.ts index 881fb2c7a..a0351bbf1 100644 --- a/mem0-ts/src/client/tests/telemetry-aliasing.test.ts +++ b/mem0-ts/src/client/tests/telemetry-aliasing.test.ts @@ -1,7 +1,7 @@ /** * Tests for PostHog identity stitching in the TS MemoryClient. * - * Covers $identify firing, idempotency via aliased_to, and the node/browser + * Covers $identify firing, idempotency via pair markers, and the node/browser * gate. Mocks fs and fetch; never touches the real ~/.mem0/config.json. */ import * as fs from "fs"; @@ -9,12 +9,23 @@ import * as os from "os"; import * as path from "path"; import { MemoryClient } from "../mem0"; import { telemetry } from "../telemetry"; -import { readMem0AnonIds, markMem0Aliased } from "../config"; +import { + getOrCreateMem0UserId, + isMem0Aliased, + markMem0Aliased, + readMem0AnonIds, +} from "../config"; import { TEST_API_KEY } from "./helpers"; import { setupMockFetch, installConsoleSuppression } from "./setup"; installConsoleSuppression(); +function setupMockFetchWithPostHog(): jest.Mock { + return setupMockFetch( + new Map([["us.i.posthog.com", { status: 200, body: "ok" }]]), + ); +} + // ─── config.ts (node-only fs read/write) ────────────────────── describe("config.ts — readMem0AnonIds / markMem0Aliased", () => { @@ -50,25 +61,36 @@ describe("config.ts — readMem0AnonIds / markMem0Aliased", () => { expect(ids).toEqual({ oss: "oss-uuid", cli: undefined, - aliasedTo: undefined, + aliasedPairs: [], }); }); - test("reads CLI anonymous_id and aliased_to", async () => { + test("reads CLI anonymous_id and aliased_pairs", async () => { fs.writeFileSync( path.join(tmpHome, "config.json"), JSON.stringify({ - telemetry: { anonymous_id: "cli-anon", aliased_to: "u@x.com" }, + telemetry: { anonymous_id: "cli-anon", aliased_pairs: ["pair-marker"] }, }), ); const ids = await readMem0AnonIds(); expect(ids).toEqual({ oss: undefined, cli: "cli-anon", - aliasedTo: "u@x.com", + aliasedPairs: ["pair-marker"], }); }); + test("getOrCreateMem0UserId creates and reuses shared SDK user_id", async () => { + const first = await getOrCreateMem0UserId(); + const second = await getOrCreateMem0UserId(); + expect(first).toBeTruthy(); + expect(second).toBe(first); + const written = JSON.parse( + fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"), + ); + expect(written.user_id).toBe(first); + }); + test("returns null on malformed JSON", async () => { fs.writeFileSync(path.join(tmpHome, "config.json"), "{not json"); expect(await readMem0AnonIds()).toBeNull(); @@ -82,13 +104,14 @@ describe("config.ts — readMem0AnonIds / markMem0Aliased", () => { telemetry: { anonymous_id: "cli-anon" }, }), ); - await markMem0Aliased("user@example.com"); + await markMem0Aliased("oss-uuid", "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"); + expect(written.telemetry.aliased_pairs).toHaveLength(1); + expect(await isMem0Aliased("oss-uuid", "user@example.com")).toBe(true); }); test("markMem0Aliased creates telemetry section when missing", async () => { @@ -96,18 +119,31 @@ describe("config.ts — readMem0AnonIds / markMem0Aliased", () => { path.join(tmpHome, "config.json"), JSON.stringify({ user_id: "oss-uuid" }), ); - await markMem0Aliased("user@example.com"); + await markMem0Aliased("oss-uuid", "user@example.com"); const written = JSON.parse( fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"), ); - expect(written.telemetry.aliased_to).toBe("user@example.com"); + expect(written.telemetry.aliased_pairs).toHaveLength(1); + }); + + test("markMem0Aliased tracks each pair independently", async () => { + fs.writeFileSync( + path.join(tmpHome, "config.json"), + JSON.stringify({ user_id: "oss-uuid" }), + ); + await markMem0Aliased("oss-uuid", "user@example.com"); + expect(await isMem0Aliased("oss-uuid", "user@example.com")).toBe(true); + expect(await isMem0Aliased("other-uuid", "user@example.com")).toBe(false); + expect(await isMem0Aliased("oss-uuid", "other@example.com")).toBe(false); }); 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(); + await expect( + markMem0Aliased("oss-uuid", "user@example.com"), + ).resolves.toBeUndefined(); }); }); @@ -130,6 +166,7 @@ describe("telemetry.captureIdentify", () => { expect(payload.event).toBe("$identify"); expect(payload.distinct_id).toBe("user@example.com"); expect(payload.properties.$anon_distinct_id).toBe("anon-uuid"); + expect(payload.properties.$process_person_profile).toBeUndefined(); }); test("skips when anon equals email", async () => { @@ -180,12 +217,12 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => { return client; } - test("fires $identify on first init and persists aliased_to", async () => { + test("fires $identify on first init and persists pair marker", async () => { fs.writeFileSync( path.join(tmpHome, "config.json"), JSON.stringify({ user_id: "oss-uuid" }), ); - const fetchMock = setupMockFetch(); + const fetchMock = setupMockFetchWithPostHog(); const client = makeStubClient("test@example.com"); await (client as any)._maybeAliasAnonToEmail(); @@ -204,7 +241,31 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => { const written = JSON.parse( fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"), ); - expect(written.telemetry.aliased_to).toBe("test@example.com"); + expect(written.telemetry.aliased_pairs).toHaveLength(1); + }); + + test("platform-first init creates shared anon ID and identifies it", async () => { + const fetchMock = setupMockFetchWithPostHog(); + + const client = makeStubClient("test@example.com"); + await (client as any)._maybeAliasAnonToEmail(); + + const written = JSON.parse( + fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"), + ); + expect(written.user_id).toBeTruthy(); + expect(written.telemetry.aliased_pairs).toHaveLength(1); + + 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(written.user_id); }); test("second init does not refire $identify", async () => { @@ -212,10 +273,11 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => { path.join(tmpHome, "config.json"), JSON.stringify({ user_id: "oss-uuid", - telemetry: { aliased_to: "test@example.com" }, + telemetry: {}, }), ); - const fetchMock = setupMockFetch(); + await markMem0Aliased("oss-uuid", "test@example.com"); + const fetchMock = setupMockFetchWithPostHog(); const client = makeStubClient("test@example.com"); await (client as any)._maybeAliasAnonToEmail(); @@ -237,7 +299,7 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => { telemetry: { anonymous_id: "cli-anon" }, }), ); - const fetchMock = setupMockFetch(); + const fetchMock = setupMockFetchWithPostHog(); const client = makeStubClient("test@example.com"); await (client as any)._maybeAliasAnonToEmail(); @@ -255,6 +317,11 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => { ); expect(anonIds).toContain("oss-uuid"); expect(anonIds).toContain("cli-anon"); + + const written = JSON.parse( + fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"), + ); + expect(written.telemetry.aliased_pairs).toHaveLength(2); }); test("noop when telemetryId is not an email", async () => { @@ -320,7 +387,7 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => { const written = JSON.parse( fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"), ); - expect(written.telemetry?.aliased_to).toBeUndefined(); + expect(written.telemetry?.aliased_pairs).toBeUndefined(); }); }); diff --git a/mem0-ts/src/oss/src/memory/index.ts b/mem0-ts/src/oss/src/memory/index.ts index f2856c493..e175e71ff 100644 --- a/mem0-ts/src/oss/src/memory/index.ts +++ b/mem0-ts/src/oss/src/memory/index.ts @@ -53,6 +53,7 @@ import { ScoredResult, } from "../utils/scoring"; import { getDefaultVectorStoreDbPath } from "../utils/sqlite"; +import { getOrCreateMem0UserId } from "../../../client/config"; // Entity params that must be passed via filters - check both snake_case and camelCase const ENTITY_PARAMS = [ @@ -466,7 +467,12 @@ export class Memory { this.telemetryId === "anonymous" || this.telemetryId === "anonymous-supabase" ) { - this.telemetryId = await this.vectorStore.getUserId(); + this.telemetryId = + (await getOrCreateMem0UserId()) || + (await this.vectorStore.getUserId()); + try { + await this.vectorStore.setUserId(this.telemetryId); + } catch {} } return this.telemetryId; } catch (error) { diff --git a/mem0/client/main.py b/mem0/client/main.py index c85300a07..69ff77bd8 100644 --- a/mem0/client/main.py +++ b/mem0/client/main.py @@ -19,7 +19,7 @@ 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, mark_aliased, read_anon_ids, setup_config +from mem0.memory.setup import get_user_id, is_aliased, mark_aliased, read_anon_ids, setup_config from mem0.memory.telemetry import capture_client_event, client_telemetry logger = logging.getLogger(__name__) @@ -36,9 +36,9 @@ ENTITY_PARAMS = frozenset({"user_id", "agent_id", "app_id", "run_id"}) def _maybe_alias_anon_to_email(user_email): """Fire $identify per prior anon ID so PostHog merges them into email. - Idempotent via telemetry.aliased_to — only writes the flag when telemetry - is actually enabled, so disabling/re-enabling MEM0_TELEMETRY still works. - Best-effort — never raises. + Idempotent via telemetry.aliased_pairs: only writes markers when + telemetry is actually enabled, so disabling/re-enabling MEM0_TELEMETRY still works. + Best-effort: never raises. """ if client_telemetry.posthog is None: return @@ -46,12 +46,15 @@ def _maybe_alias_anon_to_email(user_email): return try: anon_ids = read_anon_ids() - if anon_ids.get("aliased_to") == user_email: - return + seen = set() 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) + if not anon_id or anon_id == user_email or anon_id in seen: + continue + seen.add(anon_id) + if is_aliased(anon_id, user_email): + continue + if client_telemetry.capture_identify(anon_id, user_email): + mark_aliased(anon_id, user_email) except Exception as e: logger.debug("Failed to alias anon telemetry to %r: %s", user_email, e) diff --git a/mem0/memory/setup.py b/mem0/memory/setup.py index 75f120693..7d98bcae4 100644 --- a/mem0/memory/setup.py +++ b/mem0/memory/setup.py @@ -2,6 +2,7 @@ import json import logging import os import uuid +from hashlib import sha256 # Set up the directory path VECTOR_ID = str(uuid.uuid4()) @@ -63,29 +64,57 @@ def get_user_id(): def read_anon_ids(): - """Return both anon IDs and the aliased_to flag from ~/.mem0/config.json. + """Return anon IDs and alias markers 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. + Returns a dict with keys "oss", "cli", "aliased_pairs" (IDs 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 {} + aliased_pairs = telemetry.get("aliased_pairs") return { "oss": config.get("user_id"), "cli": telemetry.get("anonymous_id"), - "aliased_to": telemetry.get("aliased_to"), + "aliased_pairs": aliased_pairs if isinstance(aliased_pairs, list) else [], } -def mark_aliased(email): - """Persist telemetry.aliased_to = email so $identify only fires once.""" +def _alias_pair_marker(anon_id, email): + return sha256(f"{anon_id}\0{email}".encode("utf-8")).hexdigest() + + +def is_aliased(anon_id, email): + """Return whether anon_id -> email has already been identified.""" + if not anon_id or not email: + return False + config = _load_config() + telemetry = config.get("telemetry") if isinstance(config.get("telemetry"), dict) else {} + aliased_pairs = telemetry.get("aliased_pairs") + if not isinstance(aliased_pairs, list): + return False + return _alias_pair_marker(anon_id, email) in aliased_pairs + + +def mark_aliased(anon_id, email): + """Persist an anon_id -> email alias marker so $identify fires once per pair. + + The marker is hashed to avoid storing platform emails in the local config. + """ + if not anon_id or not email: + return config = _load_config() telemetry = config.get("telemetry") if not isinstance(telemetry, dict): telemetry = {} - telemetry["aliased_to"] = email + aliased_pairs = telemetry.get("aliased_pairs") + if not isinstance(aliased_pairs, list): + aliased_pairs = [] + marker = _alias_pair_marker(anon_id, email) + if marker not in aliased_pairs: + aliased_pairs.append(marker) + telemetry["aliased_pairs"] = aliased_pairs config["telemetry"] = telemetry _write_config(config) diff --git a/mem0/memory/telemetry.py b/mem0/memory/telemetry.py index c4e3d65ed..0c2c544ad 100644 --- a/mem0/memory/telemetry.py +++ b/mem0/memory/telemetry.py @@ -116,17 +116,19 @@ class AnonymousTelemetry: def capture_identify(self, anon_id, email): """Fire $identify with $anon_distinct_id so PostHog merges anon_id into email.""" if self.posthog is None: - return + return False if not anon_id or not email or anon_id == email: - return + return False try: self.posthog.capture( distinct_id=email, event="$identify", properties={"$anon_distinct_id": anon_id, "client_source": "python"}, ) + return True except Exception as e: _logger.debug("Failed to capture $identify for %r: %s", email, e) + return False def close(self): if self.posthog is not None: diff --git a/tests/test_telemetry_aliasing.py b/tests/test_telemetry_aliasing.py index 96fb8d152..bd6fcf0ee 100644 --- a/tests/test_telemetry_aliasing.py +++ b/tests/test_telemetry_aliasing.py @@ -82,7 +82,7 @@ class TestReadAnonIds: _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} + assert anon == {"oss": "oss-uuid", "cli": None, "aliased_pairs": []} def test_returns_cli_only(self, tmp_mem0_dir): import mem0.memory.setup as setup_module @@ -92,7 +92,7 @@ class TestReadAnonIds: {"telemetry": {"anonymous_id": "cli-anon-123"}}, ) anon = setup_module.read_anon_ids() - assert anon == {"oss": None, "cli": "cli-anon-123", "aliased_to": None} + assert anon == {"oss": None, "cli": "cli-anon-123", "aliased_pairs": []} def test_returns_both(self, tmp_mem0_dir): import mem0.memory.setup as setup_module @@ -101,35 +101,35 @@ class TestReadAnonIds: tmp_mem0_dir, { "user_id": "oss-uuid", - "telemetry": {"anonymous_id": "cli-anon-123", "aliased_to": "user@x.com"}, + "telemetry": {"anonymous_id": "cli-anon-123", "aliased_pairs": ["pair-marker"]}, }, ) anon = setup_module.read_anon_ids() assert anon == { "oss": "oss-uuid", "cli": "cli-anon-123", - "aliased_to": "user@x.com", + "aliased_pairs": ["pair-marker"], } 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} + assert anon == {"oss": None, "cli": None, "aliased_pairs": []} 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} + assert anon == {"oss": None, "cli": None, "aliased_pairs": []} # ─── mark_aliased ──────────────────────────────────────────────────────────── class TestMarkAliased: - def test_writes_aliased_to_preserving_other_fields(self, tmp_mem0_dir): + def test_writes_aliased_pair_preserving_other_fields(self, tmp_mem0_dir): import mem0.memory.setup as setup_module _write_config( @@ -139,19 +139,29 @@ class TestMarkAliased: "telemetry": {"anonymous_id": "cli-anon-123"}, }, ) - setup_module.mark_aliased("user@example.com") + setup_module.mark_aliased("oss-uuid", "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" + assert len(config["telemetry"]["aliased_pairs"]) == 1 + assert setup_module.is_aliased("oss-uuid", "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") + setup_module.mark_aliased("oss-uuid", "user@example.com") config = json.loads((tmp_mem0_dir / "config.json").read_text()) - assert config["telemetry"]["aliased_to"] == "user@example.com" + assert len(config["telemetry"]["aliased_pairs"]) == 1 + + def test_tracks_each_pair_independently(self, tmp_mem0_dir): + import mem0.memory.setup as setup_module + + _write_config(tmp_mem0_dir, {"user_id": "oss-uuid"}) + setup_module.mark_aliased("oss-uuid", "user@example.com") + assert setup_module.is_aliased("oss-uuid", "user@example.com") + assert not setup_module.is_aliased("new-uuid", "user@example.com") + assert not setup_module.is_aliased("oss-uuid", "other@example.com") # ─── capture_identify ──────────────────────────────────────────────────────── @@ -230,14 +240,16 @@ class TestMaybeAliasAnonToEmail: patch.object( client_main, "read_anon_ids", - return_value={"oss": "oss-uuid", "cli": None, "aliased_to": None}, + return_value={"oss": "oss-uuid", "cli": None, "aliased_pairs": []}, ), + patch.object(client_main, "is_aliased", return_value=False), patch.object(client_main, "mark_aliased") as mark, patch.object(client_main, "client_telemetry") as telemetry, ): + telemetry.capture_identify.return_value = True 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") + mark.assert_called_once_with("oss-uuid", "user@example.com") def test_fires_identify_for_cli_anon(self): from mem0.client import main as client_main @@ -246,11 +258,13 @@ class TestMaybeAliasAnonToEmail: patch.object( client_main, "read_anon_ids", - return_value={"oss": None, "cli": "cli-anon-xyz", "aliased_to": None}, + return_value={"oss": None, "cli": "cli-anon-xyz", "aliased_pairs": []}, ), + patch.object(client_main, "is_aliased", return_value=False), patch.object(client_main, "mark_aliased"), patch.object(client_main, "client_telemetry") as telemetry, ): + telemetry.capture_identify.return_value = True client_main._maybe_alias_anon_to_email("user@example.com") telemetry.capture_identify.assert_called_once_with("cli-anon-xyz", "user@example.com") @@ -261,26 +275,29 @@ class TestMaybeAliasAnonToEmail: patch.object( client_main, "read_anon_ids", - return_value={"oss": "oss-uuid", "cli": "cli-anon", "aliased_to": None}, + return_value={"oss": "oss-uuid", "cli": "cli-anon", "aliased_pairs": []}, ), + patch.object(client_main, "is_aliased", return_value=False), patch.object(client_main, "mark_aliased"), patch.object(client_main, "client_telemetry") as telemetry, ): + telemetry.capture_identify.return_value = True 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): + def test_skips_when_pair_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"}, + return_value={"oss": "oss-uuid", "cli": None, "aliased_pairs": ["pair-marker"]}, ), + patch.object(client_main, "is_aliased", return_value=True), patch.object(client_main, "mark_aliased") as mark, patch.object(client_main, "client_telemetry") as telemetry, ): @@ -323,12 +340,14 @@ class TestMaybeAliasAnonToEmail: patch.object( client_main, "read_anon_ids", - return_value={"oss": "oss-uuid", "cli": None, "aliased_to": None}, + return_value={"oss": "oss-uuid", "cli": None, "aliased_pairs": []}, ), - patch.object(client_main, "mark_aliased"), + patch.object(client_main, "is_aliased", return_value=False), + patch.object(client_main, "mark_aliased") as mark, patch.object(client_main, "client_telemetry", mock_telemetry), ): client_main._maybe_alias_anon_to_email("user@example.com") # must not raise + mark.assert_not_called() def test_skips_anon_id_equal_to_email(self): """Defensive: if the anon_id somehow already is the email, don't self-alias.""" @@ -338,8 +357,9 @@ class TestMaybeAliasAnonToEmail: patch.object( client_main, "read_anon_ids", - return_value={"oss": "user@example.com", "cli": None, "aliased_to": None}, + return_value={"oss": "user@example.com", "cli": None, "aliased_pairs": []}, ), + patch.object(client_main, "is_aliased", return_value=False), patch.object(client_main, "mark_aliased"), patch.object(client_main, "client_telemetry") as telemetry, ): @@ -363,9 +383,9 @@ class TestMaybeAliasAnonToEmail: 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.""" + calls fire $identify exactly once thanks to the persisted pair marker.""" - def test_second_call_is_noop_after_aliased_to_persisted(self, tmp_mem0_dir): + def test_second_call_is_noop_after_pair_marker_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 @@ -378,13 +398,14 @@ class TestEndToEndIdempotency: importlib.reload(client_main) with patch.object(client_main, "client_telemetry") as telemetry: + telemetry.capture_identify.return_value = True 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. + # Second call should hit the aliased_pairs 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" + assert len(config["telemetry"]["aliased_pairs"]) == 1