diff --git a/packages/omo-claude/plugin/components/telemetry/hooks/hooks.json b/packages/omo-claude/plugin/components/telemetry/hooks/hooks.json new file mode 100644 index 000000000..71394fb78 --- /dev/null +++ b/packages/omo-claude/plugin/components/telemetry/hooks/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/cli.js\" hook session-start", + "timeout": 5 + } + ] + } + ] + } +} diff --git a/packages/omo-claude/plugin/components/telemetry/package.json b/packages/omo-claude/plugin/components/telemetry/package.json new file mode 100644 index 000000000..2d8b1cbee --- /dev/null +++ b/packages/omo-claude/plugin/components/telemetry/package.json @@ -0,0 +1,56 @@ +{ + "name": "@code-yeongyu/claude-telemetry", + "version": "0.1.0", + "description": "Claude Code plugin component that emits omo-claude 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": [ + "claude-code", + "claude-code-plugin", + "omo", + "telemetry", + "posthog", + "hooks", + "daily-active" + ], + "bin": { + "claude-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-claude/plugin/components/telemetry/src/claude-hook.ts b/packages/omo-claude/plugin/components/telemetry/src/claude-hook.ts new file mode 100644 index 000000000..ae1ce8878 --- /dev/null +++ b/packages/omo-claude/plugin/components/telemetry/src/claude-hook.ts @@ -0,0 +1,49 @@ +import { + type PostHogActivityReason, + type PostHogClient, + createPluginPostHog, + getPostHogDistinctId, +} from "./posthog.js"; + +export type ClaudeSessionStartInput = { + session_id: string; + transcript_path: string | null; + cwd: string; + hook_event_name: "SessionStart"; + model?: string; + permission_mode?: string; + source: "startup" | "resume" | "clear"; +}; + +export type ClaudeTelemetryHookOptions = { + createClient?: () => PostHogClient; + getDistinctId?: () => string; +}; + +const SESSION_START_REASON: PostHogActivityReason = "session_start"; + +export async function runSessionStartHook( + _input: ClaudeSessionStartInput, + options: ClaudeTelemetryHookOptions = {}, +): 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-claude/plugin/components/telemetry/src/cli.ts b/packages/omo-claude/plugin/components/telemetry/src/cli.ts new file mode 100644 index 000000000..220772502 --- /dev/null +++ b/packages/omo-claude/plugin/components/telemetry/src/cli.ts @@ -0,0 +1,73 @@ +#!/usr/bin/env node +import { stdin as processStdin, stdout as processStdout } from "node:process"; + +import { type ClaudeSessionStartInput, runSessionStartHook } from "./claude-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: claude-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 (!isClaudeSessionStartInput(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 isClaudeSessionStartInput(value: unknown): value is ClaudeSessionStartInput { + return ( + isRecord(value) && + value["hook_event_name"] === "SessionStart" && + typeof value["session_id"] === "string" && + isStringOrNull(value["transcript_path"]) && + typeof value["cwd"] === "string" && + isOptionalString(value["model"]) && + isOptionalString(value["permission_mode"]) && + typeof value["source"] === "string" + ); +} + +function isStringOrNull(value: unknown): value is string | null { + return typeof value === "string" || value === null; +} + +function isOptionalString(value: unknown): value is string | undefined { + return typeof value === "string" || value === undefined; +} + +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-claude/plugin/components/telemetry/src/posthog.ts b/packages/omo-claude/plugin/components/telemetry/src/posthog.ts new file mode 100644 index 000000000..def27671b --- /dev/null +++ b/packages/omo-claude/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: PRODUCT_NAME, + 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(`${PRODUCT_NAME}:${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-claude/plugin/components/telemetry/tsconfig.build.json b/packages/omo-claude/plugin/components/telemetry/tsconfig.build.json new file mode 100644 index 000000000..5b5bbcafd --- /dev/null +++ b/packages/omo-claude/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-claude/plugin/components/telemetry/tsconfig.json b/packages/omo-claude/plugin/components/telemetry/tsconfig.json new file mode 100644 index 000000000..342229c02 --- /dev/null +++ b/packages/omo-claude/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/**/*"] +}