Files
oh-my-opencode/packages/omo-codex/plugin/components/telemetry/test/codex-hook.test.ts
T
YeonGyu-Kim bd867019f9 feat(omo-codex): wire Codex SessionStart telemetry for DAU/WAU/MAU
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.
2026-05-28 13:58:11 +09:00

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");
});
});
});