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.
This commit is contained in:
YeonGyu-Kim
2026-05-26 15:30:41 +09:00
parent b886ca9ca4
commit bd867019f9
22 changed files with 1128 additions and 3 deletions
@@ -0,0 +1,37 @@
# Repository Conventions
Conventions for human contributors and AI agents working on this component.
## Style
- Terse technical prose. No emojis in commits, issues, PR comments, or code.
- TypeScript strict mode. No `any`, no `@ts-ignore`, no `@ts-expect-error`, no enums.
- ESM modules with `.js` suffix in runtime import paths.
- Tabs for indentation. Double quotes for strings.
- Tests use vitest with `#given .. #when .. #then` descriptions or plain `// given / // when / // then` body comments.
## Commands
- `npm install` - install dependencies.
- `npm test` - run vitest once.
- `npm run typecheck` - strict TypeScript check.
- `npm run check` - type check, biome, and build.
- `npm run build` - emit `dist/`.
- `node dist/cli.js hook session-start < fixture.json` - smoke-test the SessionStart hook.
## Constraints
- No Bun APIs. Runtime is Node only because Codex launches plugin hooks with Node.
- The single hook handler is `runSessionStartHook`. Do not add new hook handlers without also wiring them in `hooks/hooks.json` and `plugin/hooks/hooks.json`.
- Telemetry MUST be silent on every failure path. The CLI MUST exit 0 with empty stdout even when PostHog construction, capture, or shutdown throws.
- Telemetry MUST be daily-deduplicated. Adding a new event type requires a new state file slot, not removal of the existing dedup.
- Hook output MUST stay empty (no `additionalContext`, no `systemMessage`). This component is observability-only and MUST NOT inject context into the Codex conversation.
- Constants in `src/product-identity.ts` MUST stay byte-equivalent with `packages/omo-codex/src/telemetry/product-identity.ts`. The cross-package equivalence test will fail otherwise.
- Do not couple this component back to omo internal source paths beyond what `cross-package-equivalence.test.ts` already asserts at the constants layer.
## Don'ts
- No `git add -A` or `git add .`. Stage only the files you changed.
- No `git commit --no-verify`. No force pushes. No history rewriting on shared branches.
- No new network calls. PostHog is the only allowed sink.
- No new env vars without README + privacy-policy update.
@@ -0,0 +1,102 @@
# codex-telemetry
Codex plugin component that emits a single anonymous daily-active event (`omo_codex_daily_active`) to PostHog whenever a Codex session starts.
The event is sent **at most once per UTC day per machine**. It uses a SHA256-hashed installation identifier derived from `omo-codex:${hostname}` and never sends the raw hostname. PostHog person profiles are explicitly disabled.
## Hook Wiring
The component registers a single `SessionStart` hook:
```json
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook session-start",
"timeout": 5
}
]
}
]
}
}
```
The aggregate `plugin/hooks/hooks.json` mounts this hook alongside `rules` and `ultrawork` so all three fire in parallel at the start of every Codex session.
## What Is Captured
A single PostHog `capture` call with:
- `event: "omo_codex_daily_active"`
- `distinctId: sha256("omo-codex:" + hostname)`
- `properties`:
- `platform`, `product_name`, `package_name`, `package_version`
- `runtime` (`"node"`), `runtime_version`
- `source: "plugin"`, `reason: "session_start"`
- `$os`, `$os_version`, `os_arch`, `os_type`
- `cpu_count`, `cpu_model`, `total_memory_gb`
- `locale`, `timezone`, `shell`, `ci`, `terminal`
- `day_utc` (today's UTC date)
- `$process_person_profile: false`
The component never sends prompt contents, file contents, API keys, raw hostnames, or any user-identifying data.
## Opt-Out
Set any of the following environment variables before launching Codex:
```bash
# Codex-only opt-out
export OMO_CODEX_DISABLE_POSTHOG=1
export OMO_CODEX_SEND_ANONYMOUS_TELEMETRY=0
# Global opt-out (covers both omo and omo-codex)
export OMO_DISABLE_POSTHOG=1
export OMO_SEND_ANONYMOUS_TELEMETRY=0
```
When any of these is set the component creates a no-op PostHog client and exits without any network call.
## Daily Deduplication
The component writes a small JSON state file at:
```
$XDG_DATA_HOME/omo-codex/posthog-activity.json
# or, when XDG_DATA_HOME is unset:
~/.local/share/omo-codex/posthog-activity.json
```
containing `{ "lastActiveDayUTC": "YYYY-MM-DD" }`. If the stored day matches today (UTC), the hook returns without sending anything. The file is written atomically via `rename(2)`.
## Failure Behavior
Every telemetry path is wrapped in `try`/`catch`. The hook always exits 0 with no stdout output, even when PostHog construction, capture, or shutdown fails. Codex session startup is never blocked or slowed by telemetry failures.
## Endpoint Overrides
| Variable | Default |
|----------|---------|
| `POSTHOG_HOST` | `https://us.i.posthog.com` |
| `POSTHOG_API_KEY` | shared `omo-codex` project key |
## Development
```bash
npm install
npm test # vitest (in-process + subprocess CLI smoke)
npm run typecheck
npm run build # tsc -> dist/
npm run check # typecheck + biome + build
```
The component shares its product identity constants with the `@oh-my-opencode/omo-codex` CLI installer. Drift between the two implementations is guarded by `packages/omo-codex/src/telemetry/cross-package-equivalence.test.ts`.
## Privacy
See [the omo Privacy Policy](https://github.com/code-yeongyu/oh-my-openagent/blob/dev/docs/legal/privacy-policy.md) for the full disclosure.
@@ -0,0 +1,48 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"noDefaultExport": "error",
"noEnum": "error",
"noNonNullAssertion": "error",
"useImportType": "error",
"useConst": "error",
"useNodejsImportProtocol": "off"
},
"complexity": {
"useLiteralKeys": "off"
},
"suspicious": {
"noExplicitAny": "error",
"noTsIgnore": "error",
"noControlCharactersInRegex": "off",
"noEmptyInterface": "off"
}
}
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "tab",
"indentWidth": 3,
"lineWidth": 120
},
"files": {
"includes": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts", "!**/node_modules/**/*", "!**/dist/**/*"]
},
"overrides": [
{
"includes": ["vitest.config.ts"],
"linter": {
"rules": {
"style": {
"noDefaultExport": "off"
}
}
}
}
]
}
@@ -0,0 +1,15 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook session-start",
"timeout": 5
}
]
}
]
}
}
@@ -0,0 +1,56 @@
{
"name": "@code-yeongyu/codex-telemetry",
"version": "0.1.0",
"description": "Codex plugin component that emits omo-codex anonymous daily-active telemetry on SessionStart.",
"type": "module",
"packageManager": "npm@11.12.1",
"license": "MIT",
"homepage": "https://github.com/code-yeongyu/oh-my-openagent",
"repository": {
"type": "git",
"url": "git+https://github.com/code-yeongyu/oh-my-openagent.git"
},
"bugs": {
"url": "https://github.com/code-yeongyu/oh-my-openagent/issues"
},
"keywords": [
"codex",
"codex-plugin",
"omo",
"telemetry",
"posthog",
"hooks",
"daily-active"
],
"bin": {
"codex-telemetry": "./dist/cli.js"
},
"files": [
"dist",
"hooks",
"LICENSE",
"README.md",
"CHANGELOG.md"
],
"scripts": {
"build": "tsc -p tsconfig.build.json",
"test": "vitest --run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"check": "tsc --noEmit && biome check . && npm run build"
},
"dependencies": {
"posthog-node": "^5.34.3"
},
"devDependencies": {
"@biomejs/biome": "2.4.15",
"@types/node": "^25.7.0",
"typescript": "^6.0.3",
"vitest": "^4.1.5"
},
"engines": {
"node": ">=20.0.0"
}
}
@@ -0,0 +1,21 @@
import { renameSync, unlinkSync, writeFileSync } from "node:fs";
export function writeFileAtomically(filePath: string, content: string): void {
const tempPath = `${filePath}.tmp`;
writeFileSync(tempPath, content, "utf-8");
try {
renameSync(tempPath, filePath);
} catch (error) {
const isPermissionError =
error instanceof Error && (error.message.includes("EPERM") || error.message.includes("EACCES"));
if (process.platform === "win32" && isPermissionError) {
unlinkSync(filePath);
renameSync(tempPath, filePath);
return;
}
throw error;
}
}
@@ -0,0 +1,69 @@
#!/usr/bin/env node
import { stdin as processStdin, stdout as processStdout } from "node:process";
import { type CodexSessionStartInput, runSessionStartHook } from "./codex-hook.js";
const command = process.argv[2];
const subcommand = process.argv[3];
if (command === "hook" && subcommand === "session-start") {
await runHookCli();
} else {
process.stderr.write("Usage: codex-telemetry hook session-start\n");
process.exitCode = 1;
}
async function runHookCli(): Promise<void> {
const raw = await readStdin();
if (raw.trim().length === 0) return;
const parsed = parseHookInput(raw);
if (!isCodexSessionStartInput(parsed)) return;
const output = await runSessionStartHook(parsed);
if (output.length > 0) {
processStdout.write(output);
}
}
function parseHookInput(raw: string): unknown | undefined {
try {
const parsed: unknown = JSON.parse(raw);
return parsed;
} catch {
return undefined;
}
}
function isCodexSessionStartInput(value: unknown): value is CodexSessionStartInput {
return (
isRecord(value) &&
value["hook_event_name"] === "SessionStart" &&
typeof value["session_id"] === "string" &&
isStringOrNull(value["transcript_path"]) &&
typeof value["cwd"] === "string" &&
typeof value["model"] === "string" &&
typeof value["permission_mode"] === "string" &&
typeof value["source"] === "string"
);
}
function isStringOrNull(value: unknown): value is string | null {
return typeof value === "string" || value === null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let data = "";
processStdin.setEncoding("utf8");
processStdin.on("data", (chunk: string) => {
data += chunk;
});
processStdin.once("error", reject);
processStdin.once("end", () => {
resolve(data);
});
});
}
@@ -0,0 +1,49 @@
import {
type PostHogActivityReason,
type PostHogClient,
createPluginPostHog,
getPostHogDistinctId,
} from "./posthog.js";
export type CodexSessionStartInput = {
session_id: string;
transcript_path: string | null;
cwd: string;
hook_event_name: "SessionStart";
model: string;
permission_mode: string;
source: "startup" | "resume" | "clear";
};
export type CodexTelemetryHookOptions = {
createClient?: () => PostHogClient;
getDistinctId?: () => string;
};
const SESSION_START_REASON: PostHogActivityReason = "session_start";
export async function runSessionStartHook(
_input: CodexSessionStartInput,
options: CodexTelemetryHookOptions = {},
): Promise<string> {
const createClient = options.createClient ?? createPluginPostHog;
const getDistinctId = options.getDistinctId ?? getPostHogDistinctId;
const client = createClient();
try {
client.trackActive(getDistinctId(), SESSION_START_REASON);
} catch {
await safeShutdown(client);
return "";
}
await safeShutdown(client);
return "";
}
async function safeShutdown(client: PostHogClient): Promise<void> {
try {
await client.shutdown();
} catch {
return;
}
}
@@ -0,0 +1,44 @@
import { accessSync, constants, mkdirSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { CACHE_DIR_NAME } from "./product-identity.js";
type OsProvider = Pick<typeof os, "homedir" | "tmpdir">;
let osProviderOverride: OsProvider | null = null;
export function getOsProvider(): OsProvider {
return osProviderOverride ?? os;
}
/** @internal test-only */
export function __setOsProviderForTesting(provider: OsProvider): void {
osProviderOverride = provider;
}
/** @internal test-only */
export function __resetOsProviderForTesting(): void {
osProviderOverride = null;
}
function resolveWritableDirectory(preferredDir: string, fallbackSuffix: string): string {
try {
mkdirSync(preferredDir, { recursive: true });
accessSync(preferredDir, constants.W_OK);
return preferredDir;
} catch {
const fallbackDir = path.join(getOsProvider().tmpdir(), fallbackSuffix);
mkdirSync(fallbackDir, { recursive: true });
return fallbackDir;
}
}
export function getDataDir(): string {
const preferredDataDir = process.env["XDG_DATA_HOME"] ?? path.join(getOsProvider().homedir(), ".local", "share");
return resolveWritableDirectory(preferredDataDir, "omo-codex-data");
}
export function getActivityStateDir(): string {
return path.join(getDataDir(), CACHE_DIR_NAME);
}
@@ -0,0 +1,40 @@
import { DEFAULT_POSTHOG_API_KEY, DEFAULT_POSTHOG_HOST } from "./product-identity.js";
function normalizeEnvValue(value: string | undefined): string | undefined {
return value?.trim().toLowerCase();
}
function isDisableFlag(value: string | undefined): boolean {
const normalized = normalizeEnvValue(value);
return normalized === "1" || normalized === "true";
}
function isTelemetryOptOutFlag(value: string | undefined): boolean {
const normalized = normalizeEnvValue(value);
return normalized === "0" || normalized === "false" || normalized === "no";
}
export function shouldDisablePostHog(): boolean {
return (
isDisableFlag(process.env["OMO_DISABLE_POSTHOG"]) ||
isTelemetryOptOutFlag(process.env["OMO_SEND_ANONYMOUS_TELEMETRY"]) ||
isDisableFlag(process.env["OMO_CODEX_DISABLE_POSTHOG"]) ||
isTelemetryOptOutFlag(process.env["OMO_CODEX_SEND_ANONYMOUS_TELEMETRY"])
);
}
export function getPostHogApiKey(): string {
const explicit = process.env["POSTHOG_API_KEY"];
if (explicit === undefined) {
return DEFAULT_POSTHOG_API_KEY;
}
return explicit.trim();
}
export function hasPostHogApiKey(): boolean {
return getPostHogApiKey().length > 0;
}
export function getPostHogHost(): string {
return process.env["POSTHOG_HOST"]?.trim() || DEFAULT_POSTHOG_HOST;
}
@@ -0,0 +1,79 @@
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { writeFileAtomically } from "./atomic-write.js";
import { getActivityStateDir } from "./data-path.js";
export type PostHogActivityState = {
readonly lastActiveDayUTC?: string;
};
export type PostHogActivityCaptureState = {
readonly dayUTC: string;
readonly captureDaily: boolean;
};
const POSTHOG_ACTIVITY_STATE_FILE = "posthog-activity.json";
function getPostHogActivityStateFilePath(): string {
return join(getActivityStateDir(), POSTHOG_ACTIVITY_STATE_FILE);
}
function getUtcDayString(date: Date): string {
return date.toISOString().slice(0, 10);
}
function isPostHogActivityState(value: unknown): value is PostHogActivityState {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function readPostHogActivityState(): PostHogActivityState {
const stateFilePath = getPostHogActivityStateFilePath();
if (!existsSync(stateFilePath)) {
return {};
}
try {
const stateContent = readFileSync(stateFilePath, "utf-8");
const stateJson: unknown = JSON.parse(stateContent);
if (!isPostHogActivityState(stateJson)) {
return {};
}
return stateJson;
} catch {
return {};
}
}
function writePostHogActivityState(nextState: PostHogActivityState): void {
const stateDir = getActivityStateDir();
const stateFilePath = getPostHogActivityStateFilePath();
try {
mkdirSync(stateDir, { recursive: true });
writeFileAtomically(stateFilePath, `${JSON.stringify(nextState, null, 2)}\n`);
} catch {
return;
}
}
export function getPostHogActivityCaptureState(now: Date = new Date()): PostHogActivityCaptureState {
const state = readPostHogActivityState();
const dayUTC = getUtcDayString(now);
const captureDaily = state.lastActiveDayUTC !== dayUTC;
if (captureDaily) {
writePostHogActivityState({
...state,
lastActiveDayUTC: dayUTC,
});
}
return {
dayUTC,
captureDaily,
};
}
@@ -0,0 +1,156 @@
import { createHash } from "node:crypto";
import os from "node:os";
import { PostHog } from "posthog-node";
import { getPostHogApiKey, getPostHogHost, hasPostHogApiKey, shouldDisablePostHog } from "./env-flags.js";
import { getPostHogActivityCaptureState } from "./posthog-activity-state.js";
import {
DEFAULT_POSTHOG_API_KEY,
DEFAULT_POSTHOG_HOST,
EVENT_NAME,
PACKAGE_NAME,
PRODUCT_NAME,
getComponentVersion,
} from "./product-identity.js";
export { DEFAULT_POSTHOG_API_KEY, DEFAULT_POSTHOG_HOST };
export type PostHogActivityReason = "session_start";
export type PostHogClient = {
trackActive: (distinctId: string, reason: PostHogActivityReason) => void;
shutdown: () => Promise<void>;
};
type OsProvider = Pick<typeof os, "arch" | "cpus" | "hostname" | "platform" | "release" | "totalmem" | "type">;
type ActivityStateProvider = typeof getPostHogActivityCaptureState;
let osProviderOverride: OsProvider | null = null;
let activityStateProviderOverride: ActivityStateProvider | null = null;
const NO_OP_POSTHOG: PostHogClient = {
trackActive: () => undefined,
shutdown: async () => undefined,
};
type PostHogCaptureEvent = Parameters<PostHog["capture"]>[0];
function resolveOsProvider(): OsProvider {
return osProviderOverride ?? os;
}
function resolveActivityStateProvider(): ActivityStateProvider {
return activityStateProviderOverride ?? getPostHogActivityCaptureState;
}
function getSafeCpuInfo(): { readonly count: number; readonly model: string | undefined } {
try {
const cpuInfo = resolveOsProvider().cpus();
return {
count: cpuInfo.length,
model: cpuInfo[0]?.model,
};
} catch {
return {
count: 0,
model: undefined,
};
}
}
function getSharedProperties(): NonNullable<PostHogCaptureEvent["properties"]> {
const osProvider = resolveOsProvider();
const cpuInfo = getSafeCpuInfo();
return {
platform: "omo-codex",
product_name: PRODUCT_NAME,
package_name: PACKAGE_NAME,
package_version: getComponentVersion(),
runtime: "node",
runtime_version: process.version,
source: "plugin",
$os: osProvider.platform(),
$os_version: osProvider.release(),
os_arch: osProvider.arch(),
os_type: osProvider.type(),
cpu_count: cpuInfo.count,
cpu_model: cpuInfo.model,
total_memory_gb: Math.round(osProvider.totalmem() / 1024 / 1024 / 1024),
locale: Intl.DateTimeFormat().resolvedOptions().locale,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
shell: process.env["SHELL"],
ci: Boolean(process.env["CI"]),
terminal: process.env["TERM_PROGRAM"],
};
}
export function createPluginPostHog(): PostHogClient {
if (shouldDisablePostHog() || !hasPostHogApiKey()) {
return NO_OP_POSTHOG;
}
let client: PostHog;
try {
client = new PostHog(getPostHogApiKey(), {
enableExceptionAutocapture: false,
enableLocalEvaluation: false,
strictLocalEvaluation: true,
disableRemoteConfig: true,
flushAt: 1,
flushInterval: 0,
host: getPostHogHost(),
disableGeoip: false,
});
} catch {
return NO_OP_POSTHOG;
}
const sharedProperties = getSharedProperties();
return {
trackActive: (distinctId, reason) => {
const activityState = resolveActivityStateProvider()();
if (!activityState.captureDaily) {
return;
}
client.capture({
distinctId,
event: EVENT_NAME,
properties: {
...sharedProperties,
$process_person_profile: false,
day_utc: activityState.dayUTC,
reason,
},
});
},
shutdown: async () => client.shutdown(),
};
}
export function getPostHogDistinctId(): string {
return createHash("sha256").update(`omo-codex:${resolveOsProvider().hostname()}`).digest("hex");
}
/** @internal test-only */
export function __setOsProviderForTesting(provider: OsProvider): void {
osProviderOverride = provider;
}
/** @internal test-only */
export function __resetOsProviderForTesting(): void {
osProviderOverride = null;
}
/** @internal test-only */
export function __setActivityStateProviderForTesting(provider: ActivityStateProvider): void {
activityStateProviderOverride = provider;
}
/** @internal test-only */
export function __resetActivityStateProviderForTesting(): void {
activityStateProviderOverride = null;
}
@@ -0,0 +1,35 @@
import { readFileSync } from "node:fs";
export const PRODUCT_NAME = "omo-codex";
export const PACKAGE_NAME = "@oh-my-opencode/omo-codex";
export const CACHE_DIR_NAME = "omo-codex";
export const EVENT_NAME = "omo_codex_daily_active";
export const LEGACY_PARENT_PACKAGE = "oh-my-opencode";
export const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com";
export const DEFAULT_POSTHOG_API_KEY = "phc_CFJhj5HyvA62QPhvyaUCtaq23aUfznnijg5VaaGkNk74";
type ComponentPackageManifest = { readonly version?: string };
function isComponentPackageManifest(value: unknown): value is ComponentPackageManifest {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function readComponentVersionFromManifest(): string {
try {
const manifestUrl = new URL("../package.json", import.meta.url);
const manifestText = readFileSync(manifestUrl, "utf-8");
const parsed: unknown = JSON.parse(manifestText);
if (isComponentPackageManifest(parsed) && typeof parsed.version === "string") {
return parsed.version;
}
} catch {
return "0.0.0";
}
return "0.0.0";
}
const COMPONENT_VERSION_CACHE = readComponentVersionFromManifest();
export function getComponentVersion(): string {
return COMPONENT_VERSION_CACHE;
}
@@ -0,0 +1,247 @@
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");
});
});
});
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": false,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"noEmit": false
},
"include": ["src/**/*"],
"exclude": ["test/**/*"]
}
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"noPropertyAccessFromIndexSignature": true,
"verbatimModuleSyntax": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"useDefineForClassFields": false,
"types": ["node"],
"noEmit": true
},
"include": ["src/**/*", "test/**/*"]
}
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
pool: "threads",
},
});
@@ -19,6 +19,15 @@
"timeout": 5
}
]
},
{
"hooks": [
{
"type": "command",
"command": "node \"${PLUGIN_ROOT}/components/telemetry/dist/cli.js\" hook session-start",
"timeout": 5
}
]
}
],
"UserPromptSubmit": [
+1
View File
@@ -10,6 +10,7 @@
"components/rules",
"components/lsp",
"components/lsp/packages/lsp-tools-mcp",
"components/telemetry",
"components/ultragoal",
"components/ultrawork"
],
@@ -36,6 +36,7 @@ test("#given isolated components #when hooks are inspected #then commands stay i
"components/comment-checker/dist/cli.js",
"components/lsp/dist/cli.js",
"components/rules/dist/cli.js",
"components/telemetry/dist/cli.js",
"components/ultragoal/dist/cli.js",
"components/ultrawork/hooks/sync-agents.py",
"components/ultrawork/hooks/ultrawork-detector.py",
@@ -45,7 +46,7 @@ test("#given isolated components #when hooks are inspected #then commands stay i
for (const marker of componentMarkers) {
assert.match(text, new RegExp(marker.replaceAll("/", "\\/")));
}
assert.doesNotMatch(text, /codex-(comment-checker|lsp|rules|ultragoal|ultrawork)@/);
assert.doesNotMatch(text, /codex-(comment-checker|lsp|rules|telemetry|ultragoal|ultrawork)@/);
});
test("#given aggregate MCP config #when inspected #then lsp server stays component isolated", async () => {
@@ -69,7 +70,7 @@ test("#given component directories #when scanned #then only root owns plugin ide
const componentNames = components.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
// then
assert.deepEqual(componentNames, ["comment-checker", "lsp", "rules", "ultragoal", "ultrawork"]);
assert.deepEqual(componentNames, ["comment-checker", "lsp", "rules", "telemetry", "ultragoal", "ultrawork"]);
for (const name of componentNames) {
await assert.rejects(
readFile(join(root, "components", name, ".codex-plugin", "plugin.json"), "utf8"),
@@ -0,0 +1,69 @@
import { describe, expect, it } from "bun:test"
import * as cliIdentity from "./product-identity"
describe("cross-package telemetry identity equivalence", () => {
describe("#given the omo-codex CLI telemetry product-identity module and the Codex plugin component product-identity module", () => {
it("#when both are imported #then PRODUCT_NAME, PACKAGE_NAME, CACHE_DIR_NAME, EVENT_NAME, DEFAULT_POSTHOG_HOST, and DEFAULT_POSTHOG_API_KEY are identical", async () => {
const pluginIdentity = await import(
"../../plugin/components/telemetry/src/product-identity"
)
expect(pluginIdentity.PRODUCT_NAME).toBe(cliIdentity.PRODUCT_NAME)
expect(pluginIdentity.PACKAGE_NAME).toBe(cliIdentity.PACKAGE_NAME)
expect(pluginIdentity.CACHE_DIR_NAME).toBe(cliIdentity.CACHE_DIR_NAME)
expect(pluginIdentity.EVENT_NAME).toBe(cliIdentity.EVENT_NAME)
expect(pluginIdentity.DEFAULT_POSTHOG_HOST).toBe(cliIdentity.DEFAULT_POSTHOG_HOST)
expect(pluginIdentity.DEFAULT_POSTHOG_API_KEY).toBe(cliIdentity.DEFAULT_POSTHOG_API_KEY)
expect(pluginIdentity.LEGACY_PARENT_PACKAGE).toBe(cliIdentity.LEGACY_PARENT_PACKAGE)
})
})
describe("#given the omo-codex CLI env-flags module and the Codex plugin component env-flags module", () => {
it("#when shouldDisablePostHog is checked under each opt-out env var #then both modules disable on the same flags", async () => {
const cliEnv = await import("./env-flags")
const pluginEnv = await import(
"../../plugin/components/telemetry/src/env-flags"
)
const flags = [
"OMO_DISABLE_POSTHOG",
"OMO_SEND_ANONYMOUS_TELEMETRY",
"OMO_CODEX_DISABLE_POSTHOG",
"OMO_CODEX_SEND_ANONYMOUS_TELEMETRY",
] as const
const previousValues = new Map<string, string | undefined>()
for (const flag of flags) {
previousValues.set(flag, process.env[flag])
delete process.env[flag]
}
try {
expect(cliEnv.shouldDisablePostHog()).toBe(false)
expect(pluginEnv.shouldDisablePostHog()).toBe(false)
for (const optOutFlag of ["OMO_DISABLE_POSTHOG", "OMO_CODEX_DISABLE_POSTHOG"] as const) {
process.env[optOutFlag] = "1"
expect(cliEnv.shouldDisablePostHog()).toBe(true)
expect(pluginEnv.shouldDisablePostHog()).toBe(true)
delete process.env[optOutFlag]
}
for (const sendFlag of ["OMO_SEND_ANONYMOUS_TELEMETRY", "OMO_CODEX_SEND_ANONYMOUS_TELEMETRY"] as const) {
process.env[sendFlag] = "0"
expect(cliEnv.shouldDisablePostHog()).toBe(true)
expect(pluginEnv.shouldDisablePostHog()).toBe(true)
delete process.env[sendFlag]
}
} finally {
for (const flag of flags) {
const previous = previousValues.get(flag)
if (previous === undefined) {
delete process.env[flag]
} else {
process.env[flag] = previous
}
}
}
})
})
})
+1 -1
View File
@@ -17,7 +17,7 @@ import {
export { DEFAULT_POSTHOG_API_KEY, DEFAULT_POSTHOG_HOST }
export type PostHogSource = "cli" | "plugin" | "install"
export type PostHogActivityReason = "install_started" | "install_completed" | "cli_run"
export type PostHogActivityReason = "install_started" | "install_completed" | "cli_run" | "session_start"
export type PostHogClient = {
trackActive: (distinctId: string, reason: PostHogActivityReason) => void