From 58e4b8f519b098883e9e51a134738ad5a6315558 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 26 Apr 2026 15:05:32 +0900 Subject: [PATCH 1/2] fix(telemetry): remove PostHog HAU tracking, keep DAU only --- docs/legal/privacy-policy.md | 4 +- src/shared/posthog-activity-state.test.ts | 34 +++++-- src/shared/posthog-activity-state.ts | 14 +-- src/shared/posthog.test.ts | 117 ++++++++++++++++++++-- src/shared/posthog.ts | 33 +++--- 5 files changed, 158 insertions(+), 44 deletions(-) diff --git a/docs/legal/privacy-policy.md b/docs/legal/privacy-policy.md index 295d268ef..b0167e85f 100644 --- a/docs/legal/privacy-policy.md +++ b/docs/legal/privacy-policy.md @@ -16,7 +16,7 @@ We collect limited non-personal information needed to operate and improve the Se When anonymous telemetry is enabled, the Application may collect: -- Anonymous usage events, including `run_started`, `run_completed`, `run_failed`, `install_completed`, `install_failed`, `plugin_loaded`, `omo_daily_active`, and `omo_hourly_active` +- Anonymous usage events, including `run_started`, `run_completed`, `run_failed`, `install_completed`, `install_failed`, `plugin_loaded`, and `omo_daily_active` - Application metadata such as package version, plugin name, runtime, and command or entry-point context - Error diagnostics captured during failed CLI runs - A pseudonymous installation identifier derived from a one-way hash of the local hostname @@ -25,7 +25,7 @@ We do not intentionally collect prompt contents, source files, repository conten ### Configuration and local state -The Application stores local configuration and telemetry deduplication state on your machine to support installation, configuration, and anonymous daily or hourly active tracking. +The Application stores local configuration and telemetry deduplication state on your machine to support installation, configuration, and anonymous daily active tracking. ## 2. How Telemetry Works diff --git a/src/shared/posthog-activity-state.test.ts b/src/shared/posthog-activity-state.test.ts index f2c103c21..c5ee8afd1 100644 --- a/src/shared/posthog-activity-state.test.ts +++ b/src/shared/posthog-activity-state.test.ts @@ -37,9 +37,7 @@ describe("getPostHogActivityCaptureState", () => { // then expect(result).toEqual({ dayUTC: "2026-04-11", - hourUTC: "2026-04-11T10", captureDaily: true, - captureHourly: true, }) rmSync(dataHomePath, { recursive: true, force: true }) @@ -60,9 +58,7 @@ describe("getPostHogActivityCaptureState", () => { // then expect(result).toEqual({ dayUTC: "2026-04-11", - hourUTC: "2026-04-11T10", captureDaily: true, - captureHourly: true, }) rmSync(dataHomePath, { recursive: true, force: true }) @@ -83,9 +79,7 @@ describe("getPostHogActivityCaptureState", () => { // then expect(result).toEqual({ dayUTC: "2026-04-11", - hourUTC: "2026-04-11T10", captureDaily: true, - captureHourly: true, }) rmSync(dataHomePath, { recursive: true, force: true }) @@ -112,9 +106,33 @@ describe("getPostHogActivityCaptureState", () => { // then expect(result).toEqual({ dayUTC: "2026-04-11", - hourUTC: "2026-04-11T10", captureDaily: false, - captureHourly: false, + }) + + rmSync(dataHomePath, { recursive: true, force: true }) + }) + + it("reads legacy hourly state without crashing", async () => { + // given + const dataHomePath = createDataHomePath() + const cachePath = join(dataHomePath, "oh-my-opencode") + mkdirSync(cachePath, { recursive: true }) + writeFileSync( + join(cachePath, "posthog-activity.json"), + `${JSON.stringify({ + lastActiveHourUTC: "2026-04-11T10", + })}\n`, + ) + process.env.XDG_DATA_HOME = dataHomePath + const { getPostHogActivityCaptureState } = await importPostHogActivityStateModule() + + // when + const result = getPostHogActivityCaptureState(new Date("2026-04-11T10:15:00.000Z")) + + // then + expect(result).toEqual({ + dayUTC: "2026-04-11", + captureDaily: true, }) rmSync(dataHomePath, { recursive: true, force: true }) diff --git a/src/shared/posthog-activity-state.ts b/src/shared/posthog-activity-state.ts index 6a44e6af2..352949101 100644 --- a/src/shared/posthog-activity-state.ts +++ b/src/shared/posthog-activity-state.ts @@ -8,14 +8,11 @@ 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" @@ -28,10 +25,6 @@ function getUtcDayString(date: Date): string { return date.toISOString().slice(0, 10) } -function getUtcHourString(date: Date): string { - return date.toISOString().slice(0, 13) -} - function isPostHogActivityState(value: unknown): value is PostHogActivityState { return value !== null && typeof value === "object" && !Array.isArray(value) } @@ -75,22 +68,17 @@ function writePostHogActivityState(nextState: PostHogActivityState): void { 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) { + if (captureDaily) { writePostHogActivityState({ lastActiveDayUTC: captureDaily ? dayUTC : state.lastActiveDayUTC, - lastActiveHourUTC: captureHourly ? hourUTC : state.lastActiveHourUTC, }) } return { dayUTC, - hourUTC, captureDaily, - captureHourly, } } diff --git a/src/shared/posthog.test.ts b/src/shared/posthog.test.ts index 824774343..8e65200e8 100644 --- a/src/shared/posthog.test.ts +++ b/src/shared/posthog.test.ts @@ -1,23 +1,54 @@ -import { afterEach, describe, expect, it, mock } from "bun:test" +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" + +type CapturedPostHogMessage = { + distinctId: string + event: string + properties?: Record +} async function importPostHogModule(): Promise { return import(`./posthog?test=${Date.now()}-${Math.random()}`) } +function enableTelemetryEnv(): void { + process.env.OMO_DISABLE_POSTHOG = "0" + process.env.OMO_SEND_ANONYMOUS_TELEMETRY = "1" + process.env.POSTHOG_API_KEY = "test-api-key" +} + +function clearTelemetryEnv(): void { + delete process.env.OMO_DISABLE_POSTHOG + delete process.env.OMO_SEND_ANONYMOUS_TELEMETRY + delete process.env.POSTHOG_API_KEY + delete process.env.POSTHOG_HOST +} + +function mockPostHogNode(capturedMessages: CapturedPostHogMessage[]): void { + mock.module("posthog-node", () => ({ + PostHog: class { + capture(message: CapturedPostHogMessage): void { + capturedMessages.push(message) + } + captureException(): void {} + async shutdown(): Promise {} + }, + })) +} + describe("posthog client creation", () => { + beforeEach(() => { + mock.restore() + clearTelemetryEnv() + }) + afterEach(() => { mock.restore() - delete process.env.OMO_DISABLE_POSTHOG - delete process.env.OMO_SEND_ANONYMOUS_TELEMETRY - delete process.env.POSTHOG_API_KEY - delete process.env.POSTHOG_HOST + clearTelemetryEnv() }) it("returns a no-op client when PostHog construction throws", async () => { // given - process.env.OMO_DISABLE_POSTHOG = "0" - process.env.OMO_SEND_ANONYMOUS_TELEMETRY = "1" - process.env.POSTHOG_API_KEY = "test-api-key" + enableTelemetryEnv() mock.module("posthog-node", () => ({ PostHog: class { @@ -100,3 +131,73 @@ describe("posthog client creation", () => { await expect(pluginPostHog.shutdown()).resolves.toBeUndefined() }) }) + +describe("posthog trackActive emission contract", () => { + let resetActivityStateProvider: (() => void) | null = null + + beforeEach(() => { + mock.restore() + clearTelemetryEnv() + }) + + afterEach(() => { + resetActivityStateProvider?.() + resetActivityStateProvider = null + mock.restore() + clearTelemetryEnv() + }) + + it("emits exactly one omo_daily_active and never omo_hourly_active when captureDaily is true", async () => { + // given + enableTelemetryEnv() + const captured: CapturedPostHogMessage[] = [] + mockPostHogNode(captured) + const posthogModule = await importPostHogModule() + posthogModule.__setActivityStateProviderForTesting(() => ({ + dayUTC: "2026-04-18", + captureDaily: true, + })) + resetActivityStateProvider = posthogModule.__resetActivityStateProviderForTesting + const client = posthogModule.createCliPostHog() + + // when + client.trackActive("distinct-cli", "run_started") + + // then + expect(captured).toHaveLength(1) + const emittedEvents = captured.map((message) => message.event) + expect(emittedEvents).not.toContain("omo_hourly_active") + const [dailyEvent] = captured + expect(dailyEvent?.event).toBe("omo_daily_active") + expect(dailyEvent?.distinctId).toBe("distinct-cli") + expect(dailyEvent?.properties).toMatchObject({ + day_utc: "2026-04-18", + reason: "run_started", + source: "cli", + }) + expect(dailyEvent?.properties).not.toHaveProperty("hour_utc") + }) + + it("emits nothing and never omo_hourly_active when captureDaily is false", async () => { + // given + enableTelemetryEnv() + const captured: CapturedPostHogMessage[] = [] + mockPostHogNode(captured) + const posthogModule = await importPostHogModule() + posthogModule.__setActivityStateProviderForTesting(() => ({ + dayUTC: "2026-04-18", + captureDaily: false, + })) + resetActivityStateProvider = posthogModule.__resetActivityStateProviderForTesting + const client = posthogModule.createPluginPostHog() + + // when + client.trackActive("distinct-plugin", "plugin_loaded") + + // then + expect(captured).toHaveLength(0) + const emittedEvents = captured.map((message) => message.event) + expect(emittedEvents).not.toContain("omo_daily_active") + expect(emittedEvents).not.toContain("omo_hourly_active") + }) +}) diff --git a/src/shared/posthog.ts b/src/shared/posthog.ts index 1e0eea6ae..52a275fd7 100644 --- a/src/shared/posthog.ts +++ b/src/shared/posthog.ts @@ -5,6 +5,25 @@ import packageJson from "../../package.json" with { type: "json" } import { PLUGIN_NAME, PUBLISHED_PACKAGE_NAME } from "./plugin-identity" import { getPostHogActivityCaptureState } from "./posthog-activity-state" +/** @internal test-only seam: keep null in production to use the real implementation. */ +let activityStateProviderOverride: typeof getPostHogActivityCaptureState | null = null + +function resolveActivityState(): ReturnType { + return (activityStateProviderOverride ?? getPostHogActivityCaptureState)() +} + +/** @internal test-only */ +export function __setActivityStateProviderForTesting( + provider: typeof getPostHogActivityCaptureState, +): void { + activityStateProviderOverride = provider +} + +/** @internal test-only */ +export function __resetActivityStateProviderForTesting(): void { + activityStateProviderOverride = null +} + const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com" const DEFAULT_POSTHOG_API_KEY = "phc_CFJhj5HyvA62QPhvyaUCtaq23aUfznnijg5VaaGkNk74" @@ -128,7 +147,7 @@ function createPostHogClient( }) }, trackActive: (distinctId, reason) => { - const activityState = getPostHogActivityCaptureState() + const activityState = resolveActivityState() if (activityState.captureDaily) { configuredClient.capture({ @@ -141,18 +160,6 @@ function createPostHogClient( }, }) } - - if (activityState.captureHourly) { - configuredClient.capture({ - distinctId, - event: "omo_hourly_active", - properties: { - ...sharedProperties, - hour_utc: activityState.hourUTC, - reason, - }, - }) - } }, shutdown: async () => configuredClient.shutdown(), } From 669e0667be78e90d68eb434634b77b18e04b3d4c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 28 Apr 2026 15:01:40 +0900 Subject: [PATCH 2/2] fix(telemetry): dedupe plugin_loaded event to once per UTC day The plugin_loaded event was emitted on every plugin reload, generating high event volume on PostHog (proportional to opencode restarts per user per day). With MAU > 60K and active power users restarting frequently, this drove unnecessary event spend. Add a separate daily dedup state field (lastPluginLoadedDayUTC) so the plugin_loaded capture only fires once per UTC day per machine. The existing daily activity dedup (lastActiveDayUTC, used by omo_daily_active) is preserved as an independent gate so the two dimensions cannot overwrite each other in the activity state file. --- src/index.telemetry.test.ts | 6 + src/index.ts | 27 ++-- src/shared/posthog-activity-state.test.ts | 157 +++++++++++++++++++++- src/shared/posthog-activity-state.ts | 28 +++- 4 files changed, 207 insertions(+), 11 deletions(-) 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, + } +}