fix(telemetry): stitch PostHog identities on MemoryClient init
The Python sync/async MemoryClient and the TS MemoryClient were silently switching their PostHog distinct_id from an anonymous on-disk UUID to the user's email post-ping, without sending the merge instruction. PostHog ended up tracking each user as 2-3 disconnected personas (OSS UUID, CLI anon UUID, email), and only ~4 $create_alias events fired across ~300K users. The Python CLI does this correctly today; this change ports the same pattern to MemoryClient. - mem0/memory/setup.py: make setup_config() idempotent so it backfills a top-level user_id for CLI-first users (the CLI writes telemetry.anonymous_id, not user_id, which left OSS telemetry silently dropped). Add read_anon_ids() and mark_aliased() helpers. - mem0/memory/telemetry.py: add AnonymousTelemetry.capture_identify() that posts a PostHog $identify event with $anon_distinct_id. Add "$identify" to _LIFECYCLE_EVENTS so it bypasses the 90% sampling drop. - mem0/client/main.py: at the end of __init__ for both MemoryClient and AsyncMemoryClient, fire $identify for each unaliased anon ID and persist telemetry.aliased_to so the alias only happens once per (anon_id, email) pair, ever. All telemetry calls swallow exceptions. - mem0-ts/src/client/telemetry.ts: mirror with captureIdentify(). - mem0-ts/src/client/config.ts (new): node-only readMem0AnonIds() and markMem0Aliased() using async dynamic imports of fs/path/os, gated on process.versions.node so browsers no-op cleanly. - mem0-ts/src/client/mem0.ts: call _maybeAliasAnonToEmail() inside _initializeClient after ping resolves the email. Tests cover the matrix (OSS-only, CLI-only, both, already-aliased) plus failure modes (malformed JSON, read-only fs, posthog errors, browser env). Telemetry never raises.
This commit is contained in:
@@ -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<Awaited<ReturnType<typeof loadNodeModules>>>;
|
||||
} | 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<Record<string, unknown> | 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<Mem0AnonIds | null> {
|
||||
const config = await loadRawConfig();
|
||||
if (!config) return null;
|
||||
const telemetry =
|
||||
config.telemetry && typeof config.telemetry === "object"
|
||||
? (config.telemetry as Record<string, unknown>)
|
||||
: {};
|
||||
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<void> {
|
||||
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<string, unknown>)
|
||||
: {};
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
+26
-2
@@ -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):
|
||||
|
||||
+73
-15
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user