fix: stitch oss and platform telemetry identities python sdk and typescript sdk
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Best-effort read/write of ~/.mem0/config.json from the TS SDK.
|
* 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
|
* Used to stitch PostHog identities: SDKs and CLIs persist anonymous
|
||||||
* each persist an anonymous distinct_id here, and the TS MemoryClient reads
|
* distinct_id values here, and the TS MemoryClient reads those on init to
|
||||||
* those on init to fire $identify and merge them into the email identity.
|
* fire $identify and merge them into the email identity.
|
||||||
*
|
*
|
||||||
* Node-only. Browsers (no `process.versions.node`) no-op.
|
* Node-only. Browsers (no `process.versions.node`) no-op.
|
||||||
*/
|
*/
|
||||||
@@ -11,30 +11,34 @@
|
|||||||
export interface Mem0AnonIds {
|
export interface Mem0AnonIds {
|
||||||
oss?: string;
|
oss?: string;
|
||||||
cli?: string;
|
cli?: string;
|
||||||
aliasedTo?: string;
|
aliasedPairs: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NodeFs {
|
interface NodeFs {
|
||||||
fs: typeof import("fs");
|
fs: typeof import("fs");
|
||||||
path: typeof import("path");
|
path: typeof import("path");
|
||||||
|
crypto: typeof import("crypto");
|
||||||
configPath: string;
|
configPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getNodeFs(): Promise<NodeFs | null> {
|
async function getNodeFs(): Promise<NodeFs | null> {
|
||||||
if (typeof process === "undefined" || !process.versions?.node) return null;
|
if (typeof process === "undefined" || !process.versions?.node) return null;
|
||||||
try {
|
try {
|
||||||
const [fs, path, os] = await Promise.all([
|
const [fs, path, os, crypto] = await Promise.all([
|
||||||
import("fs"),
|
import("fs"),
|
||||||
import("path"),
|
import("path"),
|
||||||
import("os"),
|
import("os"),
|
||||||
|
import("crypto"),
|
||||||
]);
|
]);
|
||||||
const fsMod = (fs as any).default ?? fs;
|
const fsMod = (fs as any).default ?? fs;
|
||||||
const pathMod = (path as any).default ?? path;
|
const pathMod = (path as any).default ?? path;
|
||||||
const osMod = (os as any).default ?? os;
|
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");
|
const dir = process.env.MEM0_DIR || pathMod.join(osMod.homedir(), ".mem0");
|
||||||
return {
|
return {
|
||||||
fs: fsMod,
|
fs: fsMod,
|
||||||
path: pathMod,
|
path: pathMod,
|
||||||
|
crypto: cryptoMod,
|
||||||
configPath: pathMod.join(dir, "config.json"),
|
configPath: pathMod.join(dir, "config.json"),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
@@ -52,6 +56,45 @@ function loadConfig(node: NodeFs): Record<string, any> | 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> {
|
export async function readMem0AnonIds(): Promise<Mem0AnonIds | null> {
|
||||||
const node = await getNodeFs();
|
const node = await getNodeFs();
|
||||||
if (!node) return null;
|
if (!node) return null;
|
||||||
@@ -67,26 +110,55 @@ export async function readMem0AnonIds(): Promise<Mem0AnonIds | null> {
|
|||||||
typeof telemetry.anonymous_id === "string"
|
typeof telemetry.anonymous_id === "string"
|
||||||
? telemetry.anonymous_id
|
? telemetry.anonymous_id
|
||||||
: undefined,
|
: undefined,
|
||||||
aliasedTo:
|
aliasedPairs: Array.isArray(telemetry.aliased_pairs)
|
||||||
typeof telemetry.aliased_to === "string"
|
? telemetry.aliased_pairs.filter(
|
||||||
? telemetry.aliased_to
|
(item: unknown) => typeof item === "string",
|
||||||
: undefined,
|
)
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markMem0Aliased(email: string): Promise<void> {
|
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();
|
const node = await getNodeFs();
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
try {
|
try {
|
||||||
node.fs.mkdirSync(node.path.dirname(node.configPath), { recursive: true });
|
|
||||||
const config = loadConfig(node) ?? {};
|
const config = loadConfig(node) ?? {};
|
||||||
const telemetry =
|
const telemetry =
|
||||||
config.telemetry && typeof config.telemetry === "object"
|
config.telemetry && typeof config.telemetry === "object"
|
||||||
? config.telemetry
|
? 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;
|
config.telemetry = telemetry;
|
||||||
node.fs.writeFileSync(node.configPath, JSON.stringify(config, null, 4));
|
writeConfig(node, config);
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort: read-only filesystems and unwritable paths just skip.
|
// Best-effort: read-only filesystems and unwritable paths just skip.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,12 @@ import {
|
|||||||
isTelemetryEnabled,
|
isTelemetryEnabled,
|
||||||
telemetry,
|
telemetry,
|
||||||
} from "./telemetry";
|
} from "./telemetry";
|
||||||
import { readMem0AnonIds, markMem0Aliased } from "./config";
|
import {
|
||||||
|
getOrCreateMem0UserId,
|
||||||
|
isMem0Aliased,
|
||||||
|
markMem0Aliased,
|
||||||
|
readMem0AnonIds,
|
||||||
|
} from "./config";
|
||||||
import { camelToSnake, camelToSnakeKeys, snakeToCamelKeys } from "./utils";
|
import { camelToSnake, camelToSnakeKeys, snakeToCamelKeys } from "./utils";
|
||||||
import { createExceptionFromResponse, MemoryError } from "../common/exceptions";
|
import { createExceptionFromResponse, MemoryError } from "../common/exceptions";
|
||||||
|
|
||||||
@@ -145,16 +150,20 @@ export default class MemoryClient {
|
|||||||
try {
|
try {
|
||||||
const email = this.telemetryId;
|
const email = this.telemetryId;
|
||||||
if (!email || !email.includes("@")) return;
|
if (!email || !email.includes("@")) return;
|
||||||
|
const sharedAnonId = await getOrCreateMem0UserId();
|
||||||
const anonIds = await readMem0AnonIds();
|
const anonIds = await readMem0AnonIds();
|
||||||
if (!anonIds) return;
|
if (!anonIds && !sharedAnonId) return;
|
||||||
if (anonIds.aliasedTo === email) return;
|
const candidates = [anonIds?.oss || sharedAnonId, anonIds?.cli].filter(
|
||||||
const candidates = [anonIds.oss, anonIds.cli].filter(
|
|
||||||
(id): id is string => !!id && id !== email,
|
(id): id is string => !!id && id !== email,
|
||||||
);
|
);
|
||||||
|
const seen = new Set<string>();
|
||||||
for (const anonId of candidates) {
|
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) {
|
} catch (error: any) {
|
||||||
console.error("Failed to alias telemetry identity:", error);
|
console.error("Failed to alias telemetry identity:", error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,12 @@ class UnifiedTelemetry implements TelemetryClient {
|
|||||||
this.host = host;
|
this.host = host;
|
||||||
}
|
}
|
||||||
|
|
||||||
async captureEvent(distinctId: string, eventName: string, properties = {}) {
|
async captureEvent(
|
||||||
if (!MEM0_TELEMETRY) return;
|
distinctId: string,
|
||||||
|
eventName: string,
|
||||||
|
properties = {},
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!MEM0_TELEMETRY) return false;
|
||||||
|
|
||||||
const eventProperties = {
|
const eventProperties = {
|
||||||
client_version: version,
|
client_version: version,
|
||||||
@@ -61,19 +65,51 @@ class UnifiedTelemetry implements TelemetryClient {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error("Telemetry event capture failed:", await response.text());
|
console.error("Telemetry event capture failed:", await response.text());
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Telemetry event capture failed:", error);
|
console.error("Telemetry event capture failed:", error);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async captureIdentify(anonId: string, email: string) {
|
async captureIdentify(anonId: string, email: string): Promise<boolean> {
|
||||||
if (!MEM0_TELEMETRY) return;
|
if (!MEM0_TELEMETRY) return false;
|
||||||
if (!anonId || !email || anonId === email) return;
|
if (!anonId || !email || anonId === email) return false;
|
||||||
await this.captureEvent(email, "$identify", {
|
|
||||||
$anon_distinct_id: anonId,
|
const payload = {
|
||||||
client_source: "typescript",
|
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() {
|
async shutdown() {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export interface TelemetryClient {
|
|||||||
distinctId: string,
|
distinctId: string,
|
||||||
eventName: string,
|
eventName: string,
|
||||||
properties?: Record<string, any>,
|
properties?: Record<string, any>,
|
||||||
): Promise<void>;
|
): Promise<boolean>;
|
||||||
shutdown(): Promise<void>;
|
shutdown(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Tests for PostHog identity stitching in the TS MemoryClient.
|
* 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.
|
* gate. Mocks fs and fetch; never touches the real ~/.mem0/config.json.
|
||||||
*/
|
*/
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
@@ -9,12 +9,23 @@ import * as os from "os";
|
|||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import { MemoryClient } from "../mem0";
|
import { MemoryClient } from "../mem0";
|
||||||
import { telemetry } from "../telemetry";
|
import { telemetry } from "../telemetry";
|
||||||
import { readMem0AnonIds, markMem0Aliased } from "../config";
|
import {
|
||||||
|
getOrCreateMem0UserId,
|
||||||
|
isMem0Aliased,
|
||||||
|
markMem0Aliased,
|
||||||
|
readMem0AnonIds,
|
||||||
|
} from "../config";
|
||||||
import { TEST_API_KEY } from "./helpers";
|
import { TEST_API_KEY } from "./helpers";
|
||||||
import { setupMockFetch, installConsoleSuppression } from "./setup";
|
import { setupMockFetch, installConsoleSuppression } from "./setup";
|
||||||
|
|
||||||
installConsoleSuppression();
|
installConsoleSuppression();
|
||||||
|
|
||||||
|
function setupMockFetchWithPostHog(): jest.Mock {
|
||||||
|
return setupMockFetch(
|
||||||
|
new Map([["us.i.posthog.com", { status: 200, body: "ok" }]]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── config.ts (node-only fs read/write) ──────────────────────
|
// ─── config.ts (node-only fs read/write) ──────────────────────
|
||||||
|
|
||||||
describe("config.ts — readMem0AnonIds / markMem0Aliased", () => {
|
describe("config.ts — readMem0AnonIds / markMem0Aliased", () => {
|
||||||
@@ -50,25 +61,36 @@ describe("config.ts — readMem0AnonIds / markMem0Aliased", () => {
|
|||||||
expect(ids).toEqual({
|
expect(ids).toEqual({
|
||||||
oss: "oss-uuid",
|
oss: "oss-uuid",
|
||||||
cli: undefined,
|
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(
|
fs.writeFileSync(
|
||||||
path.join(tmpHome, "config.json"),
|
path.join(tmpHome, "config.json"),
|
||||||
JSON.stringify({
|
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();
|
const ids = await readMem0AnonIds();
|
||||||
expect(ids).toEqual({
|
expect(ids).toEqual({
|
||||||
oss: undefined,
|
oss: undefined,
|
||||||
cli: "cli-anon",
|
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 () => {
|
test("returns null on malformed JSON", async () => {
|
||||||
fs.writeFileSync(path.join(tmpHome, "config.json"), "{not json");
|
fs.writeFileSync(path.join(tmpHome, "config.json"), "{not json");
|
||||||
expect(await readMem0AnonIds()).toBeNull();
|
expect(await readMem0AnonIds()).toBeNull();
|
||||||
@@ -82,13 +104,14 @@ describe("config.ts — readMem0AnonIds / markMem0Aliased", () => {
|
|||||||
telemetry: { anonymous_id: "cli-anon" },
|
telemetry: { anonymous_id: "cli-anon" },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await markMem0Aliased("user@example.com");
|
await markMem0Aliased("oss-uuid", "user@example.com");
|
||||||
const written = JSON.parse(
|
const written = JSON.parse(
|
||||||
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
||||||
);
|
);
|
||||||
expect(written.user_id).toBe("oss-uuid");
|
expect(written.user_id).toBe("oss-uuid");
|
||||||
expect(written.telemetry.anonymous_id).toBe("cli-anon");
|
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 () => {
|
test("markMem0Aliased creates telemetry section when missing", async () => {
|
||||||
@@ -96,18 +119,31 @@ describe("config.ts — readMem0AnonIds / markMem0Aliased", () => {
|
|||||||
path.join(tmpHome, "config.json"),
|
path.join(tmpHome, "config.json"),
|
||||||
JSON.stringify({ user_id: "oss-uuid" }),
|
JSON.stringify({ user_id: "oss-uuid" }),
|
||||||
);
|
);
|
||||||
await markMem0Aliased("user@example.com");
|
await markMem0Aliased("oss-uuid", "user@example.com");
|
||||||
const written = JSON.parse(
|
const written = JSON.parse(
|
||||||
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
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 () => {
|
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).
|
// Point at a path that cannot be written to (a file-as-dir collision).
|
||||||
fs.writeFileSync(path.join(tmpHome, "blocker"), "x");
|
fs.writeFileSync(path.join(tmpHome, "blocker"), "x");
|
||||||
process.env.MEM0_DIR = path.join(tmpHome, "blocker"); // file used as dir
|
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.event).toBe("$identify");
|
||||||
expect(payload.distinct_id).toBe("user@example.com");
|
expect(payload.distinct_id).toBe("user@example.com");
|
||||||
expect(payload.properties.$anon_distinct_id).toBe("anon-uuid");
|
expect(payload.properties.$anon_distinct_id).toBe("anon-uuid");
|
||||||
|
expect(payload.properties.$process_person_profile).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("skips when anon equals email", async () => {
|
test("skips when anon equals email", async () => {
|
||||||
@@ -180,12 +217,12 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => {
|
|||||||
return client;
|
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(
|
fs.writeFileSync(
|
||||||
path.join(tmpHome, "config.json"),
|
path.join(tmpHome, "config.json"),
|
||||||
JSON.stringify({ user_id: "oss-uuid" }),
|
JSON.stringify({ user_id: "oss-uuid" }),
|
||||||
);
|
);
|
||||||
const fetchMock = setupMockFetch();
|
const fetchMock = setupMockFetchWithPostHog();
|
||||||
|
|
||||||
const client = makeStubClient("test@example.com");
|
const client = makeStubClient("test@example.com");
|
||||||
await (client as any)._maybeAliasAnonToEmail();
|
await (client as any)._maybeAliasAnonToEmail();
|
||||||
@@ -204,7 +241,31 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => {
|
|||||||
const written = JSON.parse(
|
const written = JSON.parse(
|
||||||
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
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 () => {
|
test("second init does not refire $identify", async () => {
|
||||||
@@ -212,10 +273,11 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => {
|
|||||||
path.join(tmpHome, "config.json"),
|
path.join(tmpHome, "config.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
user_id: "oss-uuid",
|
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");
|
const client = makeStubClient("test@example.com");
|
||||||
await (client as any)._maybeAliasAnonToEmail();
|
await (client as any)._maybeAliasAnonToEmail();
|
||||||
@@ -237,7 +299,7 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => {
|
|||||||
telemetry: { anonymous_id: "cli-anon" },
|
telemetry: { anonymous_id: "cli-anon" },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const fetchMock = setupMockFetch();
|
const fetchMock = setupMockFetchWithPostHog();
|
||||||
|
|
||||||
const client = makeStubClient("test@example.com");
|
const client = makeStubClient("test@example.com");
|
||||||
await (client as any)._maybeAliasAnonToEmail();
|
await (client as any)._maybeAliasAnonToEmail();
|
||||||
@@ -255,6 +317,11 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => {
|
|||||||
);
|
);
|
||||||
expect(anonIds).toContain("oss-uuid");
|
expect(anonIds).toContain("oss-uuid");
|
||||||
expect(anonIds).toContain("cli-anon");
|
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 () => {
|
test("noop when telemetryId is not an email", async () => {
|
||||||
@@ -320,7 +387,7 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => {
|
|||||||
const written = JSON.parse(
|
const written = JSON.parse(
|
||||||
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
||||||
);
|
);
|
||||||
expect(written.telemetry?.aliased_to).toBeUndefined();
|
expect(written.telemetry?.aliased_pairs).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
ScoredResult,
|
ScoredResult,
|
||||||
} from "../utils/scoring";
|
} from "../utils/scoring";
|
||||||
import { getDefaultVectorStoreDbPath } from "../utils/sqlite";
|
import { getDefaultVectorStoreDbPath } from "../utils/sqlite";
|
||||||
|
import { getOrCreateMem0UserId } from "../../../client/config";
|
||||||
|
|
||||||
// Entity params that must be passed via filters - check both snake_case and camelCase
|
// Entity params that must be passed via filters - check both snake_case and camelCase
|
||||||
const ENTITY_PARAMS = [
|
const ENTITY_PARAMS = [
|
||||||
@@ -466,7 +467,12 @@ export class Memory {
|
|||||||
this.telemetryId === "anonymous" ||
|
this.telemetryId === "anonymous" ||
|
||||||
this.telemetryId === "anonymous-supabase"
|
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;
|
return this.telemetryId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+12
-9
@@ -19,7 +19,7 @@ from mem0.client.types import (
|
|||||||
from mem0.client.utils import api_error_handler
|
from mem0.client.utils import api_error_handler
|
||||||
|
|
||||||
# Exception classes are referenced in docstrings only
|
# 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
|
from mem0.memory.telemetry import capture_client_event, client_telemetry
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
def _maybe_alias_anon_to_email(user_email):
|
||||||
"""Fire $identify per prior anon ID so PostHog merges them into email.
|
"""Fire $identify per prior anon ID so PostHog merges them into email.
|
||||||
|
|
||||||
Idempotent via telemetry.aliased_to — only writes the flag when telemetry
|
Idempotent via telemetry.aliased_pairs: only writes markers when
|
||||||
is actually enabled, so disabling/re-enabling MEM0_TELEMETRY still works.
|
telemetry is actually enabled, so disabling/re-enabling MEM0_TELEMETRY still works.
|
||||||
Best-effort — never raises.
|
Best-effort: never raises.
|
||||||
"""
|
"""
|
||||||
if client_telemetry.posthog is None:
|
if client_telemetry.posthog is None:
|
||||||
return
|
return
|
||||||
@@ -46,12 +46,15 @@ def _maybe_alias_anon_to_email(user_email):
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
anon_ids = read_anon_ids()
|
anon_ids = read_anon_ids()
|
||||||
if anon_ids.get("aliased_to") == user_email:
|
seen = set()
|
||||||
return
|
|
||||||
for anon_id in (anon_ids.get("oss"), anon_ids.get("cli")):
|
for anon_id in (anon_ids.get("oss"), anon_ids.get("cli")):
|
||||||
if anon_id and anon_id != user_email:
|
if not anon_id or anon_id == user_email or anon_id in seen:
|
||||||
client_telemetry.capture_identify(anon_id, user_email)
|
continue
|
||||||
mark_aliased(user_email)
|
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:
|
except Exception as e:
|
||||||
logger.debug("Failed to alias anon telemetry to %r: %s", user_email, e)
|
logger.debug("Failed to alias anon telemetry to %r: %s", user_email, e)
|
||||||
|
|
||||||
|
|||||||
+38
-9
@@ -2,6 +2,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
from hashlib import sha256
|
||||||
|
|
||||||
# Set up the directory path
|
# Set up the directory path
|
||||||
VECTOR_ID = str(uuid.uuid4())
|
VECTOR_ID = str(uuid.uuid4())
|
||||||
@@ -63,29 +64,57 @@ def get_user_id():
|
|||||||
|
|
||||||
|
|
||||||
def read_anon_ids():
|
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).
|
Returns a dict with keys "oss", "cli", "aliased_pairs" (IDs may be
|
||||||
OSS Python writes top-level "user_id"; the CLI writes
|
None). OSS Python writes top-level "user_id"; the CLI writes
|
||||||
"telemetry.anonymous_id". They may coexist depending on which surface
|
"telemetry.anonymous_id". They may coexist depending on which surface ran
|
||||||
ran first.
|
first.
|
||||||
"""
|
"""
|
||||||
config = _load_config()
|
config = _load_config()
|
||||||
telemetry = config.get("telemetry") if isinstance(config.get("telemetry"), dict) else {}
|
telemetry = config.get("telemetry") if isinstance(config.get("telemetry"), dict) else {}
|
||||||
|
aliased_pairs = telemetry.get("aliased_pairs")
|
||||||
return {
|
return {
|
||||||
"oss": config.get("user_id"),
|
"oss": config.get("user_id"),
|
||||||
"cli": telemetry.get("anonymous_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):
|
def _alias_pair_marker(anon_id, email):
|
||||||
"""Persist telemetry.aliased_to = email so $identify only fires once."""
|
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()
|
config = _load_config()
|
||||||
telemetry = config.get("telemetry")
|
telemetry = config.get("telemetry")
|
||||||
if not isinstance(telemetry, dict):
|
if not isinstance(telemetry, dict):
|
||||||
telemetry = {}
|
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
|
config["telemetry"] = telemetry
|
||||||
_write_config(config)
|
_write_config(config)
|
||||||
|
|
||||||
|
|||||||
@@ -116,17 +116,19 @@ class AnonymousTelemetry:
|
|||||||
def capture_identify(self, anon_id, email):
|
def capture_identify(self, anon_id, email):
|
||||||
"""Fire $identify with $anon_distinct_id so PostHog merges anon_id into email."""
|
"""Fire $identify with $anon_distinct_id so PostHog merges anon_id into email."""
|
||||||
if self.posthog is None:
|
if self.posthog is None:
|
||||||
return
|
return False
|
||||||
if not anon_id or not email or anon_id == email:
|
if not anon_id or not email or anon_id == email:
|
||||||
return
|
return False
|
||||||
try:
|
try:
|
||||||
self.posthog.capture(
|
self.posthog.capture(
|
||||||
distinct_id=email,
|
distinct_id=email,
|
||||||
event="$identify",
|
event="$identify",
|
||||||
properties={"$anon_distinct_id": anon_id, "client_source": "python"},
|
properties={"$anon_distinct_id": anon_id, "client_source": "python"},
|
||||||
)
|
)
|
||||||
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.debug("Failed to capture $identify for %r: %s", email, e)
|
_logger.debug("Failed to capture $identify for %r: %s", email, e)
|
||||||
|
return False
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
if self.posthog is not None:
|
if self.posthog is not None:
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ class TestReadAnonIds:
|
|||||||
|
|
||||||
_write_config(tmp_mem0_dir, {"user_id": "oss-uuid"})
|
_write_config(tmp_mem0_dir, {"user_id": "oss-uuid"})
|
||||||
anon = setup_module.read_anon_ids()
|
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):
|
def test_returns_cli_only(self, tmp_mem0_dir):
|
||||||
import mem0.memory.setup as setup_module
|
import mem0.memory.setup as setup_module
|
||||||
@@ -92,7 +92,7 @@ class TestReadAnonIds:
|
|||||||
{"telemetry": {"anonymous_id": "cli-anon-123"}},
|
{"telemetry": {"anonymous_id": "cli-anon-123"}},
|
||||||
)
|
)
|
||||||
anon = setup_module.read_anon_ids()
|
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):
|
def test_returns_both(self, tmp_mem0_dir):
|
||||||
import mem0.memory.setup as setup_module
|
import mem0.memory.setup as setup_module
|
||||||
@@ -101,35 +101,35 @@ class TestReadAnonIds:
|
|||||||
tmp_mem0_dir,
|
tmp_mem0_dir,
|
||||||
{
|
{
|
||||||
"user_id": "oss-uuid",
|
"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()
|
anon = setup_module.read_anon_ids()
|
||||||
assert anon == {
|
assert anon == {
|
||||||
"oss": "oss-uuid",
|
"oss": "oss-uuid",
|
||||||
"cli": "cli-anon-123",
|
"cli": "cli-anon-123",
|
||||||
"aliased_to": "user@x.com",
|
"aliased_pairs": ["pair-marker"],
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_no_config_returns_all_none(self, tmp_mem0_dir):
|
def test_no_config_returns_all_none(self, tmp_mem0_dir):
|
||||||
import mem0.memory.setup as setup_module
|
import mem0.memory.setup as setup_module
|
||||||
|
|
||||||
anon = setup_module.read_anon_ids()
|
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):
|
def test_malformed_json_does_not_raise(self, tmp_mem0_dir):
|
||||||
import mem0.memory.setup as setup_module
|
import mem0.memory.setup as setup_module
|
||||||
|
|
||||||
(tmp_mem0_dir / "config.json").write_text("{not json")
|
(tmp_mem0_dir / "config.json").write_text("{not json")
|
||||||
anon = setup_module.read_anon_ids()
|
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 ────────────────────────────────────────────────────────────
|
# ─── mark_aliased ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class TestMarkAliased:
|
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
|
import mem0.memory.setup as setup_module
|
||||||
|
|
||||||
_write_config(
|
_write_config(
|
||||||
@@ -139,19 +139,29 @@ class TestMarkAliased:
|
|||||||
"telemetry": {"anonymous_id": "cli-anon-123"},
|
"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())
|
config = json.loads((tmp_mem0_dir / "config.json").read_text())
|
||||||
assert config["user_id"] == "oss-uuid"
|
assert config["user_id"] == "oss-uuid"
|
||||||
assert config["telemetry"]["anonymous_id"] == "cli-anon-123"
|
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):
|
def test_creates_telemetry_section_when_missing(self, tmp_mem0_dir):
|
||||||
import mem0.memory.setup as setup_module
|
import mem0.memory.setup as setup_module
|
||||||
|
|
||||||
_write_config(tmp_mem0_dir, {"user_id": "oss-uuid"})
|
_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())
|
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 ────────────────────────────────────────────────────────
|
# ─── capture_identify ────────────────────────────────────────────────────────
|
||||||
@@ -230,14 +240,16 @@ class TestMaybeAliasAnonToEmail:
|
|||||||
patch.object(
|
patch.object(
|
||||||
client_main,
|
client_main,
|
||||||
"read_anon_ids",
|
"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, "mark_aliased") as mark,
|
||||||
patch.object(client_main, "client_telemetry") as telemetry,
|
patch.object(client_main, "client_telemetry") as telemetry,
|
||||||
):
|
):
|
||||||
|
telemetry.capture_identify.return_value = True
|
||||||
client_main._maybe_alias_anon_to_email("user@example.com")
|
client_main._maybe_alias_anon_to_email("user@example.com")
|
||||||
telemetry.capture_identify.assert_called_once_with("oss-uuid", "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):
|
def test_fires_identify_for_cli_anon(self):
|
||||||
from mem0.client import main as client_main
|
from mem0.client import main as client_main
|
||||||
@@ -246,11 +258,13 @@ class TestMaybeAliasAnonToEmail:
|
|||||||
patch.object(
|
patch.object(
|
||||||
client_main,
|
client_main,
|
||||||
"read_anon_ids",
|
"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, "mark_aliased"),
|
||||||
patch.object(client_main, "client_telemetry") as telemetry,
|
patch.object(client_main, "client_telemetry") as telemetry,
|
||||||
):
|
):
|
||||||
|
telemetry.capture_identify.return_value = True
|
||||||
client_main._maybe_alias_anon_to_email("user@example.com")
|
client_main._maybe_alias_anon_to_email("user@example.com")
|
||||||
telemetry.capture_identify.assert_called_once_with("cli-anon-xyz", "user@example.com")
|
telemetry.capture_identify.assert_called_once_with("cli-anon-xyz", "user@example.com")
|
||||||
|
|
||||||
@@ -261,26 +275,29 @@ class TestMaybeAliasAnonToEmail:
|
|||||||
patch.object(
|
patch.object(
|
||||||
client_main,
|
client_main,
|
||||||
"read_anon_ids",
|
"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, "mark_aliased"),
|
||||||
patch.object(client_main, "client_telemetry") as telemetry,
|
patch.object(client_main, "client_telemetry") as telemetry,
|
||||||
):
|
):
|
||||||
|
telemetry.capture_identify.return_value = True
|
||||||
client_main._maybe_alias_anon_to_email("user@example.com")
|
client_main._maybe_alias_anon_to_email("user@example.com")
|
||||||
assert telemetry.capture_identify.call_count == 2
|
assert telemetry.capture_identify.call_count == 2
|
||||||
calls = {c.args for c in telemetry.capture_identify.call_args_list}
|
calls = {c.args for c in telemetry.capture_identify.call_args_list}
|
||||||
assert ("oss-uuid", "user@example.com") in calls
|
assert ("oss-uuid", "user@example.com") in calls
|
||||||
assert ("cli-anon", "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
|
from mem0.client import main as client_main
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(
|
patch.object(
|
||||||
client_main,
|
client_main,
|
||||||
"read_anon_ids",
|
"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, "mark_aliased") as mark,
|
||||||
patch.object(client_main, "client_telemetry") as telemetry,
|
patch.object(client_main, "client_telemetry") as telemetry,
|
||||||
):
|
):
|
||||||
@@ -323,12 +340,14 @@ class TestMaybeAliasAnonToEmail:
|
|||||||
patch.object(
|
patch.object(
|
||||||
client_main,
|
client_main,
|
||||||
"read_anon_ids",
|
"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),
|
patch.object(client_main, "client_telemetry", mock_telemetry),
|
||||||
):
|
):
|
||||||
client_main._maybe_alias_anon_to_email("user@example.com") # must not raise
|
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):
|
def test_skips_anon_id_equal_to_email(self):
|
||||||
"""Defensive: if the anon_id somehow already is the email, don't self-alias."""
|
"""Defensive: if the anon_id somehow already is the email, don't self-alias."""
|
||||||
@@ -338,8 +357,9 @@ class TestMaybeAliasAnonToEmail:
|
|||||||
patch.object(
|
patch.object(
|
||||||
client_main,
|
client_main,
|
||||||
"read_anon_ids",
|
"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, "mark_aliased"),
|
||||||
patch.object(client_main, "client_telemetry") as telemetry,
|
patch.object(client_main, "client_telemetry") as telemetry,
|
||||||
):
|
):
|
||||||
@@ -363,9 +383,9 @@ class TestMaybeAliasAnonToEmail:
|
|||||||
|
|
||||||
class TestEndToEndIdempotency:
|
class TestEndToEndIdempotency:
|
||||||
"""Verify the real config flow: two consecutive _maybe_alias_anon_to_email
|
"""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.
|
# Pre-populate config with an OSS user_id only.
|
||||||
_write_config(tmp_mem0_dir, {"user_id": "oss-uuid"})
|
_write_config(tmp_mem0_dir, {"user_id": "oss-uuid"})
|
||||||
# Reload setup so it uses the tempdir, then reload client.main so it
|
# Reload setup so it uses the tempdir, then reload client.main so it
|
||||||
@@ -378,13 +398,14 @@ class TestEndToEndIdempotency:
|
|||||||
importlib.reload(client_main)
|
importlib.reload(client_main)
|
||||||
|
|
||||||
with patch.object(client_main, "client_telemetry") as telemetry:
|
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")
|
client_main._maybe_alias_anon_to_email("user@example.com")
|
||||||
first_call_count = telemetry.capture_identify.call_count
|
first_call_count = telemetry.capture_identify.call_count
|
||||||
assert first_call_count >= 1
|
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")
|
client_main._maybe_alias_anon_to_email("user@example.com")
|
||||||
assert telemetry.capture_identify.call_count == first_call_count
|
assert telemetry.capture_identify.call_count == first_call_count
|
||||||
|
|
||||||
config = json.loads((tmp_mem0_dir / "config.json").read_text())
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user