Files
mem0/integrations/openclaw/tests/telemetry.test.ts
T
Saket Aryan 44c6a07ddf fix(plugins): flush on exit regardless of backoff, and prefer the backlog over new events
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
2026-09-17 19:30:59 +05:30

134 lines
4.9 KiB
TypeScript

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 { 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 });
vi.stubGlobal("fetch", fetchSpy);
});
afterEach(() => {
vi.restoreAllMocks();
delete (globalThis as any).__mem0_telemetry_override;
});
it("captureEvent does not throw", () => {
expect(() => captureEvent("test_event")).not.toThrow();
});
it("captureEvent accepts properties and context", () => {
expect(() =>
captureEvent("test_event", { key: "val" }, { apiKey: "m0-key", mode: "platform" }),
).not.toThrow();
});
it("captureEvent is silent when telemetry disabled", () => {
(globalThis as any).__mem0_telemetry_override = "false";
// Force re-evaluation by resetting cached value
// Since _telemetryEnabled is module-level, we test indirectly
expect(() => captureEvent("test_event")).not.toThrow();
});
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"),
});
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", () => {
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValueOnce({});
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");
});
expect(() => captureEvent("test_event")).not.toThrow();
});
});