fix(plugins): stop the TypeScript telemetry losing events and misattributing accounts (#7358)
This commit is contained in:
@@ -38,6 +38,7 @@
|
||||
"build": "bun build opencode-mem0.ts --outdir dist --target bun --format esm --entry-naming index.[ext]",
|
||||
"dev": "bun build opencode-mem0.ts --outdir dist --target bun --format esm --entry-naming index.[ext] --watch",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "bun test",
|
||||
"prepack": "bun run build",
|
||||
"postpack": ""
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { buildEvent, captureEvent, isTelemetryEnabled } from "./telemetry";
|
||||
|
||||
@@ -13,7 +15,10 @@ describe("opencode telemetry", () => {
|
||||
expect(payload).not.toBeNull();
|
||||
const props = payload!.properties as Record<string, unknown>;
|
||||
expect(payload!.event).toBe("plugin.session_start");
|
||||
expect(props.source).toBe("plugin");
|
||||
// Was "plugin", which named no particular plugin and matched no vocabulary.
|
||||
// Now shaped like every other surface. Any saved PostHog insight filtering
|
||||
// source = "plugin" needs repointing; historical data is untouched.
|
||||
expect(props.source).toBe("OPENCODE_PLUGIN");
|
||||
expect(props.platform).toBe("opencode");
|
||||
expect(props.memory_count).toBe(5);
|
||||
expect(props.$process_person_profile).toBe(false);
|
||||
@@ -30,7 +35,7 @@ describe("opencode telemetry", () => {
|
||||
const props = buildEvent("x", { platform: "HACK", source: "HACK" }, KEY)!
|
||||
.properties as Record<string, unknown>;
|
||||
expect(props.platform).toBe("opencode");
|
||||
expect(props.source).toBe("plugin");
|
||||
expect(props.source).toBe("OPENCODE_PLUGIN");
|
||||
});
|
||||
|
||||
test("returns null without an API key (no anonymous events)", () => {
|
||||
@@ -56,9 +61,12 @@ describe("opencode telemetry", () => {
|
||||
expect(typeof props.os_version).toBe("string");
|
||||
});
|
||||
|
||||
test("project_hash is sha256(projectId) when a project id is supplied", async () => {
|
||||
test("project_hash is a salted digest of the project id", async () => {
|
||||
// Previously asserted the bare sha256(projectId), which is the defect: that
|
||||
// digest is reversible by anyone who can guess a project id. Salted with the
|
||||
// API key, which is already in play here and is high entropy.
|
||||
const { createHash } = await import("node:crypto");
|
||||
const expected = createHash("sha256").update("acme-repo").digest("hex");
|
||||
const expected = createHash("sha256").update(`${KEY}:acme-repo`).digest("hex");
|
||||
const props = buildEvent("session_start", {}, KEY, "acme-repo")!
|
||||
.properties as Record<string, unknown>;
|
||||
expect(props.project_hash).toBe(expected);
|
||||
@@ -76,3 +84,38 @@ describe("opencode telemetry", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("project_hash salting", () => {
|
||||
const PROJECT = "my-project";
|
||||
|
||||
test("is not a bare digest of the project id", () => {
|
||||
// The defect: an unsalted SHA-256 over a guessable identifier is reversible
|
||||
// by anyone who can enumerate project ids.
|
||||
const unsalted = createHash("sha256").update(PROJECT).digest("hex");
|
||||
const payload = buildEvent("session_start", {}, KEY, PROJECT) as Record<string, any>;
|
||||
|
||||
expect(payload.properties.project_hash).toBeDefined();
|
||||
expect(payload.properties.project_hash).not.toBe(unsalted);
|
||||
});
|
||||
|
||||
test("differs per account for the same project", () => {
|
||||
const a = buildEvent("session_start", {}, "m0-account-a", PROJECT) as Record<string, any>;
|
||||
const b = buildEvent("session_start", {}, "m0-account-b", PROJECT) as Record<string, any>;
|
||||
|
||||
expect(a.properties.project_hash).not.toBe(b.properties.project_hash);
|
||||
});
|
||||
|
||||
test("is stable for one account, so joins still work", () => {
|
||||
const first = buildEvent("session_start", {}, KEY, PROJECT) as Record<string, any>;
|
||||
const second = buildEvent("session_end", {}, KEY, PROJECT) as Record<string, any>;
|
||||
|
||||
expect(first.properties.project_hash).toBe(second.properties.project_hash);
|
||||
});
|
||||
|
||||
test("is omitted rather than unsalted when there is no key", () => {
|
||||
const payload = buildEvent("session_start", {}, undefined, PROJECT);
|
||||
|
||||
// No key means no event at all, so there is no unsalted hash to leak.
|
||||
expect(payload).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,9 @@ const PLUGIN_VERSION = (() => {
|
||||
let currentDistinctId = "";
|
||||
const telemetry = createTelemetry({
|
||||
host: "opencode",
|
||||
source: "plugin",
|
||||
// Shaped like the platform's EventSource values, as every other surface is.
|
||||
// "plugin" said nothing about which plugin and matched no vocabulary.
|
||||
source: "OPENCODE_PLUGIN",
|
||||
version: PLUGIN_VERSION,
|
||||
distinctId: () => currentDistinctId,
|
||||
eventName: (event) => `plugin.${event}`,
|
||||
@@ -40,8 +42,23 @@ function distinctId(apiKey: string): string {
|
||||
return createHash("sha256").update(apiKey).digest("hex").slice(0, 32);
|
||||
}
|
||||
|
||||
function projectHash(projectId?: string): Record<string, string> {
|
||||
return projectId ? { project_hash: createHash("sha256").update(projectId).digest("hex") } : {};
|
||||
/**
|
||||
* Salted so the hash is not enumerable.
|
||||
*
|
||||
* An unsalted SHA-256 of a project id is reversible by anyone who can guess the
|
||||
* id, which for a project identifier is a small space. The API key is the salt:
|
||||
* it is already in play here (distinctId is a digest of it), it is high entropy,
|
||||
* and using it needs no per-install file and so no write race to get wrong. The
|
||||
* hash is therefore per account rather than per machine, which also keeps joins
|
||||
* working for one user across machines. It resets when the key rotates, which is
|
||||
* consistent, because distinctId resets with it.
|
||||
*
|
||||
* Both are omitted without a key. An event cannot be built without a distinctId
|
||||
* anyway, so this costs nothing.
|
||||
*/
|
||||
function projectHash(projectId?: string, apiKey?: string): Record<string, string> {
|
||||
if (!projectId || !apiKey) return {};
|
||||
return { project_hash: createHash("sha256").update(`${apiKey}:${projectId}`).digest("hex") };
|
||||
}
|
||||
|
||||
export function buildEvent(
|
||||
@@ -51,7 +68,7 @@ export function buildEvent(
|
||||
projectId?: string,
|
||||
): Record<string, unknown> | null {
|
||||
currentDistinctId = apiKey ? distinctId(apiKey) : "";
|
||||
const event = telemetry.build(eventType, { ...properties, ...projectHash(projectId) });
|
||||
const event = telemetry.build(eventType, { ...properties, ...projectHash(projectId, apiKey) });
|
||||
return event ? { api_key: POSTHOG_API_KEY, ...event } : null;
|
||||
}
|
||||
|
||||
@@ -62,5 +79,5 @@ export function captureEvent(
|
||||
projectId?: string,
|
||||
): void {
|
||||
currentDistinctId = apiKey ? distinctId(apiKey) : "";
|
||||
telemetry.capture(eventType, { ...properties, ...projectHash(projectId) });
|
||||
telemetry.capture(eventType, { ...properties, ...projectHash(projectId, apiKey) });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user