diff --git a/src/index.telemetry.test.ts b/src/index.telemetry.test.ts index 924a7db2c..ce427da49 100644 --- a/src/index.telemetry.test.ts +++ b/src/index.telemetry.test.ts @@ -104,6 +104,12 @@ function installModuleMocks(): void { createPluginPostHog: mockCreatePluginPostHog, getPostHogDistinctId: mockGetPostHogDistinctId, })) + mock.module("./shared/posthog-activity-state", () => ({ + getPluginLoadedCaptureState: () => ({ + dayUTC: "2026-04-18", + capturePluginLoaded: true, + }), + })) } describe("oh-my-openagent telemetry isolation", () => { diff --git a/src/index.ts b/src/index.ts index c1519fed2..7509ef651 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { installAgentSortShim } from "./shared/agent-sort-shim" import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./shared/external-plugin-detector" import { startBackgroundCheck as startTmuxCheck } from "./tools/interactive-bash" import { createPluginPostHog, getPostHogDistinctId } from "./shared/posthog" +import { getPluginLoadedCaptureState } from "./shared/posthog-activity-state" const serverPlugin: Plugin = async (input, _options): Promise => { installAgentSortShim() @@ -43,19 +44,27 @@ const serverPlugin: Plugin = async (input, _options): Promise => { } catch { // telemetry failure is non-fatal, silently ignore } + let pluginLoadedCaptureState: ReturnType | null = null try { - posthog.capture({ - distinctId, - event: "plugin_loaded", - properties: { - entry_point: "plugin", - has_openclaw: !!pluginConfig.openclaw, - tmux_enabled: isTmuxIntegrationEnabled(pluginConfig), - }, - }) + pluginLoadedCaptureState = getPluginLoadedCaptureState() } catch { // telemetry failure is non-fatal, silently ignore } + if (pluginLoadedCaptureState?.capturePluginLoaded) { + try { + posthog.capture({ + distinctId, + event: "plugin_loaded", + properties: { + entry_point: "plugin", + has_openclaw: !!pluginConfig.openclaw, + tmux_enabled: isTmuxIntegrationEnabled(pluginConfig), + }, + }) + } catch { + // telemetry failure is non-fatal, silently ignore + } + } if (pluginConfig.openclaw) { await initializeOpenClaw(pluginConfig.openclaw) } diff --git a/src/shared/posthog-activity-state.test.ts b/src/shared/posthog-activity-state.test.ts index c5ee8afd1..328f8de1a 100644 --- a/src/shared/posthog-activity-state.test.ts +++ b/src/shared/posthog-activity-state.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "bun:test" -import { mkdirSync, rmSync, writeFileSync } from "node:fs" +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { join } from "node:path" import { tmpdir } from "node:os" @@ -137,4 +137,159 @@ describe("getPostHogActivityCaptureState", () => { rmSync(dataHomePath, { recursive: true, force: true }) }) + + it("preserves lastPluginLoadedDayUTC when writing lastActiveDayUTC", async () => { + // given + const dataHomePath = createDataHomePath() + const cachePath = join(dataHomePath, "oh-my-opencode") + mkdirSync(cachePath, { recursive: true }) + writeFileSync( + join(cachePath, "posthog-activity.json"), + `${JSON.stringify({ + lastActiveDayUTC: "2026-04-10", + lastPluginLoadedDayUTC: "2026-04-11", + })}\n`, + ) + process.env.XDG_DATA_HOME = dataHomePath + const { getPostHogActivityCaptureState } = await importPostHogActivityStateModule() + + // when + getPostHogActivityCaptureState(new Date("2026-04-11T10:15:00.000Z")) + + // then + const persistedState = JSON.parse( + readFileSync(join(cachePath, "posthog-activity.json"), "utf-8"), + ) + expect(persistedState).toEqual({ + lastActiveDayUTC: "2026-04-11", + lastPluginLoadedDayUTC: "2026-04-11", + }) + + rmSync(dataHomePath, { recursive: true, force: true }) + }) +}) + +describe("getPluginLoadedCaptureState", () => { + it("returns capturePluginLoaded=true when activity file does not exist", async () => { + // given + const dataHomePath = createDataHomePath() + process.env.XDG_DATA_HOME = dataHomePath + const { getPluginLoadedCaptureState } = await importPostHogActivityStateModule() + + // when + const result = getPluginLoadedCaptureState(new Date("2026-04-11T10:15:00.000Z")) + + // then + expect(result).toEqual({ + dayUTC: "2026-04-11", + capturePluginLoaded: true, + }) + + rmSync(dataHomePath, { recursive: true, force: true }) + }) + + it("returns capturePluginLoaded=false when lastPluginLoadedDayUTC matches today", async () => { + // given + const dataHomePath = createDataHomePath() + const cachePath = join(dataHomePath, "oh-my-opencode") + mkdirSync(cachePath, { recursive: true }) + writeFileSync( + join(cachePath, "posthog-activity.json"), + `${JSON.stringify({ + lastPluginLoadedDayUTC: "2026-04-11", + })}\n`, + ) + process.env.XDG_DATA_HOME = dataHomePath + const { getPluginLoadedCaptureState } = await importPostHogActivityStateModule() + + // when + const result = getPluginLoadedCaptureState(new Date("2026-04-11T10:15:00.000Z")) + + // then + expect(result).toEqual({ + dayUTC: "2026-04-11", + capturePluginLoaded: false, + }) + + rmSync(dataHomePath, { recursive: true, force: true }) + }) + + it("returns capturePluginLoaded=true when lastPluginLoadedDayUTC is from a previous day", async () => { + // given + const dataHomePath = createDataHomePath() + const cachePath = join(dataHomePath, "oh-my-opencode") + mkdirSync(cachePath, { recursive: true }) + writeFileSync( + join(cachePath, "posthog-activity.json"), + `${JSON.stringify({ + lastPluginLoadedDayUTC: "2026-04-10", + })}\n`, + ) + process.env.XDG_DATA_HOME = dataHomePath + const { getPluginLoadedCaptureState } = await importPostHogActivityStateModule() + + // when + const result = getPluginLoadedCaptureState(new Date("2026-04-11T10:15:00.000Z")) + + // then + expect(result).toEqual({ + dayUTC: "2026-04-11", + capturePluginLoaded: true, + }) + + rmSync(dataHomePath, { recursive: true, force: true }) + }) + + it("preserves lastActiveDayUTC when writing lastPluginLoadedDayUTC", async () => { + // given + const dataHomePath = createDataHomePath() + const cachePath = join(dataHomePath, "oh-my-opencode") + mkdirSync(cachePath, { recursive: true }) + writeFileSync( + join(cachePath, "posthog-activity.json"), + `${JSON.stringify({ + lastActiveDayUTC: "2026-04-11", + lastPluginLoadedDayUTC: "2026-04-10", + })}\n`, + ) + process.env.XDG_DATA_HOME = dataHomePath + const { getPluginLoadedCaptureState } = await importPostHogActivityStateModule() + + // when + getPluginLoadedCaptureState(new Date("2026-04-11T10:15:00.000Z")) + + // then + const persistedState = JSON.parse( + readFileSync(join(cachePath, "posthog-activity.json"), "utf-8"), + ) + expect(persistedState).toEqual({ + lastActiveDayUTC: "2026-04-11", + lastPluginLoadedDayUTC: "2026-04-11", + }) + + rmSync(dataHomePath, { recursive: true, force: true }) + }) + + it("does not rewrite state when lastPluginLoadedDayUTC matches today", async () => { + // given + const dataHomePath = createDataHomePath() + const cachePath = join(dataHomePath, "oh-my-opencode") + mkdirSync(cachePath, { recursive: true }) + const initialPayload = `${JSON.stringify({ + lastActiveDayUTC: "2026-04-10", + lastPluginLoadedDayUTC: "2026-04-11", + })}\n` + writeFileSync(join(cachePath, "posthog-activity.json"), initialPayload) + process.env.XDG_DATA_HOME = dataHomePath + const { getPluginLoadedCaptureState } = await importPostHogActivityStateModule() + + // when + getPluginLoadedCaptureState(new Date("2026-04-11T10:15:00.000Z")) + + // then + const persistedPayload = readFileSync(join(cachePath, "posthog-activity.json"), "utf-8") + expect(persistedPayload).toBe(initialPayload) + + rmSync(dataHomePath, { recursive: true, force: true }) + }) }) diff --git a/src/shared/posthog-activity-state.ts b/src/shared/posthog-activity-state.ts index 352949101..e8f896679 100644 --- a/src/shared/posthog-activity-state.ts +++ b/src/shared/posthog-activity-state.ts @@ -8,6 +8,7 @@ import { writeFileAtomically } from "./write-file-atomically" type PostHogActivityState = { lastActiveDayUTC?: string + lastPluginLoadedDayUTC?: string } type PostHogActivityCaptureState = { @@ -15,6 +16,11 @@ type PostHogActivityCaptureState = { captureDaily: boolean } +type PluginLoadedCaptureState = { + dayUTC: string + capturePluginLoaded: boolean +} + const POSTHOG_ACTIVITY_STATE_FILE = "posthog-activity.json" function getPostHogActivityStateFilePath(): string { @@ -73,7 +79,8 @@ export function getPostHogActivityCaptureState(now: Date = new Date()): PostHogA if (captureDaily) { writePostHogActivityState({ - lastActiveDayUTC: captureDaily ? dayUTC : state.lastActiveDayUTC, + ...state, + lastActiveDayUTC: dayUTC, }) } @@ -82,3 +89,22 @@ export function getPostHogActivityCaptureState(now: Date = new Date()): PostHogA captureDaily, } } + +export function getPluginLoadedCaptureState(now: Date = new Date()): PluginLoadedCaptureState { + const state = readPostHogActivityState() + const dayUTC = getUtcDayString(now) + + const capturePluginLoaded = state.lastPluginLoadedDayUTC !== dayUTC + + if (capturePluginLoaded) { + writePostHogActivityState({ + ...state, + lastPluginLoadedDayUTC: dayUTC, + }) + } + + return { + dayUTC, + capturePluginLoaded, + } +}