diff --git a/bun.lock b/bun.lock index b4e9f12f5..32e143f44 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "picomatch": "^4.0.2", + "posthog-node": "^5.29.2", "vscode-jsonrpc": "^8.2.0", "zod": "^4.3.0", }, @@ -99,6 +100,8 @@ "@opencode-ai/sdk": ["@opencode-ai/sdk@1.4.0", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-mfa3MzhqNM+Az4bgPDDXL3NdG+aYOHClXmT6/4qLxf2ulyfPpMNHqb9Dfmo4D8UfmrDsPuJHmbune73/nUQnuw=="], + "@posthog/core": ["@posthog/core@1.25.2", "", {}, "sha512-h2FO7ut/BbfwpAXWpwdDHTzQgUo9ibDFEs6ZO+3cI3KPWQt5XwczK1OLAuPprcjm8T/jl0SH8jSFo5XdU4RbTg=="], + "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], "@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="], @@ -251,6 +254,8 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "posthog-node": ["posthog-node@5.29.2", "", { "dependencies": { "@posthog/core": "1.25.2" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-rI7kkF0XqDc0G1qjx+Hb4iuY9NAlL+XQNoGOpnEpRNTUcXvjY6WlsRGZ9m2whgc39emrrYdszi/YT8wZkr2xsg=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], diff --git a/src/cli/cli-installer.telemetry.test.ts b/src/cli/cli-installer.telemetry.test.ts new file mode 100644 index 000000000..c4bdee652 --- /dev/null +++ b/src/cli/cli-installer.telemetry.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test" +import * as configManager from "./config-manager" +import type { InstallArgs } from "./types" + +describe("runCliInstaller telemetry isolation", () => { + afterEach(() => { + mock.restore() + }) + + it("does not crash CLI install when telemetry shutdown throws", async () => { + // given + const restoreSpies = [ + spyOn(configManager, "detectCurrentConfig").mockReturnValue({ + isInstalled: false, + installedVersion: null, + hasClaude: false, + isMax20: false, + hasOpenAI: false, + hasGemini: false, + hasCopilot: false, + hasOpencodeZen: false, + hasZaiCodingPlan: false, + hasKimiForCoding: false, + hasOpencodeGo: false, + }), + spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true), + spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0"), + spyOn(configManager, "addPluginToOpenCodeConfig").mockResolvedValue({ + success: true, + configPath: "/tmp/opencode.jsonc", + }), + spyOn(configManager, "writeOmoConfig").mockReturnValue({ + success: true, + configPath: "/tmp/oh-my-opencode.jsonc", + }), + ] + + mock.module("../shared/posthog", () => ({ + createCliPostHog: mock(() => ({ + trackActive: mock(() => {}), + capture: mock(() => {}), + captureException: mock(() => {}), + shutdown: mock(async () => { + throw new Error("shutdown failed") + }), + })), + getPostHogDistinctId: mock(() => "install-distinct-id"), + })) + + const { runCliInstaller } = await import(`./cli-installer?telemetry=${Date.now()}-${Math.random()}`) + const args: InstallArgs = { + tui: false, + claude: "no", + openai: "yes", + gemini: "no", + copilot: "yes", + opencodeZen: "no", + zaiCodingPlan: "no", + kimiForCoding: "no", + opencodeGo: "no", + } + + // when + const result = await runCliInstaller(args, "3.4.0") + + // then + expect(result).toBe(0) + + for (const spy of restoreSpies) { + spy.mockRestore() + } + }) +}) diff --git a/src/cli/cli-installer.test.ts b/src/cli/cli-installer.test.ts index 934d18322..38514fcf0 100644 --- a/src/cli/cli-installer.test.ts +++ b/src/cli/cli-installer.test.ts @@ -19,6 +19,7 @@ describe("runCliInstaller", () => { afterEach(() => { console.log = originalConsoleLog console.error = originalConsoleError + mock.restore() }) it("blocks installation when OpenCode is below the minimum version", async () => { diff --git a/src/cli/cli-installer.ts b/src/cli/cli-installer.ts index 865ad649b..5db015531 100644 --- a/src/cli/cli-installer.ts +++ b/src/cli/cli-installer.ts @@ -65,8 +65,16 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion) if (unsupportedVersionMessage) { printWarning(unsupportedVersionMessage) - posthog.capture({ distinctId, event: "install_failed", properties: { command: "install", reason: "unsupported_opencode_version", is_update: isUpdate } }) - await posthog.shutdown() + try { + posthog.capture({ distinctId, event: "install_failed", properties: { command: "install", reason: "unsupported_opencode_version", is_update: isUpdate } }) + } catch { + // telemetry failure is non-fatal, silently ignore + } + try { + await posthog.shutdown() + } catch { + // telemetry failure is non-fatal, silently ignore + } return 1 } } @@ -82,8 +90,16 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi const pluginResult = await addPluginToOpenCodeConfig(version) if (!pluginResult.success) { printError(`Failed: ${pluginResult.error}`) - posthog.capture({ distinctId, event: "install_failed", properties: { command: "install", reason: "plugin_config_write_failed", is_update: isUpdate } }) - await posthog.shutdown() + try { + posthog.capture({ distinctId, event: "install_failed", properties: { command: "install", reason: "plugin_config_write_failed", is_update: isUpdate } }) + } catch { + // telemetry failure is non-fatal, silently ignore + } + try { + await posthog.shutdown() + } catch { + // telemetry failure is non-fatal, silently ignore + } return 1 } printSuccess( @@ -94,8 +110,16 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi const omoResult = writeOmoConfig(config) if (!omoResult.success) { printError(`Failed: ${omoResult.error}`) - posthog.capture({ distinctId, event: "install_failed", properties: { command: "install", reason: "omo_config_write_failed", is_update: isUpdate } }) - await posthog.shutdown() + try { + posthog.capture({ distinctId, event: "install_failed", properties: { command: "install", reason: "omo_config_write_failed", is_update: isUpdate } }) + } catch { + // telemetry failure is non-fatal, silently ignore + } + try { + await posthog.shutdown() + } catch { + // telemetry failure is non-fatal, silently ignore + } return 1 } printSuccess(`Config written ${SYMBOLS.arrow} ${color.dim(omoResult.configPath)}`) @@ -144,20 +168,28 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi console.log(color.dim("oMoMoMoMo... Enjoy!")) console.log() - posthog.capture({ - distinctId, - event: "install_completed", - properties: { - command: "install", - is_update: isUpdate, - has_claude: config.hasClaude, - has_openai: config.hasOpenAI, - has_gemini: config.hasGemini, - has_copilot: config.hasCopilot, - has_opencode_zen: config.hasOpencodeZen, - }, - }) - await posthog.shutdown() + try { + posthog.capture({ + distinctId, + event: "install_completed", + properties: { + command: "install", + is_update: isUpdate, + has_claude: config.hasClaude, + has_openai: config.hasOpenAI, + has_gemini: config.hasGemini, + has_copilot: config.hasCopilot, + has_opencode_zen: config.hasOpencodeZen, + }, + }) + } catch { + // telemetry failure is non-fatal, silently ignore + } + try { + await posthog.shutdown() + } catch { + // telemetry failure is non-fatal, silently ignore + } if ((config.hasClaude || config.hasGemini || config.hasCopilot) && !args.skipAuth) { printBox( diff --git a/src/cli/run/runner.telemetry.test.ts b/src/cli/run/runner.telemetry.test.ts new file mode 100644 index 000000000..0117f74a8 --- /dev/null +++ b/src/cli/run/runner.telemetry.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, mock } from "bun:test" + +describe("run telemetry isolation", () => { + afterEach(() => { + mock.restore() + }) + + it("does not crash CLI run when telemetry throws", async () => { + // given + mock.module("../../plugin-config", () => ({ + loadPluginConfig: mock(() => ({})), + })) + mock.module("./agent-resolver", () => ({ + resolveRunAgent: mock(() => "Sisyphus - Ultraworker"), + })) + mock.module("./events", () => ({ + createEventState: mock(() => ({ + messageCount: 0, + lastPartText: "Run completed", + agentColorsByName: {}, + })), + processEvents: mock(async () => {}), + serializeError: (error: unknown) => (error instanceof Error ? error.message : String(error)), + })) + mock.module("./server-connection", () => ({ + createServerConnection: mock(async () => ({ + client: { + event: { + subscribe: mock(async () => ({ stream: {} })), + }, + session: { + promptAsync: mock(async () => undefined), + }, + }, + cleanup: mock(() => {}), + })), + })) + mock.module("./session-resolver", () => ({ + resolveSession: mock(async () => "ses_test"), + })) + mock.module("./json-output", () => ({ + createJsonOutputManager: mock(() => ({ + redirectToStderr: mock(() => {}), + restore: mock(() => {}), + emitResult: mock(() => {}), + })), + })) + mock.module("./on-complete-hook", () => ({ + executeOnCompleteHook: mock(async () => {}), + })) + mock.module("./model-resolver", () => ({ + resolveRunModel: mock(() => null), + })) + mock.module("./poll-for-completion", () => ({ + pollForCompletion: mock(async () => 0), + })) + mock.module("./agent-profile-colors", () => ({ + loadAgentProfileColors: mock(async () => ({})), + })) + mock.module("./stdin-suppression", () => ({ + suppressRunInput: mock(() => mock(() => {})), + })) + mock.module("./timestamp-output", () => ({ + createTimestampedStdoutController: mock(() => ({ + enable: mock(() => {}), + restore: mock(() => {}), + })), + })) + mock.module("../../shared/posthog", () => ({ + createCliPostHog: mock(() => ({ + trackActive: () => { + throw new Error("telemetry failed") + }, + capture: mock(() => {}), + captureException: mock(() => {}), + shutdown: mock(async () => { + throw new Error("shutdown failed") + }), + })), + getPostHogDistinctId: mock(() => "run-distinct-id"), + })) + + const { run } = await import(`./runner?telemetry=${Date.now()}-${Math.random()}`) + + // when + const result = await run({ message: "test" }) + + // then + expect(result).toBe(0) + }) +}) diff --git a/src/cli/run/runner.ts b/src/cli/run/runner.ts index 5f407d7b2..d6b52a299 100644 --- a/src/cli/run/runner.ts +++ b/src/cli/run/runner.ts @@ -53,17 +53,25 @@ export async function run(options: RunOptions): Promise { const posthog = createCliPostHog() const distinctId = getPostHogDistinctId() - posthog.trackActive(distinctId, "run_started") - posthog.capture({ - distinctId, - event: "run_started", - properties: { - command: "run", - agent: resolvedAgent, - has_model: !!options.model, - has_session_id: !!options.sessionId, - }, - }) + try { + posthog.trackActive(distinctId, "run_started") + } catch { + // telemetry failure is non-fatal, silently ignore + } + try { + posthog.capture({ + distinctId, + event: "run_started", + properties: { + command: "run", + agent: resolvedAgent, + has_model: !!options.model, + has_session_id: !!options.sessionId, + }, + }) + } catch { + // telemetry failure is non-fatal, silently ignore + } try { const resolvedModel = resolveRunModel(options.model) @@ -157,27 +165,35 @@ export async function run(options: RunOptions): Promise { } if (exitCode === 0) { - posthog.capture({ - distinctId, - event: "run_completed", - properties: { - command: "run", - agent: resolvedAgent, - duration_ms: durationMs, - message_count: eventState.messageCount, - }, - }) + try { + posthog.capture({ + distinctId, + event: "run_completed", + properties: { + command: "run", + agent: resolvedAgent, + duration_ms: durationMs, + message_count: eventState.messageCount, + }, + }) + } catch { + // telemetry failure is non-fatal, silently ignore + } } else if (exitCode === 1) { - posthog.capture({ - distinctId, - event: "run_failed", - properties: { - command: "run", - agent: resolvedAgent, - exit_code: exitCode, - duration_ms: durationMs, - }, - }) + try { + posthog.capture({ + distinctId, + event: "run_failed", + properties: { + command: "run", + agent: resolvedAgent, + exit_code: exitCode, + duration_ms: durationMs, + }, + }) + } catch { + // telemetry failure is non-fatal, silently ignore + } } return exitCode @@ -194,21 +210,33 @@ export async function run(options: RunOptions): Promise { if (err instanceof Error && err.name === "AbortError") { return 130 } - posthog.captureException(err, distinctId) - posthog.capture({ - distinctId, - event: "run_failed", - properties: { - command: "run", - agent: resolvedAgent, - error: serializeError(err), - duration_ms: Date.now() - startTime, - }, - }) + try { + posthog.captureException(err, distinctId) + } catch { + // telemetry failure is non-fatal, silently ignore + } + try { + posthog.capture({ + distinctId, + event: "run_failed", + properties: { + command: "run", + agent: resolvedAgent, + error: serializeError(err), + duration_ms: Date.now() - startTime, + }, + }) + } catch { + // telemetry failure is non-fatal, silently ignore + } console.error(pc.red(`Error: ${serializeError(err)}`)) return 1 } finally { - await posthog.shutdown() + try { + await posthog.shutdown() + } catch { + // telemetry failure is non-fatal, silently ignore + } timestampOutput?.restore() } } diff --git a/src/index.telemetry.test.ts b/src/index.telemetry.test.ts new file mode 100644 index 000000000..2d554626f --- /dev/null +++ b/src/index.telemetry.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" + +const mockInitConfigContext = mock(() => {}) +const mockInjectServerAuthIntoClient = mock(() => {}) +const mockLogLegacyPluginStartupWarning = mock(() => {}) +const mockLoadPluginConfig = mock(() => ({})) +const mockIsTmuxIntegrationEnabled = mock(() => false) +const mockCreateRuntimeTmuxConfig = mock(() => ({ + enabled: false, + layout: "tiled" as const, + main_pane_size: 60, + main_pane_min_width: 80, + agent_pane_min_width: 40, + isolation: "inline" as const, +})) +const mockCreateManagers = mock(() => ({ + backgroundManager: { shutdown: async () => {} }, + skillMcpManager: { disconnectAll: async () => {} }, + configHandler: async () => {}, +})) +const mockCreateTools = mock(async () => ({ + mergedSkills: [], + availableSkills: [], + filteredTools: {}, +})) +const mockCreateHooks = mock(() => ({ + disposeHooks: () => {}, + compactionContextInjector: undefined, + compactionTodoPreserver: undefined, + claudeCodeHooks: undefined, +})) +const mockCreatePluginDispose = mock(() => async () => {}) +const mockCreatePluginInterface = mock(() => ({})) +const mockCreatePluginPostHog = mock(() => ({ + trackActive: () => { + throw new Error("telemetry failed") + }, + capture: mock(() => {}), + captureException: mock(() => {}), + shutdown: mock(async () => {}), +})) +const mockGetPostHogDistinctId = mock(() => "plugin-distinct-id") + +function installModuleMocks(): void { + mock.module("./cli/config-manager/config-context", () => ({ + initConfigContext: mockInitConfigContext, + })) + mock.module("./shared/external-plugin-detector", () => ({ + detectExternalSkillPlugin: mock(() => ({ detected: false, pluginName: null })), + getSkillPluginConflictWarning: mock(() => ""), + })) + mock.module("./shared", () => ({ + injectServerAuthIntoClient: mockInjectServerAuthIntoClient, + log: mock(() => {}), + logLegacyPluginStartupWarning: mockLogLegacyPluginStartupWarning, + })) + mock.module("./plugin-config", () => ({ + loadPluginConfig: mockLoadPluginConfig, + })) + mock.module("./create-runtime-tmux-config", () => ({ + createRuntimeTmuxConfig: mockCreateRuntimeTmuxConfig, + isTmuxIntegrationEnabled: mockIsTmuxIntegrationEnabled, + })) + mock.module("./create-managers", () => ({ + createManagers: mockCreateManagers, + })) + mock.module("./create-tools", () => ({ + createTools: mockCreateTools, + })) + mock.module("./create-hooks", () => ({ + createHooks: mockCreateHooks, + })) + mock.module("./plugin-dispose", () => ({ + createPluginDispose: mockCreatePluginDispose, + })) + mock.module("./plugin-interface", () => ({ + createPluginInterface: mockCreatePluginInterface, + })) + mock.module("./plugin-state", () => ({ + createModelCacheState: mock(() => ({})), + })) + mock.module("./shared/first-message-variant", () => ({ + createFirstMessageVariantGate: mock(() => ({ + shouldOverride: () => false, + markApplied: () => {}, + markSessionCreated: () => {}, + clear: () => {}, + })), + })) + mock.module("./openclaw", () => ({ + initializeOpenClaw: mock(async () => {}), + })) + mock.module("./tools/interactive-bash", () => ({ + interactive_bash: {}, + startBackgroundCheck: mock(() => {}), + })) + mock.module("./tools/lsp/client", () => ({ + lspManager: {}, + })) + mock.module("./shared/posthog", () => ({ + createPluginPostHog: mockCreatePluginPostHog, + getPostHogDistinctId: mockGetPostHogDistinctId, + })) +} + +describe("OhMyOpenCodePlugin telemetry isolation", () => { + beforeEach(() => { + mock.restore() + installModuleMocks() + }) + + afterEach(() => { + mock.restore() + }) + + it("does not crash plugin load when telemetry throws", async () => { + // given + const { default: plugin } = await import(`./index?telemetry=${Date.now()}-${Math.random()}`) + + // when + const result = await plugin({ + directory: "/tmp/project", + client: {}, + } as Parameters[0]) + + // then + expect(result).toMatchObject({ name: "oh-my-openagent" }) + }) +}) diff --git a/src/index.ts b/src/index.ts index 245846298..b36e6c4b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,16 +41,24 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const posthog = createPluginPostHog() const distinctId = getPostHogDistinctId() - posthog.trackActive(distinctId, "plugin_loaded") - posthog.capture({ - distinctId, - event: "plugin_loaded", - properties: { - entry_point: "plugin", - has_openclaw: !!pluginConfig.openclaw, - tmux_enabled: isTmuxIntegrationEnabled(pluginConfig), - }, - }) + try { + posthog.trackActive(distinctId, "plugin_loaded") + } catch { + // telemetry failure is non-fatal, silently ignore + } + 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 new file mode 100644 index 000000000..f2c103c21 --- /dev/null +++ b/src/shared/posthog-activity-state.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it } from "bun:test" +import { mkdirSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" + +const originalXdgDataHome = process.env.XDG_DATA_HOME + +function createDataHomePath(): string { + return join(tmpdir(), `posthog-activity-state-${Date.now()}-${Math.random()}`) +} + +async function importPostHogActivityStateModule(): Promise { + return import(`./posthog-activity-state?test=${Date.now()}-${Math.random()}`) +} + +afterEach(() => { + if (originalXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME + } else { + process.env.XDG_DATA_HOME = originalXdgDataHome + } +}) + +describe("getPostHogActivityCaptureState", () => { + it("returns default state when activity file contains null", async () => { + // given + const dataHomePath = createDataHomePath() + const cachePath = join(dataHomePath, "oh-my-opencode") + mkdirSync(cachePath, { recursive: true }) + writeFileSync(join(cachePath, "posthog-activity.json"), "null\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", + hourUTC: "2026-04-11T10", + captureDaily: true, + captureHourly: true, + }) + + rmSync(dataHomePath, { recursive: true, force: true }) + }) + + it("returns default state when activity file contains an array", async () => { + // given + const dataHomePath = createDataHomePath() + const cachePath = join(dataHomePath, "oh-my-opencode") + mkdirSync(cachePath, { recursive: true }) + writeFileSync(join(cachePath, "posthog-activity.json"), "[]\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", + hourUTC: "2026-04-11T10", + captureDaily: true, + captureHourly: true, + }) + + rmSync(dataHomePath, { recursive: true, force: true }) + }) + + it("returns default state when activity file contains a number", async () => { + // given + const dataHomePath = createDataHomePath() + const cachePath = join(dataHomePath, "oh-my-opencode") + mkdirSync(cachePath, { recursive: true }) + writeFileSync(join(cachePath, "posthog-activity.json"), "42\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", + hourUTC: "2026-04-11T10", + captureDaily: true, + captureHourly: true, + }) + + rmSync(dataHomePath, { recursive: true, force: true }) + }) + + it("reads valid activity state JSON", 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", + 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", + hourUTC: "2026-04-11T10", + captureDaily: false, + captureHourly: false, + }) + + rmSync(dataHomePath, { recursive: true, force: true }) + }) +}) diff --git a/src/shared/posthog-activity-state.ts b/src/shared/posthog-activity-state.ts index cc3bd2a4e..6a44e6af2 100644 --- a/src/shared/posthog-activity-state.ts +++ b/src/shared/posthog-activity-state.ts @@ -32,6 +32,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) +} + function readPostHogActivityState(): PostHogActivityState { const stateFilePath = getPostHogActivityStateFilePath() if (!existsSync(stateFilePath)) { @@ -40,7 +44,10 @@ function readPostHogActivityState(): PostHogActivityState { try { const content = readFileSync(stateFilePath, "utf-8") - const parsed = JSON.parse(content) as PostHogActivityState + const parsed: unknown = JSON.parse(content) + if (!isPostHogActivityState(parsed)) { + return {} + } return parsed } catch (error) { log("[posthog-activity-state] Failed to read activity state", {