Findings from an independent review of this branch. The exit-time flush was gated by its own cooldown. beforeExit called the same flush() that opens with a retryNotBefore check, so after any failed delivery a process exiting inside the 2s to 60s window sent nothing and the queue died with it. That is precisely the loss this branch exists to stop, and timer.unref makes beforeExit often the only remaining chance. flush(force) now skips the cooldown and beforeExit passes it. Overflow kept the newest and evicted the batch being retried, which threw away exactly the events the retry exists to save. Both the failure path and capture() now keep the backlog and drop the new event instead, matching Python's record(), which refuses new events once the spool is full. That change needs a bound, so delivery now gives up after five attempts, as the Python core does. Without one a payload the server will never accept would be retried for the whole session and, with the backlog now preferred, would hold the queue against everything behind it. Events carry a capture-time timestamp. They now sit through backoff and across an entire outage, so without one PostHog records them at whatever moment delivery happened to succeed. It also matters for the uuid dedupe, whose key includes the event date. openclaw wiped a resolved account on any capture without an apiKey: the fingerprint comparison was `undefined === ""`, so a keyless call looked like a key change. Guarded on a real key being present. Masked today because every call site supplies one, which is why only a test found it. Two smaller ones. opencode's PostHog source was "plugin", which named no particular plugin and matched no vocabulary; it is now OPENCODE_PLUGIN like every other surface, and a saved insight filtering source = "plugin" needs repointing. And an em dash in opencode's published description had been rewritten to a — escape by my own json.dumps when adding the test script; restored. One openclaw test asserted only not.toThrow() under a name claiming it checked the identity, and under the fingerprint gate the path it exercised no longer uses the email at all. It now pins the real condition. core 35, openclaw 432, opencode 35, pi-agent 89, deepseek 47. The wire e2e still passes 9 of 9 against a real local server, and the identity e2e 7 of 7. Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb
144 lines
4.9 KiB
TypeScript
144 lines
4.9 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.
|
|
if (apiKey) 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;
|
|
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.
|
|
});
|
|
}
|
|
|
|
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.
|
|
}
|
|
}
|