diff --git a/packages/omo-codex/plugin/components/telemetry/AGENTS.md b/packages/omo-codex/plugin/components/telemetry/AGENTS.md new file mode 100644 index 000000000..a3887f655 --- /dev/null +++ b/packages/omo-codex/plugin/components/telemetry/AGENTS.md @@ -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. diff --git a/packages/omo-codex/plugin/components/telemetry/README.md b/packages/omo-codex/plugin/components/telemetry/README.md new file mode 100644 index 000000000..c0f8655ef --- /dev/null +++ b/packages/omo-codex/plugin/components/telemetry/README.md @@ -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. diff --git a/packages/omo-codex/plugin/components/telemetry/biome.json b/packages/omo-codex/plugin/components/telemetry/biome.json new file mode 100644 index 000000000..5aa1a0dcc --- /dev/null +++ b/packages/omo-codex/plugin/components/telemetry/biome.json @@ -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" + } + } + } + } + ] +} diff --git a/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json b/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json new file mode 100644 index 000000000..154a112a2 --- /dev/null +++ b/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook session-start", + "timeout": 5 + } + ] + } + ] + } +} diff --git a/packages/omo-codex/plugin/components/telemetry/package.json b/packages/omo-codex/plugin/components/telemetry/package.json new file mode 100644 index 000000000..628bef331 --- /dev/null +++ b/packages/omo-codex/plugin/components/telemetry/package.json @@ -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" + } +} diff --git a/packages/omo-codex/plugin/components/telemetry/src/atomic-write.ts b/packages/omo-codex/plugin/components/telemetry/src/atomic-write.ts new file mode 100644 index 000000000..e9dd6465c --- /dev/null +++ b/packages/omo-codex/plugin/components/telemetry/src/atomic-write.ts @@ -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; + } +} diff --git a/packages/omo-codex/plugin/components/telemetry/src/cli.ts b/packages/omo-codex/plugin/components/telemetry/src/cli.ts new file mode 100644 index 000000000..9ce2e2f62 --- /dev/null +++ b/packages/omo-codex/plugin/components/telemetry/src/cli.ts @@ -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 { + 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readStdin(): Promise { + 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); + }); + }); +} diff --git a/packages/omo-codex/plugin/components/telemetry/src/codex-hook.ts b/packages/omo-codex/plugin/components/telemetry/src/codex-hook.ts new file mode 100644 index 000000000..6e61ebb8c --- /dev/null +++ b/packages/omo-codex/plugin/components/telemetry/src/codex-hook.ts @@ -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 { + 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 { + try { + await client.shutdown(); + } catch { + return; + } +} diff --git a/packages/omo-codex/plugin/components/telemetry/src/data-path.ts b/packages/omo-codex/plugin/components/telemetry/src/data-path.ts new file mode 100644 index 000000000..408d842b5 --- /dev/null +++ b/packages/omo-codex/plugin/components/telemetry/src/data-path.ts @@ -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; + +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); +} diff --git a/packages/omo-codex/plugin/components/telemetry/src/env-flags.ts b/packages/omo-codex/plugin/components/telemetry/src/env-flags.ts new file mode 100644 index 000000000..134f91341 --- /dev/null +++ b/packages/omo-codex/plugin/components/telemetry/src/env-flags.ts @@ -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; +} diff --git a/packages/omo-codex/plugin/components/telemetry/src/posthog-activity-state.ts b/packages/omo-codex/plugin/components/telemetry/src/posthog-activity-state.ts new file mode 100644 index 000000000..84233999f --- /dev/null +++ b/packages/omo-codex/plugin/components/telemetry/src/posthog-activity-state.ts @@ -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, + }; +} diff --git a/packages/omo-codex/plugin/components/telemetry/src/posthog.ts b/packages/omo-codex/plugin/components/telemetry/src/posthog.ts new file mode 100644 index 000000000..30ab9eb05 --- /dev/null +++ b/packages/omo-codex/plugin/components/telemetry/src/posthog.ts @@ -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; +}; + +type OsProvider = Pick; +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[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 { + 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; +} diff --git a/packages/omo-codex/plugin/components/telemetry/src/product-identity.ts b/packages/omo-codex/plugin/components/telemetry/src/product-identity.ts new file mode 100644 index 000000000..1f34d03b7 --- /dev/null +++ b/packages/omo-codex/plugin/components/telemetry/src/product-identity.ts @@ -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; +} diff --git a/packages/omo-codex/plugin/components/telemetry/test/codex-hook.test.ts b/packages/omo-codex/plugin/components/telemetry/test/codex-hook.test.ts new file mode 100644 index 000000000..066ad9b14 --- /dev/null +++ b/packages/omo-codex/plugin/components/telemetry/test/codex-hook.test.ts @@ -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 { + 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 { + 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((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"); + }); + }); +}); diff --git a/packages/omo-codex/plugin/components/telemetry/tsconfig.build.json b/packages/omo-codex/plugin/components/telemetry/tsconfig.build.json new file mode 100644 index 000000000..5b5bbcafd --- /dev/null +++ b/packages/omo-codex/plugin/components/telemetry/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "noEmit": false + }, + "include": ["src/**/*"], + "exclude": ["test/**/*"] +} diff --git a/packages/omo-codex/plugin/components/telemetry/tsconfig.json b/packages/omo-codex/plugin/components/telemetry/tsconfig.json new file mode 100644 index 000000000..342229c02 --- /dev/null +++ b/packages/omo-codex/plugin/components/telemetry/tsconfig.json @@ -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/**/*"] +} diff --git a/packages/omo-codex/plugin/components/telemetry/vitest.config.ts b/packages/omo-codex/plugin/components/telemetry/vitest.config.ts new file mode 100644 index 000000000..c4fddb41c --- /dev/null +++ b/packages/omo-codex/plugin/components/telemetry/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + pool: "threads", + }, +}); diff --git a/packages/omo-codex/plugin/hooks/hooks.json b/packages/omo-codex/plugin/hooks/hooks.json index 71040bd45..e2ac12cc1 100644 --- a/packages/omo-codex/plugin/hooks/hooks.json +++ b/packages/omo-codex/plugin/hooks/hooks.json @@ -19,6 +19,15 @@ "timeout": 5 } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/components/telemetry/dist/cli.js\" hook session-start", + "timeout": 5 + } + ] } ], "UserPromptSubmit": [ diff --git a/packages/omo-codex/plugin/package.json b/packages/omo-codex/plugin/package.json index 45593e304..06b4b858b 100644 --- a/packages/omo-codex/plugin/package.json +++ b/packages/omo-codex/plugin/package.json @@ -10,6 +10,7 @@ "components/rules", "components/lsp", "components/lsp/packages/lsp-tools-mcp", + "components/telemetry", "components/ultragoal", "components/ultrawork" ], diff --git a/packages/omo-codex/plugin/test/aggregate.test.mjs b/packages/omo-codex/plugin/test/aggregate.test.mjs index 8d4537e12..6e1ee3cea 100644 --- a/packages/omo-codex/plugin/test/aggregate.test.mjs +++ b/packages/omo-codex/plugin/test/aggregate.test.mjs @@ -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"), diff --git a/packages/omo-codex/src/telemetry/cross-package-equivalence.test.ts b/packages/omo-codex/src/telemetry/cross-package-equivalence.test.ts new file mode 100644 index 000000000..a8c157c6d --- /dev/null +++ b/packages/omo-codex/src/telemetry/cross-package-equivalence.test.ts @@ -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() + 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 + } + } + } + }) + }) +}) diff --git a/packages/omo-codex/src/telemetry/posthog.ts b/packages/omo-codex/src/telemetry/posthog.ts index 691099205..615301b22 100644 --- a/packages/omo-codex/src/telemetry/posthog.ts +++ b/packages/omo-codex/src/telemetry/posthog.ts @@ -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