Files
mem0/integrations/openclaw/telemetry.ts
T
Saket Aryan cbf97c6077 fix(plugins): bound the exit flush, and stop openclaw deleting a legacy account
Second review round. The first two are regressions from the first round.

The forced exit flush added fifteen seconds to host shutdown. Node re-emits
beforeExit whenever the handler schedules async work, so an unconditional
flush(true) looped until the five-attempt budget was spent, and against the real
3s delivery timeout that is 15s added to the shutdown of whatever editor or CLI
is hosting this. The backoff used to end that loop after one attempt; removing it
for the forced path removed the only thing bounding it. Measured at 15008ms, now
6001ms with a one-shot latch, and 12ms when delivery is healthy, which is the
only case most people ever see.

openclaw deleted a legacy account before it had anything to replace it with. An
install predating keyFingerprint has an email and no fingerprint, so the
comparison failed and clearResolvedAccount() ran immediately; if the re-resolve
then failed because the user was offline the email was gone from disk for good,
and the per-key latch was already set so nothing retried. Now cleared only when a
real fingerprint disagrees, which is the same trade the Python core makes and
documents: verify, and keep what you have until the verification succeeds. The
latch is released on a failed lookup so the next capture tries again.

The overflow test did not exercise the path it is named for: with only two
events captured the re-queue had an empty queue to merge into, so the slice on
the failure path never ran, and that is the half deciding which end gets dropped.
It now fills past the cap before flushing and asserts the exact surviving order.

core 36, openclaw 432, opencode 35, deepseek 47. Wire e2e 9 of 9 and identity
e2e 7 of 7 still pass against a real local server.

Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb
2026-09-17 19:43:25 +05:30

158 lines
5.7 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import { createTelemetry } from "../agent-plugin-core/typescript/src/telemetry.ts";
import {
clearAnonymousTelemetryId,
clearResolvedAccount,
getBaseUrl,
readPluginAuth,
writePluginAuth,
} from "./cli/config-file.ts";
declare const __OPENCLAW_PLUGIN_VERSION__: string;
export const PLUGIN_VERSION: string = __OPENCLAW_PLUGIN_VERSION__;
let cachedAnonymousId: string | undefined;
let aliasCheckDone = false;
let resolutionAttemptedFor = "";
let currentDistinctId = "";
function enabled(): boolean {
const value = (globalThis as any).__mem0_telemetry_override ?? process.env.MEM0_TELEMETRY;
return value === undefined || !["false", "0", "no", "off"].includes(String(value).toLowerCase());
}
function anonymousId(): string {
if (cachedAnonymousId) return cachedAnonymousId;
try {
const stored = readPluginAuth().anonymousTelemetryId;
if (stored) return (cachedAnonymousId = stored);
} catch {
// First run or unreadable config.
}
const created = `openclaw-anon-${randomUUID().replace(/-/g, "")}`;
try {
writePluginAuth({ anonymousTelemetryId: created });
} catch {
// An unwritable config must not break the plugin.
}
return (cachedAnonymousId = created);
}
/** SHA-256 prefix of the key an account was resolved for. */
function keyFingerprint(apiKey?: string): string {
return apiKey ? createHash("sha256").update(apiKey).digest("hex").slice(0, 16) : "";
}
function distinctId(apiKey?: string): string {
try {
const auth = readPluginAuth();
if (auth.userEmail) {
// Only when it belongs to the key in hand. Without this check a cached
// email was used forever: switch to a different account and every event
// kept reporting under the previous one, with nothing to notice it by.
if (auth.keyFingerprint === keyFingerprint(apiKey)) {
return createHash("sha256").update(auth.userEmail).digest("hex");
}
// Only a REAL key that disagrees means the account changed. Without the
// apiKey guard the comparison is `undefined === ""` for any call that
// simply omits the key, so a capture with no context wiped a perfectly
// good account out of openclaw.json.
//
// A row with an email and NO fingerprint is the legacy shape, from an
// install predating this field. Clearing it here deleted a real account
// before anything had replaced it, and if the re-resolve then failed
// because the user was offline the email was gone from disk for good. The
// Python core refuses the same trade: verify, and keep what you have until
// the verification succeeds. resolveEmail below overwrites both fields
// when it does, so there is nothing to clear first.
if (apiKey && auth.keyFingerprint) clearResolvedAccount();
}
} catch {
// Fall through to the API key or anonymous identity.
}
return apiKey ? createHash("sha256").update(apiKey).digest("hex") : anonymousId();
}
const telemetry = createTelemetry({
host: "openclaw",
source: "OPENCLAW",
version: PLUGIN_VERSION,
distinctId: () => currentDistinctId,
enabled,
});
function identifyAnonymous(id: string): void {
if (aliasCheckDone || id.startsWith("openclaw-anon-")) return;
try {
const anonymous = readPluginAuth().anonymousTelemetryId;
aliasCheckDone = true;
if (!anonymous) return;
telemetry.capture("$identify", { $anon_distinct_id: anonymous });
clearAnonymousTelemetryId();
cachedAnonymousId = undefined;
} catch {
// Aliasing is best effort.
}
}
function resolveEmail(apiKey: string): void {
// Latched per key, not once per process. A single boolean meant a key changed
// mid-session was never looked up, so the fallback identity stuck until restart.
const fingerprint = keyFingerprint(apiKey);
if (resolutionAttemptedFor === fingerprint) return;
resolutionAttemptedFor = fingerprint;
const releaseLatch = () => {
// A failed lookup must not pin the fallback identity for the rest of the
// process. Released so the next capture tries again.
if (resolutionAttemptedFor === fingerprint) resolutionAttemptedFor = "";
};
fetch(`${getBaseUrl().replace(/\/+$/, "")}/v1/ping/`, {
method: "GET",
headers: { Authorization: `Token ${apiKey}`, "Content-Type": "application/json" },
signal: AbortSignal.timeout(5_000),
})
.then((response) => response.json())
.then((data: any) => {
if (!data?.user_email) return;
writePluginAuth({ userEmail: data.user_email, keyFingerprint: fingerprint });
const oldId = createHash("sha256").update(apiKey).digest("hex");
const newId = createHash("sha256").update(data.user_email).digest("hex");
for (const event of telemetry.queueForTesting()) {
if (event.distinct_id === oldId) event.distinct_id = newId;
}
})
.catch(() => {
// The API-key hash remains a stable fallback, and the next capture retries.
releaseLatch();
});
}
export function captureEvent(
eventName: string,
properties: Record<string, unknown> = {},
context?: { apiKey?: string; mode?: string; skillsActive?: boolean },
): void {
if (!enabled()) return;
try {
currentDistinctId = distinctId(context?.apiKey);
let resolvedForThisKey = false;
try {
const auth = readPluginAuth();
resolvedForThisKey =
Boolean(auth.userEmail) && auth.keyFingerprint === keyFingerprint(context?.apiKey);
} catch {
// Resolve it below when possible.
}
if (context?.apiKey && !resolvedForThisKey) resolveEmail(context.apiKey);
identifyAnonymous(currentDistinctId);
telemetry.capture(eventName, {
mode: context?.mode,
skills_active: context?.skillsActive,
...properties,
});
} catch {
// Telemetry must never affect plugin behavior.
}
}