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 c2f278add..9260c006c 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 { @@ -55,3 +86,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 6e96853d0..eb59d5eae 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" @@ -117,7 +136,7 @@ function createPostHogClient( }) }, trackActive: (distinctId, reason) => { - const activityState = getPostHogActivityCaptureState() + const activityState = resolveActivityState() if (activityState.captureDaily) { configuredClient.capture({ @@ -130,18 +149,6 @@ function createPostHogClient( }, }) } - - if (activityState.captureHourly) { - configuredClient.capture({ - distinctId, - event: "omo_hourly_active", - properties: { - ...sharedProperties, - hour_utc: activityState.hourUTC, - reason, - }, - }) - } }, shutdown: async () => configuredClient.shutdown(), }