fix(telemetry): only stitch oss identities with evidence of real oss usage
MemoryClient init fired PostHog $identify for anon ids the SDKs had minted themselves (Python: setup_config() at import time; TS: getOrCreateMem0UserId() inside the alias path), so ~90% of stitches merged event-less anon persons into platform users, worst in ephemeral containers. - never create the anon id from platform client paths; Python mints lazily in Memory/AsyncMemory init, gated on MEM0_TELEMETRY - record telemetry.oss_used_at when OSS Memory genuinely initializes (both SDKs, shared ~/.mem0/config.json key) and only stitch the oss user_id when the marker is present; cli anon id stays presence-based - stamp client_version on $identify so fixed-SDK stitches are separable from old-SDK noise in PostHog
This commit is contained in:
@@ -3,7 +3,9 @@
|
||||
*
|
||||
* 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.
|
||||
* fire $identify and merge them into the email identity. The OSS user_id is
|
||||
* only a stitch candidate when telemetry.oss_used_at proves OSS Memory
|
||||
* actually ran here (the platform client never mints ids itself).
|
||||
*
|
||||
* Node-only. Browsers (no `process.versions.node`) no-op.
|
||||
*/
|
||||
@@ -11,6 +13,7 @@
|
||||
export interface Mem0AnonIds {
|
||||
oss?: string;
|
||||
cli?: string;
|
||||
ossUsedAt?: string;
|
||||
aliasedPairs: string[];
|
||||
}
|
||||
|
||||
@@ -110,6 +113,10 @@ export async function readMem0AnonIds(): Promise<Mem0AnonIds | null> {
|
||||
typeof telemetry.anonymous_id === "string"
|
||||
? telemetry.anonymous_id
|
||||
: undefined,
|
||||
ossUsedAt:
|
||||
typeof telemetry.oss_used_at === "string" && telemetry.oss_used_at
|
||||
? telemetry.oss_used_at
|
||||
: undefined,
|
||||
aliasedPairs: Array.isArray(telemetry.aliased_pairs)
|
||||
? telemetry.aliased_pairs.filter(
|
||||
(item: unknown) => typeof item === "string",
|
||||
@@ -137,6 +144,26 @@ export async function isMem0Aliased(
|
||||
return aliasedPairs.includes(aliasPairMarker(node, anonId, email));
|
||||
}
|
||||
|
||||
export async function markMem0OssUsed(): Promise<void> {
|
||||
const node = await getNodeFs();
|
||||
if (!node) return;
|
||||
try {
|
||||
const config = loadConfig(node) ?? {};
|
||||
const telemetry =
|
||||
config.telemetry && typeof config.telemetry === "object"
|
||||
? config.telemetry
|
||||
: {};
|
||||
if (typeof telemetry.oss_used_at === "string" && telemetry.oss_used_at) {
|
||||
return;
|
||||
}
|
||||
telemetry.oss_used_at = new Date().toISOString();
|
||||
config.telemetry = telemetry;
|
||||
writeConfig(node, config);
|
||||
} catch {
|
||||
// Best-effort: read-only filesystems and unwritable paths just skip.
|
||||
}
|
||||
}
|
||||
|
||||
export async function markMem0Aliased(
|
||||
anonId: string,
|
||||
email: string,
|
||||
|
||||
+10
-11
@@ -27,12 +27,7 @@ import {
|
||||
isTelemetryEnabled,
|
||||
telemetry,
|
||||
} from "./telemetry";
|
||||
import {
|
||||
getOrCreateMem0UserId,
|
||||
isMem0Aliased,
|
||||
markMem0Aliased,
|
||||
readMem0AnonIds,
|
||||
} from "./config";
|
||||
import { isMem0Aliased, markMem0Aliased, readMem0AnonIds } from "./config";
|
||||
import { camelToSnake, camelToSnakeKeys, snakeToCamelKeys } from "./utils";
|
||||
import { createExceptionFromResponse, MemoryError } from "../common/exceptions";
|
||||
|
||||
@@ -151,12 +146,16 @@ export default class MemoryClient {
|
||||
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,
|
||||
);
|
||||
if (!anonIds) return;
|
||||
// The OSS user_id is only a stitch candidate when telemetry.oss_used_at
|
||||
// proves OSS Memory actually ran here; never mint an id from the
|
||||
// platform client path. The CLI anon id is presence-based (only the
|
||||
// CLIs write it).
|
||||
const candidates = [
|
||||
anonIds.ossUsedAt ? anonIds.oss : undefined,
|
||||
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;
|
||||
|
||||
@@ -85,6 +85,7 @@ class UnifiedTelemetry implements TelemetryClient {
|
||||
properties: {
|
||||
$anon_distinct_id: anonId,
|
||||
client_source: "typescript",
|
||||
client_version: version,
|
||||
$lib: "posthog-node",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getOrCreateMem0UserId,
|
||||
isMem0Aliased,
|
||||
markMem0Aliased,
|
||||
markMem0OssUsed,
|
||||
readMem0AnonIds,
|
||||
} from "../config";
|
||||
import { TEST_API_KEY } from "./helpers";
|
||||
@@ -145,6 +146,55 @@ describe("config.ts — readMem0AnonIds / markMem0Aliased", () => {
|
||||
markMem0Aliased("oss-uuid", "user@example.com"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("markMem0OssUsed writes marker preserving other fields", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, "config.json"),
|
||||
JSON.stringify({
|
||||
user_id: "oss-uuid",
|
||||
telemetry: { anonymous_id: "cli-anon", aliased_pairs: ["pair-marker"] },
|
||||
}),
|
||||
);
|
||||
await markMem0OssUsed();
|
||||
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).toEqual(["pair-marker"]);
|
||||
expect(written.telemetry.oss_used_at).toBeTruthy();
|
||||
});
|
||||
|
||||
test("markMem0OssUsed is idempotent", async () => {
|
||||
await markMem0OssUsed();
|
||||
const first = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
||||
).telemetry.oss_used_at;
|
||||
await markMem0OssUsed();
|
||||
const second = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpHome, "config.json"), "utf8"),
|
||||
).telemetry.oss_used_at;
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
test("readMem0AnonIds surfaces ossUsedAt", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, "config.json"),
|
||||
JSON.stringify({
|
||||
user_id: "oss-uuid",
|
||||
telemetry: { oss_used_at: "2026-01-01T00:00:00.000Z" },
|
||||
}),
|
||||
);
|
||||
const ids = await readMem0AnonIds();
|
||||
expect(ids?.oss).toBe("oss-uuid");
|
||||
expect(ids?.ossUsedAt).toBe("2026-01-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
test("markMem0OssUsed does not throw when target dir is unwritable", async () => {
|
||||
fs.writeFileSync(path.join(tmpHome, "blocker"), "x");
|
||||
process.env.MEM0_DIR = path.join(tmpHome, "blocker"); // file used as dir
|
||||
await expect(markMem0OssUsed()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── telemetry.captureIdentify ───────────────────────────────
|
||||
@@ -166,6 +216,7 @@ describe("telemetry.captureIdentify", () => {
|
||||
expect(payload.event).toBe("$identify");
|
||||
expect(payload.distinct_id).toBe("user@example.com");
|
||||
expect(payload.properties.$anon_distinct_id).toBe("anon-uuid");
|
||||
expect(payload.properties.client_version).toBeTruthy();
|
||||
expect(payload.properties.$process_person_profile).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -220,7 +271,10 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => {
|
||||
test("fires $identify on first init and persists pair marker", async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, "config.json"),
|
||||
JSON.stringify({ user_id: "oss-uuid" }),
|
||||
JSON.stringify({
|
||||
user_id: "oss-uuid",
|
||||
telemetry: { oss_used_at: "2026-01-01T00:00:00.000Z" },
|
||||
}),
|
||||
);
|
||||
const fetchMock = setupMockFetchWithPostHog();
|
||||
|
||||
@@ -244,17 +298,38 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => {
|
||||
expect(written.telemetry.aliased_pairs).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("platform-first init creates shared anon ID and identifies it", async () => {
|
||||
test("platform-only init mints nothing and fires no $identify", async () => {
|
||||
// Regression: the client used to create the shared anon id itself and
|
||||
// immediately stitch it, merging an event-less anon person into every
|
||||
// platform user. The platform path must now be read-only.
|
||||
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(fs.existsSync(path.join(tmpHome, "config.json"))).toBe(false);
|
||||
|
||||
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(written.user_id).toBeTruthy();
|
||||
expect(written.telemetry.aliased_pairs).toHaveLength(1);
|
||||
expect(identifyCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
test("oss id without oss_used_at marker is not stitched; cli id still is", 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]) => {
|
||||
@@ -264,8 +339,32 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => {
|
||||
);
|
||||
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);
|
||||
expect(body.properties.$anon_distinct_id).toBe("cli-anon");
|
||||
});
|
||||
|
||||
test("stitches config written by the Python SDK (cross-SDK key compat)", async () => {
|
||||
// Python writes datetime.now(timezone.utc).isoformat(): +00:00 offset.
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, "config.json"),
|
||||
JSON.stringify({
|
||||
user_id: "oss-uuid",
|
||||
telemetry: { oss_used_at: "2026-01-01T00:00:00+00:00" },
|
||||
}),
|
||||
);
|
||||
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.properties.$anon_distinct_id).toBe("oss-uuid");
|
||||
});
|
||||
|
||||
test("second init does not refire $identify", async () => {
|
||||
@@ -273,7 +372,7 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => {
|
||||
path.join(tmpHome, "config.json"),
|
||||
JSON.stringify({
|
||||
user_id: "oss-uuid",
|
||||
telemetry: {},
|
||||
telemetry: { oss_used_at: "2026-01-01T00:00:00.000Z" },
|
||||
}),
|
||||
);
|
||||
await markMem0Aliased("oss-uuid", "test@example.com");
|
||||
@@ -296,7 +395,10 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => {
|
||||
path.join(tmpHome, "config.json"),
|
||||
JSON.stringify({
|
||||
user_id: "oss-uuid",
|
||||
telemetry: { anonymous_id: "cli-anon" },
|
||||
telemetry: {
|
||||
anonymous_id: "cli-anon",
|
||||
oss_used_at: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const fetchMock = setupMockFetchWithPostHog();
|
||||
@@ -407,4 +509,18 @@ describe("config.ts in browser-like environment", () => {
|
||||
jest.resetModules();
|
||||
}
|
||||
});
|
||||
|
||||
test("markMem0OssUsed is a no-op 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 { markMem0OssUsed: browserMark } = await import("../config");
|
||||
await expect(browserMark()).resolves.toBeUndefined();
|
||||
} finally {
|
||||
global.process = originalProcess;
|
||||
jest.resetModules();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
} from "./memory.types";
|
||||
import { parse_vision_messages } from "../utils/memory";
|
||||
import { HistoryManager } from "../storage/base";
|
||||
import { captureClientEvent } from "../utils/telemetry";
|
||||
import { captureClientEvent, isTelemetryEnabled } from "../utils/telemetry";
|
||||
import { lemmatizeForBm25 } from "../utils/lemmatization";
|
||||
import {
|
||||
extractEntities,
|
||||
@@ -53,7 +53,7 @@ import {
|
||||
ScoredResult,
|
||||
} from "../utils/scoring";
|
||||
import { getDefaultVectorStoreDbPath } from "../utils/sqlite";
|
||||
import { getOrCreateMem0UserId } from "../../../client/config";
|
||||
import { getOrCreateMem0UserId, markMem0OssUsed } from "../../../client/config";
|
||||
|
||||
// Entity params that must be passed via filters - check both snake_case and camelCase
|
||||
const ENTITY_PARAMS = [
|
||||
@@ -451,6 +451,12 @@ export class Memory {
|
||||
try {
|
||||
await this._getTelemetryId();
|
||||
|
||||
// Record genuine OSS usage so the platform client may stitch this
|
||||
// machine's anon id to a platform email later.
|
||||
if (isTelemetryEnabled()) {
|
||||
await markMem0OssUsed();
|
||||
}
|
||||
|
||||
// Capture initialization event
|
||||
await captureClientEvent("init", this, {
|
||||
api_version: this.apiVersion,
|
||||
|
||||
@@ -126,4 +126,8 @@ async function captureClientEvent(
|
||||
);
|
||||
}
|
||||
|
||||
export { telemetry, captureClientEvent };
|
||||
function isTelemetryEnabled(): boolean {
|
||||
return MEM0_TELEMETRY;
|
||||
}
|
||||
|
||||
export { telemetry, captureClientEvent, isTelemetryEnabled };
|
||||
|
||||
Reference in New Issue
Block a user