Merge pull request #3341 from code-yeongyu/fix/telemetry-crash-isolation
fix(telemetry): isolate PostHog failures from core flows
This commit is contained in:
@@ -19,6 +19,7 @@
|
|||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"picomatch": "^4.0.2",
|
"picomatch": "^4.0.2",
|
||||||
|
"posthog-node": "^5.29.2",
|
||||||
"vscode-jsonrpc": "^8.2.0",
|
"vscode-jsonrpc": "^8.2.0",
|
||||||
"zod": "^4.3.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=="],
|
"@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/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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -19,6 +19,7 @@ describe("runCliInstaller", () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
console.log = originalConsoleLog
|
console.log = originalConsoleLog
|
||||||
console.error = originalConsoleError
|
console.error = originalConsoleError
|
||||||
|
mock.restore()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("blocks installation when OpenCode is below the minimum version", async () => {
|
it("blocks installation when OpenCode is below the minimum version", async () => {
|
||||||
|
|||||||
+52
-20
@@ -65,8 +65,16 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
|
|||||||
const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion)
|
const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion)
|
||||||
if (unsupportedVersionMessage) {
|
if (unsupportedVersionMessage) {
|
||||||
printWarning(unsupportedVersionMessage)
|
printWarning(unsupportedVersionMessage)
|
||||||
posthog.capture({ distinctId, event: "install_failed", properties: { command: "install", reason: "unsupported_opencode_version", is_update: isUpdate } })
|
try {
|
||||||
await posthog.shutdown()
|
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
|
return 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,8 +90,16 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
|
|||||||
const pluginResult = await addPluginToOpenCodeConfig(version)
|
const pluginResult = await addPluginToOpenCodeConfig(version)
|
||||||
if (!pluginResult.success) {
|
if (!pluginResult.success) {
|
||||||
printError(`Failed: ${pluginResult.error}`)
|
printError(`Failed: ${pluginResult.error}`)
|
||||||
posthog.capture({ distinctId, event: "install_failed", properties: { command: "install", reason: "plugin_config_write_failed", is_update: isUpdate } })
|
try {
|
||||||
await posthog.shutdown()
|
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
|
return 1
|
||||||
}
|
}
|
||||||
printSuccess(
|
printSuccess(
|
||||||
@@ -94,8 +110,16 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
|
|||||||
const omoResult = writeOmoConfig(config)
|
const omoResult = writeOmoConfig(config)
|
||||||
if (!omoResult.success) {
|
if (!omoResult.success) {
|
||||||
printError(`Failed: ${omoResult.error}`)
|
printError(`Failed: ${omoResult.error}`)
|
||||||
posthog.capture({ distinctId, event: "install_failed", properties: { command: "install", reason: "omo_config_write_failed", is_update: isUpdate } })
|
try {
|
||||||
await posthog.shutdown()
|
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
|
return 1
|
||||||
}
|
}
|
||||||
printSuccess(`Config written ${SYMBOLS.arrow} ${color.dim(omoResult.configPath)}`)
|
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(color.dim("oMoMoMoMo... Enjoy!"))
|
||||||
console.log()
|
console.log()
|
||||||
|
|
||||||
posthog.capture({
|
try {
|
||||||
distinctId,
|
posthog.capture({
|
||||||
event: "install_completed",
|
distinctId,
|
||||||
properties: {
|
event: "install_completed",
|
||||||
command: "install",
|
properties: {
|
||||||
is_update: isUpdate,
|
command: "install",
|
||||||
has_claude: config.hasClaude,
|
is_update: isUpdate,
|
||||||
has_openai: config.hasOpenAI,
|
has_claude: config.hasClaude,
|
||||||
has_gemini: config.hasGemini,
|
has_openai: config.hasOpenAI,
|
||||||
has_copilot: config.hasCopilot,
|
has_gemini: config.hasGemini,
|
||||||
has_opencode_zen: config.hasOpencodeZen,
|
has_copilot: config.hasCopilot,
|
||||||
},
|
has_opencode_zen: config.hasOpencodeZen,
|
||||||
})
|
},
|
||||||
await posthog.shutdown()
|
})
|
||||||
|
} 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) {
|
if ((config.hasClaude || config.hasGemini || config.hasCopilot) && !args.skipAuth) {
|
||||||
printBox(
|
printBox(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
+71
-43
@@ -53,17 +53,25 @@ export async function run(options: RunOptions): Promise<number> {
|
|||||||
|
|
||||||
const posthog = createCliPostHog()
|
const posthog = createCliPostHog()
|
||||||
const distinctId = getPostHogDistinctId()
|
const distinctId = getPostHogDistinctId()
|
||||||
posthog.trackActive(distinctId, "run_started")
|
try {
|
||||||
posthog.capture({
|
posthog.trackActive(distinctId, "run_started")
|
||||||
distinctId,
|
} catch {
|
||||||
event: "run_started",
|
// telemetry failure is non-fatal, silently ignore
|
||||||
properties: {
|
}
|
||||||
command: "run",
|
try {
|
||||||
agent: resolvedAgent,
|
posthog.capture({
|
||||||
has_model: !!options.model,
|
distinctId,
|
||||||
has_session_id: !!options.sessionId,
|
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 {
|
try {
|
||||||
const resolvedModel = resolveRunModel(options.model)
|
const resolvedModel = resolveRunModel(options.model)
|
||||||
@@ -157,27 +165,35 @@ export async function run(options: RunOptions): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (exitCode === 0) {
|
if (exitCode === 0) {
|
||||||
posthog.capture({
|
try {
|
||||||
distinctId,
|
posthog.capture({
|
||||||
event: "run_completed",
|
distinctId,
|
||||||
properties: {
|
event: "run_completed",
|
||||||
command: "run",
|
properties: {
|
||||||
agent: resolvedAgent,
|
command: "run",
|
||||||
duration_ms: durationMs,
|
agent: resolvedAgent,
|
||||||
message_count: eventState.messageCount,
|
duration_ms: durationMs,
|
||||||
},
|
message_count: eventState.messageCount,
|
||||||
})
|
},
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// telemetry failure is non-fatal, silently ignore
|
||||||
|
}
|
||||||
} else if (exitCode === 1) {
|
} else if (exitCode === 1) {
|
||||||
posthog.capture({
|
try {
|
||||||
distinctId,
|
posthog.capture({
|
||||||
event: "run_failed",
|
distinctId,
|
||||||
properties: {
|
event: "run_failed",
|
||||||
command: "run",
|
properties: {
|
||||||
agent: resolvedAgent,
|
command: "run",
|
||||||
exit_code: exitCode,
|
agent: resolvedAgent,
|
||||||
duration_ms: durationMs,
|
exit_code: exitCode,
|
||||||
},
|
duration_ms: durationMs,
|
||||||
})
|
},
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// telemetry failure is non-fatal, silently ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return exitCode
|
return exitCode
|
||||||
@@ -194,21 +210,33 @@ export async function run(options: RunOptions): Promise<number> {
|
|||||||
if (err instanceof Error && err.name === "AbortError") {
|
if (err instanceof Error && err.name === "AbortError") {
|
||||||
return 130
|
return 130
|
||||||
}
|
}
|
||||||
posthog.captureException(err, distinctId)
|
try {
|
||||||
posthog.capture({
|
posthog.captureException(err, distinctId)
|
||||||
distinctId,
|
} catch {
|
||||||
event: "run_failed",
|
// telemetry failure is non-fatal, silently ignore
|
||||||
properties: {
|
}
|
||||||
command: "run",
|
try {
|
||||||
agent: resolvedAgent,
|
posthog.capture({
|
||||||
error: serializeError(err),
|
distinctId,
|
||||||
duration_ms: Date.now() - startTime,
|
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)}`))
|
console.error(pc.red(`Error: ${serializeError(err)}`))
|
||||||
return 1
|
return 1
|
||||||
} finally {
|
} finally {
|
||||||
await posthog.shutdown()
|
try {
|
||||||
|
await posthog.shutdown()
|
||||||
|
} catch {
|
||||||
|
// telemetry failure is non-fatal, silently ignore
|
||||||
|
}
|
||||||
timestampOutput?.restore()
|
timestampOutput?.restore()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<typeof plugin>[0])
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toMatchObject({ name: "oh-my-openagent" })
|
||||||
|
})
|
||||||
|
})
|
||||||
+18
-10
@@ -41,16 +41,24 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
|
|
||||||
const posthog = createPluginPostHog()
|
const posthog = createPluginPostHog()
|
||||||
const distinctId = getPostHogDistinctId()
|
const distinctId = getPostHogDistinctId()
|
||||||
posthog.trackActive(distinctId, "plugin_loaded")
|
try {
|
||||||
posthog.capture({
|
posthog.trackActive(distinctId, "plugin_loaded")
|
||||||
distinctId,
|
} catch {
|
||||||
event: "plugin_loaded",
|
// telemetry failure is non-fatal, silently ignore
|
||||||
properties: {
|
}
|
||||||
entry_point: "plugin",
|
try {
|
||||||
has_openclaw: !!pluginConfig.openclaw,
|
posthog.capture({
|
||||||
tmux_enabled: isTmuxIntegrationEnabled(pluginConfig),
|
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) {
|
if (pluginConfig.openclaw) {
|
||||||
await initializeOpenClaw(pluginConfig.openclaw)
|
await initializeOpenClaw(pluginConfig.openclaw)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<typeof import("./posthog-activity-state")> {
|
||||||
|
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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -32,6 +32,10 @@ function getUtcHourString(date: Date): string {
|
|||||||
return date.toISOString().slice(0, 13)
|
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 {
|
function readPostHogActivityState(): PostHogActivityState {
|
||||||
const stateFilePath = getPostHogActivityStateFilePath()
|
const stateFilePath = getPostHogActivityStateFilePath()
|
||||||
if (!existsSync(stateFilePath)) {
|
if (!existsSync(stateFilePath)) {
|
||||||
@@ -40,7 +44,10 @@ function readPostHogActivityState(): PostHogActivityState {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(stateFilePath, "utf-8")
|
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
|
return parsed
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log("[posthog-activity-state] Failed to read activity state", {
|
log("[posthog-activity-state] Failed to read activity state", {
|
||||||
|
|||||||
Reference in New Issue
Block a user