feat(telemetry): add PostHog client factory with activity state tracking
This commit is contained in:
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
+114
-10
@@ -1,37 +1,140 @@
|
|||||||
import os from "os"
|
import os from "os"
|
||||||
|
import { createHash } from "node:crypto"
|
||||||
import { PostHog } from "posthog-node"
|
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<PostHog, "capture" | "captureException" | "shutdown">
|
const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"
|
||||||
|
const DEFAULT_POSTHOG_API_KEY = "phc_CFJhj5HyvA62QPhvyaUCtaq23aUfznnijg5VaaGkNk74"
|
||||||
|
|
||||||
|
type PostHogCaptureEvent = Parameters<PostHog["capture"]>[0]
|
||||||
|
type PostHogExceptionProperties = Parameters<PostHog["captureException"]>[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<void>
|
||||||
|
}
|
||||||
|
|
||||||
const NO_OP_POSTHOG: PostHogClient = {
|
const NO_OP_POSTHOG: PostHogClient = {
|
||||||
capture: () => undefined,
|
capture: () => undefined,
|
||||||
captureException: () => undefined,
|
captureException: () => undefined,
|
||||||
|
trackActive: () => undefined,
|
||||||
shutdown: async () => undefined,
|
shutdown: async () => undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFalsy(value: string | undefined): boolean {
|
||||||
|
return value === "0" || value === "false" || value === "no"
|
||||||
|
}
|
||||||
|
|
||||||
function shouldDisablePostHog(): boolean {
|
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 {
|
function hasPostHogApiKey(): boolean {
|
||||||
return (process.env.POSTHOG_API_KEY ?? "").trim().length > 0
|
return getPostHogApiKey().length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPostHogClient(options: ConstructorParameters<typeof PostHog>[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<PostHogCaptureEvent["properties"]> {
|
||||||
|
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<typeof PostHog>[1],
|
||||||
|
): PostHogClient {
|
||||||
if (shouldDisablePostHog() || !hasPostHogApiKey()) {
|
if (shouldDisablePostHog() || !hasPostHogApiKey()) {
|
||||||
return NO_OP_POSTHOG
|
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 {
|
export function getPostHogDistinctId(): string {
|
||||||
return os.hostname()
|
return createHash("sha256")
|
||||||
|
.update(`${PUBLISHED_PACKAGE_NAME}:${os.hostname()}`)
|
||||||
|
.digest("hex")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCliPostHog(): PostHogClient {
|
export function createCliPostHog(): PostHogClient {
|
||||||
return createPostHogClient({
|
return createPostHogClient("cli", {
|
||||||
host: process.env.POSTHOG_HOST,
|
|
||||||
enableExceptionAutocapture: true,
|
enableExceptionAutocapture: true,
|
||||||
flushAt: 1,
|
flushAt: 1,
|
||||||
flushInterval: 0,
|
flushInterval: 0,
|
||||||
@@ -39,8 +142,9 @@ export function createCliPostHog(): PostHogClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createPluginPostHog(): PostHogClient {
|
export function createPluginPostHog(): PostHogClient {
|
||||||
return createPostHogClient({
|
return createPostHogClient("plugin", {
|
||||||
host: process.env.POSTHOG_HOST,
|
|
||||||
enableExceptionAutocapture: true,
|
enableExceptionAutocapture: true,
|
||||||
|
flushAt: 1,
|
||||||
|
flushInterval: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user