From d39ebad09ff6e7cd359fb1b8c669bf718f8aeb62 Mon Sep 17 00:00:00 2001 From: Saket Aryan Date: Sat, 11 Apr 2026 20:57:14 +0530 Subject: [PATCH] fix(openclaw): persistent anonymous telemetry ID, flush fix, and email resolution (#4790) Co-authored-by: Claude Opus 4.6 (1M context) --- docs/changelog/openclaw.mdx | 14 +++ openclaw/cli/commands.ts | 3 +- openclaw/cli/config-file.ts | 2 + openclaw/index.ts | 2 +- openclaw/package.json | 2 +- openclaw/providers.ts | 3 + openclaw/recall.ts | 1 + openclaw/telemetry.ts | 191 ++++++++++++++++++++++++++++++- openclaw/tests/providers.test.ts | 4 + openclaw/tests/telemetry.test.ts | 4 +- 10 files changed, 215 insertions(+), 11 deletions(-) diff --git a/docs/changelog/openclaw.mdx b/docs/changelog/openclaw.mdx index 48ffd8e30..f56dffb15 100644 --- a/docs/changelog/openclaw.mdx +++ b/docs/changelog/openclaw.mdx @@ -4,6 +4,20 @@ description: "Release notes for the OpenClaw plugin and agent harness." mode: "wide" --- + + +**Bug Fixes:** +- **Telemetry:** Replaced shared `"anonymous-openclaw"` fallback with a persistent per-machine random hash (`openclaw-anon-`), so anonymous plugin users are counted individually in PostHog ([#4790](https://github.com/mem0ai/mem0/pull/4790)) +- **Telemetry:** Added PostHog `$identify` event on first authenticated run to stitch anonymous history onto the authenticated profile ([#4790](https://github.com/mem0ai/mem0/pull/4790)) +- **Telemetry:** Fixed event loss on short-lived CLI invocations — added `beforeExit` handler to flush queued events before the process exits ([#4790](https://github.com/mem0ai/mem0/pull/4790)) +- **Telemetry:** Added lazy `/v1/ping/` email resolution so users who configure API key outside `mem0 init` show as their email in PostHog, not an md5 hash ([#4790](https://github.com/mem0ai/mem0/pull/4790)) +- **Telemetry:** Unified CLI event prefix from `openclaw.` to `openclaw.cli.` on the needsSetup branch to match the authenticated branch ([#4790](https://github.com/mem0ai/mem0/pull/4790)) + +**Improvements:** +- **API:** Added `source: "OPENCLAW"` to all provider calls (`add`, `search`, `getAll`) across tools, CLI commands, recall, and the OSS backend adapter ([#4790](https://github.com/mem0ai/mem0/pull/4790)) + + + **Bug Fixes:** diff --git a/openclaw/cli/commands.ts b/openclaw/cli/commands.ts index 603f5a002..41297bd8b 100644 --- a/openclaw/cli/commands.ts +++ b/openclaw/cli/commands.ts @@ -621,7 +621,6 @@ export function registerCliCommands( runId?: string, ): SearchOptions => { const base = buildSearchOptions(userIdOverride, lim, runId); - delete (base as any).source; base.threshold = 0.3; return base; }; @@ -718,7 +717,7 @@ export function registerCliCommands( : effectiveUserId(getCurrentSessionId()); const result = await provider.add( [{ role: "user", content: text }], - { user_id: uid }, + { user_id: uid, source: "OPENCLAW" }, ); const count = result.results?.length ?? 0; if (count > 0) { diff --git a/openclaw/cli/config-file.ts b/openclaw/cli/config-file.ts index 86287f644..8eacce714 100644 --- a/openclaw/cli/config-file.ts +++ b/openclaw/cli/config-file.ts @@ -34,6 +34,7 @@ export interface PluginAuthConfig { autoRecall?: boolean; autoCapture?: boolean; topK?: number; + anonymousTelemetryId?: string; } // ============================================================================ @@ -78,6 +79,7 @@ export function readPluginAuth(): PluginAuthConfig { autoRecall: cfg.autoRecall as boolean | undefined, autoCapture: cfg.autoCapture as boolean | undefined, topK: cfg.topK as number | undefined, + anonymousTelemetryId: cfg.anonymousTelemetryId as string | undefined, }; } diff --git a/openclaw/index.ts b/openclaw/index.ts index 72497007f..0ec92f30b 100644 --- a/openclaw/index.ts +++ b/openclaw/index.ts @@ -140,7 +140,7 @@ const memoryPlugin = definePluginEntry({ (id: string) => `${cfg.userId}:agent:${id}`, () => ({ user_id: cfg.userId, top_k: cfg.topK }), () => undefined, - (cmd: string) => _captureEvent(`openclaw.${cmd}`, { command: cmd }), + (cmd: string) => _captureEvent(`openclaw.cli.${cmd}`, { command: cmd }), ); api.registerService({ diff --git a/openclaw/package.json b/openclaw/package.json index 70c49e0c0..47e5a0edd 100644 --- a/openclaw/package.json +++ b/openclaw/package.json @@ -1,6 +1,6 @@ { "name": "@mem0/openclaw-mem0", - "version": "1.0.5", + "version": "1.0.6", "type": "module", "description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source", "license": "Apache-2.0", diff --git a/openclaw/providers.ts b/openclaw/providers.ts index 565554a44..1048586a5 100644 --- a/openclaw/providers.ts +++ b/openclaw/providers.ts @@ -506,6 +506,7 @@ export function providerToBackend( msgs as Array<{ role: string; content: string }>, { user_id: opts.userId ?? userId, + source: "OPENCLAW", ...(opts.runId && { run_id: opts.runId }), ...(opts.metadata && { metadata: opts.metadata }), ...(opts.immutable && { immutable: true }), @@ -524,6 +525,7 @@ export function providerToBackend( keyword_search: opts.keyword, reranking: opts.rerank, filters: opts.filters, + source: "OPENCLAW", }); return results as unknown as Record[]; }, @@ -537,6 +539,7 @@ export function providerToBackend( const items = await provider.getAll({ user_id: opts.userId ?? userId, page_size: opts.pageSize, + source: "OPENCLAW", }); return items as unknown as Record[]; }, diff --git a/openclaw/recall.ts b/openclaw/recall.ts index 6ed935cd1..2f4f77263 100644 --- a/openclaw/recall.ts +++ b/openclaw/recall.ts @@ -260,6 +260,7 @@ export async function recall( threshold, keyword_search: recallConfig.keywordSearch !== false, // Default on reranking: recallConfig.rerank !== false, // Default on + source: "OPENCLAW", }; if (recallConfig.filterMemories) { searchOpts.filter_memories = true; diff --git a/openclaw/telemetry.ts b/openclaw/telemetry.ts index 85440a831..ee0522851 100644 --- a/openclaw/telemetry.ts +++ b/openclaw/telemetry.ts @@ -8,10 +8,10 @@ * Disable with: MEM0_TELEMETRY=false */ -import { createHash } from "node:crypto"; -import { readPluginAuth } from "./cli/config-file.ts"; +import { createHash, randomUUID } from "node:crypto"; +import { readPluginAuth, writePluginAuth, getBaseUrl } from "./cli/config-file.ts"; -export const PLUGIN_VERSION = "1.0.4"; +export const PLUGIN_VERSION = "1.0.6"; const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"; const POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/"; @@ -22,6 +22,133 @@ const FLUSH_THRESHOLD = 10; let eventQueue: Record[] = []; let flushTimer: ReturnType | undefined; +let _cachedAnonymousId: string | undefined; +let _aliasCheckDone = false; + +/** + * Return a persistent per-machine anonymous ID, generating one if needed. + * + * Stored in ~/.openclaw/openclaw.json under the plugin's `anonymousTelemetryId` + * field so repeat sessions on the same machine share one PostHog identity + * instead of collapsing into a single shared fallback string. The result is + * cached in module memory after the first read so we don't re-touch disk on + * every queued event. + */ +function getOrCreateAnonymousId(): string { + if (_cachedAnonymousId) return _cachedAnonymousId; + try { + const auth = readPluginAuth(); + if (auth.anonymousTelemetryId) { + _cachedAnonymousId = auth.anonymousTelemetryId; + return _cachedAnonymousId; + } + } catch { + /* ignore */ + } + const newId = `openclaw-anon-${randomUUID().replace(/-/g, "")}`; + try { + writePluginAuth({ anonymousTelemetryId: newId }); + } catch { + /* ignore — return generated id anyway */ + } + _cachedAnonymousId = newId; + return newId; +} + +/** + * If we just resolved to a real identity but a stored anonymous id exists, + * build a one-shot PostHog $identify event so the pre-signup history gets + * stitched onto the authenticated profile. Returns null when no aliasing is + * needed (already done, or no anon id on disk, or still anonymous). + * + * Caller is responsible for pushing the returned event onto eventQueue ahead + * of the regular event. + */ +function maybeBuildIdentifyEvent( + distinctId: string, +): Record | null { + if (_aliasCheckDone) return null; + if (!distinctId || distinctId.startsWith("openclaw-anon-")) return null; + try { + const auth = readPluginAuth(); + const storedAnon = auth.anonymousTelemetryId; + if (!storedAnon) { + _aliasCheckDone = true; + return null; + } + const identifyEvent = { + event: "$identify", + distinct_id: distinctId, + properties: { + $anon_distinct_id: storedAnon, + $lib: "posthog-node", + }, + }; + try { + writePluginAuth({ anonymousTelemetryId: "" }); + } catch { + /* ignore — alias may double-fire next session, harmless */ + } + _aliasCheckDone = true; + _cachedAnonymousId = undefined; + return identifyEvent; + } catch { + return null; + } +} + +let _emailResolutionAttempted = false; + +/** + * If we have an apiKey but no cached userEmail, do a one-shot /v1/ping/ + * call to resolve the email and cache it. This runs async as a side-effect; + * the current event ships with md5(apiKey) but subsequent events (including + * those flushed by the beforeExit handler in the same process) will use + * the resolved email. + */ +function maybeResolveEmail(apiKey: string): void { + if (_emailResolutionAttempted) return; + _emailResolutionAttempted = true; + + const baseUrl = getBaseUrl().replace(/\/+$/, ""); + fetch(`${baseUrl}/v1/ping/`, { + method: "GET", + headers: { + Authorization: `Token ${apiKey}`, + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(5_000), + }) + .then((res) => res.json()) + .then((data: any) => { + const email = data?.user_email; + if (email) { + try { + writePluginAuth({ userEmail: email }); + } catch { + /* ignore */ + } + // Upgrade any already-queued events from md5(apiKey) to email + const oldId = createHash("md5").update(apiKey).digest("hex"); + for (const ev of eventQueue) { + if (ev.distinct_id === oldId) { + ev.distinct_id = email; + } + // Also upgrade $identify's distinct_id if present + if ( + ev.event === "$identify" && + ev.distinct_id === oldId + ) { + ev.distinct_id = email; + } + } + } + }) + .catch(() => { + /* silently swallow — md5(apiKey) is used as fallback */ + }); +} + let _telemetryEnabled: boolean | undefined; function isTelemetryEnabled(): boolean { if (_telemetryEnabled !== undefined) return _telemetryEnabled; @@ -42,7 +169,8 @@ function isTelemetryEnabled(): boolean { /** * Return a stable anonymous identifier for the current user. * - * Priority: cached userEmail (from /v1/ping/) > MD5(apiKey) > fallback. + * Priority: cached userEmail (from /v1/ping/) > MD5(apiKey) > + * persistent per-machine anonymous ID. */ function getDistinctId(apiKey?: string): string { try { @@ -54,7 +182,7 @@ function getDistinctId(apiKey?: string): string { if (apiKey) { return createHash("md5").update(apiKey).digest("hex"); } - return "anonymous-openclaw"; + return getOrCreateAnonymousId(); } function ensureFlushTimer(): void { @@ -65,6 +193,42 @@ function ensureFlushTimer(): void { } } +let _exitHandlerInstalled = false; + +/** + * Install a one-time `beforeExit` handler that drains queued events on + * process exit. Without this, short-lived CLI invocations (e.g. one + * `openclaw mem0 status` call) exit before the unref'd flushTimer fires + * and before FLUSH_THRESHOLD is hit, dropping every queued event silently. + * + * Returning a Promise from a `beforeExit` handler keeps the event loop + * alive until that Promise resolves, so the awaited fetch actually has + * time to land at PostHog. + */ +function ensureExitHandler(): void { + if (_exitHandlerInstalled) return; + _exitHandlerInstalled = true; + process.on("beforeExit", async () => { + if (eventQueue.length === 0) return; + const batch = eventQueue; + eventQueue = []; + const body = JSON.stringify({ api_key: POSTHOG_API_KEY, batch }); + try { + await fetch(POSTHOG_HOST, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": String(Buffer.byteLength(body)), + }, + body, + signal: AbortSignal.timeout(3_000), + }); + } catch { + /* silently swallow */ + } + }); +} + function flushEvents(): void { if (eventQueue.length === 0) return; const batch = eventQueue; @@ -97,6 +261,22 @@ export function captureEvent( try { const distinctId = getDistinctId(ctx?.apiKey); + // If we resolved to md5(apiKey) instead of email, kick off a background + // /v1/ping/ to resolve and cache the email. The current event ships with + // the hash, but the async resolution upgrades any still-queued events + // (including this one) before the beforeExit flush fires. + if (ctx?.apiKey && distinctId && !distinctId.includes("@") && !distinctId.startsWith("openclaw-anon-")) { + maybeResolveEmail(ctx.apiKey); + } + + // First authenticated event after a previous anonymous session: queue a + // $identify ahead of the regular event so PostHog merges the anonymous + // history onto the authenticated profile in the same batch flush. + const identifyEvent = maybeBuildIdentifyEvent(distinctId); + if (identifyEvent) { + eventQueue.push(identifyEvent); + } + eventQueue.push({ event: eventName, distinct_id: distinctId, @@ -115,6 +295,7 @@ export function captureEvent( }); ensureFlushTimer(); + ensureExitHandler(); if (eventQueue.length >= FLUSH_THRESHOLD) { flushEvents(); diff --git a/openclaw/tests/providers.test.ts b/openclaw/tests/providers.test.ts index f586272e1..f1e54fc25 100644 --- a/openclaw/tests/providers.test.ts +++ b/openclaw/tests/providers.test.ts @@ -61,6 +61,7 @@ describe("providerToBackend — search", () => { keyword_search: true, reranking: true, filters: { category: "preference" }, + source: "OPENCLAW", }); expect(results).toHaveLength(1); expect((results[0] as any).id).toBe("m1"); @@ -79,6 +80,7 @@ describe("providerToBackend — search", () => { keyword_search: undefined, reranking: undefined, filters: undefined, + source: "OPENCLAW", }); }); }); @@ -173,6 +175,7 @@ describe("providerToBackend — listMemories", () => { expect(provider.getAll).toHaveBeenCalledWith({ user_id: DEFAULT_USER, page_size: 50, + source: "OPENCLAW", }); expect(results).toHaveLength(1); }); @@ -186,6 +189,7 @@ describe("providerToBackend — listMemories", () => { expect(provider.getAll).toHaveBeenCalledWith({ user_id: DEFAULT_USER, page_size: undefined, + source: "OPENCLAW", }); }); }); diff --git a/openclaw/tests/telemetry.test.ts b/openclaw/tests/telemetry.test.ts index 23ddd51ef..893d47d10 100644 --- a/openclaw/tests/telemetry.test.ts +++ b/openclaw/tests/telemetry.test.ts @@ -24,7 +24,7 @@ describe("telemetry", () => { }); it("exports PLUGIN_VERSION", () => { - expect(PLUGIN_VERSION).toBe("1.0.4"); + expect(PLUGIN_VERSION).toBe("1.0.6"); }); it("captureEvent does not throw", () => { @@ -51,7 +51,7 @@ describe("telemetry", () => { expect(() => captureEvent("test_event")).not.toThrow(); }); - it("falls back to anonymous-openclaw when no apiKey", () => { + it("falls back to a generated anonymous id when no apiKey", () => { (readPluginAuth as ReturnType).mockReturnValueOnce({}); expect(() => captureEvent("test_event", {}, {})).not.toThrow(); });