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();
});