refactor(telemetry): tighten alias helpers and gate on MEM0_TELEMETRY

Review pass on the PostHog identity-stitching fix:

- Gate _maybe_alias_anon_to_email and _maybeAliasAnonToEmail on telemetry
  being enabled. Previous version still read ~/.mem0/config.json and
  persisted telemetry.aliased_to even when MEM0_TELEMETRY=false, which
  meant re-enabling telemetry later would skip identity merging forever.
  Python checks client_telemetry.posthog; TS exports a new
  isTelemetryEnabled() getter from telemetry.ts.
- Trim capture_identify properties to {$anon_distinct_id, client_source}.
  The full platform metadata duplicated capture_event's payload and is
  not needed for $identify; PostHog enriches the person profile from
  subsequent events anyway.
- Flatten config.ts: drop the four-helper structure (loadNodeModules /
  configPath / loadRawConfig / read+write) into one getNodeFs() + a
  shared loadConfig(). Same behaviour, ~30 fewer lines, easier to read.
- Drop the tautological Mem0AnonIds shape test and the
  void createMockFetch hack at the end of the TS test file.
- Add tests for the new telemetry-disabled gate (Python + TS).

Logic traced end-to-end: 114 Python tests + 590 TS tests pass. Ruff and
Prettier clean. Zero new typecheck errors.
This commit is contained in:
gabrielstein-mem0
2026-04-30 17:01:58 -07:00
parent 789122bbea
commit 8a9915d3fe
7 changed files with 119 additions and 108 deletions
+41 -66
View File
@@ -2,12 +2,10 @@
* 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: the OSS Python SDK and the Python CLI
* each persist an anonymous distinct_id here, and the TS MemoryClient needs * each persist an anonymous distinct_id here, and the TS MemoryClient reads
* to read those on init so it can fire $identify and merge them into the * those on init to fire $identify and merge them into the email identity.
* email-based identity.
* *
* Node-only. In browsers (or any environment without `process.versions.node`) * Node-only. Browsers (no `process.versions.node`) no-op.
* every function returns null/no-ops without attempting `fs` access.
*/ */
export interface Mem0AnonIds { export interface Mem0AnonIds {
@@ -16,62 +14,38 @@ export interface Mem0AnonIds {
aliasedTo?: string; aliasedTo?: string;
} }
function isNode(): boolean { interface NodeFs {
try {
return (
typeof process !== "undefined" &&
!!process.versions &&
!!process.versions.node
);
} catch {
return false;
}
}
async function loadNodeModules(): Promise<{
fs: typeof import("fs"); fs: typeof import("fs");
path: typeof import("path"); path: typeof import("path");
os: typeof import("os"); configPath: string;
} | null> { }
if (!isNode()) return null;
async function getNodeFs(): Promise<NodeFs | null> {
if (typeof process === "undefined" || !process.versions?.node) return null;
try { try {
const [fs, path, os] = await Promise.all([ const [fs, path, os] = await Promise.all([
import("fs"), import("fs"),
import("path"), import("path"),
import("os"), import("os"),
]); ]);
const fsMod = (fs as any).default ?? fs;
const pathMod = (path as any).default ?? path;
const osMod = (os as any).default ?? os;
const dir = process.env.MEM0_DIR || pathMod.join(osMod.homedir(), ".mem0");
return { return {
fs: fs.default ?? fs, fs: fsMod,
path: path.default ?? path, path: pathMod,
os: os.default ?? os, configPath: pathMod.join(dir, "config.json"),
}; };
} catch { } catch {
return null; return null;
} }
} }
async function configPath(): Promise<{ function loadConfig(node: NodeFs): Record<string, any> | null {
path: string;
modules: NonNullable<Awaited<ReturnType<typeof loadNodeModules>>>;
} | null> {
const modules = await loadNodeModules();
if (!modules) return null;
try { try {
const dir = if (!node.fs.existsSync(node.configPath)) return null;
process.env.MEM0_DIR || modules.path.join(modules.os.homedir(), ".mem0"); const parsed = JSON.parse(node.fs.readFileSync(node.configPath, "utf8"));
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; return parsed && typeof parsed === "object" ? parsed : null;
} catch { } catch {
return null; return null;
@@ -79,40 +53,41 @@ async function loadRawConfig(): Promise<Record<string, unknown> | null> {
} }
export async function readMem0AnonIds(): Promise<Mem0AnonIds | null> { export async function readMem0AnonIds(): Promise<Mem0AnonIds | null> {
const config = await loadRawConfig(); const node = await getNodeFs();
if (!node) return null;
const config = loadConfig(node);
if (!config) return null; if (!config) return null;
const telemetry = const telemetry =
config.telemetry && typeof config.telemetry === "object" config.telemetry && typeof config.telemetry === "object"
? (config.telemetry as Record<string, unknown>) ? config.telemetry
: {}; : {};
const oss = typeof config.user_id === "string" ? config.user_id : undefined; return {
const cli = oss: typeof config.user_id === "string" ? config.user_id : undefined,
typeof telemetry.anonymous_id === "string" cli:
? telemetry.anonymous_id typeof telemetry.anonymous_id === "string"
: undefined; ? telemetry.anonymous_id
const aliasedTo = : undefined,
typeof telemetry.aliased_to === "string" ? telemetry.aliased_to : undefined; aliasedTo:
return { oss, cli, aliasedTo }; typeof telemetry.aliased_to === "string"
? telemetry.aliased_to
: undefined,
};
} }
export async function markMem0Aliased(email: string): Promise<void> { export async function markMem0Aliased(email: string): Promise<void> {
const resolved = await configPath(); const node = await getNodeFs();
if (!resolved) return; if (!node) return;
try { try {
const dirname = resolved.modules.path.dirname(resolved.path); node.fs.mkdirSync(node.path.dirname(node.configPath), { recursive: true });
resolved.modules.fs.mkdirSync(dirname, { recursive: true }); const config = loadConfig(node) ?? {};
const config = (await loadRawConfig()) ?? {};
const telemetry = const telemetry =
config.telemetry && typeof config.telemetry === "object" config.telemetry && typeof config.telemetry === "object"
? (config.telemetry as Record<string, unknown>) ? config.telemetry
: {}; : {};
telemetry.aliased_to = email; telemetry.aliased_to = email;
config.telemetry = telemetry; config.telemetry = telemetry;
resolved.modules.fs.writeFileSync( node.fs.writeFileSync(node.configPath, JSON.stringify(config, null, 4));
resolved.path,
JSON.stringify(config, null, 4),
);
} catch { } catch {
// Read-only filesystem (Lambda, container) — alias is best-effort. // Best-effort: read-only filesystems and unwritable paths just skip.
} }
} }
+7 -1
View File
@@ -20,7 +20,12 @@ import {
CreateMemoryExportPayload, CreateMemoryExportPayload,
GetMemoryExportPayload, GetMemoryExportPayload,
} from "./mem0.types"; } from "./mem0.types";
import { captureClientEvent, generateHash, telemetry } from "./telemetry"; import {
captureClientEvent,
generateHash,
isTelemetryEnabled,
telemetry,
} from "./telemetry";
import { readMem0AnonIds, markMem0Aliased } from "./config"; import { readMem0AnonIds, markMem0Aliased } 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";
@@ -136,6 +141,7 @@ export default class MemoryClient {
} }
private async _maybeAliasAnonToEmail(): Promise<void> { private async _maybeAliasAnonToEmail(): Promise<void> {
if (!isTelemetryEnabled()) return;
try { try {
const email = this.telemetryId; const email = this.telemetryId;
if (!email || !email.includes("@")) return; if (!email || !email.includes("@")) return;
+5 -1
View File
@@ -81,6 +81,10 @@ class UnifiedTelemetry implements TelemetryClient {
} }
} }
function isTelemetryEnabled(): boolean {
return MEM0_TELEMETRY;
}
const telemetry = new UnifiedTelemetry(POSTHOG_API_KEY, POSTHOG_HOST); const telemetry = new UnifiedTelemetry(POSTHOG_API_KEY, POSTHOG_HOST);
async function captureClientEvent( async function captureClientEvent(
@@ -110,4 +114,4 @@ async function captureClientEvent(
); );
} }
export { telemetry, captureClientEvent, generateHash }; export { telemetry, captureClientEvent, generateHash, isTelemetryEnabled };
@@ -9,8 +9,8 @@ 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, Mem0AnonIds } from "../config"; import { readMem0AnonIds, markMem0Aliased } from "../config";
import { createMockFetch, TEST_API_KEY } from "./helpers"; import { TEST_API_KEY } from "./helpers";
import { setupMockFetch, installConsoleSuppression } from "./setup"; import { setupMockFetch, installConsoleSuppression } from "./setup";
installConsoleSuppression(); installConsoleSuppression();
@@ -285,16 +285,42 @@ describe("MemoryClient — _maybeAliasAnonToEmail", () => {
(client as any)._maybeAliasAnonToEmail(), (client as any)._maybeAliasAnonToEmail(),
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
}); });
});
// ─── Type smoke test ────────────────────────────────────────── 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();
describe("Mem0AnonIds shape", () => { jest.resetModules();
test("interface accepts partial fields", () => { const original = process.env.MEM0_TELEMETRY;
const a: Mem0AnonIds = { oss: "x" }; process.env.MEM0_TELEMETRY = "false";
const b: Mem0AnonIds = { cli: "y", aliasedTo: "u@x.com" }; try {
expect(a.oss).toBe("x"); const { MemoryClient: ColdClient } = await import("../mem0");
expect(b.aliasedTo).toBe("u@x.com"); 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_to).toBeUndefined();
}); });
}); });
@@ -303,11 +329,9 @@ describe("Mem0AnonIds shape", () => {
describe("config.ts in browser-like environment", () => { describe("config.ts in browser-like environment", () => {
test("readMem0AnonIds returns null when not Node", async () => { test("readMem0AnonIds returns null when not Node", async () => {
const originalProcess = global.process; const originalProcess = global.process;
// Simulate a browser: no `process` global. // @ts-expect-error force-undefining global to simulate a browser
// @ts-expect-error force-undefining global
delete global.process; delete global.process;
try { try {
// Re-import to pick up the missing global.
jest.resetModules(); jest.resetModules();
const { readMem0AnonIds: browserRead } = await import("../config"); const { readMem0AnonIds: browserRead } = await import("../config");
expect(await browserRead()).toBeNull(); expect(await browserRead()).toBeNull();
@@ -317,7 +341,3 @@ describe("config.ts in browser-like environment", () => {
} }
}); });
}); });
// ─── Suppress unused-import warnings ──────────────────────────
// Some helpers above are intentionally re-exported for clarity in tests.
void createMockFetch;
+6 -5
View File
@@ -34,13 +34,14 @@ 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):
"""Stitch prior anonymous PostHog identities to the resolved email. """Fire $identify per prior anon ID so PostHog merges them into email.
Reads ~/.mem0/config.json for both anon IDs (OSS user_id, CLI Idempotent via telemetry.aliased_to — only writes the flag when telemetry
telemetry.anonymous_id), fires one $identify per anon that hasn't been is actually enabled, so disabling/re-enabling MEM0_TELEMETRY still works.
aliased yet, then sets telemetry.aliased_to so this only happens once Best-effort — never raises.
per (anon_id, email) pair. Best-effort — never raises.
""" """
if client_telemetry.posthog is None:
return
if not user_email or "@" not in user_email: if not user_email or "@" not in user_email:
return return
try: try:
+6 -18
View File
@@ -114,29 +114,17 @@ class AnonymousTelemetry:
_logger.debug("Failed to capture telemetry event %r: %s", event_name, e) _logger.debug("Failed to capture telemetry event %r: %s", event_name, e)
def capture_identify(self, anon_id, email): def capture_identify(self, anon_id, email):
"""Send a PostHog $identify event so the anon person merges into the """Fire $identify with $anon_distinct_id so PostHog merges anon_id into email."""
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: if self.posthog is None:
return return
if not anon_id or not email or anon_id == email: if not anon_id or not email or anon_id == email:
return 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: try:
self.posthog.capture(distinct_id=email, event="$identify", properties=properties) self.posthog.capture(
distinct_id=email,
event="$identify",
properties={"$anon_distinct_id": anon_id, "client_source": "python"},
)
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)
+17
View File
@@ -297,6 +297,23 @@ class TestMaybeAliasAnonToEmail:
client_main._maybe_alias_anon_to_email("not-an-email") client_main._maybe_alias_anon_to_email("not-an-email")
telemetry.capture_identify.assert_not_called() telemetry.capture_identify.assert_not_called()
def test_skips_when_telemetry_disabled(self):
"""When client_telemetry.posthog is None (MEM0_TELEMETRY=false), do nothing —
no fs read, no fs write, no event. Re-enabling telemetry later must still alias."""
from mem0.client import main as client_main
disabled = MagicMock()
disabled.posthog = None
with (
patch.object(client_main, "client_telemetry", disabled),
patch.object(client_main, "read_anon_ids") as read,
patch.object(client_main, "mark_aliased") as mark,
):
client_main._maybe_alias_anon_to_email("user@example.com")
read.assert_not_called()
mark.assert_not_called()
disabled.capture_identify.assert_not_called()
def test_does_not_raise_on_telemetry_failure(self): def test_does_not_raise_on_telemetry_failure(self):
from mem0.client import main as client_main from mem0.client import main as client_main