diff --git a/.github/ISSUE_TEMPLATE/partner-issue.md b/.github/ISSUE_TEMPLATE/partner-issue.md new file mode 100644 index 000000000..89a72f6e3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/partner-issue.md @@ -0,0 +1,23 @@ +--- +name: Partner issue +about: Issues reported by partners via support +title: "[Partner] " +labels: partner-issue +assignees: '' +--- + +**Partner:** + +**Help Scout conversation URL:** + +**Priority:** P1 / P2 / P3 + +**Category:** Bug / Performance / Onboarding / Other + +**What they reported:** + +**Steps to reproduce:** + +**Expected vs. actual behavior:** + +**Partner impact (how many affected, what it cost them):** diff --git a/package.json b/package.json index 5bed560f8..8cf067022 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "url": "https://github.com/genesis-ai-dev/codex-editor" }, "license": "MIT", - "version": "0.22.0", + "version": "0.24.0", "engines": { "node": ">=18.0.0", "vscode": "^1.78.0" @@ -203,6 +203,11 @@ "title": "Advanced Tool Settings", "category": "Codex Editor" }, + { + "command": "codex-editor-extension.toggleTelemetry", + "title": "Toggle Telemetry (PostHog)", + "category": "Codex Editor" + }, { "command": "codex-editor-extension.forceReindex", "title": "Force Reindex", @@ -892,6 +897,18 @@ "default": "", "description": "Limits context building to specified books. Leave empty to include all. We recommend to leave empty for best results." }, + "codex-editor-extension.telemetryEnabled": { + "title": "Enable Telemetry", + "type": "boolean", + "default": true, + "description": "When enabled, anonymous usage telemetry and error reports are sent via PostHog to help improve the editor." + }, + "codex-editor-extension.sessionRecordingEnabled": { + "title": "Session Recording", + "type": "boolean", + "default": false, + "description": "When enabled, session replays are buffered locally and only sent when an error occurs, to help diagnose issues." + }, "codex-editor-extension.debugMode": { "title": "Debugging Mode", "type": "boolean", @@ -1133,6 +1150,7 @@ "pdf-parse": "^1.1.1", "pdfjs-dist": "4.0.379", "pnpm": "^8.15.5", + "posthog-node": "^5.21.2", "proc-log": "^5.0.0", "react-wordcloud": "^1.2.7", "sax": "^1.4.1", diff --git a/src/activationHelpers/contextAware/commands.ts b/src/activationHelpers/contextAware/commands.ts index 990b6241f..a721290ee 100644 --- a/src/activationHelpers/contextAware/commands.ts +++ b/src/activationHelpers/contextAware/commands.ts @@ -364,5 +364,25 @@ export async function registerCommands(context: vscode.ExtensionContext) { testProjectLoadingPerformanceCommand, migrateAudioFilesCommand, + vscode.commands.registerCommand( + "codex-editor-extension.toggleTelemetry", + async () => { + const config = vscode.workspace.getConfiguration("codex-editor-extension"); + const current = config.get("telemetryEnabled", true); + const next = !current; + + await config.update("telemetryEnabled", next, vscode.ConfigurationTarget.Global); + + const label = next ? "enabled" : "disabled"; + const action = await vscode.window.showInformationMessage( + `Telemetry has been ${label}. Reload the window to apply the change.`, + "Reload Window" + ); + + if (action === "Reload Window") { + await vscode.commands.executeCommand("workbench.action.reloadWindow"); + } + } + ), ); } diff --git a/src/cellLabelImporter/cellLabelImporter.ts b/src/cellLabelImporter/cellLabelImporter.ts index 0b8df32d8..4dd928b93 100644 --- a/src/cellLabelImporter/cellLabelImporter.ts +++ b/src/cellLabelImporter/cellLabelImporter.ts @@ -12,6 +12,7 @@ import { matchCellLabels } from "./matcher"; import { copyToTempStorage, getColumnHeaders } from "./utils"; import { updateCellLabels } from "./updater"; import { getNonce } from "../utils/getNonce"; +import { getPostHogWebviewScript } from "../utils/telemetry"; import { safePostMessageToPanel } from "../utils/webviewUtils"; const DEBUG_CELL_LABEL_IMPORTER = false; @@ -122,7 +123,8 @@ async function getHtmlForCellLabelImporterView( img-src ${webview.cspSource} https: data:; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}'; - font-src ${webview.cspSource};"> + font-src ${webview.cspSource}; + connect-src https://*.posthog.com https://*.i.posthog.com;"> @@ -136,6 +138,7 @@ async function getHtmlForCellLabelImporterView(
+ ${getPostHogWebviewScript(nonce, "CellLabelImporter")} `; diff --git a/src/codexMigrationTool/codexMigrationTool.ts b/src/codexMigrationTool/codexMigrationTool.ts index c1866dad9..61f57109a 100644 --- a/src/codexMigrationTool/codexMigrationTool.ts +++ b/src/codexMigrationTool/codexMigrationTool.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import * as path from "path"; import { getNonce } from "../utils/getNonce"; +import { getPostHogWebviewScript } from "../utils/telemetry"; import { safePostMessageToPanel } from "../utils/webviewUtils"; import { matchMigrationCells } from "./matcher"; import { applyMigrationToTargetFile } from "./updater"; @@ -50,7 +51,8 @@ async function getHtmlForCodexMigrationToolView( img-src ${webview.cspSource} https: data:; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}'; - font-src ${webview.cspSource};"> + font-src ${webview.cspSource}; + connect-src https://*.posthog.com https://*.i.posthog.com;"> `; diff --git a/src/copilotSettings/copilotSettings.ts b/src/copilotSettings/copilotSettings.ts index e1e012a3b..3058a97da 100644 --- a/src/copilotSettings/copilotSettings.ts +++ b/src/copilotSettings/copilotSettings.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode"; import { callLLM } from "../utils/llmUtils"; import { CompletionConfig } from "@/utils/llmUtils"; import { MetadataManager } from "../utils/metadataManager"; +import { getPostHogWebviewScript } from "../utils/telemetry"; interface ProjectLanguage { tag: string; @@ -68,6 +69,7 @@ export async function openSystemMessageEditor() {
+ ${getPostHogWebviewScript(nonce, "CopilotSettings")} `; diff --git a/src/extension.ts b/src/extension.ts index 6b8b7a6fe..912bb08ef 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -64,10 +64,11 @@ import { initializeAudioMerger } from "./utils/audioMerger"; import { initializeAudioExtractor } from "./utils/audioExtractor"; import { initializeAudioExporter } from "./exportHandler/audioExporter"; import { checkTools, getUnavailableTools } from "./utils/toolsManager"; -import { initToolPreferences, setNativeGitAvailable, getGitToolMode, getSqliteToolMode } from "./utils/toolPreferences"; +import { initToolPreferences, setNativeGitAvailable, getGitToolMode, getSqliteToolMode, getAudioToolMode } from "./utils/toolPreferences"; import { downloadFFmpeg } from "./utils/ffmpegManager"; import { MissingToolsWarningProvider } from "./providers/MissingToolsWarning/MissingToolsWarningProvider"; import { cleanupOrphanedProjectFiles } from "./utils/fileUtils"; +import { initTelemetry, shutdownTelemetry, captureException, captureEvent } from "./utils/telemetry"; // markUserAsUpdatedInRemoteList is now called in performProjectUpdate before window reload import * as fs from "fs"; import * as os from "os"; @@ -337,6 +338,18 @@ export async function activate(context: vscode.ExtensionContext) { // Continue with activation even if splash screen fails } + initTelemetry(); + + process.on("uncaughtException", (error) => { + console.error("[Extension] Uncaught exception:", error); + captureException(error, { source: "uncaughtException" }); + }); + + process.on("unhandledRejection", (reason) => { + console.error("[Extension] Unhandled rejection:", reason); + captureException(reason, { source: "unhandledRejection" }); + }); + let stepStart = activationStart; try { @@ -402,6 +415,13 @@ export async function activate(context: vscode.ExtensionContext) { const gitMode = getGitToolMode(); const effectiveBackend = gitMode === "builtin" ? "isomorphic-git (forced)" : gitAvailable ? "dugite (native)" : "isomorphic-git (no binary)"; console.log(`[codex] Git backend mode: ${gitMode}, effective: ${effectiveBackend}`); + if (!gitAvailable || gitMode === "builtin") { + captureEvent("tool_fallback_used", { + tool: "git", + reason: gitMode === "builtin" ? "user_preference_builtin" : "binary_unavailable", + mode: gitMode, + }); + } stepStart = finishRealtimeStep(); // If metadata.json is missing but the workspace has a .git with a remote, @@ -505,6 +525,11 @@ export async function activate(context: vscode.ExtensionContext) { await initFts5Sqlite(context); const reason = sqliteMode === "builtin" ? "user preference" : "native unavailable"; console.log(`[SQLite] fts5-sql-bundle (WASM) initialized (${reason})`); + captureEvent("tool_fallback_used", { + tool: "sqlite", + reason: sqliteMode === "builtin" ? "user_preference_builtin" : "native_load_failed", + mode: sqliteMode, + }); } catch (fallbackError: any) { if (!nativeLoaded) { console.error("[SQLite] Both native and fts5 fallback failed:", fallbackError?.message || fallbackError); @@ -550,6 +575,14 @@ export async function activate(context: vscode.ExtensionContext) { `[Extension] Tools status — git: ${ok(toolCheckResult.git)}, sqlite: ${ok(toolCheckResult.sqlite)}, ffmpeg: ${ok(toolCheckResult.ffmpeg)}` ); + if (!toolCheckResult.ffmpeg) { + captureEvent("tool_fallback_used", { + tool: "audio", + reason: "ffmpeg_unavailable", + mode: getAudioToolMode(), + }); + } + // When offline, non-critical tools (git, audio) being unavailable is // expected and not actionable -- skip the blocking warning panel. const hasCriticalMissing = !toolCheckResult.sqlite; @@ -1534,6 +1567,8 @@ export async function deactivate() { currentStepTimer = null; } + await shutdownTelemetry(); + // Close the index manager's database connection and clear the global reference try { const { clearSQLiteIndexManager } = await import( diff --git a/src/globalProvider.ts b/src/globalProvider.ts index e213a3359..0f54cd656 100644 --- a/src/globalProvider.ts +++ b/src/globalProvider.ts @@ -3,6 +3,7 @@ import { CodexCellEditorProvider } from "./providers/codexCellEditorProvider/cod import { CustomWebviewProvider } from "./providers/parallelPassagesWebview/customParallelPassagesWebviewProvider"; import { GlobalContentType, GlobalMessage } from "../types"; import { getNonce } from "./utils/getNonce"; +import { getPostHogWebviewScript } from "./utils/telemetry"; import { safePostMessageToView } from "./utils/webviewUtils"; @@ -137,6 +138,7 @@ export abstract class BaseWebviewProvider implements vscode.WebviewViewProvider
+ ${getPostHogWebviewScript(nonce, this.getScriptPath()[0])} `; diff --git a/src/projectManager/utils/merge/resolvers.ts b/src/projectManager/utils/merge/resolvers.ts index 89df804a8..d0f6b3d16 100644 --- a/src/projectManager/utils/merge/resolvers.ts +++ b/src/projectManager/utils/merge/resolvers.ts @@ -237,7 +237,7 @@ function mergeProjectSwap( * Merges originalFilesHashes registries from base, ours, and theirs. * Union by hash: combine all entries. For same hash, merge referencedBy and originalNames. */ -function mergeOriginalFilesHashes( +export function mergeOriginalFilesHashes( base: { version?: number; files?: Record; fileNameToHash?: Record } | undefined, ours: { version?: number; files?: Record; fileNameToHash?: Record } | undefined, theirs: { version?: number; files?: Record; fileNameToHash?: Record } | undefined diff --git a/src/providers/MissingToolsWarning/MissingToolsWarningProvider.ts b/src/providers/MissingToolsWarning/MissingToolsWarningProvider.ts index 9aa53af4a..4ae3cd22d 100644 --- a/src/providers/MissingToolsWarning/MissingToolsWarningProvider.ts +++ b/src/providers/MissingToolsWarning/MissingToolsWarningProvider.ts @@ -6,6 +6,7 @@ import { safePostMessageToPanel } from "../../utils/webviewUtils"; import type { ToolCheckResult } from "../../utils/toolsManager"; import { getAudioToolMode, getGitToolMode, getSqliteToolMode } from "../../utils/toolPreferences"; import { resetRetryCount } from "../../utils/binaryIntegrityUtils"; +import { captureEvent } from "../../utils/telemetry"; import type { MessagesToMissingToolsWarning, MessagesFromMissingToolsWarning, @@ -274,6 +275,9 @@ export class MissingToolsWarningProvider { const { setAudioToolMode } = await import("../../utils/toolPreferences"); const current = getAudioToolMode(); const next = current === "auto" ? "builtin" : "auto"; + if (next === "builtin") { + captureEvent("tool_fallback_used", { tool: "audio", reason: "user_chose_fallback", mode: next }); + } await setAudioToolMode(next); const { checkTools } = await import("../../utils/toolsManager"); @@ -292,6 +296,9 @@ export class MissingToolsWarningProvider { const { setGitToolMode } = await import("../../utils/toolPreferences"); const current = getGitToolMode(); const next = current === "auto" ? "builtin" : "auto"; + if (next === "builtin") { + captureEvent("tool_fallback_used", { tool: "git", reason: "user_chose_fallback", mode: next }); + } await setGitToolMode(next); const { checkTools } = await import("../../utils/toolsManager"); @@ -324,6 +331,9 @@ export class MissingToolsWarningProvider { const { setSqliteToolMode } = await import("../../utils/toolPreferences"); const current = getSqliteToolMode(); const next = current === "auto" ? "builtin" : "auto"; + if (next === "builtin") { + captureEvent("tool_fallback_used", { tool: "sqlite", reason: "user_chose_fallback", mode: next }); + } // If switching to the WASM fallback, ensure it's initialized if (next === "builtin") { diff --git a/src/providers/SplashScreen/SplashScreenProvider.ts b/src/providers/SplashScreen/SplashScreenProvider.ts index 9395fe298..76e537916 100644 --- a/src/providers/SplashScreen/SplashScreenProvider.ts +++ b/src/providers/SplashScreen/SplashScreenProvider.ts @@ -166,7 +166,7 @@ export class SplashScreenProvider { return getWebviewHtml(webview, { extensionUri: this._extensionUri } as vscode.ExtensionContext, { title: "Codex Editor Loading", scriptPath: ["SplashScreen", "index.js"], - csp: `default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-\${nonce}' 'strict-dynamic' https://static.cloudflareinsights.com; worker-src ${webview.cspSource} blob:; connect-src https://*.vscode-cdn.net https://*.frontierrnd.com; img-src ${webview.cspSource} https: data:; font-src ${webview.cspSource}; media-src ${webview.cspSource} https: blob:;`, + csp: `default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-\${nonce}' 'strict-dynamic' https://static.cloudflareinsights.com; worker-src ${webview.cspSource} blob:; connect-src https://*.vscode-cdn.net https://*.frontierrnd.com https://*.posthog.com https://*.i.posthog.com; img-src ${webview.cspSource} https: data:; font-src ${webview.cspSource}; media-src ${webview.cspSource} https: blob:;`, initialData: { timings: this._timings, syncDetails: this._syncDetails }, inlineStyles: ` body { margin: 0; padding: 0; height: 100vh; width: 100vw; overflow: hidden; background-color: var(--vscode-editor-background); color: var(--vscode-foreground); font-family: var(--vscode-font-family); } diff --git a/src/providers/VideoPlayer/VideoPlayerProvider.ts b/src/providers/VideoPlayer/VideoPlayerProvider.ts index 31513e93b..afa5a8a20 100644 --- a/src/providers/VideoPlayer/VideoPlayerProvider.ts +++ b/src/providers/VideoPlayer/VideoPlayerProvider.ts @@ -50,7 +50,7 @@ export class VideoPlayerProvider return getWebviewHtml(webview, this.context, { title: "Codex Video Player", scriptPath: ["VideoPlayer", "index.js"], - csp: `default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-\${nonce}' https://static.cloudflareinsights.com; connect-src https://*.vscode-cdn.net https://*.frontierrnd.com; worker-src ${webview.cspSource}; img-src ${webview.cspSource} https:; font-src ${webview.cspSource};` + csp: `default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-\${nonce}' https://static.cloudflareinsights.com; connect-src https://*.vscode-cdn.net https://*.frontierrnd.com https://*.posthog.com https://*.i.posthog.com; worker-src ${webview.cspSource}; img-src ${webview.cspSource} https:; font-src ${webview.cspSource};` }); } } diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index a561b9e22..68abe3ff1 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -102,6 +102,14 @@ async function withErrorHandling( } } +async function safeExecuteSmartEditCommand(commandId: string, ...args: unknown[]): Promise { + const allCommands = await vscode.commands.getCommands(true); + if (!allCommands.includes(commandId)) { + return null; + } + return vscode.commands.executeCommand(commandId, ...args); +} + // Message handler context type interface MessageHandlerContext { event: EditorPostMessages; @@ -1248,7 +1256,7 @@ const messageHandlers: Record Promise { const typedEvent = event as Extract; - const backtranslation = await vscode.commands.executeCommand( + const backtranslation = await safeExecuteSmartEditCommand( "codex-smart-edits.generateBacktranslation", typedEvent.content.text, typedEvent.content.cellId, @@ -1262,7 +1270,7 @@ const messageHandlers: Record Promise { const typedEvent = event as Extract; - const updatedBacktranslation = await vscode.commands.executeCommand( + const updatedBacktranslation = await safeExecuteSmartEditCommand( "codex-smart-edits.editBacktranslation", typedEvent.content.cellId, typedEvent.content.newText, @@ -1277,7 +1285,7 @@ const messageHandlers: Record Promise { const typedEvent = event as Extract; - const backtranslation = await vscode.commands.executeCommand( + const backtranslation = await safeExecuteSmartEditCommand( "codex-smart-edits.getBacktranslation", typedEvent.content.cellId ); @@ -1291,10 +1299,9 @@ const messageHandlers: Record Promise; const cellIds = typedEvent.content.cellIds; - // Fetch backtranslations for all cell IDs const backtranslations: { [cellId: string]: SavedBacktranslation | null; } = {}; for (const cellId of cellIds) { - const backtranslation = await vscode.commands.executeCommand( + const backtranslation = await safeExecuteSmartEditCommand( "codex-smart-edits.getBacktranslation", cellId ); @@ -1309,7 +1316,7 @@ const messageHandlers: Record Promise { const typedEvent = event as Extract; - const backtranslation = await vscode.commands.executeCommand( + const backtranslation = await safeExecuteSmartEditCommand( "codex-smart-edits.setBacktranslation", typedEvent.content.cellId, typedEvent.content.originalText, diff --git a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts index 3580cd06a..17faa20fa 100755 --- a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts @@ -28,6 +28,7 @@ import { SyncManager } from "../../projectManager/syncManager"; import bibleData from "../../../webviews/codex-webviews/src/assets/bible-books-lookup.json"; import { getNonce } from "../../utils/getNonce"; +import { getPostHogWebviewScript } from "../../utils/telemetry"; import { safePostMessageToPanel } from "../../utils/webviewUtils"; import path from "path"; import * as fs from "fs"; @@ -1541,7 +1542,7 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider - + Codex Cell Editor @@ -1561,6 +1562,7 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider
+ ${getPostHogWebviewScript(nonce, "CodexCellEditor")}