feat(cli): add PostHog telemetry and source tracking to Python & Node CLIs (#4699)
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mem0/cli",
|
"name": "@mem0/cli",
|
||||||
"version": "0.2.1",
|
"version": "0.2.2",
|
||||||
"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": {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PlatformConfig } from "../config.js";
|
import type { PlatformConfig } from "../config.js";
|
||||||
|
import { isAgentMode } from "../state.js";
|
||||||
|
import { CLI_VERSION } from "../version.js";
|
||||||
import {
|
import {
|
||||||
APIError,
|
APIError,
|
||||||
type AddOptions,
|
type AddOptions,
|
||||||
@@ -24,6 +26,9 @@ export class PlatformBackend implements Backend {
|
|||||||
this.headers = {
|
this.headers = {
|
||||||
Authorization: `Token ${config.apiKey}`,
|
Authorization: `Token ${config.apiKey}`,
|
||||||
"Content-Type": "application/json",
|
"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}`;
|
url += `?${qs}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
...this.headers,
|
||||||
|
"X-Mem0-Caller-Type": isAgentMode() ? "agent" : "user",
|
||||||
|
};
|
||||||
|
|
||||||
const fetchOpts: RequestInit = {
|
const fetchOpts: RequestInit = {
|
||||||
method,
|
method,
|
||||||
headers: this.headers,
|
headers,
|
||||||
signal: AbortSignal.timeout(30_000),
|
signal: AbortSignal.timeout(30_000),
|
||||||
};
|
};
|
||||||
if (opts?.json) {
|
if (opts?.json) {
|
||||||
@@ -286,11 +296,15 @@ export class PlatformBackend implements Backend {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async ping(): Promise<Record<string, unknown>> {
|
||||||
|
return (await this._request("GET", "/v1/ping/")) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
async status(
|
async status(
|
||||||
opts: { userId?: string; agentId?: string } = {},
|
opts: { userId?: string; agentId?: string } = {},
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
await this._request("GET", "/v1/ping/");
|
await this.ping();
|
||||||
return { connected: true, backend: "platform", base_url: this.baseUrl };
|
return { connected: true, backend: "platform", base_url: this.baseUrl };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -41,10 +41,16 @@ async function emailLogin(
|
|||||||
const url = baseUrl.replace(/\/+$/, "");
|
const url = baseUrl.replace(/\/+$/, "");
|
||||||
let codeValue = code;
|
let codeValue = code;
|
||||||
|
|
||||||
|
const sourceHeaders = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Mem0-Source": "cli",
|
||||||
|
"X-Mem0-Client-Language": "node",
|
||||||
|
};
|
||||||
|
|
||||||
if (!codeValue) {
|
if (!codeValue) {
|
||||||
const resp = await fetch(`${url}/api/v1/auth/email_code/`, {
|
const resp = await fetch(`${url}/api/v1/auth/email_code/`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: sourceHeaders,
|
||||||
body: JSON.stringify({ email }),
|
body: JSON.stringify({ email }),
|
||||||
signal: AbortSignal.timeout(30_000),
|
signal: AbortSignal.timeout(30_000),
|
||||||
});
|
});
|
||||||
@@ -85,7 +91,7 @@ async function emailLogin(
|
|||||||
|
|
||||||
const verifyResp = await fetch(`${url}/api/v1/auth/email_code/verify/`, {
|
const verifyResp = await fetch(`${url}/api/v1/auth/email_code/verify/`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: sourceHeaders,
|
||||||
body: JSON.stringify({ email, code: codeValue.trim() }),
|
body: JSON.stringify({ email, code: codeValue.trim() }),
|
||||||
signal: AbortSignal.timeout(30_000),
|
signal: AbortSignal.timeout(30_000),
|
||||||
});
|
});
|
||||||
@@ -215,6 +221,16 @@ async function validatePlatform(config: Mem0Config): Promise<void> {
|
|||||||
});
|
});
|
||||||
if (status.connected) {
|
if (status.connected) {
|
||||||
printSuccess("Connected to mem0 Platform!");
|
printSuccess("Connected to mem0 Platform!");
|
||||||
|
// Cache user_email from ping response for telemetry distinct_id
|
||||||
|
try {
|
||||||
|
const pingData = (await backend.ping()) as Record<string, unknown>;
|
||||||
|
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 {
|
} else {
|
||||||
printError(
|
printError(
|
||||||
`Could not connect: ${status.error ?? "Unknown error"}`,
|
`Could not connect: ${status.error ?? "Unknown error"}`,
|
||||||
@@ -307,6 +323,7 @@ export async function runInit(
|
|||||||
|
|
||||||
config.platform.apiKey = apiKeyVal;
|
config.platform.apiKey = apiKeyVal;
|
||||||
config.platform.baseUrl = baseUrl;
|
config.platform.baseUrl = baseUrl;
|
||||||
|
config.platform.userEmail = email;
|
||||||
config.defaults.userId =
|
config.defaults.userId =
|
||||||
opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli";
|
opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli";
|
||||||
|
|
||||||
@@ -385,6 +402,7 @@ export async function runInit(
|
|||||||
|
|
||||||
config.platform.apiKey = apiKeyVal;
|
config.platform.apiKey = apiKeyVal;
|
||||||
config.platform.baseUrl = baseUrl;
|
config.platform.baseUrl = baseUrl;
|
||||||
|
config.platform.userEmail = email;
|
||||||
config.defaults.userId =
|
config.defaults.userId =
|
||||||
opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli";
|
opts.userId || process.env.USER || process.env.USERNAME || "mem0-cli";
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const CONFIG_VERSION = 1;
|
|||||||
export interface PlatformConfig {
|
export interface PlatformConfig {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
userEmail: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DefaultsConfig {
|
export interface DefaultsConfig {
|
||||||
@@ -49,6 +50,7 @@ export function createDefaultConfig(): Mem0Config {
|
|||||||
platform: {
|
platform: {
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
baseUrl: DEFAULT_BASE_URL,
|
baseUrl: DEFAULT_BASE_URL,
|
||||||
|
userEmail: "",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -70,6 +72,7 @@ export function loadConfig(): Mem0Config {
|
|||||||
const plat = data.platform ?? {};
|
const plat = data.platform ?? {};
|
||||||
config.platform.apiKey = plat.api_key ?? "";
|
config.platform.apiKey = plat.api_key ?? "";
|
||||||
config.platform.baseUrl = plat.base_url ?? DEFAULT_BASE_URL;
|
config.platform.baseUrl = plat.base_url ?? DEFAULT_BASE_URL;
|
||||||
|
config.platform.userEmail = plat.user_email ?? "";
|
||||||
|
|
||||||
const defaults = data.defaults ?? {};
|
const defaults = data.defaults ?? {};
|
||||||
config.defaults.userId = defaults.user_id ?? "";
|
config.defaults.userId = defaults.user_id ?? "";
|
||||||
@@ -114,6 +117,7 @@ export function saveConfig(config: Mem0Config): void {
|
|||||||
platform: {
|
platform: {
|
||||||
api_key: config.platform.apiKey,
|
api_key: config.platform.apiKey,
|
||||||
base_url: config.platform.baseUrl,
|
base_url: config.platform.baseUrl,
|
||||||
|
user_email: config.platform.userEmail,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -131,6 +135,7 @@ export function redactKey(key: string): string {
|
|||||||
const KEY_MAP: Record<string, [keyof Mem0Config, string]> = {
|
const KEY_MAP: Record<string, [keyof Mem0Config, string]> = {
|
||||||
"platform.api_key": ["platform", "apiKey"],
|
"platform.api_key": ["platform", "apiKey"],
|
||||||
"platform.base_url": ["platform", "baseUrl"],
|
"platform.base_url": ["platform", "baseUrl"],
|
||||||
|
"platform.user_email": ["platform", "userEmail"],
|
||||||
"defaults.user_id": ["defaults", "userId"],
|
"defaults.user_id": ["defaults", "userId"],
|
||||||
"defaults.agent_id": ["defaults", "agentId"],
|
"defaults.agent_id": ["defaults", "agentId"],
|
||||||
"defaults.app_id": ["defaults", "appId"],
|
"defaults.app_id": ["defaults", "appId"],
|
||||||
@@ -139,6 +144,7 @@ const KEY_MAP: Record<string, [keyof Mem0Config, string]> = {
|
|||||||
// Short-form aliases
|
// Short-form aliases
|
||||||
api_key: ["platform", "apiKey"],
|
api_key: ["platform", "apiKey"],
|
||||||
base_url: ["platform", "baseUrl"],
|
base_url: ["platform", "baseUrl"],
|
||||||
|
user_email: ["platform", "userEmail"],
|
||||||
user_id: ["defaults", "userId"],
|
user_id: ["defaults", "userId"],
|
||||||
agent_id: ["defaults", "agentId"],
|
agent_id: ["defaults", "agentId"],
|
||||||
app_id: ["defaults", "appId"],
|
app_id: ["defaults", "appId"],
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { Mem0Config } from "./config.js";
|
|||||||
import { loadConfig } from "./config.js";
|
import { loadConfig } from "./config.js";
|
||||||
import { richFormatHelp } from "./help.js";
|
import { richFormatHelp } from "./help.js";
|
||||||
import { setAgentMode } from "./state.js";
|
import { setAgentMode } from "./state.js";
|
||||||
|
import { captureEvent } from "./telemetry.js";
|
||||||
import { CLI_VERSION } from "./version.js";
|
import { CLI_VERSION } from "./version.js";
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
@@ -123,6 +124,26 @@ program
|
|||||||
.addHelpCommand(false)
|
.addHelpCommand(false)
|
||||||
.configureHelp({ formatHelp: richFormatHelp });
|
.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 ──────────────────────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
program
|
program
|
||||||
|
|||||||
@@ -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<string, unknown> = {},
|
||||||
|
): 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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Standalone telemetry sender — runs as a detached child process.
|
||||||
|
*
|
||||||
|
* Usage: node telemetry-sender.cjs '<json context>'
|
||||||
|
*
|
||||||
|
* 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(() => {});
|
||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "mem0-cli"
|
name = "mem0-cli"
|
||||||
version = "0.2.1"
|
version = "0.2.2"
|
||||||
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,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.1"
|
__version__ = "0.2.2"
|
||||||
|
|||||||
@@ -55,6 +55,40 @@ event_app = typer.Typer(
|
|||||||
# entity_app and event_app registered after Memory commands to control panel ordering
|
# 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 ───────────────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -165,8 +199,11 @@ def main_callback(
|
|||||||
if version:
|
if version:
|
||||||
from mem0_cli.commands.utils import cmd_version
|
from mem0_cli.commands.utils import cmd_version
|
||||||
|
|
||||||
|
_fire_telemetry("version")
|
||||||
cmd_version()
|
cmd_version()
|
||||||
raise typer.Exit()
|
raise typer.Exit()
|
||||||
|
if ctx.invoked_subcommand:
|
||||||
|
_fire_telemetry(ctx.invoked_subcommand)
|
||||||
|
|
||||||
|
|
||||||
# ── Memory: add ───────────────────────────────────────────────────────────
|
# ── Memory: add ───────────────────────────────────────────────────────────
|
||||||
@@ -571,12 +608,14 @@ def delete(
|
|||||||
|
|
||||||
# ── Dispatch ─────────────────────────────────────────────────────
|
# ── Dispatch ─────────────────────────────────────────────────────
|
||||||
if memory_id is not None:
|
if memory_id is not None:
|
||||||
|
_fire_telemetry("delete", {"delete_mode": "single"})
|
||||||
from mem0_cli.commands.memory import cmd_delete
|
from mem0_cli.commands.memory import cmd_delete
|
||||||
|
|
||||||
backend = _get_backend(api_key, base_url)
|
backend = _get_backend(api_key, base_url)
|
||||||
cmd_delete(backend, memory_id, dry_run=dry_run, force=force, output=output)
|
cmd_delete(backend, memory_id, dry_run=dry_run, force=force, output=output)
|
||||||
|
|
||||||
elif all_:
|
elif all_:
|
||||||
|
_fire_telemetry("delete", {"delete_mode": "all"})
|
||||||
from mem0_cli.commands.memory import cmd_delete_all
|
from mem0_cli.commands.memory import cmd_delete_all
|
||||||
|
|
||||||
backend, config = _get_backend_and_config(api_key, base_url)
|
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)
|
cmd_delete_all(backend, force=force, dry_run=dry_run, all_=project, **ids, output=output)
|
||||||
|
|
||||||
else: # --entity
|
else: # --entity
|
||||||
|
_fire_telemetry("delete", {"delete_mode": "entity"})
|
||||||
from mem0_cli.commands.entities import cmd_entities_delete
|
from mem0_cli.commands.entities import cmd_entities_delete
|
||||||
|
|
||||||
backend = _get_backend(api_key, base_url)
|
backend = _get_backend(api_key, base_url)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Any
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from mem0_cli import __version__
|
||||||
from mem0_cli.backend.base import Backend
|
from mem0_cli.backend.base import Backend
|
||||||
from mem0_cli.config import PlatformConfig
|
from mem0_cli.config import PlatformConfig
|
||||||
|
|
||||||
@@ -21,11 +22,17 @@ class PlatformBackend(Backend):
|
|||||||
headers={
|
headers={
|
||||||
"Authorization": f"Token {config.api_key}",
|
"Authorization": f"Token {config.api_key}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
"X-Mem0-Source": "cli",
|
||||||
|
"X-Mem0-Client-Language": "python",
|
||||||
|
"X-Mem0-Client-Version": __version__,
|
||||||
},
|
},
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
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)
|
resp = self._client.request(method, path, **kwargs)
|
||||||
if resp.status_code == 401:
|
if resp.status_code == 401:
|
||||||
raise AuthError("Authentication failed. Your API key may be invalid or expired.")
|
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}/")
|
result = self._request("DELETE", f"/v2/entities/{entity_type}/{entity_id}/")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def ping(self) -> dict:
|
||||||
|
"""Call the ping endpoint and return the raw response."""
|
||||||
|
return self._request("GET", "/v1/ping/")
|
||||||
|
|
||||||
def status(
|
def status(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -289,7 +300,7 @@ class PlatformBackend(Backend):
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Check connectivity using the ping endpoint."""
|
"""Check connectivity using the ping endpoint."""
|
||||||
try:
|
try:
|
||||||
self._request("GET", "/v1/ping/")
|
self.ping()
|
||||||
return {"connected": True, "backend": "platform", "base_url": self.base_url}
|
return {"connected": True, "backend": "platform", "base_url": self.base_url}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"connected": False, "backend": "platform", "error": str(e)}
|
return {"connected": False, "backend": "platform", "error": str(e)}
|
||||||
|
|||||||
@@ -108,6 +108,10 @@ def _email_login(
|
|||||||
The caller expects at minimum an ``api_key`` field.
|
The caller expects at minimum an ``api_key`` field.
|
||||||
"""
|
"""
|
||||||
url = base_url.rstrip("/")
|
url = base_url.rstrip("/")
|
||||||
|
_source_headers = {
|
||||||
|
"X-Mem0-Source": "cli",
|
||||||
|
"X-Mem0-Client-Language": "python",
|
||||||
|
}
|
||||||
|
|
||||||
with httpx.Client(timeout=30.0) as client:
|
with httpx.Client(timeout=30.0) as client:
|
||||||
# If code is already provided, skip sending — user already has a code
|
# If code is already provided, skip sending — user already has a code
|
||||||
@@ -116,6 +120,7 @@ def _email_login(
|
|||||||
resp = client.post(
|
resp = client.post(
|
||||||
f"{url}/api/v1/auth/email_code/",
|
f"{url}/api/v1/auth/email_code/",
|
||||||
json={"email": email},
|
json={"email": email},
|
||||||
|
headers=_source_headers,
|
||||||
)
|
)
|
||||||
if resp.status_code == 429:
|
if resp.status_code == 429:
|
||||||
print_error(err_console, "Too many attempts. Try again in a few minutes.")
|
print_error(err_console, "Too many attempts. Try again in a few minutes.")
|
||||||
@@ -148,6 +153,7 @@ def _email_login(
|
|||||||
resp = client.post(
|
resp = client.post(
|
||||||
f"{url}/api/v1/auth/email_code/verify/",
|
f"{url}/api/v1/auth/email_code/verify/",
|
||||||
json={"email": email, "code": code.strip()},
|
json={"email": email, "code": code.strip()},
|
||||||
|
headers=_source_headers,
|
||||||
)
|
)
|
||||||
if resp.status_code == 429:
|
if resp.status_code == 429:
|
||||||
print_error(err_console, "Too many attempts. Try again in a few minutes.")
|
print_error(err_console, "Too many attempts. Try again in a few minutes.")
|
||||||
@@ -229,6 +235,7 @@ def run_init(
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
config.platform.api_key = api_key_val
|
config.platform.api_key = api_key_val
|
||||||
config.platform.base_url = base_url
|
config.platform.base_url = base_url
|
||||||
|
config.platform.user_email = email
|
||||||
config.defaults.user_id = (
|
config.defaults.user_id = (
|
||||||
user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
|
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)
|
raise typer.Exit(1)
|
||||||
config.platform.api_key = api_key_val
|
config.platform.api_key = api_key_val
|
||||||
config.platform.base_url = base_url
|
config.platform.base_url = base_url
|
||||||
|
config.platform.user_email = email_addr
|
||||||
config.defaults.user_id = (
|
config.defaults.user_id = (
|
||||||
user_id or os.environ.get("USER") or os.environ.get("USERNAME") or "mem0-cli"
|
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"):
|
if status.get("connected"):
|
||||||
print_success(console, "Connected to mem0 Platform!")
|
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:
|
else:
|
||||||
print_error(
|
print_error(
|
||||||
err_console,
|
err_console,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ CONFIG_VERSION = 1
|
|||||||
class PlatformConfig:
|
class PlatformConfig:
|
||||||
api_key: str = ""
|
api_key: str = ""
|
||||||
base_url: str = DEFAULT_BASE_URL
|
base_url: str = DEFAULT_BASE_URL
|
||||||
|
user_email: str = ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -48,6 +49,7 @@ class Mem0Config:
|
|||||||
SHORT_KEY_ALIASES: dict[str, str] = {
|
SHORT_KEY_ALIASES: dict[str, str] = {
|
||||||
"api_key": "platform.api_key",
|
"api_key": "platform.api_key",
|
||||||
"base_url": "platform.base_url",
|
"base_url": "platform.base_url",
|
||||||
|
"user_email": "platform.user_email",
|
||||||
"user_id": "defaults.user_id",
|
"user_id": "defaults.user_id",
|
||||||
"agent_id": "defaults.agent_id",
|
"agent_id": "defaults.agent_id",
|
||||||
"app_id": "defaults.app_id",
|
"app_id": "defaults.app_id",
|
||||||
@@ -76,6 +78,7 @@ def load_config() -> Mem0Config:
|
|||||||
plat = data.get("platform", {})
|
plat = data.get("platform", {})
|
||||||
config.platform.api_key = plat.get("api_key", "")
|
config.platform.api_key = plat.get("api_key", "")
|
||||||
config.platform.base_url = plat.get("base_url", DEFAULT_BASE_URL)
|
config.platform.base_url = plat.get("base_url", DEFAULT_BASE_URL)
|
||||||
|
config.platform.user_email = plat.get("user_email", "")
|
||||||
|
|
||||||
defaults = data.get("defaults", {})
|
defaults = data.get("defaults", {})
|
||||||
config.defaults.user_id = defaults.get("user_id", "")
|
config.defaults.user_id = defaults.get("user_id", "")
|
||||||
@@ -132,6 +135,7 @@ def save_config(config: Mem0Config) -> None:
|
|||||||
"platform": {
|
"platform": {
|
||||||
"api_key": config.platform.api_key,
|
"api_key": config.platform.api_key,
|
||||||
"base_url": config.platform.base_url,
|
"base_url": config.platform.base_url,
|
||||||
|
"user_email": config.platform.user_email,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Standalone telemetry sender — runs as a detached subprocess.
|
||||||
|
|
||||||
|
Usage: python -m mem0_cli.telemetry_sender '<json context>'
|
||||||
|
|
||||||
|
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()
|
||||||
Reference in New Issue
Block a user