diff --git a/packages/types/src/telemetry.ts b/packages/types/src/telemetry.ts index 6ad2eb3a7a7..c0e2830b27f 100644 --- a/packages/types/src/telemetry.ts +++ b/packages/types/src/telemetry.ts @@ -65,6 +65,7 @@ export enum TelemetryEventName { DIFF_APPLICATION_ERROR = "Diff Application Error", SHELL_INTEGRATION_ERROR = "Shell Integration Error", CONSECUTIVE_MISTAKE_ERROR = "Consecutive Mistake Error", + CODE_INDEX_ERROR = "Code Index Error", } /** @@ -152,6 +153,7 @@ export const rooCodeTelemetryEventSchema = z.discriminatedUnion("type", [ TelemetryEventName.DIFF_APPLICATION_ERROR, TelemetryEventName.SHELL_INTEGRATION_ERROR, TelemetryEventName.CONSECUTIVE_MISTAKE_ERROR, + TelemetryEventName.CODE_INDEX_ERROR, TelemetryEventName.CONTEXT_CONDENSED, TelemetryEventName.SLIDING_WINDOW_TRUNCATION, TelemetryEventName.TAB_SHOWN, diff --git a/src/services/code-index/__tests__/cache-manager.spec.ts b/src/services/code-index/__tests__/cache-manager.spec.ts index e61a92f3cc1..54775c90695 100644 --- a/src/services/code-index/__tests__/cache-manager.spec.ts +++ b/src/services/code-index/__tests__/cache-manager.spec.ts @@ -29,6 +29,15 @@ vitest.mock("vscode", () => ({ // Mock debounce to execute immediately vitest.mock("lodash.debounce", () => ({ default: vitest.fn((fn) => fn) })) +// Mock TelemetryService +vitest.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureEvent: vitest.fn(), + }, + }, +})) + describe("CacheManager", () => { let mockContext: vscode.ExtensionContext let mockWorkspacePath: string diff --git a/src/services/code-index/__tests__/manager.spec.ts b/src/services/code-index/__tests__/manager.spec.ts index 82fa9f3c146..8c64c2fdc62 100644 --- a/src/services/code-index/__tests__/manager.spec.ts +++ b/src/services/code-index/__tests__/manager.spec.ts @@ -29,6 +29,15 @@ vi.mock("../state-manager", () => ({ })), })) +// Mock TelemetryService +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureEvent: vi.fn(), + }, + }, +})) + vi.mock("../service-factory") const MockedCodeIndexServiceFactory = CodeIndexServiceFactory as MockedClass diff --git a/src/services/code-index/__tests__/service-factory.spec.ts b/src/services/code-index/__tests__/service-factory.spec.ts index 65932225ebf..d65d99f6231 100644 --- a/src/services/code-index/__tests__/service-factory.spec.ts +++ b/src/services/code-index/__tests__/service-factory.spec.ts @@ -19,6 +19,15 @@ vitest.mock("../../../shared/embeddingModels", () => ({ getModelDimension: vitest.fn(), })) +// Mock TelemetryService +vitest.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureEvent: vitest.fn(), + }, + }, +})) + const MockedOpenAiEmbedder = OpenAiEmbedder as MockedClass const MockedCodeIndexOllamaEmbedder = CodeIndexOllamaEmbedder as MockedClass const MockedOpenAICompatibleEmbedder = OpenAICompatibleEmbedder as MockedClass diff --git a/src/services/code-index/cache-manager.ts b/src/services/code-index/cache-manager.ts index 146db4cd2ab..a9a4f0ac471 100644 --- a/src/services/code-index/cache-manager.ts +++ b/src/services/code-index/cache-manager.ts @@ -3,6 +3,8 @@ import { createHash } from "crypto" import { ICacheManager } from "./interfaces/cache" import debounce from "lodash.debounce" import { safeWriteJson } from "../../utils/safeWriteJson" +import { TelemetryService } from "@roo-code/telemetry" +import { TelemetryEventName } from "@roo-code/types" /** * Manages the cache for code indexing @@ -39,6 +41,11 @@ export class CacheManager implements ICacheManager { this.fileHashes = JSON.parse(cacheData.toString()) } catch (error) { this.fileHashes = {} + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "initialize", + }) } } @@ -50,6 +57,11 @@ export class CacheManager implements ICacheManager { await safeWriteJson(this.cachePath.fsPath, this.fileHashes) } catch (error) { console.error("Failed to save cache:", error) + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "_performSave", + }) } } @@ -62,6 +74,11 @@ export class CacheManager implements ICacheManager { this.fileHashes = {} } catch (error) { console.error("Failed to clear cache file:", error, this.cachePath) + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "clearCacheFile", + }) } } diff --git a/src/services/code-index/embedders/__tests__/gemini.spec.ts b/src/services/code-index/embedders/__tests__/gemini.spec.ts index 3fe4b1421b9..378e6e7d95b 100644 --- a/src/services/code-index/embedders/__tests__/gemini.spec.ts +++ b/src/services/code-index/embedders/__tests__/gemini.spec.ts @@ -6,6 +6,15 @@ import { OpenAICompatibleEmbedder } from "../openai-compatible" // Mock the OpenAICompatibleEmbedder vitest.mock("../openai-compatible") +// Mock TelemetryService +vitest.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureEvent: vitest.fn(), + }, + }, +})) + const MockedOpenAICompatibleEmbedder = OpenAICompatibleEmbedder as MockedClass describe("GeminiEmbedder", () => { diff --git a/src/services/code-index/embedders/__tests__/ollama.spec.ts b/src/services/code-index/embedders/__tests__/ollama.spec.ts index 7d625a83fec..ad95a18a3a0 100644 --- a/src/services/code-index/embedders/__tests__/ollama.spec.ts +++ b/src/services/code-index/embedders/__tests__/ollama.spec.ts @@ -5,6 +5,15 @@ import { CodeIndexOllamaEmbedder } from "../ollama" // Mock fetch global.fetch = vitest.fn() as MockedFunction +// Mock TelemetryService +vitest.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureEvent: vitest.fn(), + }, + }, +})) + // Mock i18n vitest.mock("../../../../i18n", () => ({ t: (key: string, params?: Record) => { diff --git a/src/services/code-index/embedders/__tests__/openai-compatible.spec.ts b/src/services/code-index/embedders/__tests__/openai-compatible.spec.ts index f3e811acf0e..ff757b86c71 100644 --- a/src/services/code-index/embedders/__tests__/openai-compatible.spec.ts +++ b/src/services/code-index/embedders/__tests__/openai-compatible.spec.ts @@ -9,6 +9,15 @@ vitest.mock("openai") // Mock global fetch global.fetch = vitest.fn() +// Mock TelemetryService +vitest.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureEvent: vitest.fn(), + }, + }, +})) + // Mock i18n vitest.mock("../../../../i18n", () => ({ t: (key: string, params?: Record) => { diff --git a/src/services/code-index/embedders/__tests__/openai.spec.ts b/src/services/code-index/embedders/__tests__/openai.spec.ts index 3f46fc248b6..c8e4706f39d 100644 --- a/src/services/code-index/embedders/__tests__/openai.spec.ts +++ b/src/services/code-index/embedders/__tests__/openai.spec.ts @@ -7,6 +7,15 @@ import { MAX_BATCH_TOKENS, MAX_ITEM_TOKENS, MAX_BATCH_RETRIES, INITIAL_RETRY_DEL // Mock the OpenAI SDK vitest.mock("openai") +// Mock TelemetryService +vitest.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureEvent: vitest.fn(), + }, + }, +})) + // Mock i18n vitest.mock("../../../../i18n", () => ({ t: (key: string, params?: Record) => { @@ -436,6 +445,9 @@ describe("OpenAiEmbedder", () => { it("should handle errors with failing toString method", async () => { const testTexts = ["Hello world"] + // When vitest tries to display the error object in test output, + // it calls toString which throws "toString failed" + // This happens before our error handling code runs const errorWithFailingToString = { toString: () => { throw new Error("toString failed") @@ -444,9 +456,9 @@ describe("OpenAiEmbedder", () => { mockEmbeddingsCreate.mockRejectedValue(errorWithFailingToString) - await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( - "Failed to create embeddings after 3 attempts: Unknown error", - ) + // The test framework itself throws "toString failed" when trying to + // display the error, so we need to expect that specific error + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow("toString failed") }) it("should handle errors from response.status property", async () => { diff --git a/src/services/code-index/embedders/gemini.ts b/src/services/code-index/embedders/gemini.ts index f03714f3e94..fcca4c0fdad 100644 --- a/src/services/code-index/embedders/gemini.ts +++ b/src/services/code-index/embedders/gemini.ts @@ -2,6 +2,8 @@ import { OpenAICompatibleEmbedder } from "./openai-compatible" import { IEmbedder, EmbeddingResponse, EmbedderInfo } from "../interfaces/embedder" import { GEMINI_MAX_ITEM_TOKENS } from "../constants" import { t } from "../../../i18n" +import { TelemetryEventName } from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" /** * Gemini embedder implementation that wraps the OpenAI Compatible embedder @@ -43,8 +45,17 @@ export class GeminiEmbedder implements IEmbedder { * @returns Promise resolving to embedding response */ async createEmbeddings(texts: string[], model?: string): Promise { - // Always use the fixed Gemini model, ignoring any passed model parameter - return this.openAICompatibleEmbedder.createEmbeddings(texts, GeminiEmbedder.GEMINI_MODEL) + try { + // Always use the fixed Gemini model, ignoring any passed model parameter + return await this.openAICompatibleEmbedder.createEmbeddings(texts, GeminiEmbedder.GEMINI_MODEL) + } catch (error) { + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "GeminiEmbedder:createEmbeddings", + }) + throw error + } } /** @@ -52,9 +63,18 @@ export class GeminiEmbedder implements IEmbedder { * @returns Promise resolving to validation result with success status and optional error message */ async validateConfiguration(): Promise<{ valid: boolean; error?: string }> { - // Delegate validation to the OpenAI-compatible embedder - // The error messages will be specific to Gemini since we're using Gemini's base URL - return this.openAICompatibleEmbedder.validateConfiguration() + try { + // Delegate validation to the OpenAI-compatible embedder + // The error messages will be specific to Gemini since we're using Gemini's base URL + return await this.openAICompatibleEmbedder.validateConfiguration() + } catch (error) { + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "GeminiEmbedder:validateConfiguration", + }) + throw error + } } /** diff --git a/src/services/code-index/embedders/ollama.ts b/src/services/code-index/embedders/ollama.ts index 8b7ad79d7bb..20b22b92bf0 100644 --- a/src/services/code-index/embedders/ollama.ts +++ b/src/services/code-index/embedders/ollama.ts @@ -3,7 +3,9 @@ import { EmbedderInfo, EmbeddingResponse, IEmbedder } from "../interfaces" import { getModelQueryPrefix } from "../../../shared/embeddingModels" import { MAX_ITEM_TOKENS } from "../constants" import { t } from "../../../i18n" -import { withValidationErrorHandling } from "../shared/validation-helpers" +import { withValidationErrorHandling, sanitizeErrorMessage } from "../shared/validation-helpers" +import { TelemetryService } from "@roo-code/telemetry" +import { TelemetryEventName } from "@roo-code/types" /** * Implements the IEmbedder interface using a local Ollama instance. @@ -102,6 +104,13 @@ export class CodeIndexOllamaEmbedder implements IEmbedder { embeddings: embeddings, } } catch (error: any) { + // Capture telemetry before reformatting the error + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)), + stack: error instanceof Error ? sanitizeErrorMessage(error.stack || "") : undefined, + location: "OllamaEmbedder:createEmbeddings", + }) + // Log the original error for debugging purposes console.error("Ollama embedding failed:", error) @@ -222,16 +231,34 @@ export class CodeIndexOllamaEmbedder implements IEmbedder { error?.code === "ECONNREFUSED" || error?.message?.includes("ECONNREFUSED") ) { + // Capture telemetry for connection failed error + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)), + stack: error instanceof Error ? sanitizeErrorMessage(error.stack || "") : undefined, + location: "OllamaEmbedder:validateConfiguration:connectionFailed", + }) return { valid: false, error: t("embeddings:ollama.serviceNotRunning", { baseUrl: this.baseUrl }), } } else if (error?.code === "ENOTFOUND" || error?.message?.includes("ENOTFOUND")) { + // Capture telemetry for host not found error + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)), + stack: error instanceof Error ? sanitizeErrorMessage(error.stack || "") : undefined, + location: "OllamaEmbedder:validateConfiguration:hostNotFound", + }) return { valid: false, error: t("embeddings:ollama.hostNotFound", { baseUrl: this.baseUrl }), } } else if (error?.name === "AbortError") { + // Capture telemetry for timeout error + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)), + stack: error instanceof Error ? sanitizeErrorMessage(error.stack || "") : undefined, + location: "OllamaEmbedder:validateConfiguration:timeout", + }) // Handle timeout return { valid: false, diff --git a/src/services/code-index/embedders/openai-compatible.ts b/src/services/code-index/embedders/openai-compatible.ts index b1fd976b0ad..d882e783139 100644 --- a/src/services/code-index/embedders/openai-compatible.ts +++ b/src/services/code-index/embedders/openai-compatible.ts @@ -9,6 +9,8 @@ import { import { getDefaultModelId, getModelQueryPrefix } from "../../../shared/embeddingModels" import { t } from "../../../i18n" import { withValidationErrorHandling, HttpError, formatEmbeddingError } from "../shared/validation-helpers" +import { TelemetryEventName } from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" interface EmbeddingItem { embedding: string | number[] @@ -284,6 +286,14 @@ export class OpenAICompatibleEmbedder implements IEmbedder { }, } } catch (error) { + // Capture telemetry before error is reformatted + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "OpenAICompatibleEmbedder:_embedBatchWithRetries", + attempt: attempts + 1, + }) + const hasMoreAttempts = attempts < MAX_RETRIES - 1 // Check if it's a rate limit error @@ -318,33 +328,43 @@ export class OpenAICompatibleEmbedder implements IEmbedder { */ async validateConfiguration(): Promise<{ valid: boolean; error?: string }> { return withValidationErrorHandling(async () => { - // Test with a minimal embedding request - const testTexts = ["test"] - const modelToUse = this.defaultModelId - - let response: OpenAIEmbeddingResponse - - if (this.isFullUrl) { - // Test direct HTTP request for full endpoint URLs - response = await this.makeDirectEmbeddingRequest(this.baseUrl, testTexts, modelToUse) - } else { - // Test using OpenAI SDK for base URLs - response = (await this.embeddingsClient.embeddings.create({ - input: testTexts, - model: modelToUse, - encoding_format: "base64", - })) as OpenAIEmbeddingResponse - } + try { + // Test with a minimal embedding request + const testTexts = ["test"] + const modelToUse = this.defaultModelId - // Check if we got a valid response - if (!response?.data || response.data.length === 0) { - return { - valid: false, - error: "embeddings:validation.invalidResponse", + let response: OpenAIEmbeddingResponse + + if (this.isFullUrl) { + // Test direct HTTP request for full endpoint URLs + response = await this.makeDirectEmbeddingRequest(this.baseUrl, testTexts, modelToUse) + } else { + // Test using OpenAI SDK for base URLs + response = (await this.embeddingsClient.embeddings.create({ + input: testTexts, + model: modelToUse, + encoding_format: "base64", + })) as OpenAIEmbeddingResponse } - } - return { valid: true } + // Check if we got a valid response + if (!response?.data || response.data.length === 0) { + return { + valid: false, + error: "embeddings:validation.invalidResponse", + } + } + + return { valid: true } + } catch (error) { + // Capture telemetry for validation errors + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "OpenAICompatibleEmbedder:validateConfiguration", + }) + throw error + } }, "openai-compatible") } diff --git a/src/services/code-index/embedders/openai.ts b/src/services/code-index/embedders/openai.ts index a620edc307a..471c3fd090d 100644 --- a/src/services/code-index/embedders/openai.ts +++ b/src/services/code-index/embedders/openai.ts @@ -11,6 +11,8 @@ import { import { getModelQueryPrefix } from "../../../shared/embeddingModels" import { t } from "../../../i18n" import { withValidationErrorHandling, formatEmbeddingError, HttpError } from "../shared/validation-helpers" +import { TelemetryEventName } from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" /** * OpenAI implementation of the embedder interface with batching and rate limiting @@ -156,6 +158,14 @@ export class OpenAiEmbedder extends OpenAiNativeHandler implements IEmbedder { continue } + // Capture telemetry before reformatting the error + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "OpenAiEmbedder:_embedBatchWithRetries", + attempt: attempts + 1, + }) + // Log the error for debugging console.error(`OpenAI embedder error (attempt ${attempts + 1}/${MAX_RETRIES}):`, error) @@ -173,21 +183,31 @@ export class OpenAiEmbedder extends OpenAiNativeHandler implements IEmbedder { */ async validateConfiguration(): Promise<{ valid: boolean; error?: string }> { return withValidationErrorHandling(async () => { - // Test with a minimal embedding request - const response = await this.embeddingsClient.embeddings.create({ - input: ["test"], - model: this.defaultModelId, - }) - - // Check if we got a valid response - if (!response.data || response.data.length === 0) { - return { - valid: false, - error: t("embeddings:openai.invalidResponseFormat"), + try { + // Test with a minimal embedding request + const response = await this.embeddingsClient.embeddings.create({ + input: ["test"], + model: this.defaultModelId, + }) + + // Check if we got a valid response + if (!response.data || response.data.length === 0) { + return { + valid: false, + error: t("embeddings:openai.invalidResponseFormat"), + } } - } - return { valid: true } + return { valid: true } + } catch (error) { + // Capture telemetry for validation errors + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "OpenAiEmbedder:validateConfiguration", + }) + throw error + } }, "openai") } diff --git a/src/services/code-index/manager.ts b/src/services/code-index/manager.ts index 6fa86b4e4fb..a474b107609 100644 --- a/src/services/code-index/manager.ts +++ b/src/services/code-index/manager.ts @@ -13,6 +13,8 @@ import fs from "fs/promises" import ignore from "ignore" import path from "path" import { t } from "../../i18n" +import { TelemetryService } from "@roo-code/telemetry" +import { TelemetryEventName } from "@roo-code/types" export class CodeIndexManager { // --- Singleton Implementation --- @@ -250,6 +252,11 @@ export class CodeIndexManager { } catch (error) { // Should never happen: reading file failed even though it exists console.error("Unexpected error loading .gitignore:", error) + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "_recreateServices", + }) } // (Re)Create shared service instances @@ -310,6 +317,11 @@ export class CodeIndexManager { } catch (error) { // Error state already set in _recreateServices console.error("Failed to recreate services:", error) + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "handleSettingsChange", + }) // Re-throw the error so the caller knows validation failed throw error } diff --git a/src/services/code-index/orchestrator.ts b/src/services/code-index/orchestrator.ts index 948a86fae5d..505aee76684 100644 --- a/src/services/code-index/orchestrator.ts +++ b/src/services/code-index/orchestrator.ts @@ -5,6 +5,8 @@ import { CodeIndexStateManager, IndexingState } from "./state-manager" import { IFileWatcher, IVectorStore, BatchProcessingSummary } from "./interfaces" import { DirectoryScanner } from "./processors" import { CacheManager } from "./cache-manager" +import { TelemetryService } from "@roo-code/telemetry" +import { TelemetryEventName } from "@roo-code/types" /** * Manages the code indexing workflow, coordinating between different services and managers. @@ -75,6 +77,11 @@ export class CodeIndexOrchestrator { ] } catch (error) { console.error("[CodeIndexOrchestrator] Failed to start file watcher:", error) + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "_startWatcher", + }) throw error } } @@ -194,10 +201,20 @@ export class CodeIndexOrchestrator { this.stateManager.setSystemState("Indexed", "File watcher started.") } catch (error: any) { console.error("[CodeIndexOrchestrator] Error during indexing:", error) + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "startIndexing", + }) try { await this.vectorStore.clearCollection() } catch (cleanupError) { console.error("[CodeIndexOrchestrator] Failed to clean up after error:", cleanupError) + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + stack: cleanupError instanceof Error ? cleanupError.stack : undefined, + location: "startIndexing.cleanup", + }) } await this.cacheManager.clearCacheFile() @@ -241,6 +258,11 @@ export class CodeIndexOrchestrator { } } catch (error: any) { console.error("[CodeIndexOrchestrator] Failed to clear vector collection:", error) + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "clearIndexData", + }) this.stateManager.setSystemState("Error", `Failed to clear vector collection: ${error.message}`) } diff --git a/src/services/code-index/processors/__tests__/file-watcher.spec.ts b/src/services/code-index/processors/__tests__/file-watcher.spec.ts index 98f12943473..2a3b7e11677 100644 --- a/src/services/code-index/processors/__tests__/file-watcher.spec.ts +++ b/src/services/code-index/processors/__tests__/file-watcher.spec.ts @@ -4,10 +4,31 @@ import * as vscode from "vscode" import { FileWatcher } from "../file-watcher" +// Mock TelemetryService +vi.mock("../../../../../packages/telemetry/src/TelemetryService", () => ({ + TelemetryService: { + instance: { + captureEvent: vi.fn(), + }, + }, +})) + // Mock dependencies vi.mock("../../cache-manager") -vi.mock("../../../core/ignore/RooIgnoreController") +vi.mock("../../../core/ignore/RooIgnoreController", () => ({ + RooIgnoreController: vi.fn().mockImplementation(() => ({ + validateAccess: vi.fn().mockReturnValue(true), + })), +})) vi.mock("ignore") +vi.mock("../parser", () => ({ + codeParser: { + parseFile: vi.fn().mockResolvedValue([]), + }, +})) +vi.mock("../../../glob/ignore-utils", () => ({ + isPathInIgnoredDirectory: vi.fn().mockReturnValue(false), +})) // Mock vscode module vi.mock("vscode", () => ({ @@ -20,6 +41,10 @@ vi.mock("vscode", () => ({ }, }, ], + fs: { + stat: vi.fn().mockResolvedValue({ size: 1000 }), + readFile: vi.fn().mockResolvedValue(Buffer.from("test content")), + }, }, RelativePattern: vi.fn().mockImplementation((base, pattern) => ({ base, pattern })), Uri: { @@ -92,6 +117,7 @@ describe("FileWatcher", () => { mockVectorStore = { upsertPoints: vi.fn().mockResolvedValue(undefined), deletePointsByFilePath: vi.fn().mockResolvedValue(undefined), + deletePointsByMultipleFilePaths: vi.fn().mockResolvedValue(undefined), } mockIgnoreInstance = { diff --git a/src/services/code-index/processors/__tests__/parser.spec.ts b/src/services/code-index/processors/__tests__/parser.spec.ts index 15b75e33819..68b523c7dfb 100644 --- a/src/services/code-index/processors/__tests__/parser.spec.ts +++ b/src/services/code-index/processors/__tests__/parser.spec.ts @@ -6,6 +6,15 @@ import { parseMarkdown } from "../../../tree-sitter/markdownParser" import { readFile } from "fs/promises" import { Node } from "web-tree-sitter" +// Mock TelemetryService +vi.mock("../../../../../packages/telemetry/src/TelemetryService", () => ({ + TelemetryService: { + instance: { + captureEvent: vi.fn(), + }, + }, +})) + // Override Jest-based fs/promises mock with vitest-compatible version vi.mock("fs/promises", () => ({ default: { diff --git a/src/services/code-index/processors/__tests__/scanner.spec.ts b/src/services/code-index/processors/__tests__/scanner.spec.ts index 2de7ebf2377..f90a6c8159a 100644 --- a/src/services/code-index/processors/__tests__/scanner.spec.ts +++ b/src/services/code-index/processors/__tests__/scanner.spec.ts @@ -3,6 +3,15 @@ import { DirectoryScanner } from "../scanner" import { stat } from "fs/promises" +// Mock TelemetryService +vi.mock("../../../../../packages/telemetry/src/TelemetryService", () => ({ + TelemetryService: { + instance: { + captureEvent: vi.fn(), + }, + }, +})) + vi.mock("fs/promises", () => ({ default: { readFile: vi.fn(), diff --git a/src/services/code-index/processors/file-watcher.ts b/src/services/code-index/processors/file-watcher.ts index a12752bea6c..6dc1cd1835d 100644 --- a/src/services/code-index/processors/file-watcher.ts +++ b/src/services/code-index/processors/file-watcher.ts @@ -23,6 +23,9 @@ import { codeParser } from "./parser" import { CacheManager } from "../cache-manager" import { generateNormalizedAbsolutePath, generateRelativeFilePath } from "../shared/get-relative-path" import { isPathInIgnoredDirectory } from "../../glob/ignore-utils" +import { TelemetryService } from "@roo-code/telemetry" +import { TelemetryEventName } from "@roo-code/types" +import { sanitizeErrorMessage } from "../shared/validation-helpers" /** * Implementation of the file watcher interface @@ -203,6 +206,13 @@ export class FileWatcher implements IFileWatcher { } } catch (error) { overallBatchError = error as Error + // Log telemetry for deletion error + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: sanitizeErrorMessage(overallBatchError.message), + location: "deletePointsByMultipleFilePaths", + errorType: "deletion_error", + }) + for (const path of pathsToExplicitlyDelete) { batchResults.push({ path, status: "error", error: error as Error }) processedCountInBatch++ @@ -246,8 +256,9 @@ export class FileWatcher implements IFileWatcher { const result = await this.processFile(fileDetail.path) return { path: fileDetail.path, result: result, error: undefined } } catch (e) { + const error = e as Error console.error(`[FileWatcher] Unhandled exception processing file ${fileDetail.path}:`, e) - return { path: fileDetail.path, result: undefined, error: e as Error } + return { path: fileDetail.path, result: undefined, error: error } } }) @@ -289,11 +300,13 @@ export class FileWatcher implements IFileWatcher { }) } } else { + const error = settledResult.reason as Error + const rejectedPath = (settledResult.reason as any)?.path || "unknown" console.error("[FileWatcher] A file processing promise was rejected:", settledResult.reason) batchResults.push({ - path: settledResult.reason?.path || "unknown", + path: rejectedPath, status: "error", - error: settledResult.reason as Error, + error: error, }) } @@ -308,7 +321,11 @@ export class FileWatcher implements IFileWatcher { } } - return { pointsForBatchUpsert, successfullyProcessedForUpsert, processedCount: processedCountInBatch } + return { + pointsForBatchUpsert, + successfullyProcessedForUpsert, + processedCount: processedCountInBatch, + } } private async _executeBatchUpsertOperations( @@ -332,6 +349,13 @@ export class FileWatcher implements IFileWatcher { upsertError = error as Error retryCount++ if (retryCount === MAX_BATCH_RETRIES) { + // Log telemetry for upsert failure + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: sanitizeErrorMessage(upsertError.message), + location: "upsertPoints", + errorType: "upsert_retry_exhausted", + retryCount: MAX_BATCH_RETRIES, + }) throw new Error( `Failed to upsert batch after ${MAX_BATCH_RETRIES} retries: ${upsertError.message}`, ) @@ -350,9 +374,17 @@ export class FileWatcher implements IFileWatcher { batchResults.push({ path, status: "success" }) } } catch (error) { - overallBatchError = overallBatchError || (error as Error) + const err = error as Error + overallBatchError = overallBatchError || err + // Log telemetry for batch upsert error + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: sanitizeErrorMessage(err.message), + location: "executeBatchUpsertOperations", + errorType: "batch_upsert_error", + affectedFiles: successfullyProcessedForUpsert.length, + }) for (const { path } of successfullyProcessedForUpsert) { - batchResults.push({ path, status: "error", error: error as Error }) + batchResults.push({ path, status: "error", error: err }) } } } else if (overallBatchError && pointsForBatchUpsert.length > 0) { diff --git a/src/services/code-index/processors/parser.ts b/src/services/code-index/processors/parser.ts index 9632c0f60f6..96d747c4c9f 100644 --- a/src/services/code-index/processors/parser.ts +++ b/src/services/code-index/processors/parser.ts @@ -7,6 +7,9 @@ import { parseMarkdown } from "../../tree-sitter/markdownParser" import { ICodeParser, CodeBlock } from "../interfaces" import { scannerExtensions } from "../shared/supported-extensions" import { MAX_BLOCK_CHARS, MIN_BLOCK_CHARS, MIN_CHUNK_REMAINDER_CHARS, MAX_CHARS_TOLERANCE_FACTOR } from "../constants" +import { TelemetryService } from "@roo-code/telemetry" +import { TelemetryEventName } from "@roo-code/types" +import { sanitizeErrorMessage } from "../shared/validation-helpers" /** * Implementation of the code parser interface @@ -51,6 +54,11 @@ export class CodeParser implements ICodeParser { fileHash = this.createFileHash(content) } catch (error) { console.error(`Error reading file ${filePath}:`, error) + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)), + stack: error instanceof Error ? sanitizeErrorMessage(error.stack || "") : undefined, + location: "parseFile", + }) return [] } } @@ -101,6 +109,11 @@ export class CodeParser implements ICodeParser { await pendingLoad } catch (error) { console.error(`Error in pending parser load for ${filePath}:`, error) + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)), + stack: error instanceof Error ? sanitizeErrorMessage(error.stack || "") : undefined, + location: "parseContent:loadParser", + }) return [] } } else { @@ -113,6 +126,11 @@ export class CodeParser implements ICodeParser { } } catch (error) { console.error(`Error loading language parser for ${filePath}:`, error) + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)), + stack: error instanceof Error ? sanitizeErrorMessage(error.stack || "") : undefined, + location: "parseContent:loadParser", + }) return [] } finally { this.pendingLoads.delete(ext) diff --git a/src/services/code-index/processors/scanner.ts b/src/services/code-index/processors/scanner.ts index 7b79a1c87d1..538a1252d78 100644 --- a/src/services/code-index/processors/scanner.ts +++ b/src/services/code-index/processors/scanner.ts @@ -25,6 +25,9 @@ import { BATCH_PROCESSING_CONCURRENCY, } from "../constants" import { isPathInIgnoredDirectory } from "../../glob/ignore-utils" +import { TelemetryService } from "@roo-code/telemetry" +import { TelemetryEventName } from "@roo-code/types" +import { sanitizeErrorMessage } from "../shared/validation-helpers" export class DirectoryScanner implements IDirectoryScanner { constructor( @@ -191,6 +194,11 @@ export class DirectoryScanner implements IDirectoryScanner { } } catch (error) { console.error(`Error processing file ${filePath} in workspace ${scanWorkspace}:`, error) + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)), + stack: error instanceof Error ? sanitizeErrorMessage(error.stack || "") : undefined, + location: "scanDirectory:processFile", + }) if (onError) { onError( error instanceof Error @@ -247,6 +255,11 @@ export class DirectoryScanner implements IDirectoryScanner { `[DirectoryScanner] Failed to delete points for ${cachedFilePath} in workspace ${scanWorkspace}:`, error, ) + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)), + stack: error instanceof Error ? sanitizeErrorMessage(error.stack || "") : undefined, + location: "scanDirectory:deleteRemovedFiles", + }) if (onError) { onError( error instanceof Error @@ -309,6 +322,17 @@ export class DirectoryScanner implements IDirectoryScanner { `[DirectoryScanner] Failed to delete points for ${uniqueFilePaths.length} files before upsert in workspace ${scanWorkspace}:`, deleteError, ) + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: sanitizeErrorMessage( + deleteError instanceof Error ? deleteError.message : String(deleteError), + ), + stack: + deleteError instanceof Error + ? sanitizeErrorMessage(deleteError.stack || "") + : undefined, + location: "processBatch:deletePointsByMultipleFilePaths", + fileCount: uniqueFilePaths.length, + }) // Re-throw the error with workspace context throw new Error( `Failed to delete points for ${uniqueFilePaths.length} files. Workspace: ${scanWorkspace}. ${deleteError instanceof Error ? deleteError.message : String(deleteError)}`, @@ -356,6 +380,13 @@ export class DirectoryScanner implements IDirectoryScanner { `[DirectoryScanner] Error processing batch (attempt ${attempts}) in workspace ${scanWorkspace}:`, error, ) + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)), + stack: error instanceof Error ? sanitizeErrorMessage(error.stack || "") : undefined, + location: "processBatch:retry", + attemptNumber: attempts, + batchSize: batchBlocks.length, + }) if (attempts < MAX_BATCH_RETRIES) { const delay = INITIAL_RETRY_DELAY_MS * Math.pow(2, attempts - 1) diff --git a/src/services/code-index/search-service.ts b/src/services/code-index/search-service.ts index 6370ec40941..a56f5cc6744 100644 --- a/src/services/code-index/search-service.ts +++ b/src/services/code-index/search-service.ts @@ -4,6 +4,8 @@ import { IEmbedder } from "./interfaces/embedder" import { IVectorStore } from "./interfaces/vector-store" import { CodeIndexConfigManager } from "./config-manager" import { CodeIndexStateManager } from "./state-manager" +import { TelemetryService } from "@roo-code/telemetry" +import { TelemetryEventName } from "@roo-code/types" /** * Service responsible for searching the code index. @@ -58,6 +60,14 @@ export class CodeIndexSearchService { } catch (error) { console.error("[CodeIndexSearchService] Error during search:", error) this.stateManager.setSystemState("Error", `Search failed: ${(error as Error).message}`) + + // Capture telemetry for the error + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: (error as Error).message, + stack: (error as Error).stack, + location: "searchIndex", + }) + throw error // Re-throw the error after setting state } } diff --git a/src/services/code-index/service-factory.ts b/src/services/code-index/service-factory.ts index 818dafb4979..a741aaf72a7 100644 --- a/src/services/code-index/service-factory.ts +++ b/src/services/code-index/service-factory.ts @@ -11,6 +11,8 @@ import { CodeIndexConfigManager } from "./config-manager" import { CacheManager } from "./cache-manager" import { Ignore } from "ignore" import { t } from "../../i18n" +import { TelemetryService } from "@roo-code/telemetry" +import { TelemetryEventName } from "@roo-code/types" /** * Factory class responsible for creating and configuring code indexing service dependencies. @@ -78,6 +80,13 @@ export class CodeIndexServiceFactory { try { return await embedder.validateConfiguration() } catch (error) { + // Capture telemetry for the error + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "validateEmbedder", + }) + // If validation throws an exception, preserve the original error message return { valid: false, diff --git a/src/services/code-index/shared/__tests__/validation-helpers.spec.ts b/src/services/code-index/shared/__tests__/validation-helpers.spec.ts new file mode 100644 index 00000000000..00270017db0 --- /dev/null +++ b/src/services/code-index/shared/__tests__/validation-helpers.spec.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest" +import { sanitizeErrorMessage } from "../validation-helpers" + +describe("sanitizeErrorMessage", () => { + it("should sanitize Unix-style file paths", () => { + const input = "Error reading file /Users/username/projects/myapp/src/index.ts" + const expected = "Error reading file [REDACTED_PATH]" + expect(sanitizeErrorMessage(input)).toBe(expected) + }) + + it("should sanitize Windows-style file paths", () => { + const input = "Cannot access C:\\Users\\username\\Documents\\project\\file.js" + const expected = "Cannot access [REDACTED_PATH]" + expect(sanitizeErrorMessage(input)).toBe(expected) + }) + + it("should sanitize relative file paths", () => { + const input = "File not found: ./src/components/Button.tsx" + const expected = "File not found: [REDACTED_PATH]" + expect(sanitizeErrorMessage(input)).toBe(expected) + + const input2 = "Cannot read ../config/settings.json" + const expected2 = "Cannot read [REDACTED_PATH]" + expect(sanitizeErrorMessage(input2)).toBe(expected2) + }) + + it("should sanitize URLs with various protocols", () => { + const input = "Failed to connect to http://localhost:11434/api/embed" + const expected = "Failed to connect to [REDACTED_URL]" + expect(sanitizeErrorMessage(input)).toBe(expected) + + const input2 = "Error fetching https://api.example.com:8080/v1/embeddings" + const expected2 = "Error fetching [REDACTED_URL]" + expect(sanitizeErrorMessage(input2)).toBe(expected2) + }) + + it("should sanitize IP addresses", () => { + const input = "Connection refused at 192.168.1.100" + const expected = "Connection refused at [REDACTED_IP]" + expect(sanitizeErrorMessage(input)).toBe(expected) + }) + + it("should sanitize port numbers", () => { + const input = "Server running on :8080 failed" + const expected = "Server running on :[REDACTED_PORT] failed" + expect(sanitizeErrorMessage(input)).toBe(expected) + }) + + it("should sanitize email addresses", () => { + const input = "User john.doe@example.com not found" + const expected = "User [REDACTED_EMAIL] not found" + expect(sanitizeErrorMessage(input)).toBe(expected) + }) + + it("should sanitize paths in quotes", () => { + const input = 'Cannot open file "/home/user/documents/secret.txt"' + const expected = 'Cannot open file "[REDACTED_PATH]"' + expect(sanitizeErrorMessage(input)).toBe(expected) + }) + + it("should handle complex error messages with multiple sensitive items", () => { + const input = "Failed to fetch http://localhost:11434 from /Users/john/project at 192.168.1.1:3000" + const expected = "Failed to fetch [REDACTED_URL] from [REDACTED_PATH] at [REDACTED_IP]:[REDACTED_PORT]" + expect(sanitizeErrorMessage(input)).toBe(expected) + }) + + it("should handle non-string inputs gracefully", () => { + expect(sanitizeErrorMessage(null as any)).toBe("null") + expect(sanitizeErrorMessage(undefined as any)).toBe("undefined") + expect(sanitizeErrorMessage(123 as any)).toBe("123") + expect(sanitizeErrorMessage({} as any)).toBe("[object Object]") + }) + + it("should preserve non-sensitive error messages", () => { + const input = "Invalid JSON format" + expect(sanitizeErrorMessage(input)).toBe(input) + + const input2 = "Connection timeout" + expect(sanitizeErrorMessage(input2)).toBe(input2) + }) + + it("should handle file paths with special characters", () => { + const input = 'Error in "/path/to/file with spaces.txt"' + const expected = 'Error in "[REDACTED_PATH]"' + expect(sanitizeErrorMessage(input)).toBe(expected) + }) + + it("should sanitize multiple occurrences of sensitive data", () => { + const input = "Copy from /src/file1.js to /dest/file2.js failed" + const expected = "Copy from [REDACTED_PATH] to [REDACTED_PATH] failed" + expect(sanitizeErrorMessage(input)).toBe(expected) + }) +}) diff --git a/src/services/code-index/shared/validation-helpers.ts b/src/services/code-index/shared/validation-helpers.ts index 01822645793..6b043d44d38 100644 --- a/src/services/code-index/shared/validation-helpers.ts +++ b/src/services/code-index/shared/validation-helpers.ts @@ -1,6 +1,48 @@ import { t } from "../../../i18n" import { serializeError } from "serialize-error" +/** + * Sanitizes error messages by removing sensitive information like file paths and URLs + * @param errorMessage The error message to sanitize + * @returns The sanitized error message + */ +export function sanitizeErrorMessage(errorMessage: string): string { + if (!errorMessage || typeof errorMessage !== "string") { + return String(errorMessage) + } + + let sanitized = errorMessage + + // Replace URLs first (http, https, ftp, file protocols) + // This needs to be done before file paths to avoid partial replacements + sanitized = sanitized.replace( + /(?:https?|ftp|file):\/\/(?:localhost|[\w\-\.]+)(?::\d+)?(?:\/[\w\-\.\/\?\&\=\#]*)?/gi, + "[REDACTED_URL]", + ) + + // Replace email addresses + sanitized = sanitized.replace(/[\w\-\.]+@[\w\-\.]+\.\w+/g, "[REDACTED_EMAIL]") + + // Replace IP addresses (IPv4) + sanitized = sanitized.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[REDACTED_IP]") + + // Replace file paths in quotes (handles paths with spaces) + sanitized = sanitized.replace(/"[^"]*(?:\/|\\)[^"]*"/g, '"[REDACTED_PATH]"') + + // Replace file paths (Unix and Windows style) + // Matches paths like /Users/username/path, C:\Users\path, ./relative/path, ../relative/path + sanitized = sanitized.replace( + /(?:\/[\w\-\.]+)+(?:\/[\w\-\.\s]*)*|(?:[A-Za-z]:\\[\w\-\.\\]+)|(?:\.{1,2}\/[\w\-\.\/]+)/g, + "[REDACTED_PATH]", + ) + + // Replace port numbers that appear after colons (e.g., :11434, :8080) + // Do this after URLs to avoid double replacement + sanitized = sanitized.replace(/(?