diff --git a/src/shared/posthog-activity-state.ts b/src/shared/posthog-activity-state.ts new file mode 100644 index 000000000..cc3bd2a4e --- /dev/null +++ b/src/shared/posthog-activity-state.ts @@ -0,0 +1,89 @@ +import { existsSync, mkdirSync, readFileSync } from "node:fs" +import { join } from "node:path" + +import { getDataDir } from "./data-path" +import { log } from "./logger" +import { CACHE_DIR_NAME } from "./plugin-identity" +import { writeFileAtomically } from "./write-file-atomically" + +type PostHogActivityState = { + lastActiveDayUTC?: string + lastActiveHourUTC?: string +} + +type PostHogActivityCaptureState = { + dayUTC: string + hourUTC: string + captureDaily: boolean + captureHourly: boolean +} + +const POSTHOG_ACTIVITY_STATE_FILE = "posthog-activity.json" + +function getPostHogActivityStateFilePath(): string { + return join(getDataDir(), CACHE_DIR_NAME, POSTHOG_ACTIVITY_STATE_FILE) +} + +function getUtcDayString(date: Date): string { + return date.toISOString().slice(0, 10) +} + +function getUtcHourString(date: Date): string { + return date.toISOString().slice(0, 13) +} + +function readPostHogActivityState(): PostHogActivityState { + const stateFilePath = getPostHogActivityStateFilePath() + if (!existsSync(stateFilePath)) { + return {} + } + + try { + const content = readFileSync(stateFilePath, "utf-8") + const parsed = JSON.parse(content) as PostHogActivityState + return parsed + } catch (error) { + log("[posthog-activity-state] Failed to read activity state", { + error: String(error), + stateFilePath, + }) + return {} + } +} + +function writePostHogActivityState(nextState: PostHogActivityState): void { + const stateFilePath = getPostHogActivityStateFilePath() + + try { + mkdirSync(join(getDataDir(), CACHE_DIR_NAME), { recursive: true }) + writeFileAtomically(stateFilePath, `${JSON.stringify(nextState, null, 2)}\n`) + } catch (error) { + log("[posthog-activity-state] Failed to write activity state", { + error: String(error), + stateFilePath, + }) + } +} + +export function getPostHogActivityCaptureState(now: Date = new Date()): PostHogActivityCaptureState { + const state = readPostHogActivityState() + const dayUTC = getUtcDayString(now) + const hourUTC = getUtcHourString(now) + + const captureDaily = state.lastActiveDayUTC !== dayUTC + const captureHourly = state.lastActiveHourUTC !== hourUTC + + if (captureDaily || captureHourly) { + writePostHogActivityState({ + lastActiveDayUTC: captureDaily ? dayUTC : state.lastActiveDayUTC, + lastActiveHourUTC: captureHourly ? hourUTC : state.lastActiveHourUTC, + }) + } + + return { + dayUTC, + hourUTC, + captureDaily, + captureHourly, + } +} diff --git a/src/shared/posthog.ts b/src/shared/posthog.ts index 17b692ec1..b56025d35 100644 --- a/src/shared/posthog.ts +++ b/src/shared/posthog.ts @@ -1,37 +1,140 @@ import os from "os" +import { createHash } from "node:crypto" import { PostHog } from "posthog-node" +import packageJson from "../../package.json" with { type: "json" } +import { PLUGIN_NAME, PUBLISHED_PACKAGE_NAME } from "./plugin-identity" +import { getPostHogActivityCaptureState } from "./posthog-activity-state" -type PostHogClient = Pick +const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com" +const DEFAULT_POSTHOG_API_KEY = "phc_CFJhj5HyvA62QPhvyaUCtaq23aUfznnijg5VaaGkNk74" + +type PostHogCaptureEvent = Parameters[0] +type PostHogExceptionProperties = Parameters[2] +type PostHogSource = "cli" | "plugin" +type PostHogActivityReason = "run_started" | "plugin_loaded" + +type PostHogClient = { + capture: (message: PostHogCaptureEvent) => void + captureException: ( + error: unknown, + distinctId?: string, + additionalProperties?: PostHogExceptionProperties, + ) => void + trackActive: (distinctId: string, reason: PostHogActivityReason) => void + shutdown: () => Promise +} const NO_OP_POSTHOG: PostHogClient = { capture: () => undefined, captureException: () => undefined, + trackActive: () => undefined, shutdown: async () => undefined, } +function isFalsy(value: string | undefined): boolean { + return value === "0" || value === "false" || value === "no" +} + function shouldDisablePostHog(): boolean { - return process.env.OMO_DISABLE_POSTHOG === "true" + if (process.env.OMO_DISABLE_POSTHOG === "true" || process.env.OMO_DISABLE_POSTHOG === "1") { + return true + } + + return isFalsy(process.env.OMO_SEND_ANONYMOUS_TELEMETRY?.trim().toLowerCase()) } function hasPostHogApiKey(): boolean { - return (process.env.POSTHOG_API_KEY ?? "").trim().length > 0 + return getPostHogApiKey().length > 0 } -function createPostHogClient(options: ConstructorParameters[1]): PostHogClient { +function getPostHogApiKey(): string { + return process.env.POSTHOG_API_KEY?.trim() || DEFAULT_POSTHOG_API_KEY +} + +function getPostHogHost(): string { + return process.env.POSTHOG_HOST?.trim() || DEFAULT_POSTHOG_HOST +} + +function getSharedProperties(source: PostHogSource): NonNullable { + return { + platform: "oh-my-opencode", + package_name: PUBLISHED_PACKAGE_NAME, + plugin_name: PLUGIN_NAME, + package_version: packageJson.version, + runtime: "bun", + source, + } +} + +function createPostHogClient( + source: PostHogSource, + options: ConstructorParameters[1], +): PostHogClient { if (shouldDisablePostHog() || !hasPostHogApiKey()) { return NO_OP_POSTHOG } - return new PostHog(process.env.POSTHOG_API_KEY ?? "", options) + const configuredClient = new PostHog(getPostHogApiKey(), { + ...options, + host: getPostHogHost(), + }) + const sharedProperties = getSharedProperties(source) + + return { + capture: (message) => { + configuredClient.capture({ + ...message, + properties: { + ...sharedProperties, + ...message.properties, + }, + }) + }, + captureException: (error, distinctId, additionalProperties) => { + configuredClient.captureException(error, distinctId, { + ...sharedProperties, + ...additionalProperties, + }) + }, + trackActive: (distinctId, reason) => { + const activityState = getPostHogActivityCaptureState() + + if (activityState.captureDaily) { + configuredClient.capture({ + distinctId, + event: "omo_daily_active", + properties: { + ...sharedProperties, + day_utc: activityState.dayUTC, + reason, + }, + }) + } + + if (activityState.captureHourly) { + configuredClient.capture({ + distinctId, + event: "omo_hourly_active", + properties: { + ...sharedProperties, + hour_utc: activityState.hourUTC, + reason, + }, + }) + } + }, + shutdown: async () => configuredClient.shutdown(), + } } export function getPostHogDistinctId(): string { - return os.hostname() + return createHash("sha256") + .update(`${PUBLISHED_PACKAGE_NAME}:${os.hostname()}`) + .digest("hex") } export function createCliPostHog(): PostHogClient { - return createPostHogClient({ - host: process.env.POSTHOG_HOST, + return createPostHogClient("cli", { enableExceptionAutocapture: true, flushAt: 1, flushInterval: 0, @@ -39,8 +142,9 @@ export function createCliPostHog(): PostHogClient { } export function createPluginPostHog(): PostHogClient { - return createPostHogClient({ - host: process.env.POSTHOG_HOST, + return createPostHogClient("plugin", { enableExceptionAutocapture: true, + flushAt: 1, + flushInterval: 0, }) }