fix (telemetry): stitch oss and platform telemetry identities for python and typescript sdk

Co-authored-by: Younes Slaoui <younes.slaoui@mem0.ai>
This commit is contained in:
Gabriel Stein
2026-05-05 13:48:21 -07:00
committed by GitHub
co-authored by Younes Slaoui
parent 0fdaa29b4a
commit a734e057cf
10 changed files with 1235 additions and 24 deletions
+165
View File
@@ -0,0 +1,165 @@
/**
* Best-effort read/write of ~/.mem0/config.json from the TS SDK.
*
* 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.
*/
export interface Mem0AnonIds {
oss?: string;
cli?: string;
aliasedPairs: string[];
}
interface NodeFs {
fs: typeof import("fs");
path: typeof import("path");
crypto: typeof import("crypto");
configPath: string;
}
async function getNodeFs(): Promise<NodeFs | null> {
if (typeof process === "undefined" || !process.versions?.node) return null;
try {
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 {
return null;
}
}
function loadConfig(node: NodeFs): Record<string, any> | null {
try {
if (!node.fs.existsSync(node.configPath)) return null;
const parsed = JSON.parse(node.fs.readFileSync(node.configPath, "utf8"));
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
}
function writeConfig(node: NodeFs, config: Record<string, any>): 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<string | null> {
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<Mem0AnonIds | null> {
const node = await getNodeFs();
if (!node) return null;
const config = loadConfig(node);
if (!config) return null;
const telemetry =
config.telemetry && typeof config.telemetry === "object"
? config.telemetry
: {};
return {
oss: typeof config.user_id === "string" ? config.user_id : undefined,
cli:
typeof telemetry.anonymous_id === "string"
? telemetry.anonymous_id
: undefined,
aliasedPairs: Array.isArray(telemetry.aliased_pairs)
? telemetry.aliased_pairs.filter(
(item: unknown) => typeof item === "string",
)
: [],
};
}
export async function isMem0Aliased(
anonId: string,
email: string,
): Promise<boolean> {
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<void> {
const node = await getNodeFs();
if (!node) return;
try {
const config = loadConfig(node) ?? {};
const telemetry =
config.telemetry && typeof config.telemetry === "object"
? config.telemetry
: {};
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;
writeConfig(node, config);
} catch {
// Best-effort: read-only filesystems and unwritable paths just skip.
}
}
+38 -1
View File
@@ -20,7 +20,18 @@ import {
CreateMemoryExportPayload,
GetMemoryExportPayload,
} from "./mem0.types";
import { captureClientEvent, generateHash } from "./telemetry";
import {
captureClientEvent,
generateHash,
isTelemetryEnabled,
telemetry,
} from "./telemetry";
import {
getOrCreateMem0UserId,
isMem0Aliased,
markMem0Aliased,
readMem0AnonIds,
} from "./config";
import { camelToSnake, camelToSnakeKeys, snakeToCamelKeys } from "./utils";
import { createExceptionFromResponse, MemoryError } from "../common/exceptions";
@@ -118,6 +129,8 @@ export default class MemoryClient {
this.telemetryId = generateHash(this.apiKey);
}
await this._maybeAliasAnonToEmail();
captureClientEvent("init", this, {
client_type: "MemoryClient",
}).catch((error: any) => {
@@ -132,6 +145,30 @@ export default class MemoryClient {
}
}
private async _maybeAliasAnonToEmail(): Promise<void> {
if (!isTelemetryEnabled()) return;
try {
const email = this.telemetryId;
if (!email || !email.includes("@")) return;
const sharedAnonId = await getOrCreateMem0UserId();
const anonIds = await readMem0AnonIds();
if (!anonIds && !sharedAnonId) return;
const candidates = [anonIds?.oss || sharedAnonId, anonIds?.cli].filter(
(id): id is string => !!id && id !== email,
);
const seen = new Set<string>();
for (const anonId of candidates) {
if (seen.has(anonId) || (await isMem0Aliased(anonId, email))) continue;
seen.add(anonId);
if (await telemetry.captureIdentify(anonId, email)) {
await markMem0Aliased(anonId, email);
}
}
} catch (error: any) {
console.error("Failed to alias telemetry identity:", error);
}
}
private _captureEvent(methodName: string, args: any[]) {
captureClientEvent(methodName, this, {
success: true,
+52 -3
View File
@@ -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<boolean> {
if (!MEM0_TELEMETRY) return false;
const eventProperties = {
client_version: version,
@@ -61,9 +65,50 @@ 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): Promise<boolean> {
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;
}
}
@@ -72,6 +117,10 @@ class UnifiedTelemetry implements TelemetryClient {
}
}
function isTelemetryEnabled(): boolean {
return MEM0_TELEMETRY;
}
const telemetry = new UnifiedTelemetry(POSTHOG_API_KEY, POSTHOG_HOST);
async function captureClientEvent(
@@ -101,4 +150,4 @@ async function captureClientEvent(
);
}
export { telemetry, captureClientEvent, generateHash };
export { telemetry, captureClientEvent, generateHash, isTelemetryEnabled };
+1 -1
View File
@@ -3,7 +3,7 @@ export interface TelemetryClient {
distinctId: string,
eventName: string,
properties?: Record<string, any>,
): Promise<void>;
): Promise<boolean>;
shutdown(): Promise<void>;
}
@@ -0,0 +1,410 @@
/**
* Tests for PostHog identity stitching in the TS MemoryClient.
*
* 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";
import * as os from "os";
import * as path from "path";
import { MemoryClient } from "../mem0";
import { telemetry } from "../telemetry";
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", () => {
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,
aliasedPairs: [],
});
});
test("reads CLI anonymous_id and aliased_pairs", async () => {
fs.writeFileSync(
path.join(tmpHome, "config.json"),
JSON.stringify({
telemetry: { anonymous_id: "cli-anon", aliased_pairs: ["pair-marker"] },
}),
);
const ids = await readMem0AnonIds();
expect(ids).toEqual({
oss: undefined,
cli: "cli-anon",
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();
});
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("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_pairs).toHaveLength(1);
expect(await isMem0Aliased("oss-uuid", "user@example.com")).toBe(true);
});
test("markMem0Aliased creates telemetry section when missing", async () => {
fs.writeFileSync(
path.join(tmpHome, "config.json"),
JSON.stringify({ user_id: "oss-uuid" }),
);
await markMem0Aliased("oss-uuid", "user@example.com");
const written = JSON.parse(
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
);
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("oss-uuid", "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");
expect(payload.properties.$process_person_profile).toBeUndefined();
});
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 pair marker", async () => {
fs.writeFileSync(
path.join(tmpHome, "config.json"),
JSON.stringify({ user_id: "oss-uuid" }),
);
const fetchMock = setupMockFetchWithPostHog();
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_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 () => {
fs.writeFileSync(
path.join(tmpHome, "config.json"),
JSON.stringify({
user_id: "oss-uuid",
telemetry: {},
}),
);
await markMem0Aliased("oss-uuid", "test@example.com");
const fetchMock = setupMockFetchWithPostHog();
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 = setupMockFetchWithPostHog();
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");
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 () => {
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();
});
test("noop when telemetry disabled — no fs read, no fs write, no events", async () => {
fs.writeFileSync(
path.join(tmpHome, "config.json"),
JSON.stringify({ user_id: "oss-uuid" }),
);
const fetchMock = setupMockFetch();
jest.resetModules();
const original = process.env.MEM0_TELEMETRY;
process.env.MEM0_TELEMETRY = "false";
try {
const { MemoryClient: ColdClient } = await import("../mem0");
const client = Object.create(ColdClient.prototype);
client.apiKey = TEST_API_KEY;
client.host = "https://api.mem0.ai";
client.telemetryId = "test@example.com";
await client._maybeAliasAnonToEmail();
} finally {
if (original === undefined) delete process.env.MEM0_TELEMETRY;
else process.env.MEM0_TELEMETRY = original;
jest.resetModules();
}
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);
const written = JSON.parse(
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
);
expect(written.telemetry?.aliased_pairs).toBeUndefined();
});
});
// ─── 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;
// @ts-expect-error force-undefining global to simulate a browser
delete global.process;
try {
jest.resetModules();
const { readMem0AnonIds: browserRead } = await import("../config");
expect(await browserRead()).toBeNull();
} finally {
global.process = originalProcess;
jest.resetModules();
}
});
});
+7 -1
View File
@@ -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) {