From 785d6e2dee024951cdd1900c5c2400273a1f00db Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 10 Apr 2026 15:53:04 +0900 Subject: [PATCH] feat(cli): add PostHog tracking and improve runner hooks Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/cli/cli-installer.ts | 23 ++ src/cli/run/on-complete-hook.test.ts | 405 ++++++++++++++------------- src/cli/run/on-complete-hook.ts | 3 +- src/cli/run/runner.ts | 46 +++ 4 files changed, 287 insertions(+), 190 deletions(-) diff --git a/src/cli/cli-installer.ts b/src/cli/cli-installer.ts index e7cea03c1..4d7afdd85 100644 --- a/src/cli/cli-installer.ts +++ b/src/cli/cli-installer.ts @@ -23,8 +23,11 @@ import { validateNonTuiArgs, } from "./install-validators" import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version" +import { createCliPostHog, getPostHogDistinctId } from "../shared/posthog" export async function runCliInstaller(args: InstallArgs, version: string): Promise { + const posthog = createCliPostHog() + const distinctId = getPostHogDistinctId() const validation = validateNonTuiArgs(args) if (!validation.valid) { printHeader(false) @@ -62,6 +65,8 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion) if (unsupportedVersionMessage) { printWarning(unsupportedVersionMessage) + posthog.capture({ distinctId, event: "install_failed", properties: { reason: "unsupported_opencode_version", is_update: isUpdate } }) + await posthog.shutdown() return 1 } } @@ -77,6 +82,8 @@ 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: { reason: "plugin_config_write_failed", is_update: isUpdate } }) + await posthog.shutdown() return 1 } printSuccess( @@ -87,6 +94,8 @@ 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: { reason: "omo_config_write_failed", is_update: isUpdate } }) + await posthog.shutdown() return 1 } printSuccess(`Config written ${SYMBOLS.arrow} ${color.dim(omoResult.configPath)}`) @@ -129,6 +138,20 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi console.log(color.dim("oMoMoMoMo... Enjoy!")) console.log() + posthog.capture({ + distinctId, + event: "install_completed", + properties: { + 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() + if ((config.hasClaude || config.hasGemini || config.hasCopilot) && !args.skipAuth) { printBox( `Run ${color.cyan("opencode auth login")} and select your provider:\n` + diff --git a/src/cli/run/on-complete-hook.test.ts b/src/cli/run/on-complete-hook.test.ts index a81e8f20f..2834d8487 100644 --- a/src/cli/run/on-complete-hook.test.ts +++ b/src/cli/run/on-complete-hook.test.ts @@ -1,7 +1,8 @@ -import { describe, it, expect, spyOn, beforeEach, afterEach } from "bun:test" +import { describe, it, expect, spyOn, beforeEach, afterEach, mock } from "bun:test" import * as spawnWithWindowsHideModule from "../../shared/spawn-with-windows-hide" import * as loggerModule from "../../shared/logger" -import { executeOnCompleteHook } from "./on-complete-hook" + +type OnCompleteHookModule = typeof import("./on-complete-hook") describe("executeOnCompleteHook", () => { let originalPlatform: NodeJS.Platform @@ -31,16 +32,27 @@ describe("executeOnCompleteHook", () => { } satisfies ReturnType } - let logSpy: ReturnType> + let logCalls: Array> + + async function importFreshExecuteOnCompleteHook(): Promise< + OnCompleteHookModule["executeOnCompleteHook"] + > { + const onCompleteHookModule = await import(`./on-complete-hook?test=${Date.now()}-${Math.random()}`) + return onCompleteHookModule.executeOnCompleteHook + } beforeEach(() => { + mock.restore() originalPlatform = process.platform originalEnv = { SHELL: process.env.SHELL, PSModulePath: process.env.PSModulePath, ComSpec: process.env.ComSpec, } - logSpy = spyOn(loggerModule, "log").mockImplementation(() => {}) + logCalls = [] + spyOn(loggerModule, "log").mockImplementation((message: string, data?: unknown) => { + logCalls.push([message, data]) + }) }) afterEach(() => { @@ -52,7 +64,7 @@ describe("executeOnCompleteHook", () => { delete process.env[key] } } - logSpy.mockRestore() + mock.restore() }) it("uses sh on unix shells and passes correct env vars", async () => { @@ -60,32 +72,36 @@ describe("executeOnCompleteHook", () => { Object.defineProperty(process, "platform", { value: "linux" }) process.env.SHELL = "/bin/bash" delete process.env.PSModulePath - const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0)) + const spawnCalls: Array> = [] + spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockImplementation(( + command, + options, + ) => { + spawnCalls.push([command, options]) + return createProc(0) + }) + const executeOnCompleteHook = await importFreshExecuteOnCompleteHook() - try { - // when - await executeOnCompleteHook({ - command: "echo test", - sessionId: "session-123", - exitCode: 0, - durationMs: 5000, - messageCount: 10, - }) + // when + await executeOnCompleteHook({ + command: "echo test", + sessionId: "session-123", + exitCode: 0, + durationMs: 5000, + messageCount: 10, + }) - // then - expect(spawnSpy).toHaveBeenCalledTimes(1) - const [args, options] = spawnSpy.mock.calls[0] as Parameters + // then + expect(spawnCalls).toHaveLength(1) + const [args, options] = spawnCalls[0] - expect(args).toEqual(["sh", "-c", "echo test"]) - expect(options?.env?.SESSION_ID).toBe("session-123") - expect(options?.env?.EXIT_CODE).toBe("0") - expect(options?.env?.DURATION_MS).toBe("5000") - expect(options?.env?.MESSAGE_COUNT).toBe("10") - expect(options?.stdout).toBe("pipe") - expect(options?.stderr).toBe("pipe") - } finally { - spawnSpy.mockRestore() - } + expect(args).toEqual(["sh", "-c", "echo test"]) + expect(options?.env?.SESSION_ID).toBe("session-123") + expect(options?.env?.EXIT_CODE).toBe("0") + expect(options?.env?.DURATION_MS).toBe("5000") + expect(options?.env?.MESSAGE_COUNT).toBe("10") + expect(options?.stdout).toBe("pipe") + expect(options?.stderr).toBe("pipe") }) it("uses powershell when PowerShell is detected on Windows", async () => { @@ -93,24 +109,28 @@ describe("executeOnCompleteHook", () => { Object.defineProperty(process, "platform", { value: "win32" }) process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules" delete process.env.SHELL - const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0)) + const spawnCalls: Array> = [] + spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockImplementation(( + command, + options, + ) => { + spawnCalls.push([command, options]) + return createProc(0) + }) + const executeOnCompleteHook = await importFreshExecuteOnCompleteHook() - try { - // when - await executeOnCompleteHook({ - command: "Write-Host done", - sessionId: "session-123", - exitCode: 0, - durationMs: 5000, - messageCount: 10, - }) + // when + await executeOnCompleteHook({ + command: "Write-Host done", + sessionId: "session-123", + exitCode: 0, + durationMs: 5000, + messageCount: 10, + }) - // then - const [args] = spawnSpy.mock.calls[0] as Parameters - expect(args).toEqual(["powershell.exe", "-NoProfile", "-Command", "Write-Host done"]) - } finally { - spawnSpy.mockRestore() - } + // then + const [args] = spawnCalls[0] + expect(args).toEqual(["powershell.exe", "-NoProfile", "-Command", "Write-Host done"]) }) it("uses pwsh when PowerShell is detected on non-Windows platforms", async () => { @@ -118,24 +138,28 @@ describe("executeOnCompleteHook", () => { Object.defineProperty(process, "platform", { value: "linux" }) process.env.PSModulePath = "/usr/local/share/powershell/Modules" delete process.env.SHELL - const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0)) + const spawnCalls: Array> = [] + spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockImplementation(( + command, + options, + ) => { + spawnCalls.push([command, options]) + return createProc(0) + }) + const executeOnCompleteHook = await importFreshExecuteOnCompleteHook() - try { - // when - await executeOnCompleteHook({ - command: "Write-Host done", - sessionId: "session-123", - exitCode: 0, - durationMs: 5000, - messageCount: 10, - }) + // when + await executeOnCompleteHook({ + command: "Write-Host done", + sessionId: "session-123", + exitCode: 0, + durationMs: 5000, + messageCount: 10, + }) - // then - const [args] = spawnSpy.mock.calls[0] as Parameters - expect(args).toEqual(["pwsh", "-NoProfile", "-Command", "Write-Host done"]) - } finally { - spawnSpy.mockRestore() - } + // then + const [args] = spawnCalls[0] + expect(args).toEqual(["pwsh", "-NoProfile", "-Command", "Write-Host done"]) }) it("falls back to cmd.exe on Windows when PowerShell is not detected", async () => { @@ -144,179 +168,182 @@ describe("executeOnCompleteHook", () => { delete process.env.PSModulePath delete process.env.SHELL process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe" - const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0)) + const spawnCalls: Array> = [] + spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockImplementation(( + command, + options, + ) => { + spawnCalls.push([command, options]) + return createProc(0) + }) + const executeOnCompleteHook = await importFreshExecuteOnCompleteHook() - try { - // when - await executeOnCompleteHook({ - command: "echo done", - sessionId: "session-123", - exitCode: 0, - durationMs: 5000, - messageCount: 10, - }) + // when + await executeOnCompleteHook({ + command: "echo done", + sessionId: "session-123", + exitCode: 0, + durationMs: 5000, + messageCount: 10, + }) - // then - const [args] = spawnSpy.mock.calls[0] as Parameters - expect(args).toEqual(["C:\\Windows\\System32\\cmd.exe", "/d", "/s", "/c", "echo done"]) - } finally { - spawnSpy.mockRestore() - } + // then + const [args] = spawnCalls[0] + expect(args).toEqual(["C:\\Windows\\System32\\cmd.exe", "/d", "/s", "/c", "echo done"]) }) it("env var values are strings", async () => { // given - const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0)) + const spawnCalls: Array> = [] + spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockImplementation(( + command, + options, + ) => { + spawnCalls.push([command, options]) + return createProc(0) + }) + const executeOnCompleteHook = await importFreshExecuteOnCompleteHook() - try { - // when - await executeOnCompleteHook({ - command: "echo test", - sessionId: "session-123", - exitCode: 1, - durationMs: 12345, - messageCount: 42, - }) + // when + await executeOnCompleteHook({ + command: "echo test", + sessionId: "session-123", + exitCode: 1, + durationMs: 12345, + messageCount: 42, + }) - // then - const [_, options] = spawnSpy.mock.calls[0] as Parameters + // then + const [, options] = spawnCalls[0] - expect(options?.env?.EXIT_CODE).toBe("1") - expect(options?.env?.EXIT_CODE).toBeTypeOf("string") - expect(options?.env?.DURATION_MS).toBe("12345") - expect(options?.env?.DURATION_MS).toBeTypeOf("string") - expect(options?.env?.MESSAGE_COUNT).toBe("42") - expect(options?.env?.MESSAGE_COUNT).toBeTypeOf("string") - } finally { - spawnSpy.mockRestore() - } + expect(options?.env?.EXIT_CODE).toBe("1") + expect(options?.env?.EXIT_CODE).toBeTypeOf("string") + expect(options?.env?.DURATION_MS).toBe("12345") + expect(options?.env?.DURATION_MS).toBeTypeOf("string") + expect(options?.env?.MESSAGE_COUNT).toBe("42") + expect(options?.env?.MESSAGE_COUNT).toBeTypeOf("string") }) it("empty command string is no-op", async () => { // given - const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0)) + const spawnCalls: Array> = [] + spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockImplementation(( + command, + options, + ) => { + spawnCalls.push([command, options]) + return createProc(0) + }) + const executeOnCompleteHook = await importFreshExecuteOnCompleteHook() - try { - // when - await executeOnCompleteHook({ - command: "", - sessionId: "session-123", - exitCode: 0, - durationMs: 5000, - messageCount: 10, - }) + // when + await executeOnCompleteHook({ + command: "", + sessionId: "session-123", + exitCode: 0, + durationMs: 5000, + messageCount: 10, + }) - // then - expect(spawnSpy).not.toHaveBeenCalled() - } finally { - spawnSpy.mockRestore() - } + // then + expect(spawnCalls).toHaveLength(0) }) it("whitespace-only command is no-op", async () => { // given - const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0)) + const spawnCalls: Array> = [] + spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockImplementation(( + command, + options, + ) => { + spawnCalls.push([command, options]) + return createProc(0) + }) + const executeOnCompleteHook = await importFreshExecuteOnCompleteHook() - try { - // when - await executeOnCompleteHook({ - command: " ", - sessionId: "session-123", - exitCode: 0, - durationMs: 5000, - messageCount: 10, - }) + // when + await executeOnCompleteHook({ + command: " ", + sessionId: "session-123", + exitCode: 0, + durationMs: 5000, + messageCount: 10, + }) - // then - expect(spawnSpy).not.toHaveBeenCalled() - } finally { - spawnSpy.mockRestore() - } + // then + expect(spawnCalls).toHaveLength(0) }) it("command failure logs warning but does not throw", async () => { // given - const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(1)) + spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(1)) + const executeOnCompleteHook = await importFreshExecuteOnCompleteHook() - try { - // when - expect( - executeOnCompleteHook({ - command: "false", - sessionId: "session-123", - exitCode: 0, - durationMs: 5000, - messageCount: 10, - }) - ).resolves.toBeUndefined() + // when + await executeOnCompleteHook({ + command: "false", + sessionId: "session-123", + exitCode: 0, + durationMs: 5000, + messageCount: 10, + }) - // then - const warningCall = logSpy.mock.calls.find( - (call) => call[0] === "On-complete hook exited with non-zero code" - ) - expect(warningCall).toBeDefined() - } finally { - spawnSpy.mockRestore() - } + // then + const warningCall = logCalls.find( + (call) => call[0] === "On-complete hook exited with non-zero code" + ) + expect(warningCall).toBeDefined() }) it("spawn error logs warning but does not throw", async () => { // given const spawnError = new Error("Command not found") - const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockImplementation(() => { + spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockImplementation(() => { throw spawnError }) + const executeOnCompleteHook = await importFreshExecuteOnCompleteHook() - try { - // when - expect( - executeOnCompleteHook({ - command: "nonexistent-command", - sessionId: "session-123", - exitCode: 0, - durationMs: 5000, - messageCount: 10, - }) - ).resolves.toBeUndefined() + // when + await executeOnCompleteHook({ + command: "nonexistent-command", + sessionId: "session-123", + exitCode: 0, + durationMs: 5000, + messageCount: 10, + }) - // then - const errorCall = logSpy.mock.calls.find( - (call) => call[0] === "Failed to execute on-complete hook" - ) - expect(errorCall).toBeDefined() - } finally { - spawnSpy.mockRestore() - } + // then + const errorCall = logCalls.find( + (call) => call[0] === "Failed to execute on-complete hook" + ) + expect(errorCall).toBeDefined() }) it("hook stdout and stderr are logged to file logger", async () => { // given - const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue( + spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue( createProc(0, { stdout: "hook output\n", stderr: "hook warning\n" }) ) + const executeOnCompleteHook = await importFreshExecuteOnCompleteHook() - try { - // when - await executeOnCompleteHook({ - command: "echo test", - sessionId: "session-123", - exitCode: 0, - durationMs: 5000, - messageCount: 10, - }) + // when + await executeOnCompleteHook({ + command: "echo test", + sessionId: "session-123", + exitCode: 0, + durationMs: 5000, + messageCount: 10, + }) - // then - const stdoutCall = logSpy.mock.calls.find( - (call) => call[0] === "On-complete hook stdout" - ) - const stderrCall = logSpy.mock.calls.find( - (call) => call[0] === "On-complete hook stderr" - ) + // then + const stdoutCall = logCalls.find( + (call) => call[0] === "On-complete hook stdout" + ) + const stderrCall = logCalls.find( + (call) => call[0] === "On-complete hook stderr" + ) - expect(stdoutCall?.[1]).toEqual({ command: "echo test", stdout: "hook output" }) - expect(stderrCall?.[1]).toEqual({ command: "echo test", stderr: "hook warning" }) - } finally { - spawnSpy.mockRestore() - } + expect(stdoutCall?.[1]).toEqual({ command: "echo test", stdout: "hook output" }) + expect(stderrCall?.[1]).toEqual({ command: "echo test", stderr: "hook warning" }) }) }) diff --git a/src/cli/run/on-complete-hook.ts b/src/cli/run/on-complete-hook.ts index 0a77ca1de..e247ad68d 100644 --- a/src/cli/run/on-complete-hook.ts +++ b/src/cli/run/on-complete-hook.ts @@ -1,5 +1,6 @@ import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide" -import { detectShellType, log } from "../../shared" +import { detectShellType } from "../../shared" +import { log } from "../../shared/logger" async function readOutput( stream: ReadableStream | undefined, diff --git a/src/cli/run/runner.ts b/src/cli/run/runner.ts index bd3547482..2bea2cdec 100644 --- a/src/cli/run/runner.ts +++ b/src/cli/run/runner.ts @@ -12,6 +12,7 @@ import { pollForCompletion } from "./poll-for-completion" import { loadAgentProfileColors } from "./agent-profile-colors" import { suppressRunInput } from "./stdin-suppression" import { createTimestampedStdoutController } from "./timestamp-output" +import { createCliPostHog, getPostHogDistinctId } from "../../shared/posthog" export { resolveRunAgent } @@ -50,6 +51,18 @@ export async function run(options: RunOptions): Promise { const resolvedAgent = resolveRunAgent(options, pluginConfig) const abortController = new AbortController() + const posthog = createCliPostHog() + const distinctId = getPostHogDistinctId() + posthog.capture({ + distinctId, + event: "run_started", + properties: { + agent: resolvedAgent, + has_model: !!options.model, + has_session_id: !!options.sessionId, + }, + }) + try { const resolvedModel = resolveRunModel(options.model) @@ -141,6 +154,28 @@ export async function run(options: RunOptions): Promise { }) } + if (exitCode === 0) { + posthog.capture({ + distinctId, + event: "run_completed", + properties: { + agent: resolvedAgent, + duration_ms: durationMs, + message_count: eventState.messageCount, + }, + }) + } else if (exitCode === 1) { + posthog.capture({ + distinctId, + event: "run_failed", + properties: { + agent: resolvedAgent, + exit_code: exitCode, + duration_ms: durationMs, + }, + }) + } + return exitCode } catch (err) { cleanup() @@ -155,9 +190,20 @@ 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: { + agent: resolvedAgent, + error: serializeError(err), + duration_ms: Date.now() - startTime, + }, + }) console.error(pc.red(`Error: ${serializeError(err)}`)) return 1 } finally { + await posthog.shutdown() timestampOutput?.restore() } }