diff --git a/cli/node/package.json b/cli/node/package.json index b0daf3361..b3900d5a4 100644 --- a/cli/node/package.json +++ b/cli/node/package.json @@ -1,6 +1,6 @@ { "name": "@mem0/cli", - "version": "0.2.1", + "version": "0.2.2", "description": "The official CLI for mem0 — the memory layer for AI agents", "type": "module", "bin": { diff --git a/cli/node/src/backend/platform.ts b/cli/node/src/backend/platform.ts index 49a4dbac3..bb7ddccae 100644 --- a/cli/node/src/backend/platform.ts +++ b/cli/node/src/backend/platform.ts @@ -3,6 +3,8 @@ */ import type { PlatformConfig } from "../config.js"; +import { isAgentMode } from "../state.js"; +import { CLI_VERSION } from "../version.js"; import { APIError, type AddOptions, @@ -24,6 +26,9 @@ export class PlatformBackend implements Backend { this.headers = { Authorization: `Token ${config.apiKey}`, "Content-Type": "application/json", + "X-Mem0-Source": "cli", + "X-Mem0-Client-Language": "node", + "X-Mem0-Client-Version": CLI_VERSION, }; } @@ -38,9 +43,14 @@ export class PlatformBackend implements Backend { url += `?${qs}`; } + const headers = { + ...this.headers, + "X-Mem0-Caller-Type": isAgentMode() ? "agent" : "user", + }; + const fetchOpts: RequestInit = { method, - headers: this.headers, + headers, signal: AbortSignal.timeout(30_000), }; if (opts?.json) { @@ -286,11 +296,15 @@ export class PlatformBackend implements Backend { return result; } + async ping(): Promise> { + return (await this._request("GET", "/v1/ping/")) as Record; + } + async status( opts: { userId?: string; agentId?: string } = {}, ): Promise> { try { - await this._request("GET", "/v1/ping/"); + await this.ping(); return { connected: true, backend: "platform", base_url: this.baseUrl }; } catch (e) { return { diff --git a/cli/node/src/commands/init.ts b/cli/node/src/commands/init.ts index b1cdcae80..9f65cc564 100644 --- a/cli/node/src/commands/init.ts +++ b/cli/node/src/commands/init.ts @@ -41,10 +41,16 @@ async function emailLogin( const url = baseUrl.replace(/\/+$/, ""); let codeValue = code; + const sourceHeaders = { + "Content-Type": "application/json", + "X-Mem0-Source": "cli", + "X-Mem0-Client-Language": "node", + }; + if (!codeValue) { const resp = await fetch(`${url}/api/v1/auth/email_code/`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: sourceHeaders, body: JSON.stringify({ email }), signal: AbortSignal.timeout(30_000), }); @@ -85,7 +91,7 @@ async function emailLogin( const verifyResp = await fetch(`${url}/api/v1/auth/email_code/verify/`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: sourceHeaders, body: JSON.stringify({ email, code: codeValue.trim() }), signal: AbortSignal.timeout(30_000), }); @@ -215,6 +221,16 @@ async function validatePlatform(config: Mem0Config): Promise { }); if (status.connected) { printSuccess("Connected to mem0 Platform!"); + // Cache user_email from ping response for telemetry distinct_id + try { + const pingData = (await backend.ping()) as Record; + const userEmail = pingData?.user_email as string | undefined; + if (userEmail) { + config.platform.userEmail = userEmail; + } + } catch { + /* ignore — telemetry ID will fall back to API key hash */ + } } else { printError( `Could not connect: ${status.error ?? "Unknown error"}`, @@ -307,6 +323,7 @@ export async function runInit( config.platform.apiKey = apiKeyVal; config.platform.baseUrl = baseUrl; + config.platform.userEmail = email; config.defaults.userId = opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli"; @@ -385,6 +402,7 @@ export async function runInit( config.platform.apiKey = apiKeyVal; config.platform.baseUrl = baseUrl; + config.platform.userEmail = email; config.defaults.userId = opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli"; diff --git a/cli/node/src/config.ts b/cli/node/src/config.ts index e16221d7c..d69576729 100644 --- a/cli/node/src/config.ts +++ b/cli/node/src/config.ts @@ -20,6 +20,7 @@ export const CONFIG_VERSION = 1; export interface PlatformConfig { apiKey: string; baseUrl: string; + userEmail: string; } export interface DefaultsConfig { @@ -49,6 +50,7 @@ export function createDefaultConfig(): Mem0Config { platform: { apiKey: "", baseUrl: DEFAULT_BASE_URL, + userEmail: "", }, }; } @@ -70,6 +72,7 @@ export function loadConfig(): Mem0Config { const plat = data.platform ?? {}; config.platform.apiKey = plat.api_key ?? ""; config.platform.baseUrl = plat.base_url ?? DEFAULT_BASE_URL; + config.platform.userEmail = plat.user_email ?? ""; const defaults = data.defaults ?? {}; config.defaults.userId = defaults.user_id ?? ""; @@ -114,6 +117,7 @@ export function saveConfig(config: Mem0Config): void { platform: { api_key: config.platform.apiKey, base_url: config.platform.baseUrl, + user_email: config.platform.userEmail, }, }; @@ -131,6 +135,7 @@ export function redactKey(key: string): string { const KEY_MAP: Record = { "platform.api_key": ["platform", "apiKey"], "platform.base_url": ["platform", "baseUrl"], + "platform.user_email": ["platform", "userEmail"], "defaults.user_id": ["defaults", "userId"], "defaults.agent_id": ["defaults", "agentId"], "defaults.app_id": ["defaults", "appId"], @@ -139,6 +144,7 @@ const KEY_MAP: Record = { // Short-form aliases api_key: ["platform", "apiKey"], base_url: ["platform", "baseUrl"], + user_email: ["platform", "userEmail"], user_id: ["defaults", "userId"], agent_id: ["defaults", "agentId"], app_id: ["defaults", "appId"], diff --git a/cli/node/src/index.ts b/cli/node/src/index.ts index 95495b30c..8c0acb17f 100644 --- a/cli/node/src/index.ts +++ b/cli/node/src/index.ts @@ -14,6 +14,7 @@ import type { Mem0Config } from "./config.js"; import { loadConfig } from "./config.js"; import { richFormatHelp } from "./help.js"; import { setAgentMode } from "./state.js"; +import { captureEvent } from "./telemetry.js"; import { CLI_VERSION } from "./version.js"; const program = new Command(); @@ -123,6 +124,26 @@ program .addHelpCommand(false) .configureHelp({ formatHelp: richFormatHelp }); +// ── Telemetry hook ─────────────────────────────────────────────────────── + +program.hook("preAction", (_thisCommand, actionCommand) => { + try { + const commandName = actionCommand.name(); + const parentName = actionCommand.parent?.name(); + const fullCommand = + parentName && parentName !== "mem0" + ? `${parentName}.${commandName}` + : commandName; + const isAgent = !!(program.opts().json || program.opts().agent); + captureEvent(`cli.${fullCommand}`, { + command: fullCommand, + is_agent: isAgent, + }); + } catch { + /* silently swallow */ + } +}); + // ── Init ────────────────────────────────────────────────────────────────── program diff --git a/cli/node/src/telemetry.ts b/cli/node/src/telemetry.ts new file mode 100644 index 000000000..9a22fd2dd --- /dev/null +++ b/cli/node/src/telemetry.ts @@ -0,0 +1,101 @@ +/** + * CLI telemetry — anonymous usage tracking via PostHog. + * + * Sends fire-and-forget events by spawning a detached child process + * (telemetry-sender.cjs). The parent CLI process exits immediately; + * the child handles email resolution, caching, and the HTTP POST. + * + * Disable with: MEM0_TELEMETRY=false + */ + +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { CONFIG_FILE, loadConfig } from "./config.js"; +import { CLI_VERSION } from "./version.js"; + +const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"; +const POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SENDER_SCRIPT = path.join(__dirname, "..", "telemetry-sender.cjs"); + +function isTelemetryEnabled(): boolean { + try { + return process.env.MEM0_TELEMETRY !== "false"; + } catch { + return true; + } +} + +/** + * Return a stable anonymous identifier for the current user. + * + * Priority: cached user_email (from /v1/ping/) > MD5(api_key) > fallback. + * Matches the SDK pattern in mem0-ts/src/client/mem0.ts. + */ +function getDistinctId(): string { + try { + const config = loadConfig(); + if (config.platform.userEmail) { + return config.platform.userEmail; + } + if (config.platform.apiKey) { + return createHash("md5").update(config.platform.apiKey).digest("hex"); + } + } catch { + /* ignore */ + } + return "anonymous-cli"; +} + +/** + * Fire a PostHog event (non-blocking, returns void, never throws). + * Spawns telemetry-sender.cjs as a detached subprocess. + */ +export function captureEvent( + eventName: string, + properties: Record = {}, +): void { + if (!isTelemetryEnabled()) return; + + try { + const config = loadConfig(); + const distinctId = getDistinctId(); + + const payload = { + api_key: POSTHOG_API_KEY, + distinct_id: distinctId, + event: eventName, + properties: { + source: "CLI", + language: "node", + cli_version: CLI_VERSION, + node_version: process.version, + os: process.platform, + ...properties, + $process_person_profile: false, + $lib: "posthog-node", + }, + }; + + const context = { + payload, + posthogHost: POSTHOG_HOST, + needsEmail: !distinctId || !distinctId.includes("@"), + mem0ApiKey: config.platform.apiKey || "", + mem0BaseUrl: config.platform.baseUrl || "https://api.mem0.ai", + configPath: CONFIG_FILE, + }; + + const child = spawn( + process.execPath, + [SENDER_SCRIPT, JSON.stringify(context)], + { detached: true, stdio: "ignore" }, + ); + child.unref(); + } catch { + /* silently swallow */ + } +} diff --git a/cli/node/telemetry-sender.cjs b/cli/node/telemetry-sender.cjs new file mode 100644 index 000000000..e18b2a57d --- /dev/null +++ b/cli/node/telemetry-sender.cjs @@ -0,0 +1,108 @@ +/** + * Standalone telemetry sender — runs as a detached child process. + * + * Usage: node telemetry-sender.cjs '' + * + * This script is spawned by telemetry.captureEvent() and runs independently + * of the parent CLI process. It: + * + * 1. Resolves the user's email via /v1/ping/ if not already cached + * 2. Caches the email in ~/.mem0/config.json for future runs + * 3. Sends the PostHog event + * + * All errors are silently swallowed — this process must never produce output + * or affect the user experience. + */ + +"use strict"; + +const https = require("https"); +const fs = require("fs"); + +function httpsRequest(url, method, headers, body) { + return new Promise((resolve, reject) => { + const u = new URL(url); + const opts = { + hostname: u.hostname, + path: u.pathname + u.search, + method, + headers, + timeout: 10000, + }; + const req = https.request(opts, (res) => { + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => { + try { + resolve(JSON.parse(data)); + } catch { + resolve({}); + } + }); + }); + req.on("error", reject); + req.on("timeout", () => { + req.destroy(); + reject(new Error("timeout")); + }); + if (body) { + req.end(body); + } else { + req.end(); + } + }); +} + +async function resolveAndCacheEmail(ctx, payload) { + try { + const pingUrl = ctx.mem0BaseUrl.replace(/\/+$/, "") + "/v1/ping/"; + const data = await httpsRequest(pingUrl, "GET", { + Authorization: "Token " + ctx.mem0ApiKey, + "Content-Type": "application/json", + }); + if (data.user_email) { + payload.distinct_id = data.user_email; + cacheEmail(ctx.configPath, data.user_email); + } + } catch { + // silently swallow + } +} + +function cacheEmail(configPath, email) { + if (!configPath) return; + try { + const raw = fs.readFileSync(configPath, "utf-8"); + const cfg = JSON.parse(raw); + if (!cfg.platform) cfg.platform = {}; + cfg.platform.user_email = email; + fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2)); + } catch { + // silently swallow + } +} + +async function sendPosthogEvent(posthogHost, payload) { + try { + const body = JSON.stringify(payload); + await httpsRequest(posthogHost, "POST", { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + }, body); + } catch { + // silently swallow + } +} + +async function main() { + const ctx = JSON.parse(process.argv[2]); + const payload = ctx.payload; + + if (ctx.needsEmail && ctx.mem0ApiKey) { + await resolveAndCacheEmail(ctx, payload); + } + + await sendPosthogEvent(ctx.posthogHost, payload); +} + +main().catch(() => {}); diff --git a/cli/python/pyproject.toml b/cli/python/pyproject.toml index 98641dc19..fc1090943 100644 --- a/cli/python/pyproject.toml +++ b/cli/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mem0-cli" -version = "0.2.1" +version = "0.2.2" description = "The official CLI for mem0 — the memory layer for AI agents" readme = "README.md" license = "Apache-2.0" diff --git a/cli/python/src/mem0_cli/__init__.py b/cli/python/src/mem0_cli/__init__.py index b217fb786..8d0c4e6aa 100644 --- a/cli/python/src/mem0_cli/__init__.py +++ b/cli/python/src/mem0_cli/__init__.py @@ -1,3 +1,3 @@ """mem0 CLI — the command-line interface for the mem0 memory layer.""" -__version__ = "0.2.1" +__version__ = "0.2.2" diff --git a/cli/python/src/mem0_cli/app.py b/cli/python/src/mem0_cli/app.py index 93b3adcd2..ff2881267 100644 --- a/cli/python/src/mem0_cli/app.py +++ b/cli/python/src/mem0_cli/app.py @@ -55,6 +55,40 @@ event_app = typer.Typer( # entity_app and event_app registered after Memory commands to control panel ordering +# ── Telemetry helper ───────────────────────────────────────────────────── + + +def _fire_telemetry(command_name: str, extra: dict | None = None) -> None: + """Fire a PostHog telemetry event (non-blocking, never fails).""" + try: + from mem0_cli.telemetry import capture_event + + props = {"command": command_name} + if extra: + props.update(extra) + capture_event(f"cli.{command_name}", props) + except Exception: + pass + + +@config_app.callback(invoke_without_command=True) +def _config_callback(ctx: typer.Context) -> None: + if ctx.invoked_subcommand: + _fire_telemetry(f"config.{ctx.invoked_subcommand}") + + +@entity_app.callback(invoke_without_command=True) +def _entity_callback(ctx: typer.Context) -> None: + if ctx.invoked_subcommand: + _fire_telemetry(f"entity.{ctx.invoked_subcommand}") + + +@event_app.callback(invoke_without_command=True) +def _event_callback(ctx: typer.Context) -> None: + if ctx.invoked_subcommand: + _fire_telemetry(f"event.{ctx.invoked_subcommand}") + + # ── Helpers ─────────────────────────────────────────────────────────────── @@ -165,8 +199,11 @@ def main_callback( if version: from mem0_cli.commands.utils import cmd_version + _fire_telemetry("version") cmd_version() raise typer.Exit() + if ctx.invoked_subcommand: + _fire_telemetry(ctx.invoked_subcommand) # ── Memory: add ─────────────────────────────────────────────────────────── @@ -571,12 +608,14 @@ def delete( # ── Dispatch ───────────────────────────────────────────────────── if memory_id is not None: + _fire_telemetry("delete", {"delete_mode": "single"}) from mem0_cli.commands.memory import cmd_delete backend = _get_backend(api_key, base_url) cmd_delete(backend, memory_id, dry_run=dry_run, force=force, output=output) elif all_: + _fire_telemetry("delete", {"delete_mode": "all"}) from mem0_cli.commands.memory import cmd_delete_all backend, config = _get_backend_and_config(api_key, base_url) @@ -584,6 +623,7 @@ def delete( cmd_delete_all(backend, force=force, dry_run=dry_run, all_=project, **ids, output=output) else: # --entity + _fire_telemetry("delete", {"delete_mode": "entity"}) from mem0_cli.commands.entities import cmd_entities_delete backend = _get_backend(api_key, base_url) diff --git a/cli/python/src/mem0_cli/backend/platform.py b/cli/python/src/mem0_cli/backend/platform.py index 77d5853b0..0114d1f26 100644 --- a/cli/python/src/mem0_cli/backend/platform.py +++ b/cli/python/src/mem0_cli/backend/platform.py @@ -6,6 +6,7 @@ from typing import Any import httpx +from mem0_cli import __version__ from mem0_cli.backend.base import Backend from mem0_cli.config import PlatformConfig @@ -21,11 +22,17 @@ class PlatformBackend(Backend): headers={ "Authorization": f"Token {config.api_key}", "Content-Type": "application/json", + "X-Mem0-Source": "cli", + "X-Mem0-Client-Language": "python", + "X-Mem0-Client-Version": __version__, }, timeout=30.0, ) def _request(self, method: str, path: str, **kwargs: Any) -> Any: + from mem0_cli.state import is_agent_mode + + self._client.headers["X-Mem0-Caller-Type"] = "agent" if is_agent_mode() else "user" resp = self._client.request(method, path, **kwargs) if resp.status_code == 401: raise AuthError("Authentication failed. Your API key may be invalid or expired.") @@ -281,6 +288,10 @@ class PlatformBackend(Backend): result = self._request("DELETE", f"/v2/entities/{entity_type}/{entity_id}/") return result + def ping(self) -> dict: + """Call the ping endpoint and return the raw response.""" + return self._request("GET", "/v1/ping/") + def status( self, *, @@ -289,7 +300,7 @@ class PlatformBackend(Backend): ) -> dict[str, Any]: """Check connectivity using the ping endpoint.""" try: - self._request("GET", "/v1/ping/") + self.ping() return {"connected": True, "backend": "platform", "base_url": self.base_url} except Exception as e: return {"connected": False, "backend": "platform", "error": str(e)} diff --git a/cli/python/src/mem0_cli/commands/init_cmd.py b/cli/python/src/mem0_cli/commands/init_cmd.py index ec7bb9464..fadfb406f 100644 --- a/cli/python/src/mem0_cli/commands/init_cmd.py +++ b/cli/python/src/mem0_cli/commands/init_cmd.py @@ -108,6 +108,10 @@ def _email_login( The caller expects at minimum an ``api_key`` field. """ url = base_url.rstrip("/") + _source_headers = { + "X-Mem0-Source": "cli", + "X-Mem0-Client-Language": "python", + } with httpx.Client(timeout=30.0) as client: # If code is already provided, skip sending — user already has a code @@ -116,6 +120,7 @@ def _email_login( resp = client.post( f"{url}/api/v1/auth/email_code/", json={"email": email}, + headers=_source_headers, ) if resp.status_code == 429: print_error(err_console, "Too many attempts. Try again in a few minutes.") @@ -148,6 +153,7 @@ def _email_login( resp = client.post( f"{url}/api/v1/auth/email_code/verify/", json={"email": email, "code": code.strip()}, + headers=_source_headers, ) if resp.status_code == 429: print_error(err_console, "Too many attempts. Try again in a few minutes.") @@ -229,6 +235,7 @@ def run_init( raise typer.Exit(1) config.platform.api_key = api_key_val config.platform.base_url = base_url + config.platform.user_email = email config.defaults.user_id = ( user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli" ) @@ -299,6 +306,7 @@ def run_init( raise typer.Exit(1) config.platform.api_key = api_key_val config.platform.base_url = base_url + config.platform.user_email = email_addr config.defaults.user_id = ( user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli" ) @@ -384,6 +392,14 @@ def _validate_platform(config: Mem0Config) -> None: ) if status.get("connected"): print_success(console, "Connected to mem0 Platform!") + # Cache user_email from ping response for telemetry distinct_id + try: + ping_data = backend.ping() + user_email = ping_data.get("user_email") if isinstance(ping_data, dict) else None + if user_email: + config.platform.user_email = user_email + except Exception: + pass else: print_error( err_console, diff --git a/cli/python/src/mem0_cli/config.py b/cli/python/src/mem0_cli/config.py index 9ffa64161..2d5949bd0 100644 --- a/cli/python/src/mem0_cli/config.py +++ b/cli/python/src/mem0_cli/config.py @@ -27,6 +27,7 @@ CONFIG_VERSION = 1 class PlatformConfig: api_key: str = "" base_url: str = DEFAULT_BASE_URL + user_email: str = "" @dataclass @@ -48,6 +49,7 @@ class Mem0Config: SHORT_KEY_ALIASES: dict[str, str] = { "api_key": "platform.api_key", "base_url": "platform.base_url", + "user_email": "platform.user_email", "user_id": "defaults.user_id", "agent_id": "defaults.agent_id", "app_id": "defaults.app_id", @@ -76,6 +78,7 @@ def load_config() -> Mem0Config: plat = data.get("platform", {}) config.platform.api_key = plat.get("api_key", "") config.platform.base_url = plat.get("base_url", DEFAULT_BASE_URL) + config.platform.user_email = plat.get("user_email", "") defaults = data.get("defaults", {}) config.defaults.user_id = defaults.get("user_id", "") @@ -132,6 +135,7 @@ def save_config(config: Mem0Config) -> None: "platform": { "api_key": config.platform.api_key, "base_url": config.platform.base_url, + "user_email": config.platform.user_email, }, } diff --git a/cli/python/src/mem0_cli/telemetry.py b/cli/python/src/mem0_cli/telemetry.py new file mode 100644 index 000000000..d7cebdde6 --- /dev/null +++ b/cli/python/src/mem0_cli/telemetry.py @@ -0,0 +1,96 @@ +"""CLI telemetry — anonymous usage tracking via PostHog. + +Sends fire-and-forget events to PostHog by spawning a detached subprocess +(telemetry_sender.py). The parent CLI process exits immediately; the +subprocess handles email resolution, caching, and the HTTP POST. + +Disable with: MEM0_TELEMETRY=false +""" + +from __future__ import annotations + +import hashlib +import json +import os +import platform +import subprocess +import sys +from typing import Any + +POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX" +POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/" + + +def _is_telemetry_enabled() -> bool: + val = os.environ.get("MEM0_TELEMETRY", "true").lower() + return val not in ("false", "0", "no") + + +def _get_distinct_id() -> str: + """Return a stable anonymous identifier for the current user. + + Priority: cached user_email (from /v1/ping/) > MD5(api_key) > fallback. + Matches the SDK pattern in mem0/client/main.py. + """ + try: + from mem0_cli.config import load_config + + config = load_config() + if config.platform.user_email: + return config.platform.user_email + if config.platform.api_key: + return hashlib.md5(config.platform.api_key.encode()).hexdigest() + except Exception: + pass + return "anonymous-cli" + + +def capture_event(event_name: str, properties: dict[str, Any] | None = None) -> None: + """Fire a PostHog event via a detached subprocess (non-blocking).""" + if not _is_telemetry_enabled(): + return + + try: + from mem0_cli import __version__ + from mem0_cli.config import CONFIG_FILE, load_config + from mem0_cli.state import is_agent_mode + + config = load_config() + distinct_id = _get_distinct_id() + + payload = { + "api_key": POSTHOG_API_KEY, + "distinct_id": distinct_id, + "event": event_name, + "properties": { + "source": "CLI", + "language": "python", + "cli_version": __version__, + "agent_mode": is_agent_mode(), + "python_version": sys.version, + "os": sys.platform, + "os_version": platform.version(), + "$process_person_profile": False, + "$lib": "posthog-python", + **(properties or {}), + }, + } + + context = { + "payload": payload, + "posthog_host": POSTHOG_HOST, + "needs_email": not distinct_id or "@" not in distinct_id, + "mem0_api_key": config.platform.api_key or "", + "mem0_base_url": config.platform.base_url or "https://api.mem0.ai", + "config_path": str(CONFIG_FILE), + } + + subprocess.Popen( + [sys.executable, "-m", "mem0_cli.telemetry_sender", json.dumps(context)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + close_fds=True, + ) + except Exception: + pass diff --git a/cli/python/src/mem0_cli/telemetry_sender.py b/cli/python/src/mem0_cli/telemetry_sender.py new file mode 100644 index 000000000..e277a53ea --- /dev/null +++ b/cli/python/src/mem0_cli/telemetry_sender.py @@ -0,0 +1,86 @@ +"""Standalone telemetry sender — runs as a detached subprocess. + +Usage: python -m mem0_cli.telemetry_sender '' + +This module is spawned by telemetry.capture_event() and runs independently +of the parent CLI process. It: + +1. Resolves the user's email via /v1/ping/ if not already cached +2. Caches the email in ~/.mem0/config.json for future runs +3. Sends the PostHog event + +All errors are silently swallowed — this process must never produce output +or affect the user experience. +""" + +from __future__ import annotations + +import json +import sys +import urllib.request + + +def main() -> None: + ctx = json.loads(sys.argv[1]) + payload = ctx["payload"] + + if ctx.get("needs_email") and ctx.get("mem0_api_key"): + _resolve_and_cache_email(ctx, payload) + + _send_posthog_event(ctx["posthog_host"], payload) + + +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.""" + try: + ping_url = ctx["mem0_base_url"].rstrip("/") + "/v1/ping/" + req = urllib.request.Request( + ping_url, + headers={ + "Authorization": "Token " + ctx["mem0_api_key"], + "Content-Type": "application/json", + }, + ) + resp = urllib.request.urlopen(req, timeout=10) + data = json.loads(resp.read()) + email = data.get("user_email") + if email: + payload["distinct_id"] = email + _cache_email(ctx.get("config_path"), email) + except Exception: + pass + + +def _cache_email(config_path: str | None, email: str) -> None: + """Write user_email into the config file for future runs.""" + if not config_path: + return + try: + with open(config_path) as f: + cfg = json.load(f) + cfg.setdefault("platform", {})["user_email"] = email + with open(config_path, "w") as f: + json.dump(cfg, f, indent=2) + except Exception: + pass + + +def _send_posthog_event(posthog_host: str, payload: dict) -> None: + """POST the event to PostHog.""" + try: + body = json.dumps(payload).encode() + req = urllib.request.Request( + posthog_host, + data=body, + headers={"Content-Type": "application/json"}, + ) + urllib.request.urlopen(req, timeout=10) + except Exception: + pass + + +if __name__ == "__main__": + import contextlib + + with contextlib.suppress(Exception): + main()