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:
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user