feat(omo-claude): vendor telemetry component with claude identity
SessionStart daily-active component: omo_claude_daily_active, omo-claude: salt, platform derived from PRODUCT_NAME, shared PostHog key/host. model/permission_mode optional in validator. Equivalence + distinctness tests pass; SessionStart smoke exits 0 with OMO_DISABLE_POSTHOG=1 (no send). Builds; F3 cache boot verified. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/cli.js\" hook session-start",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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,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<void> {
|
||||||
|
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<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,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: 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;
|
||||||
|
}
|
||||||
@@ -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/**/*"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user