fix(cli): persistent anonymous telemetry ID + pass source=CLI in all API calls (#4789)

This commit is contained in:
Saket Aryan
2026-04-11 21:00:05 +05:30
committed by GitHub
parent d39ebad09f
commit 3882af7450
12 changed files with 202 additions and 25 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@mem0/cli", "name": "@mem0/cli",
"version": "0.2.2", "version": "0.2.3",
"description": "The official CLI for mem0 — the memory layer for AI agents", "description": "The official CLI for mem0 — the memory layer for AI agents",
"type": "module", "type": "module",
"bin": { "bin": {
+12 -9
View File
@@ -116,6 +116,7 @@ export class PlatformBackend implements Backend {
if (opts.expires) payload.expiration_date = opts.expires; if (opts.expires) payload.expiration_date = opts.expires;
if (opts.categories) payload.categories = opts.categories; if (opts.categories) payload.categories = opts.categories;
if (opts.enableGraph) payload.enable_graph = true; if (opts.enableGraph) payload.enable_graph = true;
payload.source = "CLI";
return (await this._request("POST", "/v1/memories/", { return (await this._request("POST", "/v1/memories/", {
json: payload, json: payload,
@@ -176,6 +177,7 @@ export class PlatformBackend implements Backend {
if (opts.keyword) payload.keyword_search = true; if (opts.keyword) payload.keyword_search = true;
if (opts.fields) payload.fields = opts.fields; if (opts.fields) payload.fields = opts.fields;
if (opts.enableGraph) payload.enable_graph = true; if (opts.enableGraph) payload.enable_graph = true;
payload.source = "CLI";
const result = (await this._request("POST", "/v2/memories/search/", { const result = (await this._request("POST", "/v2/memories/search/", {
json: payload, json: payload,
@@ -186,10 +188,9 @@ export class PlatformBackend implements Backend {
} }
async get(memoryId: string): Promise<Record<string, unknown>> { async get(memoryId: string): Promise<Record<string, unknown>> {
return (await this._request("GET", `/v1/memories/${memoryId}/`)) as Record< return (await this._request("GET", `/v1/memories/${memoryId}/`, {
string, params: { source: "CLI" },
unknown })) as Record<string, unknown>;
>;
} }
async listMemories( async listMemories(
@@ -227,6 +228,7 @@ export class PlatformBackend implements Backend {
}); });
if (apiFilters) payload.filters = apiFilters; if (apiFilters) payload.filters = apiFilters;
if (opts.enableGraph) payload.enable_graph = true; if (opts.enableGraph) payload.enable_graph = true;
payload.source = "CLI";
const result = (await this._request("POST", "/v2/memories/", { const result = (await this._request("POST", "/v2/memories/", {
json: payload, json: payload,
@@ -245,6 +247,7 @@ export class PlatformBackend implements Backend {
const payload: Record<string, unknown> = {}; const payload: Record<string, unknown> = {};
if (content) payload.text = content; if (content) payload.text = content;
if (metadata) payload.metadata = metadata; if (metadata) payload.metadata = metadata;
payload.source = "CLI";
return (await this._request("PUT", `/v1/memories/${memoryId}/`, { return (await this._request("PUT", `/v1/memories/${memoryId}/`, {
json: payload, json: payload,
})) as Record<string, unknown>; })) as Record<string, unknown>;
@@ -255,7 +258,7 @@ export class PlatformBackend implements Backend {
opts: DeleteOptions = {}, opts: DeleteOptions = {},
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
if (opts.all) { if (opts.all) {
const params: Record<string, string> = {}; const params: Record<string, string> = { source: "CLI" };
if (opts.userId) params.user_id = opts.userId; if (opts.userId) params.user_id = opts.userId;
if (opts.agentId) params.agent_id = opts.agentId; if (opts.agentId) params.agent_id = opts.agentId;
if (opts.appId) params.app_id = opts.appId; if (opts.appId) params.app_id = opts.appId;
@@ -265,10 +268,9 @@ export class PlatformBackend implements Backend {
})) as Record<string, unknown>; })) as Record<string, unknown>;
} }
if (memoryId) { if (memoryId) {
return (await this._request( return (await this._request("DELETE", `/v1/memories/${memoryId}/`, {
"DELETE", params: { source: "CLI" },
`/v1/memories/${memoryId}/`, })) as Record<string, unknown>;
)) as Record<string, unknown>;
} }
throw new Error("Either memoryId or --all is required"); throw new Error("Either memoryId or --all is required");
} }
@@ -291,6 +293,7 @@ export class PlatformBackend implements Backend {
result = (await this._request( result = (await this._request(
"DELETE", "DELETE",
`/v2/entities/${entityType}/${entityId}/`, `/v2/entities/${entityType}/${entityId}/`,
{ params: { source: "CLI" } },
)) as Record<string, unknown>; )) as Record<string, unknown>;
} }
return result; return result;
+14
View File
@@ -31,10 +31,15 @@ export interface DefaultsConfig {
enableGraph: boolean; enableGraph: boolean;
} }
export interface TelemetryConfig {
anonymousId: string;
}
export interface Mem0Config { export interface Mem0Config {
version: number; version: number;
defaults: DefaultsConfig; defaults: DefaultsConfig;
platform: PlatformConfig; platform: PlatformConfig;
telemetry: TelemetryConfig;
} }
export function createDefaultConfig(): Mem0Config { export function createDefaultConfig(): Mem0Config {
@@ -52,6 +57,9 @@ export function createDefaultConfig(): Mem0Config {
baseUrl: DEFAULT_BASE_URL, baseUrl: DEFAULT_BASE_URL,
userEmail: "", userEmail: "",
}, },
telemetry: {
anonymousId: "",
},
}; };
} }
@@ -80,6 +88,9 @@ export function loadConfig(): Mem0Config {
config.defaults.appId = defaults.app_id ?? ""; config.defaults.appId = defaults.app_id ?? "";
config.defaults.runId = defaults.run_id ?? ""; config.defaults.runId = defaults.run_id ?? "";
config.defaults.enableGraph = defaults.enable_graph ?? false; config.defaults.enableGraph = defaults.enable_graph ?? false;
const telemetry = data.telemetry ?? {};
config.telemetry.anonymousId = telemetry.anonymous_id ?? "";
} }
// Environment variable overrides // Environment variable overrides
@@ -119,6 +130,9 @@ export function saveConfig(config: Mem0Config): void {
base_url: config.platform.baseUrl, base_url: config.platform.baseUrl,
user_email: config.platform.userEmail, user_email: config.platform.userEmail,
}, },
telemetry: {
anonymous_id: config.telemetry.anonymousId,
},
}; };
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2)); fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2));
+52 -5
View File
@@ -9,10 +9,10 @@
*/ */
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { createHash } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { CONFIG_FILE, loadConfig } from "./config.js"; import { CONFIG_FILE, loadConfig, saveConfig } from "./config.js";
import { CLI_VERSION } from "./version.js"; import { CLI_VERSION } from "./version.js";
const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"; const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX";
@@ -29,11 +29,34 @@ function isTelemetryEnabled(): boolean {
} }
} }
/**
* Return a persistent per-machine anonymous ID, generating one if needed.
*
* Stored in ~/.mem0/config.json under `telemetry.anonymous_id` so that
* repeat runs on the same machine share one PostHog identity instead of
* collapsing into a single shared fallback string.
*/
function getOrCreateAnonymousId(): string {
const config = loadConfig();
if (config.telemetry.anonymousId) {
return config.telemetry.anonymousId;
}
const newId = `cli-anon-${randomUUID().replace(/-/g, "")}`;
config.telemetry.anonymousId = newId;
try {
saveConfig(config);
} catch {
/* ignore persistence failure — still return the generated ID */
}
return newId;
}
/** /**
* Return a stable anonymous identifier for the current user. * Return a stable anonymous identifier for the current user.
* *
* Priority: cached user_email (from /v1/ping/) > MD5(api_key) > fallback. * Priority: cached user_email (from /v1/ping/) > MD5(api_key) >
* Matches the SDK pattern in mem0-ts/src/client/mem0.ts. * persistent per-machine anonymous ID.
*/ */
function getDistinctId(): string { function getDistinctId(): string {
try { try {
@@ -47,7 +70,11 @@ function getDistinctId(): string {
} catch { } catch {
/* ignore */ /* ignore */
} }
return "anonymous-cli"; try {
return getOrCreateAnonymousId();
} catch {
return `cli-anon-${randomUUID().replace(/-/g, "")}`;
}
} }
/** /**
@@ -69,6 +96,25 @@ export function captureEvent(
const config = loadConfig(); const config = loadConfig();
const distinctId = preResolvedEmail || getDistinctId(); const distinctId = preResolvedEmail || getDistinctId();
// Detect anonymous → identified transition. If a stored anonymous_id
// exists and we just resolved to a real identity, fire a one-shot
// $identify event so PostHog stitches the pre-signup history onto
// the authenticated profile. Clear the stored id so we don't re-alias.
let anonIdToAlias: string | null = null;
if (
distinctId &&
!distinctId.startsWith("cli-anon-") &&
config.telemetry.anonymousId
) {
anonIdToAlias = config.telemetry.anonymousId;
config.telemetry.anonymousId = "";
try {
saveConfig(config);
} catch {
/* ignore — alias may double-fire next run, harmless */
}
}
const payload = { const payload = {
api_key: POSTHOG_API_KEY, api_key: POSTHOG_API_KEY,
distinct_id: distinctId, distinct_id: distinctId,
@@ -92,6 +138,7 @@ export function captureEvent(
mem0ApiKey: config.platform.apiKey || "", mem0ApiKey: config.platform.apiKey || "",
mem0BaseUrl: config.platform.baseUrl || "https://api.mem0.ai", mem0BaseUrl: config.platform.baseUrl || "https://api.mem0.ai",
configPath: CONFIG_FILE, configPath: CONFIG_FILE,
anonDistinctIdToAlias: anonIdToAlias,
}; };
const child = spawn( const child = spawn(
+21
View File
@@ -94,6 +94,19 @@ async function sendPosthogEvent(posthogHost, payload) {
} }
} }
async function sendIdentifyEvent(ctx, payload, anonId) {
const identifyPayload = {
api_key: payload.api_key,
event: "$identify",
distinct_id: payload.distinct_id,
properties: {
$anon_distinct_id: anonId,
$lib: (payload.properties && payload.properties.$lib) || "posthog-node",
},
};
await sendPosthogEvent(ctx.posthogHost, identifyPayload);
}
async function main() { async function main() {
const ctx = JSON.parse(process.argv[2]); const ctx = JSON.parse(process.argv[2]);
const payload = ctx.payload; const payload = ctx.payload;
@@ -102,6 +115,14 @@ async function main() {
await resolveAndCacheEmail(ctx, payload); await resolveAndCacheEmail(ctx, payload);
} }
// Fire $identify *after* email resolution so PostHog links the stored
// anonymous id directly to the final identity (email, not the api-key
// hash). The regular event is sent next so it lands under the merged
// profile.
if (ctx.anonDistinctIdToAlias) {
await sendIdentifyEvent(ctx, payload, ctx.anonDistinctIdToAlias);
}
await sendPosthogEvent(ctx.posthogHost, payload); await sendPosthogEvent(ctx.posthogHost, payload);
} }
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "mem0-cli" name = "mem0-cli"
version = "0.2.2" version = "0.2.3"
description = "The official CLI for mem0 — the memory layer for AI agents" description = "The official CLI for mem0 — the memory layer for AI agents"
readme = "README.md" readme = "README.md"
license = "Apache-2.0" license = "Apache-2.0"
+1 -1
View File
@@ -1,3 +1,3 @@
"""mem0 CLI — the command-line interface for the mem0 memory layer.""" """mem0 CLI — the command-line interface for the mem0 memory layer."""
__version__ = "0.2.2" __version__ = "0.2.3"
+10 -4
View File
@@ -93,6 +93,7 @@ class PlatformBackend(Backend):
payload["categories"] = categories payload["categories"] = categories
if enable_graph: if enable_graph:
payload["enable_graph"] = True payload["enable_graph"] = True
payload["source"] = "CLI"
return self._request("POST", "/v1/memories/", json=payload) return self._request("POST", "/v1/memories/", json=payload)
@@ -172,6 +173,7 @@ class PlatformBackend(Backend):
payload["fields"] = fields payload["fields"] = fields
if enable_graph: if enable_graph:
payload["enable_graph"] = True payload["enable_graph"] = True
payload["source"] = "CLI"
result = self._request("POST", "/v2/memories/search/", json=payload) result = self._request("POST", "/v2/memories/search/", json=payload)
return ( return (
@@ -181,7 +183,7 @@ class PlatformBackend(Backend):
) )
def get(self, memory_id: str) -> dict: def get(self, memory_id: str) -> dict:
return self._request("GET", f"/v1/memories/{memory_id}/") return self._request("GET", f"/v1/memories/{memory_id}/", params={"source": "CLI"})
def list_memories( def list_memories(
self, self,
@@ -220,6 +222,7 @@ class PlatformBackend(Backend):
payload["filters"] = api_filters payload["filters"] = api_filters
if enable_graph: if enable_graph:
payload["enable_graph"] = True payload["enable_graph"] = True
payload["source"] = "CLI"
result = self._request("POST", "/v2/memories/", json=payload, params=params) result = self._request("POST", "/v2/memories/", json=payload, params=params)
return ( return (
@@ -236,6 +239,7 @@ class PlatformBackend(Backend):
payload["text"] = content payload["text"] = content
if metadata: if metadata:
payload["metadata"] = metadata payload["metadata"] = metadata
payload["source"] = "CLI"
return self._request("PUT", f"/v1/memories/{memory_id}/", json=payload) return self._request("PUT", f"/v1/memories/{memory_id}/", json=payload)
def delete( def delete(
@@ -249,7 +253,7 @@ class PlatformBackend(Backend):
run_id: str | None = None, run_id: str | None = None,
) -> dict: ) -> dict:
if all: if all:
params: dict[str, str] = {} params: dict[str, str] = {"source": "CLI"}
if user_id: if user_id:
params["user_id"] = user_id params["user_id"] = user_id
if agent_id: if agent_id:
@@ -260,7 +264,7 @@ class PlatformBackend(Backend):
params["run_id"] = run_id params["run_id"] = run_id
return self._request("DELETE", "/v1/memories/", params=params) return self._request("DELETE", "/v1/memories/", params=params)
elif memory_id: elif memory_id:
return self._request("DELETE", f"/v1/memories/{memory_id}/") return self._request("DELETE", f"/v1/memories/{memory_id}/", params={"source": "CLI"})
else: else:
raise ValueError("Either memory_id or --all is required") raise ValueError("Either memory_id or --all is required")
@@ -285,7 +289,9 @@ class PlatformBackend(Backend):
# Delete each provided entity via the v2 path-based endpoint # Delete each provided entity via the v2 path-based endpoint
result: dict = {} result: dict = {}
for entity_type, entity_id in entities.items(): for entity_type, entity_id in entities.items():
result = self._request("DELETE", f"/v2/entities/{entity_type}/{entity_id}/") result = self._request(
"DELETE", f"/v2/entities/{entity_type}/{entity_id}/", params={"source": "CLI"}
)
return result return result
def ping(self, timeout: float | None = None) -> dict: def ping(self, timeout: float | None = None) -> dict:
+12
View File
@@ -39,11 +39,17 @@ class DefaultsConfig:
enable_graph: bool = False enable_graph: bool = False
@dataclass
class TelemetryConfig:
anonymous_id: str = ""
@dataclass @dataclass
class Mem0Config: class Mem0Config:
version: int = CONFIG_VERSION version: int = CONFIG_VERSION
defaults: DefaultsConfig = field(default_factory=DefaultsConfig) defaults: DefaultsConfig = field(default_factory=DefaultsConfig)
platform: PlatformConfig = field(default_factory=PlatformConfig) platform: PlatformConfig = field(default_factory=PlatformConfig)
telemetry: TelemetryConfig = field(default_factory=TelemetryConfig)
SHORT_KEY_ALIASES: dict[str, str] = { SHORT_KEY_ALIASES: dict[str, str] = {
@@ -87,6 +93,9 @@ def load_config() -> Mem0Config:
config.defaults.run_id = defaults.get("run_id", "") config.defaults.run_id = defaults.get("run_id", "")
config.defaults.enable_graph = defaults.get("enable_graph", False) config.defaults.enable_graph = defaults.get("enable_graph", False)
telemetry = data.get("telemetry", {})
config.telemetry.anonymous_id = telemetry.get("anonymous_id", "")
# Environment variable overrides # Environment variable overrides
env_key = os.environ.get("MEM0_API_KEY") env_key = os.environ.get("MEM0_API_KEY")
if env_key: if env_key:
@@ -137,6 +146,9 @@ def save_config(config: Mem0Config) -> None:
"base_url": config.platform.base_url, "base_url": config.platform.base_url,
"user_email": config.platform.user_email, "user_email": config.platform.user_email,
}, },
"telemetry": {
"anonymous_id": config.telemetry.anonymous_id,
},
} }
with open(CONFIG_FILE, "w") as f: with open(CONFIG_FILE, "w") as f:
+45 -4
View File
@@ -9,12 +9,14 @@ Disable with: MEM0_TELEMETRY=false
from __future__ import annotations from __future__ import annotations
import contextlib
import hashlib import hashlib
import json import json
import os import os
import platform import platform
import subprocess import subprocess
import sys import sys
import uuid
from typing import Any from typing import Any
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX" POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
@@ -26,11 +28,31 @@ def _is_telemetry_enabled() -> bool:
return val not in ("false", "0", "no") return val not in ("false", "0", "no")
def _get_or_create_anonymous_id() -> str:
"""Return a persistent per-machine anonymous ID, generating one if needed.
Stored in ~/.mem0/config.json under `telemetry.anonymous_id` so that
repeat runs on the same machine share one PostHog identity instead of
collapsing into a single shared fallback string.
"""
from mem0_cli.config import load_config, save_config
config = load_config()
if config.telemetry.anonymous_id:
return config.telemetry.anonymous_id
new_id = f"cli-anon-{uuid.uuid4().hex}"
config.telemetry.anonymous_id = new_id
with contextlib.suppress(Exception):
save_config(config)
return new_id
def _get_distinct_id() -> str: def _get_distinct_id() -> str:
"""Return a stable anonymous identifier for the current user. """Return a stable anonymous identifier for the current user.
Priority: cached user_email (from /v1/ping/) > MD5(api_key) > fallback. Priority: cached user_email (from /v1/ping/) > MD5(api_key) >
Matches the SDK pattern in mem0/client/main.py. persistent per-machine anonymous ID.
""" """
try: try:
from mem0_cli.config import load_config from mem0_cli.config import load_config
@@ -42,7 +64,10 @@ def _get_distinct_id() -> str:
return hashlib.md5(config.platform.api_key.encode()).hexdigest() return hashlib.md5(config.platform.api_key.encode()).hexdigest()
except Exception: except Exception:
pass pass
return "anonymous-cli" try:
return _get_or_create_anonymous_id()
except Exception:
return f"cli-anon-{uuid.uuid4().hex}"
def capture_event( def capture_event(
@@ -61,12 +86,27 @@ def capture_event(
try: try:
from mem0_cli import __version__ from mem0_cli import __version__
from mem0_cli.config import CONFIG_FILE, load_config from mem0_cli.config import CONFIG_FILE, load_config, save_config
from mem0_cli.state import is_agent_mode from mem0_cli.state import is_agent_mode
config = load_config() config = load_config()
distinct_id = pre_resolved_email or _get_distinct_id() distinct_id = pre_resolved_email or _get_distinct_id()
# Detect anonymous → identified transition. If a stored anonymous_id
# exists and we just resolved to a real identity, fire a one-shot
# $identify event so PostHog stitches the pre-signup history onto
# the authenticated profile. Clear the stored id so we don't re-alias.
anon_id_to_alias: str | None = None
if (
distinct_id
and not distinct_id.startswith("cli-anon-")
and config.telemetry.anonymous_id
):
anon_id_to_alias = config.telemetry.anonymous_id
config.telemetry.anonymous_id = ""
with contextlib.suppress(Exception):
save_config(config)
payload = { payload = {
"api_key": POSTHOG_API_KEY, "api_key": POSTHOG_API_KEY,
"distinct_id": distinct_id, "distinct_id": distinct_id,
@@ -92,6 +132,7 @@ def capture_event(
"mem0_api_key": config.platform.api_key or "", "mem0_api_key": config.platform.api_key or "",
"mem0_base_url": config.platform.base_url or "https://api.mem0.ai", "mem0_base_url": config.platform.base_url or "https://api.mem0.ai",
"config_path": str(CONFIG_FILE), "config_path": str(CONFIG_FILE),
"anon_distinct_id_to_alias": anon_id_to_alias,
} }
subprocess.Popen( subprocess.Popen(
@@ -27,9 +27,31 @@ def main() -> None:
if ctx.get("needs_email") and ctx.get("mem0_api_key"): if ctx.get("needs_email") and ctx.get("mem0_api_key"):
_resolve_and_cache_email(ctx, payload) _resolve_and_cache_email(ctx, payload)
# Fire $identify *after* email resolution so PostHog links the stored
# anonymous id directly to the final identity (email, not the api-key
# hash). The regular event is sent next so it lands under the merged
# profile.
anon_id = ctx.get("anon_distinct_id_to_alias")
if anon_id:
_send_identify_event(ctx, payload, anon_id)
_send_posthog_event(ctx["posthog_host"], payload) _send_posthog_event(ctx["posthog_host"], payload)
def _send_identify_event(ctx: dict, payload: dict, anon_id: str) -> None:
"""Send a PostHog $identify event aliasing anon_id → payload['distinct_id']."""
identify_payload = {
"api_key": payload["api_key"],
"event": "$identify",
"distinct_id": payload["distinct_id"],
"properties": {
"$anon_distinct_id": anon_id,
"$lib": payload.get("properties", {}).get("$lib", "posthog-python"),
},
}
_send_posthog_event(ctx["posthog_host"], identify_payload)
def _resolve_and_cache_email(ctx: dict, payload: dict) -> None: def _resolve_and_cache_email(ctx: dict, payload: dict) -> None:
"""Call /v1/ping/ to get the user's email, update the payload, and cache it.""" """Call /v1/ping/ to get the user's email, update the payload, and cache it."""
try: try:
+11
View File
@@ -1160,6 +1160,17 @@ mode: "wide"
<Tab title="CLI"> <Tab title="CLI">
<Update label="2026-04-11" description="Python v0.2.3 / Node v0.2.3">
**Bug Fixes:**
- **Telemetry:** Replaced shared `"anonymous-cli"` fallback with a persistent per-machine random hash (`cli-anon-<uuid>`), so anonymous CLI users are counted individually in PostHog instead of collapsing into one identity ([#4789](https://github.com/mem0ai/mem0/pull/4789))
- **Telemetry:** Added PostHog `$identify` event on first authenticated run to stitch pre-signup anonymous history onto the authenticated user profile ([#4789](https://github.com/mem0ai/mem0/pull/4789))
**Improvements:**
- **API:** All API calls now include `source=CLI` in request bodies (POST/PUT) and query params (GET/DELETE) for server-side attribution ([#4789](https://github.com/mem0ai/mem0/pull/4789))
</Update>
<Update label="2026-04-06" description="Python v0.2.2 / Node v0.2.2"> <Update label="2026-04-06" description="Python v0.2.2 / Node v0.2.2">
**New Features:** **New Features:**