diff --git a/src/copilot/contextProvider.ts b/src/copilot/contextProvider.ts index 4857b8e6..6640ee13 100644 --- a/src/copilot/contextProvider.ts +++ b/src/copilot/contextProvider.ts @@ -18,7 +18,8 @@ import { ContextResolverFunction, CopilotApi, ContextProviderRegistrationError, - ContextProviderResolverError + ContextProviderResolverError, + sendContextResolutionTelemetry } from './utils'; export async function registerCopilotContextProviders( @@ -70,70 +71,95 @@ function createJavaContextResolver(): ContextResolverFunction { }; } -/** - * Send telemetry data for Java context resolution - */ -function sendContextTelemetry(request: ResolveRequest, start: number, items: SupportedContextItem[], status: string, error?: string) { - const duration = Math.round(performance.now() - start); - const tokenCount = JavaContextProviderUtils.calculateTokenCount(items); - const telemetryData: any = { - "action": "resolveJavaContext", - "completionId": request.completionId, - "duration": duration, - "itemCount": items.length, - "tokenCount": tokenCount, - "status": status - }; - if (error) { - telemetryData.error = error; - } - sendInfo("", telemetryData); -} - async function resolveJavaContext(request: ResolveRequest, copilotCancel: vscode.CancellationToken): Promise { const items: SupportedContextItem[] = []; const start = performance.now(); + + let dependenciesResult: CopilotHelper.IResolveResult | undefined; + let importsResult: CopilotHelper.IResolveResult | undefined; + try { // Check for cancellation before starting JavaContextProviderUtils.checkCancellation(copilotCancel); + // Resolve project dependencies and convert to context items - const projectDependencyItems = await CopilotHelper.resolveAndConvertProjectDependencies( + dependenciesResult = await CopilotHelper.resolveAndConvertProjectDependencies( vscode.window.activeTextEditor, copilotCancel, JavaContextProviderUtils.checkCancellation ); JavaContextProviderUtils.checkCancellation(copilotCancel); - items.push(...projectDependencyItems); + items.push(...dependenciesResult.items); JavaContextProviderUtils.checkCancellation(copilotCancel); // Resolve local imports and convert to context items - const localImportItems = await CopilotHelper.resolveAndConvertLocalImports( + importsResult = await CopilotHelper.resolveAndConvertLocalImports( vscode.window.activeTextEditor, copilotCancel, JavaContextProviderUtils.checkCancellation ); JavaContextProviderUtils.checkCancellation(copilotCancel); - items.push(...localImportItems); + items.push(...importsResult.items); } catch (error: any) { if (error instanceof CopilotCancellationError) { - sendContextTelemetry(request, start, items, "cancelled_by_copilot"); + sendContextResolutionTelemetry( + request, + start, + items, + "cancelled_by_copilot", + undefined, + dependenciesResult?.emptyReason, + importsResult?.emptyReason, + dependenciesResult?.itemCount, + importsResult?.itemCount + ); throw error; } if (error instanceof vscode.CancellationError || error.message === CancellationError.CANCELED) { - sendContextTelemetry(request, start, items, "cancelled_internally"); + sendContextResolutionTelemetry( + request, + start, + items, + "cancelled_internally", + undefined, + dependenciesResult?.emptyReason, + importsResult?.emptyReason, + dependenciesResult?.itemCount, + importsResult?.itemCount + ); throw new InternalCancellationError(); } // Send telemetry for general errors (but continue with partial results) - sendContextTelemetry(request, start, items, "error_partial_results", error.message || "unknown_error"); + sendContextResolutionTelemetry( + request, + start, + items, + "error_partial_results", + error.message || "unknown_error", + dependenciesResult?.emptyReason, + importsResult?.emptyReason, + dependenciesResult?.itemCount, + importsResult?.itemCount + ); // Return partial results and log completion for error case return items; } // Send telemetry data once at the end for success case - sendContextTelemetry(request, start, items, "succeeded"); + sendContextResolutionTelemetry( + request, + start, + items, + "succeeded", + undefined, + dependenciesResult?.emptyReason, + importsResult?.emptyReason, + dependenciesResult?.itemCount, + importsResult?.itemCount + ); return items; } diff --git a/src/copilot/copilotHelper.ts b/src/copilot/copilotHelper.ts index 2a0b4cfd..67bf3196 100644 --- a/src/copilot/copilotHelper.ts +++ b/src/copilot/copilotHelper.ts @@ -2,8 +2,7 @@ // Licensed under the MIT license. import { commands, Uri, CancellationToken } from "vscode"; -import { sendError, sendInfo } from "vscode-extension-telemetry-wrapper"; -import { GetImportClassContentError, GetProjectDependenciesError, sendContextOperationTelemetry, JavaContextProviderUtils } from "./utils"; +import { JavaContextProviderUtils } from "./utils"; import { Commands } from '../commands'; /** @@ -52,17 +51,6 @@ export interface IProjectDependenciesResult { * Helper class for Copilot integration to analyze Java project dependencies */ export namespace CopilotHelper { - /** - * Resolves all local project types imported by the given file (backward compatibility version) - * @param fileUri The URI of the Java file to analyze - * @param cancellationToken Optional cancellation token to abort the operation - * @returns Array of import class information - */ - export async function resolveLocalImports(fileUri: Uri, cancellationToken?: CancellationToken): Promise { - const result = await resolveLocalImportsWithReason(fileUri, cancellationToken); - return result.classInfoList; - } - /** * Resolves all local project types imported by the given file with detailed error reporting * @param fileUri The URI of the Java file to analyze @@ -85,46 +73,41 @@ export namespace CopilotHelper { Commands.JAVA_PROJECT_GET_IMPORT_CLASS_CONTENT, normalizedUri ) as Promise; + + // Build promises array for race condition + // Note: Client-side timeout is NECESSARY even if backend has timeout because: + // 1. Network delays may prevent backend response from arriving + // 2. Process hangs won't trigger backend timeout + // 3. Command dispatch failures need to be caught + const promises: Promise[] = [ + commandPromise, + new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(ErrorMessage.OperationTimedOut)); + }, 80); // 80ms client-side timeout (independent of backend timeout) + }) + ]; + + // Add cancellation promise if token provided if (cancellationToken) { - const result = await Promise.race([ - commandPromise, + promises.push( new Promise((_, reject) => { cancellationToken.onCancellationRequested(() => { reject(new Error(ErrorMessage.OperationCancelled)); }); - }), - new Promise((_, reject) => { - setTimeout(() => { - reject(new Error(ErrorMessage.OperationTimedOut)); - }, 80); // 80ms timeout }) - ]); - if (!result) { - return { - classInfoList: [], - emptyReason: EmptyReason.CommandNullResult, - isEmpty: true - }; - } - return result; - } else { - const result = await Promise.race([ - commandPromise, - new Promise((_, reject) => { - setTimeout(() => { - reject(new Error(ErrorMessage.OperationTimedOut)); - }, 80); // 80ms timeout - }) - ]); - if (!result) { - return { - classInfoList: [], - emptyReason: EmptyReason.CommandNullResult, - isEmpty: true - }; - } - return result; + ); } + + const result = await Promise.race(promises); + if (!result) { + return { + classInfoList: [], + emptyReason: EmptyReason.CommandNullResult, + isEmpty: true + }; + } + return result; } catch (error: any) { if (error.message === ErrorMessage.OperationCancelled) { return { @@ -141,7 +124,6 @@ export namespace CopilotHelper { }; } const errorMessage = 'TsException_' + ((error as Error).message || "unknown"); - sendError(new GetImportClassContentError(errorMessage)); return { classInfoList: [], emptyReason: errorMessage, @@ -150,24 +132,6 @@ export namespace CopilotHelper { } } - /** - * Resolves project dependencies for the given project URI (backward compatibility version) - * @param projectUri The URI of the Java project to analyze - * @param cancellationToken Optional cancellation token to abort the operation - * @returns Object containing project dependencies as key-value pairs - */ - export async function resolveProjectDependencies(projectUri: Uri, cancellationToken?: CancellationToken): Promise { - const result = await resolveProjectDependenciesWithReason(projectUri, cancellationToken); - - // Convert to legacy format - const dependencies: IProjectDependency = {}; - for (const dep of result.dependencyInfoList) { - dependencies[dep.key] = dep.value; - } - - return dependencies; - } - /** * Resolves project dependencies with detailed error reporting * @param projectUri The URI of the Java project to analyze @@ -194,46 +158,40 @@ export namespace CopilotHelper { normalizedUri ) as Promise; + // Build promises array for race condition + // Note: Client-side timeout is NECESSARY even if backend has timeout because: + // 1. Network delays may prevent backend response from arriving + // 2. Process hangs won't trigger backend timeout + // 3. Command dispatch failures need to be caught + const promises: Promise[] = [ + commandPromise, + new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(ErrorMessage.OperationTimedOut)); + }, 40); // 40ms client-side timeout (independent of backend timeout) + }) + ]; + + // Add cancellation promise if token provided if (cancellationToken) { - const result = await Promise.race([ - commandPromise, + promises.push( new Promise((_, reject) => { cancellationToken.onCancellationRequested(() => { reject(new Error(ErrorMessage.OperationCancelled)); }); - }), - new Promise((_, reject) => { - setTimeout(() => { - reject(new Error(ErrorMessage.OperationTimedOut)); - }, 40); // 40ms timeout }) - ]); - if (!result) { - return { - dependencyInfoList: [], - emptyReason: EmptyReason.CommandNullResult, - isEmpty: true - }; - } - return result; - } else { - const result = await Promise.race([ - commandPromise, - new Promise((_, reject) => { - setTimeout(() => { - reject(new Error(ErrorMessage.OperationTimedOut)); - }, 40); // 40ms timeout - }) - ]); - if (!result) { - return { - dependencyInfoList: [], - emptyReason: EmptyReason.CommandNullResult, - isEmpty: true - }; - } - return result; + ); + } + + const result = await Promise.race(promises); + if (!result) { + return { + dependencyInfoList: [], + emptyReason: EmptyReason.CommandNullResult, + isEmpty: true + }; } + return result; } catch (error: any) { if (error.message === ErrorMessage.OperationCancelled) { return { @@ -250,7 +208,6 @@ export namespace CopilotHelper { }; } const errorMessage = 'TsException_' + ((error as Error).message || "unknown"); - sendError(new GetProjectDependenciesError(errorMessage)); return { dependencyInfoList: [], emptyReason: errorMessage, @@ -259,27 +216,35 @@ export namespace CopilotHelper { } } + /** + * Result interface for dependency resolution with diagnostic information + */ + export interface IResolveResult { + items: any[]; + emptyReason?: string; + itemCount: number; + } + /** * Resolves project dependencies and converts them to context items with cancellation support - * @param workspaceFolders The workspace folders, or undefined if none + * @param activeEditor The active text editor, or undefined if none * @param copilotCancel Cancellation token from Copilot * @param checkCancellation Function to check for cancellation - * @returns Array of context items for project dependencies, or empty array if no workspace folders + * @returns Result object containing context items and diagnostic information */ export async function resolveAndConvertProjectDependencies( activeEditor: { document: { uri: Uri; languageId: string } } | undefined, copilotCancel: CancellationToken, checkCancellation: (token: CancellationToken) => void - ): Promise<{ name: string; value: string; importance: number }[]> { + ): Promise { const items: any[] = []; - // Check if workspace folders exist + + // Check if active editor exists if (!activeEditor) { - sendContextOperationTelemetry("resolveLocalImports", "ContextEmpty", sendInfo, EmptyReason.NoActiveEditor); - return items; + return { items: [], emptyReason: EmptyReason.NoActiveEditor, itemCount: 0 }; } if (activeEditor.document.languageId !== 'java') { - sendContextOperationTelemetry("resolveLocalImports", "ContextEmpty", sendInfo, EmptyReason.NotJavaFile); - return items; + return { items: [], emptyReason: EmptyReason.NotJavaFile, itemCount: 0 }; } const documentUri = activeEditor.document.uri; @@ -289,12 +254,12 @@ export namespace CopilotHelper { // Check for cancellation after dependency resolution checkCancellation(copilotCancel); - // Send telemetry if result is empty + // Return empty result with reason if no dependencies found if (projectDependenciesResult.isEmpty && projectDependenciesResult.emptyReason) { - sendContextOperationTelemetry("resolveProjectDependencies", "ContextEmpty", sendInfo, projectDependenciesResult.emptyReason); + return { items: [], emptyReason: projectDependenciesResult.emptyReason, itemCount: 0 }; } - // Check for cancellation after telemetry + // Check for cancellation after dependency resolution checkCancellation(copilotCancel); // Convert project dependencies to context items @@ -306,7 +271,7 @@ export namespace CopilotHelper { items.push(...contextItems); } - return items; + return { items, itemCount: items.length }; } /** @@ -314,23 +279,21 @@ export namespace CopilotHelper { * @param activeEditor The active text editor, or undefined if none * @param copilotCancel Cancellation token from Copilot * @param checkCancellation Function to check for cancellation - * @param createContextItems Function to create context items from imports - * @returns Array of context items for local imports, or empty array if no valid editor + * @returns Result object containing context items and diagnostic information */ export async function resolveAndConvertLocalImports( activeEditor: { document: { uri: Uri; languageId: string } } | undefined, copilotCancel: CancellationToken, checkCancellation: (token: CancellationToken) => void - ): Promise { + ): Promise { const items: any[] = []; + // Check if there's an active editor with a Java document if (!activeEditor) { - sendContextOperationTelemetry("resolveLocalImports", "ContextEmpty", sendInfo, EmptyReason.NoActiveEditor); - return items; + return { items: [], emptyReason: EmptyReason.NoActiveEditor, itemCount: 0 }; } if (activeEditor.document.languageId !== 'java') { - sendContextOperationTelemetry("resolveLocalImports", "ContextEmpty", sendInfo, EmptyReason.NotJavaFile); - return items; + return { items: [], emptyReason: EmptyReason.NotJavaFile, itemCount: 0 }; } const documentUri = activeEditor.document.uri; @@ -343,10 +306,11 @@ export namespace CopilotHelper { // Check for cancellation after resolution checkCancellation(copilotCancel); - // Send telemetry if result is empty + // Return empty result with reason if no imports found if (importClassResult.isEmpty && importClassResult.emptyReason) { - sendContextOperationTelemetry("resolveLocalImports", "ContextEmpty", sendInfo, importClassResult.emptyReason); + return { items: [], emptyReason: importClassResult.emptyReason, itemCount: 0 }; } + // Check for cancellation before processing results checkCancellation(copilotCancel); if (importClassResult.classInfoList && importClassResult.classInfoList.length > 0) { @@ -357,6 +321,6 @@ export namespace CopilotHelper { items.push(...contextItems); } - return items; + return { items, itemCount: items.length }; } } diff --git a/src/copilot/utils.ts b/src/copilot/utils.ts index 69717aa2..4a2b424a 100644 --- a/src/copilot/utils.ts +++ b/src/copilot/utils.ts @@ -7,6 +7,7 @@ import { SupportedContextItem, type ContextProvider, } from '@github/copilot-language-server'; +import { sendInfo } from "vscode-extension-telemetry-wrapper"; /** * Error classes for Copilot context provider cancellation handling */ @@ -210,24 +211,54 @@ export class ContextProviderResolverError extends Error { } /** - * Send telemetry data for context operations (like resolveProjectDependencies, resolveLocalImports) - * @param action The action being performed - * @param status The status of the action (e.g., "ContextEmpty", "succeeded") - * @param reason Optional reason for empty context + * Send consolidated telemetry data for Java context resolution + * This is the centralized function for sending context resolution telemetry + * + * @param request The resolve request from Copilot + * @param start Performance timestamp when resolution started + * @param items The resolved context items + * @param status Status of the resolution ("succeeded", "cancelled_by_copilot", "cancelled_internally", "error_partial_results") * @param sendInfo The sendInfo function from vscode-extension-telemetry-wrapper + * @param error Optional error message + * @param dependenciesEmptyReason Optional reason why dependencies were empty + * @param importsEmptyReason Optional reason why imports were empty + * @param dependenciesCount Number of dependency items resolved + * @param importsCount Number of import items resolved */ -export function sendContextOperationTelemetry( - action: string, +export function sendContextResolutionTelemetry( + request: ResolveRequest, + start: number, + items: SupportedContextItem[], status: string, - sendInfo: (eventName: string, properties?: any) => void, - reason?: string + error?: string, + dependenciesEmptyReason?: string, + importsEmptyReason?: string, + dependenciesCount?: number, + importsCount?: number ): void { + const duration = Math.round(performance.now() - start); + const tokenCount = JavaContextProviderUtils.calculateTokenCount(items); const telemetryData: any = { - "action": action, - "status": status + "action": "resolveJavaContext", + "completionId": request.completionId, + "duration": duration, + "itemCount": items.length, + "tokenCount": tokenCount, + "status": status, + "dependenciesCount": dependenciesCount ?? 0, + "importsCount": importsCount ?? 0 }; - if (reason) { - telemetryData.ContextEmptyReason = reason; + + // Add empty reasons if present + if (dependenciesEmptyReason) { + telemetryData.dependenciesEmptyReason = dependenciesEmptyReason; + } + if (importsEmptyReason) { + telemetryData.importsEmptyReason = importsEmptyReason; } + if (error) { + telemetryData.error = error; + } + sendInfo("", telemetryData); } \ No newline at end of file