fix(mem0-plugin): accurate per-editor telemetry attribution + OpenCode telemetry (#5518)
This commit is contained in:
@@ -2,6 +2,12 @@
|
||||
|
||||
All notable changes to the `@mem0/opencode-plugin` will be documented in this file.
|
||||
|
||||
## 0.1.4 — Anonymous usage telemetry
|
||||
|
||||
### Added
|
||||
|
||||
- **PostHog telemetry (`telemetry.ts`):** Anonymous, fire-and-forget usage events. Opt out with `MEM0_TELEMETRY=false`. Only fires when an API key is present; never sends memory content, prompts, or the API key — only an anonymized `sha256(apiKey)[:32]` identity plus event type, platform, and plugin version. Emits the same schema as the Mem0 editor plugin (`plugin.*` events, `source: "plugin"`, `platform: "opencode"`) so OpenCode appears as a `platform` in the shared plugin dashboard. Events: `plugin.session_start` (with memory count) and `plugin.tool_use` (`add` / `search` / `update` / `delete`).
|
||||
|
||||
## 0.1.3 — File-context injection, session summaries & activity timeline
|
||||
|
||||
### Added
|
||||
|
||||
@@ -9,6 +9,7 @@ import { existsSync, readdirSync, cpSync, mkdirSync, readFileSync, writeFileSync
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { createHash } from "crypto";
|
||||
import { captureEvent } from "./telemetry";
|
||||
|
||||
async function getUserId(): Promise<string> {
|
||||
if (process.env.MEM0_USER_ID) return process.env.MEM0_USER_ID;
|
||||
@@ -368,6 +369,8 @@ const Mem0Plugin: Plugin = async (ctx) => {
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
captureEvent("session_start", { memory_count: memoryCount }, apiKey);
|
||||
}
|
||||
|
||||
if (NUDGE_RE.test(safeText)) {
|
||||
@@ -602,6 +605,17 @@ const Mem0Plugin: Plugin = async (ctx) => {
|
||||
if (MEM0_MCP_RE.test(toolName)) {
|
||||
if (toolName.includes("add_memory")) stats.adds++;
|
||||
if (toolName.includes("search")) stats.searches++;
|
||||
|
||||
const tool = toolName.includes("add_memory")
|
||||
? "add_memory"
|
||||
: toolName.includes("search")
|
||||
? "search_memories"
|
||||
: toolName.includes("delete")
|
||||
? "delete_memory"
|
||||
: toolName.includes("update")
|
||||
? "update_memory"
|
||||
: "other";
|
||||
captureEvent("tool_use", { tool }, apiKey);
|
||||
}
|
||||
|
||||
if (toolName === "bash" && toolOutput.length >= 50) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mem0/opencode-plugin",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"type": "module",
|
||||
"description": "Mem0 persistent memory plugin for OpenCode — add, search, and manage memories across sessions",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { buildEvent, captureEvent, isTelemetryEnabled } from "./telemetry";
|
||||
|
||||
const KEY = "m0-testkey123";
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.MEM0_TELEMETRY;
|
||||
});
|
||||
|
||||
describe("opencode telemetry", () => {
|
||||
test("buildEvent uses the shared plugin.* schema with platform=opencode", () => {
|
||||
const payload = buildEvent("session_start", { memory_count: 5 }, KEY);
|
||||
expect(payload).not.toBeNull();
|
||||
const props = payload!.properties as Record<string, unknown>;
|
||||
expect(payload!.event).toBe("plugin.session_start");
|
||||
expect(props.source).toBe("plugin");
|
||||
expect(props.platform).toBe("opencode");
|
||||
expect(props.memory_count).toBe(5);
|
||||
expect(props.$process_person_profile).toBe(false);
|
||||
expect(typeof props.plugin_version).toBe("string");
|
||||
});
|
||||
|
||||
test("distinct_id is sha256(apiKey)[:32] — matches the editor plugin", async () => {
|
||||
const { createHash } = await import("node:crypto");
|
||||
const expected = createHash("sha256").update(KEY).digest("hex").slice(0, 32);
|
||||
expect(buildEvent("session_start", {}, KEY)!.distinct_id).toBe(expected);
|
||||
});
|
||||
|
||||
test("system properties win over caller-supplied ones", () => {
|
||||
const props = buildEvent("x", { platform: "HACK", source: "HACK" }, KEY)!
|
||||
.properties as Record<string, unknown>;
|
||||
expect(props.platform).toBe("opencode");
|
||||
expect(props.source).toBe("plugin");
|
||||
});
|
||||
|
||||
test("returns null without an API key (no anonymous events)", () => {
|
||||
expect(buildEvent("session_start", {}, undefined)).toBeNull();
|
||||
});
|
||||
|
||||
test("opt-out via MEM0_TELEMETRY disables events", () => {
|
||||
process.env.MEM0_TELEMETRY = "false";
|
||||
expect(isTelemetryEnabled()).toBe(false);
|
||||
expect(buildEvent("session_start", {}, KEY)).toBeNull();
|
||||
});
|
||||
|
||||
test("captureEvent never throws (and sends nothing when opted out)", () => {
|
||||
process.env.MEM0_TELEMETRY = "false";
|
||||
expect(() => captureEvent("session_start", {}, KEY)).not.toThrow();
|
||||
expect(() => captureEvent("session_start", {}, undefined)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Plugin telemetry for the Mem0 OpenCode plugin — anonymous usage tracking
|
||||
* via PostHog.
|
||||
*
|
||||
* Emits the SAME event schema as the Mem0 editor plugin's telemetry.py
|
||||
* (event names prefixed `plugin.`, `source: "plugin"`, `platform: "opencode"`,
|
||||
* `distinct_id = sha256(apiKey)[:32]`) so OpenCode shows up as just another
|
||||
* `platform` value in the shared plugin dashboard instead of a separate
|
||||
* event namespace.
|
||||
*
|
||||
* Fire-and-forget: never throws, never blocks, failures are swallowed. Only
|
||||
* fires when an API key is present (same as the editor plugin — anonymous
|
||||
* installs without a key emit nothing). Disable with MEM0_TELEMETRY=false.
|
||||
*
|
||||
* Never sends: memory content, API keys, raw user/project IDs. Only sends:
|
||||
* event type, platform, plugin version, anonymized hash of the API key.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX";
|
||||
const POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/";
|
||||
const REQUEST_TIMEOUT_MS = 2_000;
|
||||
|
||||
function _loadPluginVersion(): string {
|
||||
// Source context: telemetry.ts sits next to package.json (./).
|
||||
// Bundled context: dist/index.js sits one level below it (../).
|
||||
for (const rel of ["./package.json", "../package.json"]) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(new URL(rel, import.meta.url), "utf-8"));
|
||||
if (pkg?.name === "@mem0/opencode-plugin" && pkg.version) return pkg.version;
|
||||
} catch {
|
||||
/* try next candidate */
|
||||
}
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const PLUGIN_VERSION = _loadPluginVersion();
|
||||
|
||||
export function isTelemetryEnabled(): boolean {
|
||||
const val = process.env.MEM0_TELEMETRY;
|
||||
if (val === undefined) return true;
|
||||
const s = val.toLowerCase();
|
||||
return s !== "false" && s !== "0" && s !== "no" && s !== "off";
|
||||
}
|
||||
|
||||
function distinctId(apiKey: string): string {
|
||||
// Matches telemetry.py `_distinct_id()` so the same user is one person in
|
||||
// PostHog whether they use OpenCode or any other Mem0 editor plugin.
|
||||
return createHash("sha256").update(apiKey).digest("hex").slice(0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the PostHog event payload, or null when telemetry is disabled or no
|
||||
* API key is available. Pure (aside from env/version reads) and exported for
|
||||
* testing. System-controlled properties are applied last so a caller cannot
|
||||
* override `source`/`platform`/etc.
|
||||
*/
|
||||
export function buildEvent(
|
||||
eventType: string,
|
||||
properties: Record<string, unknown>,
|
||||
apiKey: string | undefined,
|
||||
): Record<string, unknown> | null {
|
||||
if (!isTelemetryEnabled() || !apiKey) return null;
|
||||
return {
|
||||
api_key: POSTHOG_API_KEY,
|
||||
distinct_id: distinctId(apiKey),
|
||||
event: `plugin.${eventType}`,
|
||||
properties: {
|
||||
...properties,
|
||||
source: "plugin",
|
||||
platform: "opencode",
|
||||
plugin_version: PLUGIN_VERSION,
|
||||
os: process.platform,
|
||||
sample_rate: 1.0,
|
||||
$process_person_profile: false,
|
||||
$lib: "posthog-node",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Send a usage event, fire-and-forget. Never throws, never blocks. */
|
||||
export function captureEvent(
|
||||
eventType: string,
|
||||
properties: Record<string, unknown>,
|
||||
apiKey: string | undefined,
|
||||
): void {
|
||||
const payload = buildEvent(eventType, properties, apiKey);
|
||||
if (!payload) return;
|
||||
try {
|
||||
void fetch(POSTHOG_HOST, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
}).catch(() => {
|
||||
/* fire-and-forget */
|
||||
});
|
||||
} catch {
|
||||
/* never throw */
|
||||
}
|
||||
}
|
||||
@@ -16,5 +16,5 @@
|
||||
"emitDeclarationOnly": true
|
||||
},
|
||||
"include": ["**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user