diff --git a/src/languageModelTool.ts b/src/languageModelTool.ts index 6587869..341fb7e 100644 --- a/src/languageModelTool.ts +++ b/src/languageModelTool.ts @@ -5,14 +5,24 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; import { + beginDebugSessionInvocation, classifyBreakpoint, classifyError, classifyEvalContext, + classifyJavaMajorVersion, + classifyPlatform, classifyRemoveScope, classifyScopeType, classifyStep, classifyTarget, + ClassNameDetectionFailure, + ClassNameDetectionStrategy, + completeAttempt, ErrorCategory, + InvocationGuard, + LaunchProjectType, + nextAttempt, + OperatingSystem, recordLaunchInternal, recordToolInvocation, TOOL_NAMES, @@ -23,10 +33,18 @@ import { // Constants // ============================================================================ const CONSTANTS = { - /** Timeout for waitForSession mode (ms) */ - SESSION_WAIT_TIMEOUT: 45000, - /** Maximum wait time for smart polling (ms) */ - SMART_POLLING_MAX_WAIT: 15000, + /** + * Timeout for waitForSession mode (ms). Set high enough to cover Started P95 + * (~100s in 30d telemetry); previous 45s clipped ~10% of legitimate starts + * as timeouts. + */ + SESSION_WAIT_TIMEOUT: 120000, + /** + * Maximum wait time for smart polling (ms). Previous 15s only covered ~60% + * of successful starts; bumped to align with the eventBased threshold so we + * do not bias data by polling strategy. + */ + SMART_POLLING_MAX_WAIT: 90000, /** Interval between polling checks (ms) */ SMART_POLLING_INTERVAL: 300, /** Timeout for build tasks (ms) */ @@ -39,6 +57,34 @@ const CONSTANTS = { MAX_FILE_SEARCH_DEPTH: 10 }; +// ---------------------------------------------------------------------------- +// Process-wide context probed lazily on first use. The value is constant for +// the VS Code session lifetime, so we cache it. +// ---------------------------------------------------------------------------- +let cachedJavaMajorVersion: string | undefined; + +function getJavaMajorVersion(): string { + if (cachedJavaMajorVersion !== undefined) { + return cachedJavaMajorVersion; + } + // Best-effort probe: read JAVA_HOME / PATH-resolved `java -version` output. + // We DO NOT shell out here (extension activation perf budget) — instead we + // look at the redhat.java extension's resolved JDK if available. + try { + const javaExt = vscode.extensions.getExtension('redhat.java'); + const apiVersion = (javaExt?.exports as { javaRequirement?: { java_version?: string } } | undefined) + ?.javaRequirement?.java_version; + cachedJavaMajorVersion = classifyJavaMajorVersion(apiVersion ?? process.env.JAVA_VERSION); + } catch { + cachedJavaMajorVersion = 'unknown'; + } + return cachedJavaMajorVersion; +} + +function getOs(): OperatingSystem { + return classifyPlatform(process.platform); +} + interface DebugJavaApplicationInput { target: string; workspacePath: string; @@ -78,11 +124,20 @@ export function registerLanguageModelTool(context: vscode.ExtensionContext): vsc async invoke(options: { input: DebugJavaApplicationInput }, token: vscode.CancellationToken): Promise { const startedAt = Date.now(); const targetType = classifyTarget(options.input.target); + const attempt = nextAttempt(TOOL_NAMES.DEBUG_JAVA_APPLICATION); + const os = getOs(); + const javaMajorVersion = getJavaMajorVersion(); + const guard = beginDebugSessionInvocation({ + os, + javaMajorVersion, + projectSystem: undefined, // resolved inside debugJavaApplication; not yet known here + isCancelled: () => token.isCancellationRequested, + }); let outcome: ToolOutcome = 'success'; let errorCategory: ErrorCategory | undefined; try { - const result = await debugJavaApplication(options.input, token); + const result = await debugJavaApplication(options.input, token, guard); if (!result.success) { outcome = result.status === 'timeout' ? 'timeout' : 'failure'; errorCategory = result.success ? undefined : classifyError(result.message); @@ -103,6 +158,7 @@ export function registerLanguageModelTool(context: vscode.ExtensionContext): vsc } catch (error) { outcome = token.isCancellationRequested ? 'cancelled' : 'failure'; errorCategory = classifyError(error); + guard.markException(error); const errorMessage = error instanceof Error ? error.message : String(error); @@ -117,7 +173,24 @@ export function registerLanguageModelTool(context: vscode.ExtensionContext): vsc targetType, skipBuild: !!options.input.skipBuild, durationMs: Date.now() - startedAt, + os, + javaMajorVersion, + retryCount: attempt.retryCount, + previousOutcome: attempt.previousOutcome, }); + completeAttempt(TOOL_NAMES.DEBUG_JAVA_APPLICATION, outcome); + // Do NOT call guard.markOutcomeRecorded() here. The wrapper + // recordToolInvocation above is a separate event from the + // launchInternal stream that the guard is tracking; marking + // it would mask every silent / cancelled / exception path + // and defeat the closed-loop attribution this PR exists for. + // `markOutcomeRecorded()` is now called inline at the four + // session-terminal recordLaunchInternal sites inside + // `debugJavaApplication` (started / timeout, both + // eventBased and smartPolling). For cancelled / exception + // we fall through to guard.close() so it can emit + // debugSession.cancelled / debugSession.exception. + guard.close(); } } }; @@ -137,7 +210,8 @@ export function registerLanguageModelTool(context: vscode.ExtensionContext): vsc */ async function debugJavaApplication( input: DebugJavaApplicationInput, - token: vscode.CancellationToken + token: vscode.CancellationToken, + guard?: InvocationGuard, ): Promise { if (token.isCancellationRequested) { return { @@ -206,18 +280,34 @@ async function debugJavaApplication( // ownership was split across constructDebugCommand and the targetInfo // formatting block // - // After this refactor, the caller owns detection and its telemetry, - // and constructDebugCommand accepts a pre-resolved name. + // After this refactor, the caller owns detection and its telemetry, and + // constructDebugCommand accepts a pre-resolved name. Detection now + // returns a structured result so we can emit `classNameDetection.failed` + // with a precise failureReason instead of a single boolean bucket. let detectedClassName: string | null = null; if (!input.target.endsWith('.jar') && !input.target.startsWith('-') && !input.target.includes('.')) { - detectedClassName = findFullyQualifiedClassName(input.workspacePath, input.target, projectType); - recordLaunchInternal({ - name: 'classNameDetection', - projectType, - detected: !!detectedClassName, - }); + const detection = findFullyQualifiedClassName(input.workspacePath, input.target, projectType); + detectedClassName = detection.className; + if (detection.className !== null) { + recordLaunchInternal({ + name: 'classNameDetection', + projectType, + detected: true, + }); + } else { + // Detection failed. Emit the structured failure event so we can + // distinguish "no candidate src dir" from "found file but no + // package" — the previous boolean `detected: false` collapsed all + // four root causes into one bucket. + recordLaunchInternal({ + name: 'classNameDetection.failed', + projectType, + strategy: detection.strategy, + failureReason: detection.failureReason, + }); + } } const debugCommand = constructDebugCommand(input, projectType, detectedClassName); @@ -249,9 +339,9 @@ async function debugJavaApplication( } else if (input.target.includes('.')) { targetInfo = input.target; } else { - // Simple class name - reuse the detection result from Step 3 above - // (do NOT call findFullyQualifiedClassName again — it walks the FS - // and the result is already in `detectedClassName`). + // Simple class name - reuse the detection result resolved once in + // Step 3 above (do NOT call findFullyQualifiedClassName again — it + // walks the FS and the result is already in `detectedClassName`). if (detectedClassName) { targetInfo = `${detectedClassName} (detected from ${input.target})`; } else { @@ -264,6 +354,7 @@ async function debugJavaApplication( if (input.waitForSession) { return new Promise((resolve) => { let sessionStarted = false; + const waitStartedAt = Date.now(); // Listen for debug session start const sessionDisposable = vscode.debug.onDidStartDebugSession((session) => { @@ -277,7 +368,10 @@ async function debugJavaApplication( recordLaunchInternal({ name: 'debugSessionStarted.eventBased', sessionId: session.id, + elapsedMs: Date.now() - waitStartedAt, + thresholdMs: CONSTANTS.SESSION_WAIT_TIMEOUT, }); + guard?.markOutcomeRecorded(); resolve({ success: true, @@ -297,7 +391,12 @@ async function debugJavaApplication( if (!sessionStarted) { sessionDisposable.dispose(); - recordLaunchInternal({ name: 'debugSessionTimeout.eventBased' }); + recordLaunchInternal({ + name: 'debugSessionTimeout.eventBased', + elapsedMs: Date.now() - waitStartedAt, + thresholdMs: CONSTANTS.SESSION_WAIT_TIMEOUT, + }); + guard?.markOutcomeRecorded(); resolve({ success: false, @@ -343,6 +442,7 @@ async function debugJavaApplication( sessionId: session.id, elapsedMs, }); + guard?.markOutcomeRecorded(); return { success: true, @@ -357,11 +457,13 @@ async function debugJavaApplication( await new Promise(resolve => setTimeout(resolve, pollInterval)); } - // Timeout: session not detected within 15 seconds + // Timeout: session not detected within polling window recordLaunchInternal({ name: 'debugSessionTimeout.smartPolling', maxWaitTime, + elapsedMs: Date.now() - startTime, }); + guard?.markOutcomeRecorded(); return { success: true, @@ -646,8 +748,8 @@ function constructDebugCommand( // Use the caller-supplied detection result; we deliberately do not // call findFullyQualifiedClassName a second time here (see the - // dedupe note at the call site). Detection telemetry is owned by - // the caller. + // dedupe note at the call site in debugJavaApplication Step 3). + // Detection telemetry is owned by the caller. if (!input.target.includes('.') && preDetectedClassName) { className = preDetectedClassName; } @@ -666,6 +768,42 @@ function constructDebugCommand( return command; } +/** + * Result of `findFullyQualifiedClassName` — a discriminated union so the + * type system enforces that callers handle the failure case without an + * easy-to-misread sentinel string. + * + * On success: `className` carries the resolved FQN and `failureReason` is + * absent. On failure: `className` is null and `failureReason` is the + * structured root cause. The boolean `detected: true/false` event was + * historically the only signal — it collapsed four very different root + * causes (sourceDirMissing / fileNotFound / parseError / + * noPackageDeclaration) into one bucket and made on-call triage + * impossible. + */ +type ClassNameDetectionResult = + | { + className: string; + /** Which source-directory layout was used. */ + strategy: ClassNameDetectionStrategy; + failureReason?: undefined; + } + | { + className: null; + /** Which source-directory layout was last tried. */ + strategy: ClassNameDetectionStrategy; + failureReason: ClassNameDetectionFailure; + }; + +function strategyForProjectType(projectType: LaunchProjectType): ClassNameDetectionStrategy { + switch (projectType) { + case 'maven': return 'mavenStandard'; + case 'gradle': return 'gradleStandard'; + case 'vscode': return 'vscodeSrc'; + case 'unknown': return 'workspaceRoot'; + } +} + /** * Tries to find the fully qualified class name by searching for the Java file. * This helps when user provides just "App" instead of "com.example.App". @@ -673,8 +811,10 @@ function constructDebugCommand( function findFullyQualifiedClassName( workspacePath: string, simpleClassName: string, - projectType: 'maven' | 'gradle' | 'vscode' | 'unknown' -): string | null { + projectType: LaunchProjectType +): ClassNameDetectionResult { + const defaultStrategy = strategyForProjectType(projectType); + // Determine source directories based on project type const sourceDirs: string[] = []; @@ -698,30 +838,71 @@ function findFullyQualifiedClassName( break; } - // Search for the Java file + // Track the dominant failure reason as we walk the candidate dirs. + // Priority on success: returned immediately. + // Priority on failure: parseError > noPackageDeclaration > fileNotFound > sourceDirMissing. + let anyDirExisted = false; + let anyFileFound = false; + let sawParseError = false; + for (const srcDir of sourceDirs) { if (!fs.existsSync(srcDir)) { continue; } + anyDirExisted = true; try { const javaFile = findJavaFile(srcDir, simpleClassName, 0); if (javaFile) { + anyFileFound = true; // Extract package name from the file const packageName = extractPackageName(javaFile); if (packageName) { - return `${packageName}.${simpleClassName}`; - } else { - // No package, use simple name - return simpleClassName; + return { + className: `${packageName}.${simpleClassName}`, + strategy: defaultStrategy, + }; } + // Found the file but no package declaration. Surface this + // as a structured failure (className: null) so callers can + // emit `classNameDetection.failed` with + // failureReason='noPackageDeclaration'. Callers already + // fall back to `input.target` on null, which preserves the + // previous command behaviour for default-package classes + // while making the telemetry distinguishable. + return { + className: null, + failureReason: 'noPackageDeclaration', + strategy: defaultStrategy, + }; } } catch (error) { - // Continue searching in other directories + // We at least reached the file system but could not read/scan it. + sawParseError = true; } } - return null; + let failureReason: ClassNameDetectionFailure; + if (sawParseError) { + failureReason = 'parseError'; + } else if (anyFileFound) { + // Defensive: the no-package case already returns above with + // failureReason='noPackageDeclaration', so reaching this branch + // means a future refactor has left the function falling through + // with anyFileFound=true. Preserve the same classification rather + // than silently bucketing into fileNotFound. + failureReason = 'noPackageDeclaration'; + } else if (anyDirExisted) { + failureReason = 'fileNotFound'; + } else { + failureReason = 'sourceDirMissing'; + } + + return { + className: null, + failureReason, + strategy: defaultStrategy, + }; } /** @@ -970,6 +1151,9 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs async invoke(options: { input: SetBreakpointInput }, _token: vscode.CancellationToken): Promise { const startedAt = Date.now(); const breakpointKind = classifyBreakpoint(options.input); + const attempt = nextAttempt(TOOL_NAMES.SET_JAVA_BREAKPOINT); + const os = getOs(); + const javaMajorVersion = getJavaMajorVersion(); let outcome: ToolOutcome = 'success'; let errorCategory: ErrorCategory | undefined; @@ -1012,7 +1196,12 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs errorCategory, breakpointKind, durationMs: Date.now() - startedAt, + os, + javaMajorVersion, + retryCount: attempt.retryCount, + previousOutcome: attempt.previousOutcome, }); + completeAttempt(TOOL_NAMES.SET_JAVA_BREAKPOINT, outcome); } } }; @@ -1023,6 +1212,9 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs async invoke(options: { input: StepOperationInput }, _token: vscode.CancellationToken): Promise { const startedAt = Date.now(); const stepKind = classifyStep(options.input.operation); + const attempt = nextAttempt(TOOL_NAMES.DEBUG_STEP_OPERATION); + const os = getOs(); + const javaMajorVersion = getJavaMajorVersion(); let outcome: ToolOutcome = 'success'; let errorCategory: ErrorCategory | undefined; @@ -1080,7 +1272,12 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs errorCategory, stepKind, durationMs: Date.now() - startedAt, + os, + javaMajorVersion, + retryCount: attempt.retryCount, + previousOutcome: attempt.previousOutcome, }); + completeAttempt(TOOL_NAMES.DEBUG_STEP_OPERATION, outcome); } } }; @@ -1092,6 +1289,9 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs const startedAt = Date.now(); const scopeTypeEnum = classifyScopeType(options.input.scopeType); const hasFilter = !!options.input.filter; + const attempt = nextAttempt(TOOL_NAMES.GET_DEBUG_VARIABLES); + const os = getOs(); + const javaMajorVersion = getJavaMajorVersion(); let outcome: ToolOutcome = 'success'; let errorCategory: ErrorCategory | undefined; @@ -1191,7 +1391,12 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs scopeType: scopeTypeEnum, hasFilter, durationMs: Date.now() - startedAt, + os, + javaMajorVersion, + retryCount: attempt.retryCount, + previousOutcome: attempt.previousOutcome, }); + completeAttempt(TOOL_NAMES.GET_DEBUG_VARIABLES, outcome); } } }; @@ -1201,6 +1406,9 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs const getStackTraceTool: LanguageModelTool = { async invoke(options: { input: GetStackTraceInput }, _token: vscode.CancellationToken): Promise { const startedAt = Date.now(); + const attempt = nextAttempt(TOOL_NAMES.GET_DEBUG_STACK_TRACE); + const os = getOs(); + const javaMajorVersion = getJavaMajorVersion(); let outcome: ToolOutcome = 'success'; let errorCategory: ErrorCategory | undefined; let frameCount = 0; @@ -1258,7 +1466,12 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs errorCategory, frameCount, durationMs: Date.now() - startedAt, + os, + javaMajorVersion, + retryCount: attempt.retryCount, + previousOutcome: attempt.previousOutcome, }); + completeAttempt(TOOL_NAMES.GET_DEBUG_STACK_TRACE, outcome); } } }; @@ -1269,6 +1482,9 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs async invoke(options: { input: EvaluateExpressionInput }, _token: vscode.CancellationToken): Promise { const startedAt = Date.now(); const evalContext = classifyEvalContext(options.input.context); + const attempt = nextAttempt(TOOL_NAMES.EVALUATE_DEBUG_EXPRESSION); + const os = getOs(); + const javaMajorVersion = getJavaMajorVersion(); let outcome: ToolOutcome = 'success'; let errorCategory: ErrorCategory | undefined; @@ -1353,7 +1569,12 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs errorCategory, evalContext, durationMs: Date.now() - startedAt, + os, + javaMajorVersion, + retryCount: attempt.retryCount, + previousOutcome: attempt.previousOutcome, }); + completeAttempt(TOOL_NAMES.EVALUATE_DEBUG_EXPRESSION, outcome); } } }; @@ -1363,6 +1584,9 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs const getThreadsTool: LanguageModelTool<{}> = { async invoke(_options: { input: {} }, _token: vscode.CancellationToken): Promise { const startedAt = Date.now(); + const attempt = nextAttempt(TOOL_NAMES.GET_DEBUG_THREADS); + const os = getOs(); + const javaMajorVersion = getJavaMajorVersion(); let outcome: ToolOutcome = 'success'; let errorCategory: ErrorCategory | undefined; let threadCount = 0; @@ -1445,7 +1669,12 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs threadCount, suspendedCount, durationMs: Date.now() - startedAt, + os, + javaMajorVersion, + retryCount: attempt.retryCount, + previousOutcome: attempt.previousOutcome, }); + completeAttempt(TOOL_NAMES.GET_DEBUG_THREADS, outcome); } } }; @@ -1456,6 +1685,9 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs async invoke(options: { input: RemoveBreakpointsInput }, _token: vscode.CancellationToken): Promise { const startedAt = Date.now(); const removeScope = classifyRemoveScope(options.input); + const attempt = nextAttempt(TOOL_NAMES.REMOVE_JAVA_BREAKPOINTS); + const os = getOs(); + const javaMajorVersion = getJavaMajorVersion(); let outcome: ToolOutcome = 'success'; let errorCategory: ErrorCategory | undefined; let removedCount = 0; @@ -1513,7 +1745,12 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs removeScope, removedCount, durationMs: Date.now() - startedAt, + os, + javaMajorVersion, + retryCount: attempt.retryCount, + previousOutcome: attempt.previousOutcome, }); + completeAttempt(TOOL_NAMES.REMOVE_JAVA_BREAKPOINTS, outcome); } } }; @@ -1523,6 +1760,9 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs const stopDebugSessionTool: LanguageModelTool = { async invoke(_options: { input: StopDebugSessionInput }, _token: vscode.CancellationToken): Promise { const startedAt = Date.now(); + const attempt = nextAttempt(TOOL_NAMES.STOP_DEBUG_SESSION); + const os = getOs(); + const javaMajorVersion = getJavaMajorVersion(); let outcome: ToolOutcome = 'success'; let errorCategory: ErrorCategory | undefined; @@ -1560,7 +1800,12 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs outcome, errorCategory, durationMs: Date.now() - startedAt, + os, + javaMajorVersion, + retryCount: attempt.retryCount, + previousOutcome: attempt.previousOutcome, }); + completeAttempt(TOOL_NAMES.STOP_DEBUG_SESSION, outcome); } } }; @@ -1570,6 +1815,9 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs const getDebugSessionInfoTool: LanguageModelTool = { async invoke(_options: { input: GetDebugSessionInfoInput }, _token: vscode.CancellationToken): Promise { const startedAt = Date.now(); + const attempt = nextAttempt(TOOL_NAMES.GET_DEBUG_SESSION_INFO); + const os = getOs(); + const javaMajorVersion = getJavaMajorVersion(); let outcome: ToolOutcome = 'success'; let errorCategory: ErrorCategory | undefined; let isPausedFlag = false; @@ -1771,7 +2019,12 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs errorCategory, isPaused: isPausedFlag, durationMs: Date.now() - startedAt, + os, + javaMajorVersion, + retryCount: attempt.retryCount, + previousOutcome: attempt.previousOutcome, }); + completeAttempt(TOOL_NAMES.GET_DEBUG_SESSION_INFO, outcome); } } }; diff --git a/src/lmToolTelemetry.ts b/src/lmToolTelemetry.ts index 617e0b3..3a3153d 100644 --- a/src/lmToolTelemetry.ts +++ b/src/lmToolTelemetry.ts @@ -68,6 +68,46 @@ export type ErrorCategory = | 'cancelled' | 'other'; +/** + * Why the launch-time classname detection failed to resolve a fully-qualified + * class name. Replaces the previous boolean `detected: false` so we can + * distinguish "we never had a chance" (sourceDirMissing) from "we found the + * file but it has no package" (noPackageDeclaration). + */ +export type ClassNameDetectionFailure = + | 'sourceDirMissing' // None of the candidate src directories existed. + | 'fileNotFound' // We walked the candidate dirs but never found ClassName.java. + | 'parseError' // We found the file but could not read or scan it. + | 'noPackageDeclaration'; // We found and parsed the file; it has no `package ...;`. + +/** + * Which candidate source-directory layout the detector was using when it + * decided the outcome. Together with {@link ClassNameDetectionFailure} this + * lets us correlate failures to project layouts. + */ +export type ClassNameDetectionStrategy = + | 'mavenStandard' // /src/main/java + | 'gradleStandard' // /src/main/java (same path, different driver) + | 'vscodeSrc' // /src + | 'workspaceRoot'; // / + +/** + * Coarse OS classification. Kept as a closed enum so we can slice telemetry + * by platform without depending on Common Schema `common.os` (which is + * stripped by some downstream telemetry filters). + */ +export type OperatingSystem = 'win32' | 'darwin' | 'linux' | 'other'; + +/** + * The outermost reason an invocation ended without emitting any of the + * regular outcome events. Used by the InvocationGuard to close the loop on + * silently-returning paths. + */ +export type SentinelOutcome = + | 'silentReturn' // The invoke function returned without recording an outcome. + | 'cancelled' // CancellationToken fired before any outcome was recorded. + | 'exception'; // An unhandled exception propagated out of invoke. + export type TargetType = 'mainClass' | 'jar' | 'rawArgs' | 'unknown'; export type BreakpointKind = @@ -254,6 +294,53 @@ export function classifyScopeType(scopeType: string | undefined): ScopeType { } } +/** + * Coerce a Node `process.platform` string into the closed {@link OperatingSystem} + * enum so telemetry sliced by `os` always matches `common.os` semantics. + */ +export function classifyPlatform(platform: string | undefined): OperatingSystem { + switch (platform) { + case 'win32': + case 'darwin': + case 'linux': + return platform; + default: + return 'other'; + } +} + +/** + * Extract the Java major version from a `java -version` (or + * `Runtime.version()`) style string. Returns `'unknown'` when the input + * is empty or unrecognised. Examples: + * "21.0.1" -> "21" + * "1.8.0_392" -> "8" + * "17.0.5+9" -> "17" + * "openjdk 21 2023" -> "21" + * + * Only the major version is emitted so we cannot fingerprint a build. + */ +export function classifyJavaMajorVersion(versionString: string | undefined | null): string { + if (!versionString) { + return 'unknown'; + } + const trimmed = String(versionString).trim(); + if (!trimmed) { + return 'unknown'; + } + // Legacy "1.X" naming (Java 8 and earlier). + const legacy = trimmed.match(/(?:^|\s|"|\()1\.(\d+)(?:[._]|$)/); + if (legacy) { + return legacy[1]; + } + // Modern major-only naming, e.g. "21", "21.0.1", "openjdk 17". + const modern = trimmed.match(/(?:^|\s|"|\()(\d{1,3})(?:[.+_]|$|\s)/); + if (modern) { + return modern[1]; + } + return 'unknown'; +} + // ============================================================================ // Recording helpers — the only entrypoints to `sendInfo` inside LMT code // ============================================================================ @@ -304,6 +391,26 @@ export interface ToolInvocationRecord { sessionId?: string; /** vscode-java-debug's own adapter type — value is constant `'java'`. */ sessionType?: string; + /** + * Cross-cutting diagnostic context. These are redundant with Common + * Schema fields (`common.os`, etc.) but are emitted explicitly so + * dashboards can slice without a join and downstream telemetry + * filters do not strip them. + */ + os?: OperatingSystem; + /** Java major version e.g. "21", "17", "8". Use `'unknown'` if not yet probed. */ + javaMajorVersion?: string; + /** Project flavour detected at launch time; same enum as launch-internal events. */ + projectSystem?: LaunchProjectType; + /** + * Retry instrumentation. `retryCount` is 0 for the first attempt within + * a VS Code session for this tool, 1 for the next, etc. + * `previousOutcome` is the terminal outcome of the immediately previous + * attempt, so we can distinguish auto-retry (LM driven, immediate) from + * user-driven retry (after editing code). + */ + retryCount?: number; + previousOutcome?: ToolOutcome; } /** @@ -317,6 +424,36 @@ export interface ToolInvocationRecord { */ export function recordToolInvocation(record: ToolInvocationRecord): void { const normalized = normalizeToolInvocationRecord(record); + /* __GDPR__ + "languageModelTool..invoke" : { + "owner": "vscode-java-debug", + "comment": "Outcome of a single Language Model Tool invocation.", + "operationName": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "outcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "errorCategory": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" }, + "durationMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true }, + "targetType": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "breakpointKind": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "stepKind": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "evalContext": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "removeScope": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "scopeType": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "isPaused": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "skipBuild": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "hasFilter": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "frameCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true }, + "threadCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true }, + "suspendedCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true }, + "removedCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true }, + "sessionId": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "sessionType": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "os": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "javaMajorVersion": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "projectSystem": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "retryCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true }, + "previousOutcome": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" } + } + */ sanitizedSend({ operationName: `languageModelTool.${normalized.tool}.invoke`, outcome: normalized.outcome, @@ -337,6 +474,11 @@ export function recordToolInvocation(record: ToolInvocationRecord): void { removedCount: normalized.removedCount, sessionId: normalized.sessionId, sessionType: normalized.sessionType, + os: normalized.os, + javaMajorVersion: normalized.javaMajorVersion, + projectSystem: normalized.projectSystem, + retryCount: normalized.retryCount, + previousOutcome: normalized.previousOutcome, }); } @@ -400,6 +542,18 @@ export interface ChatActivationRecord { * post-ship without per-turn cost. */ export function recordChatActivation(record: ChatActivationRecord): void { + /* __GDPR__ + "languageModelTool.chatActivationSnapshot" : { + "owner": "vscode-java-debug", + "comment": "Emitted once at Language Model Tool registration time; reports adoption surface.", + "operationName": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "javaLSReadyAtActivation": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "lmtCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true }, + "chatSkillsCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true }, + "chatInstructionsCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true }, + "extensionVersion": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ sanitizedSend({ operationName: 'languageModelTool.chatActivationSnapshot', javaLSReadyAtActivation: record.javaLSReadyAtActivation, @@ -423,16 +577,31 @@ export type LaunchProjectType = 'maven' | 'gradle' | 'vscode' | 'unknown'; * * Note: `sessionId` here is VS Code's opaque debug-session GUID, never * the user-visible `launch.json` session name. + * + * Sentinel / silentReturn / cancelled / exception are the closed-loop + * variants emitted by {@link InvocationGuard}: they guarantee that every + * `debug_java_application` invocation produces at least one terminal + * event, even on code paths that previously silently returned. */ export type LaunchInternalEvent = | { name: 'cleanupExistingSession'; sessionId: string } | { name: 'cleanupExistingSessionFailed'; errorCategory: ErrorCategory } - | { name: 'debugSessionStarted.eventBased'; sessionId: string } - | { name: 'debugSessionTimeout.eventBased' } + | { name: 'debugSessionStarted.eventBased'; sessionId: string; elapsedMs?: number; thresholdMs?: number } + | { name: 'debugSessionTimeout.eventBased'; elapsedMs?: number; thresholdMs?: number } | { name: 'debugSessionDetected'; sessionId: string; elapsedMs: number } - | { name: 'debugSessionTimeout.smartPolling'; maxWaitTime: number } + | { name: 'debugSessionTimeout.smartPolling'; maxWaitTime: number; elapsedMs?: number } | { name: 'classNameDetection'; projectType: LaunchProjectType; detected: boolean } - | { name: 'getDebugSessionInfo.threadError'; errorCategory: ErrorCategory }; + | { + name: 'classNameDetection.failed'; + projectType: LaunchProjectType; + strategy: ClassNameDetectionStrategy; + failureReason: ClassNameDetectionFailure; + } + | { name: 'getDebugSessionInfo.threadError'; errorCategory: ErrorCategory } + | { name: 'debugSession.sentinel'; os: OperatingSystem; javaMajorVersion: string; projectSystem?: LaunchProjectType } + | { name: 'debugSession.silentReturn'; durationMs: number } + | { name: 'debugSession.cancelled'; durationMs: number } + | { name: 'debugSession.exception'; errorCategory: ErrorCategory; durationMs: number }; /** * Internal-debug event for the launch-flow nested instrumentation @@ -443,8 +612,179 @@ export type LaunchInternalEvent = */ export function recordLaunchInternal(event: LaunchInternalEvent): void { const { name, ...properties } = event; + /* __GDPR__ + "languageModelTool." : { + "owner": "vscode-java-debug", + "comment": "Internal launch-flow instrumentation; one of the LaunchInternalEvent variants.", + "operationName": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "sessionId": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "errorCategory": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" }, + "elapsedMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true }, + "thresholdMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true }, + "maxWaitTime": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true }, + "durationMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true }, + "projectType": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "projectSystem": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "detected": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "strategy": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "failureReason": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" }, + "os": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "javaMajorVersion": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ sanitizedSend({ operationName: `languageModelTool.${name}`, ...properties, }); } + +// ============================================================================ +// InvocationGuard — closed-loop sentinel for debug_java_application +// ============================================================================ +// +// Background: dashboards show ~33 % of `debug_java_application` invocations +// produce NO terminal event (neither started / timeout / cleanup / classname +// failed). The cause is silent-return paths inside the invoke handler. This +// guard wraps the handler so that any code path that exits without recording +// an outcome emits `debugSession.silentReturn`, and exceptions / cancellation +// are surfaced as their own dedicated events. +// +// Usage: +// const guard = beginDebugSessionInvocation(context, retryContext); +// try { +// const result = await actualWork(); +// guard.markOutcomeRecorded(); // call this whenever a regular event has fired +// return result; +// } catch (e) { +// guard.markException(e); +// throw e; +// } finally { +// guard.close(); // emits silentReturn / cancelled if needed +// } +// +// The guard is intentionally NOT a try/finally helper itself so callers can +// keep their existing control flow and let the type system check that +// `markOutcomeRecorded` is reached on the happy path. + +export interface InvocationContext { + os: OperatingSystem; + javaMajorVersion: string; + projectSystem?: LaunchProjectType; + /** Cancellation token; checked when the guard closes so we can emit `cancelled` instead of `silentReturn`. */ + isCancelled: () => boolean; +} + +export interface InvocationGuard { + /** Mark that some other terminal event (`started`, `timeout`, `classNameDetection.failed`, ...) was emitted. */ + markOutcomeRecorded(): void; + /** Mark that an unhandled exception is about to propagate. Pre-computes the errorCategory. */ + markException(err: unknown): void; + /** Always call from `finally`. Emits a closing event if nothing else did. */ + close(): void; +} + +/** + * Open an InvocationGuard for a `debug_java_application` call. Immediately + * emits a `debugSession.sentinel` event so we have a complete invocation + * count even if everything downstream fails. + */ +export function beginDebugSessionInvocation(context: InvocationContext): InvocationGuard { + const startedAt = Date.now(); + recordLaunchInternal({ + name: 'debugSession.sentinel', + os: context.os, + javaMajorVersion: context.javaMajorVersion, + projectSystem: context.projectSystem, + }); + + let outcomeRecorded = false; + let exceptionCategory: ErrorCategory | undefined; + + return { + markOutcomeRecorded(): void { + outcomeRecorded = true; + }, + markException(err: unknown): void { + exceptionCategory = classifyError(err); + }, + close(): void { + if (outcomeRecorded) { + return; + } + const durationMs = Date.now() - startedAt; + if (exceptionCategory !== undefined) { + recordLaunchInternal({ + name: 'debugSession.exception', + errorCategory: exceptionCategory, + durationMs, + }); + return; + } + if (context.isCancelled()) { + recordLaunchInternal({ + name: 'debugSession.cancelled', + durationMs, + }); + return; + } + recordLaunchInternal({ + name: 'debugSession.silentReturn', + durationMs, + }); + }, + }; +} + +// ============================================================================ +// SessionInvocationTracker — per-VS-Code-session retry attribution +// ============================================================================ +// +// We want each `recordToolInvocation` to carry `retryCount` (0-based) and +// `previousOutcome` so we can distinguish: +// - LM auto-retry (immediate, same session, previous outcome = timeout/failure) +// - User-driven retry (delayed, possibly different inputs) +// - First attempt +// +// The tracker is in-process only (lifetime = VS Code window) and stores no +// user-identifying data — it remembers only the previous outcome enum per +// tool name. + +interface ToolAttempt { + count: number; + previousOutcome?: ToolOutcome; +} + +const sessionAttempts = new Map(); + +/** + * Return the retry attribution for the next invocation of `tool`. Always + * call BEFORE the actual work so the returned `retryCount` reflects the + * attempt about to happen (0 for the first call). + */ +export function nextAttempt(tool: ToolName): { retryCount: number; previousOutcome?: ToolOutcome } { + const prev = sessionAttempts.get(tool); + return { + retryCount: prev?.count ?? 0, + previousOutcome: prev?.previousOutcome, + }; +} + +/** + * Record the terminal outcome of an attempt so the next call to + * {@link nextAttempt} can return the updated retry context. + */ +export function completeAttempt(tool: ToolName, outcome: ToolOutcome): void { + const prev = sessionAttempts.get(tool); + sessionAttempts.set(tool, { + count: (prev?.count ?? 0) + 1, + previousOutcome: outcome, + }); +} + +/** + * Test-only helper: reset the attempt map. Production code should never + * call this — VS Code session lifetime IS the intended scope. + */ +export function __resetAttemptsForTests(): void { + sessionAttempts.clear(); +}