The Python plugin telemetry was hardened across #7322 to #7326. The TypeScript side has the same defect classes and was not touched, because the two share no code: agent-plugin-core/python generates into six bundles, agent-plugin-core/ typescript is a separate core each plugin wraps. Fixing one surfaced nothing about the other, which is how this survived. Three fixes, all confirmed by running the code rather than reading it. A failed delivery deleted the batch. The core detached the queue before the await and swallowed the error, so one blip destroyed the events with nothing recording that it happened. Probed: two events in, delivery throws, queue goes to zero, no retry ever. The batch is now put back, bounded by maxQueueSize and biased to the newest so a long outage costs the oldest events rather than unbounded memory, and repeated failures back off to a ceiling instead of retrying every flush against a host that is blocking us. Every event now carries a uuid stamped at capture, which is what makes the retry safe: PostHog collapses anything it already accepted. Deliberately no disk spool, and that is written into the code so it reads as a decision. Python spools because its hooks are per-tool-call processes that exit immediately. These plugins live inside a host for a whole session, so re-queueing covers the same transient failures without the claim and lease machinery that took three review rounds to get right on the Python side. What it leaves uncovered is narrow: a session that both starts and ends offline. openclaw used a cached email forever. The refresh was guarded by !hasEmail, so after an API key change every event kept reporting under the previous account. The email is now bound to a fingerprint of the key it was resolved for and only used while those agree; a mismatch forgets the account and re-resolves. The resolution latch is per key rather than once per process, so a key changed mid-session is actually looked up. A row with an email and no fingerprint, which is what an upgrade from the current version looks like, is verified rather than adopted, matching the decision reached on #7325. opencode hashed the project id unsalted, which is reversible for anyone who can enumerate project ids. Salted with the API key rather than a stored per-install value: it is already in play, it is high entropy, and it needs no new file and so no write race to get wrong. Per account rather than per machine, which also keeps joins working across machines, and it resets on key rotation consistently with distinctId, which already did. Also wired opencode's tests into CI. They existed and nothing ran them, so the regression test asked for on #7322 would not have gated anything. Verified: agent-plugin-core/ts 30, openclaw 431, opencode 35, pi-agent 89, deepseek 47. The new tests were each checked against the unfixed code first; the core ones fail 3 of 3 and the openclaw one fails without the fingerprint gate. Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb
114 lines
4.1 KiB
TypeScript
114 lines
4.1 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 available", () => {
|
|
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValueOnce({
|
|
userEmail: "test@example.com",
|
|
});
|
|
expect(() => captureEvent("test_event")).not.toThrow();
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|