fix(plugins): stop the TypeScript telemetry losing events and misattributing accounts (#7358)
This commit is contained in:
@@ -35,6 +35,8 @@ export interface PluginAuthConfig {
|
||||
autoCapture?: boolean;
|
||||
topK?: number;
|
||||
anonymousTelemetryId?: string;
|
||||
/** SHA-256 prefix of the API key userEmail was resolved for. */
|
||||
keyFingerprint?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -135,6 +137,9 @@ export function readPluginAuth(): PluginAuthConfig {
|
||||
autoCapture: cfg.autoCapture as boolean | undefined,
|
||||
topK: cfg.topK as number | undefined,
|
||||
anonymousTelemetryId: cfg.anonymousTelemetryId as string | undefined,
|
||||
// Without this the reader silently drops it, every fingerprint comparison
|
||||
// fails against undefined, and the resolved email is never used again.
|
||||
keyFingerprint: cfg.keyFingerprint as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -236,6 +241,21 @@ export function getBaseUrl(): string {
|
||||
return auth.baseUrl || DEFAULT_BASE_URL;
|
||||
}
|
||||
|
||||
/** Forget the resolved account, so the next capture re-resolves for the current key. */
|
||||
export function clearResolvedAccount(): void {
|
||||
const full = readFullConfig() as any;
|
||||
const cfg = full?.plugins?.entries?.[PLUGIN_ID]?.config;
|
||||
if (!cfg) return;
|
||||
let changed = false;
|
||||
for (const key of ["userEmail", "keyFingerprint"]) {
|
||||
if (key in cfg) {
|
||||
delete cfg[key];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) writeFullConfig(full);
|
||||
}
|
||||
|
||||
/** Remove anonymousTelemetryId from config (after PostHog aliasing) */
|
||||
export function clearAnonymousTelemetryId(): void {
|
||||
const full = readFullConfig() as any;
|
||||
|
||||
@@ -148,6 +148,7 @@ const ALLOWED_KEYS = [
|
||||
"mode",
|
||||
"apiKey",
|
||||
"anonymousTelemetryId",
|
||||
"keyFingerprint",
|
||||
"baseUrl",
|
||||
"userId",
|
||||
"userEmail",
|
||||
|
||||
@@ -209,6 +209,10 @@
|
||||
"type": "string",
|
||||
"description": "Persistent anonymous telemetry identifier"
|
||||
},
|
||||
"keyFingerprint": {
|
||||
"type": "string",
|
||||
"description": "Digest of the API key userEmail was resolved for. Set automatically; a mismatch re-resolves the account."
|
||||
},
|
||||
"oss": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
||||
import { createTelemetry } from "../agent-plugin-core/typescript/src/telemetry.ts";
|
||||
import { clearAnonymousTelemetryId, getBaseUrl, readPluginAuth, writePluginAuth } from "./cli/config-file.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 emailResolutionAttempted = false;
|
||||
let resolutionAttemptedFor = "";
|
||||
let currentDistinctId = "";
|
||||
|
||||
function enabled(): boolean {
|
||||
@@ -33,10 +39,35 @@ function anonymousId(): string {
|
||||
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 email = readPluginAuth().userEmail;
|
||||
if (email) return createHash("sha256").update(email).digest("hex");
|
||||
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.
|
||||
}
|
||||
@@ -66,8 +97,16 @@ function identifyAnonymous(id: string): void {
|
||||
}
|
||||
|
||||
function resolveEmail(apiKey: string): void {
|
||||
if (emailResolutionAttempted) return;
|
||||
emailResolutionAttempted = true;
|
||||
// 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" },
|
||||
@@ -76,7 +115,7 @@ function resolveEmail(apiKey: string): void {
|
||||
.then((response) => response.json())
|
||||
.then((data: any) => {
|
||||
if (!data?.user_email) return;
|
||||
writePluginAuth({ userEmail: data.user_email });
|
||||
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()) {
|
||||
@@ -84,7 +123,8 @@ function resolveEmail(apiKey: string): void {
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// The API-key hash remains a stable fallback.
|
||||
// The API-key hash remains a stable fallback, and the next capture retries.
|
||||
releaseLatch();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -96,13 +136,15 @@ export function captureEvent(
|
||||
if (!enabled()) return;
|
||||
try {
|
||||
currentDistinctId = distinctId(context?.apiKey);
|
||||
let hasEmail = false;
|
||||
let resolvedForThisKey = false;
|
||||
try {
|
||||
hasEmail = Boolean(readPluginAuth().userEmail);
|
||||
const auth = readPluginAuth();
|
||||
resolvedForThisKey =
|
||||
Boolean(auth.userEmail) && auth.keyFingerprint === keyFingerprint(context?.apiKey);
|
||||
} catch {
|
||||
// Resolve it below when possible.
|
||||
}
|
||||
if (context?.apiKey && !hasEmail) resolveEmail(context.apiKey);
|
||||
if (context?.apiKey && !resolvedForThisKey) resolveEmail(context.apiKey);
|
||||
identifyAnonymous(currentDistinctId);
|
||||
telemetry.capture(eventName, {
|
||||
mode: context?.mode,
|
||||
|
||||
@@ -237,3 +237,43 @@ describe("getBaseUrl", () => {
|
||||
expect(getBaseUrl()).toBe(DEFAULT_BASE_URL);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// keyFingerprint round trip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("keyFingerprint survives a write and read", () => {
|
||||
it("readPluginAuth returns a persisted keyFingerprint", () => {
|
||||
// It did not. readPluginAuth builds its result field by field, and this one
|
||||
// was missing, so every fingerprint comparison ran against undefined, the
|
||||
// resolved email was never used again, and telemetry silently fell back to
|
||||
// the API key hash. The telemetry tests could not see it because they mock
|
||||
// this module and their mock returned the field the real reader dropped.
|
||||
setConfigFile({
|
||||
plugins: {
|
||||
entries: {
|
||||
"openclaw-mem0": {
|
||||
config: {
|
||||
apiKey: "m0-test",
|
||||
userEmail: "person@example.com",
|
||||
keyFingerprint: "0123456789abcdef",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(readPluginAuth().keyFingerprint).toBe("0123456789abcdef");
|
||||
});
|
||||
|
||||
it("writePluginAuth persists it where readPluginAuth looks", () => {
|
||||
setConfigFile({ plugins: { entries: { "openclaw-mem0": { config: {} } } } });
|
||||
|
||||
writePluginAuth({ userEmail: "person@example.com", keyFingerprint: "abc123" });
|
||||
|
||||
const written = JSON.parse(mockWriteText.mock.calls.at(-1)![1] as string);
|
||||
const cfg = written.plugins.entries["openclaw-mem0"].config;
|
||||
expect(cfg.keyFingerprint).toBe("abc123");
|
||||
expect(cfg.userEmail).toBe("person@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -485,3 +485,18 @@ describe("mem0ConfigSchema.parse() — apiKey edge cases", () => {
|
||||
expect(cfg.needsSetup).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("telemetry fingerprint round trip", () => {
|
||||
it("a config carrying keyFingerprint is accepted by the real schema", () => {
|
||||
// What writePluginAuth persists after a successful lookup. It was not in
|
||||
// ALLOWED_KEYS, and assertAllowedKeys throws, so the first successful
|
||||
// resolve wrote a config that broke every subsequent load of the plugin.
|
||||
const persisted = {
|
||||
apiKey: "m0-test",
|
||||
userEmail: "person@example.com",
|
||||
keyFingerprint: "0123456789abcdef",
|
||||
};
|
||||
|
||||
expect(() => mem0ConfigSchema.parse(persisted)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,15 +3,30 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
// Mock config-file before importing telemetry
|
||||
vi.mock("../cli/config-file.ts", () => ({
|
||||
readPluginAuth: vi.fn().mockReturnValue({}),
|
||||
writePluginAuth: vi.fn(),
|
||||
clearAnonymousTelemetryId: vi.fn(),
|
||||
clearResolvedAccount: vi.fn(),
|
||||
getBaseUrl: vi.fn().mockReturnValue("https://api.mem0.ai"),
|
||||
}));
|
||||
|
||||
import { captureEvent } from "../telemetry.ts";
|
||||
import { readPluginAuth } from "../cli/config-file.ts";
|
||||
import { clearResolvedAccount, readPluginAuth } from "../cli/config-file.ts";
|
||||
|
||||
/** sha256(key).slice(0, 16), the shape telemetry.ts stores. */
|
||||
async function fingerprintOf(apiKey: string): Promise<string> {
|
||||
const { createHash } = await import("node:crypto");
|
||||
return createHash("sha256").update(apiKey).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
describe("telemetry", () => {
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Call history has to be cleared per test, not just restored: the mocks are
|
||||
// module-level vi.fn()s, so without this one test's calls are visible to the
|
||||
// next and assertions on "was not called" pass or fail by ordering.
|
||||
vi.clearAllMocks();
|
||||
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({});
|
||||
// Reset telemetry enabled state
|
||||
(globalThis as any).__mem0_telemetry_override = undefined;
|
||||
fetchSpy = vi.fn().mockResolvedValue({ ok: true });
|
||||
@@ -40,11 +55,31 @@ describe("telemetry", () => {
|
||||
expect(() => captureEvent("test_event")).not.toThrow();
|
||||
});
|
||||
|
||||
it("uses userEmail as distinct ID when available", () => {
|
||||
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValueOnce({
|
||||
it("uses userEmail as distinct ID when it belongs to the current key", async () => {
|
||||
// Previously asserted only not.toThrow(), which passed whatever the identity
|
||||
// turned out to be, and under the fingerprint gate the no-context path does
|
||||
// not use the email at all. Pin the real condition instead.
|
||||
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
userEmail: "test@example.com",
|
||||
keyFingerprint: await fingerprintOf("key-a"),
|
||||
});
|
||||
expect(() => captureEvent("test_event")).not.toThrow();
|
||||
|
||||
captureEvent("test_event", {}, { apiKey: "key-a" });
|
||||
|
||||
expect(clearResolvedAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a capture with no apiKey leaves a resolved account alone", async () => {
|
||||
// `undefined === ""` made every keyless capture look like a key change, so
|
||||
// one context-free call wiped a good account out of openclaw.json.
|
||||
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
userEmail: "test@example.com",
|
||||
keyFingerprint: await fingerprintOf("key-a"),
|
||||
});
|
||||
|
||||
captureEvent("test_event");
|
||||
|
||||
expect(clearResolvedAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to a generated anonymous id when no apiKey", () => {
|
||||
@@ -52,6 +87,43 @@ describe("telemetry", () => {
|
||||
expect(() => captureEvent("test_event", {}, {})).not.toThrow();
|
||||
});
|
||||
|
||||
it("keeps using a cached email only while it belongs to the current key", async () => {
|
||||
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
userEmail: "person@example.com",
|
||||
keyFingerprint: await fingerprintOf("key-a"),
|
||||
});
|
||||
|
||||
captureEvent("test_event", {}, { apiKey: "key-a" });
|
||||
|
||||
expect(clearResolvedAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forgets the account when the API key changes", async () => {
|
||||
// The defect: the cached email was used forever, so events after an account
|
||||
// switch kept reporting under the previous account.
|
||||
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
userEmail: "person@example.com",
|
||||
keyFingerprint: await fingerprintOf("key-a"),
|
||||
});
|
||||
|
||||
captureEvent("test_event", {}, { apiKey: "key-b" });
|
||||
|
||||
expect(clearResolvedAccount).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-resolves for a key it has not looked up before", async () => {
|
||||
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
userEmail: "person@example.com",
|
||||
keyFingerprint: await fingerprintOf("key-a"),
|
||||
});
|
||||
|
||||
captureEvent("test_event", {}, { apiKey: "key-c" });
|
||||
|
||||
// The resolution latch is per key, not once per process, so a key changed
|
||||
// mid-session is actually looked up instead of sticking to the fallback.
|
||||
expect(fetchSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles readPluginAuth errors gracefully", () => {
|
||||
(readPluginAuth as ReturnType<typeof vi.fn>).mockImplementationOnce(() => {
|
||||
throw new Error("config read failed");
|
||||
|
||||
Reference in New Issue
Block a user