bd867019f9
Adds a new Codex plugin component `telemetry` that emits a single omo_codex_daily_active event (reason: session_start) from every Codex SessionStart hook, with the same UTC-day deduplication, hashed installation identifier, and four-flag opt-out as the install-time event. Previously omo-codex telemetry only fired on install_completed, so DAU/WAU/MAU under-reported real Codex usage. - New plugin component packages/omo-codex/plugin/components/telemetry/ mirrors the rules/comment-checker/lsp pattern: own src/, tsc build, vitest tests, package.json (posthog-node dep), hooks/hooks.json. - src/codex-hook.ts wraps createPluginPostHog().trackActive(..., "session_start") with safeShutdown so Codex session startup never blocks on telemetry. - Plugin root hooks.json + workspaces register the new component alongside rules and ultrawork on SessionStart. - Aggregate test expectations updated to include the telemetry directory. - cross-package-equivalence.test.ts pins product-identity constants and shouldDisablePostHog behavior to stay byte-equivalent between the CLI installer (src/telemetry/) and the plugin runtime (plugin/components/telemetry/src/), so the two PostHog sources never drift on event name, distinct_id base, dedup file path, or opt-out flags. - PostHogActivityReason union in the CLI-side posthog.ts gains "session_start" so future CLI paths can emit the same reason without a type break.
248 lines
7.1 KiB
TypeScript
248 lines
7.1 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import { type CodexSessionStartInput, runSessionStartHook } from "../src/codex-hook.js";
|
|
import type { PostHogActivityReason, PostHogClient } from "../src/posthog.js";
|
|
|
|
const CLI_PATH = fileURLToPath(new URL("../dist/cli.js", import.meta.url));
|
|
|
|
type CapturedCall = {
|
|
distinctId: string;
|
|
reason: PostHogActivityReason;
|
|
};
|
|
|
|
type CliResult = {
|
|
exitCode: number | null;
|
|
stdout: string;
|
|
stderr: string;
|
|
};
|
|
|
|
const tempDirectories: string[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const directory of tempDirectories.splice(0)) {
|
|
rmSync(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function makeSessionStartInput(overrides: Partial<CodexSessionStartInput> = {}): CodexSessionStartInput {
|
|
return {
|
|
session_id: "session-123",
|
|
transcript_path: null,
|
|
cwd: "/tmp/project",
|
|
hook_event_name: "SessionStart",
|
|
model: "gpt-5.5",
|
|
permission_mode: "default",
|
|
source: "startup",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeRecordingClient(): { client: PostHogClient; calls: CapturedCall[]; shutdownCalls: number } {
|
|
const calls: CapturedCall[] = [];
|
|
let shutdownCalls = 0;
|
|
const client: PostHogClient = {
|
|
trackActive: (distinctId, reason) => {
|
|
calls.push({ distinctId, reason });
|
|
},
|
|
shutdown: async () => {
|
|
shutdownCalls += 1;
|
|
},
|
|
};
|
|
return {
|
|
client,
|
|
calls,
|
|
get shutdownCalls() {
|
|
return shutdownCalls;
|
|
},
|
|
};
|
|
}
|
|
|
|
function runHookCli(input: string, env: NodeJS.ProcessEnv = {}): Promise<CliResult> {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(process.execPath, [CLI_PATH, "hook", "session-start"], {
|
|
env: { ...process.env, ...env },
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.setEncoding("utf8");
|
|
child.stderr.setEncoding("utf8");
|
|
child.stdout.on("data", (chunk: string) => {
|
|
stdout += chunk;
|
|
});
|
|
child.stderr.on("data", (chunk: string) => {
|
|
stderr += chunk;
|
|
});
|
|
child.once("error", reject);
|
|
child.once("close", (exitCode) => {
|
|
resolve({ exitCode, stdout, stderr });
|
|
});
|
|
child.stdin.end(input);
|
|
});
|
|
}
|
|
|
|
describe("runSessionStartHook", () => {
|
|
describe("#given a SessionStart payload and recording client", () => {
|
|
it("#when invoked #then calls trackActive once with session_start reason", async () => {
|
|
const recorder = makeRecordingClient();
|
|
|
|
const output = await runSessionStartHook(makeSessionStartInput(), {
|
|
createClient: () => recorder.client,
|
|
getDistinctId: () => "distinct-id-abc",
|
|
});
|
|
|
|
expect(recorder.calls).toEqual([{ distinctId: "distinct-id-abc", reason: "session_start" }]);
|
|
expect(output).toBe("");
|
|
});
|
|
|
|
it("#when invoked #then awaits shutdown exactly once even after trackActive success", async () => {
|
|
const recorder = makeRecordingClient();
|
|
|
|
await runSessionStartHook(makeSessionStartInput(), {
|
|
createClient: () => recorder.client,
|
|
getDistinctId: () => "distinct-id-abc",
|
|
});
|
|
|
|
expect(recorder.shutdownCalls).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("#given a client whose trackActive throws", () => {
|
|
it("#when invoked #then swallows the error, still shuts down, and returns empty string", async () => {
|
|
let shutdownCalls = 0;
|
|
const throwingClient: PostHogClient = {
|
|
trackActive: () => {
|
|
throw new Error("trackActive failed");
|
|
},
|
|
shutdown: async () => {
|
|
shutdownCalls += 1;
|
|
},
|
|
};
|
|
|
|
const output = await runSessionStartHook(makeSessionStartInput(), {
|
|
createClient: () => throwingClient,
|
|
getDistinctId: () => "distinct-id-abc",
|
|
});
|
|
|
|
expect(output).toBe("");
|
|
expect(shutdownCalls).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("#given a client whose shutdown rejects", () => {
|
|
it("#when invoked #then swallows the rejection and returns empty string", async () => {
|
|
const rejectingClient: PostHogClient = {
|
|
trackActive: () => undefined,
|
|
shutdown: async () => {
|
|
throw new Error("shutdown failed");
|
|
},
|
|
};
|
|
|
|
const output = await runSessionStartHook(makeSessionStartInput(), {
|
|
createClient: () => rejectingClient,
|
|
getDistinctId: () => "distinct-id-abc",
|
|
});
|
|
|
|
expect(output).toBe("");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("telemetry CLI session-start hook (subprocess)", () => {
|
|
describe("#given OMO_DISABLE_POSTHOG=1 set in environment", () => {
|
|
it("#when CLI receives valid SessionStart JSON #then exits 0 with no stdout output", async () => {
|
|
const payload = JSON.stringify(makeSessionStartInput());
|
|
const dataDir = mkdtempSync(path.join(tmpdir(), "codex-telemetry-data-"));
|
|
tempDirectories.push(dataDir);
|
|
|
|
const result = await runHookCli(payload, {
|
|
OMO_DISABLE_POSTHOG: "1",
|
|
XDG_DATA_HOME: dataDir,
|
|
});
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.stdout).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("#given OMO_CODEX_SEND_ANONYMOUS_TELEMETRY=0 set in environment", () => {
|
|
it("#when CLI receives valid SessionStart JSON #then exits 0 with no stdout output", async () => {
|
|
const payload = JSON.stringify(makeSessionStartInput());
|
|
const dataDir = mkdtempSync(path.join(tmpdir(), "codex-telemetry-data-"));
|
|
tempDirectories.push(dataDir);
|
|
|
|
const result = await runHookCli(payload, {
|
|
OMO_CODEX_SEND_ANONYMOUS_TELEMETRY: "0",
|
|
XDG_DATA_HOME: dataDir,
|
|
});
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.stdout).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("#given malformed JSON on stdin", () => {
|
|
it("#when CLI receives invalid input #then exits 0 with no stdout output", async () => {
|
|
const dataDir = mkdtempSync(path.join(tmpdir(), "codex-telemetry-data-"));
|
|
tempDirectories.push(dataDir);
|
|
|
|
const result = await runHookCli("not-a-json-object", {
|
|
OMO_DISABLE_POSTHOG: "1",
|
|
XDG_DATA_HOME: dataDir,
|
|
});
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.stdout).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("#given empty stdin", () => {
|
|
it("#when CLI receives empty input #then exits 0 with no stdout output", async () => {
|
|
const dataDir = mkdtempSync(path.join(tmpdir(), "codex-telemetry-data-"));
|
|
tempDirectories.push(dataDir);
|
|
|
|
const result = await runHookCli("", {
|
|
OMO_DISABLE_POSTHOG: "1",
|
|
XDG_DATA_HOME: dataDir,
|
|
});
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.stdout).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("#given unknown subcommand", () => {
|
|
it("#when CLI is invoked with bad subcommand #then exits non-zero with usage on stderr", async () => {
|
|
const result = await new Promise<CliResult>((resolve, reject) => {
|
|
const child = spawn(process.execPath, [CLI_PATH, "hook", "bogus"], {
|
|
env: process.env,
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.setEncoding("utf8");
|
|
child.stderr.setEncoding("utf8");
|
|
child.stdout.on("data", (chunk: string) => {
|
|
stdout += chunk;
|
|
});
|
|
child.stderr.on("data", (chunk: string) => {
|
|
stderr += chunk;
|
|
});
|
|
child.once("error", reject);
|
|
child.once("close", (exitCode) => {
|
|
resolve({ exitCode, stdout, stderr });
|
|
});
|
|
child.stdin.end();
|
|
});
|
|
|
|
expect(result.exitCode).not.toBe(0);
|
|
expect(result.stderr).toContain("Usage");
|
|
});
|
|
});
|
|
});
|