From 915c1998e77b5931c3a0782d41602998edd533d2 Mon Sep 17 00:00:00 2001 From: wenytang-ms <223556219+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 15:49:11 +0800 Subject: [PATCH 1/2] Harden Language Model Tool telemetry against PII leaks Centralise all LMT telemetry through src/lmToolTelemetry.ts so user-supplied strings (target, expression, sessionName, file paths, class names, JVM stack traces, etc.) can no longer reach the telemetry pipeline. The new module exposes a typed sanitizedSend choke point that only accepts enums, booleans, numbers and opaque session IDs. Telemetry changes: - Drop sendError(error) on debug_java_application failure (stack trace leaked user class / method names). - Strip PII fields from every existing event: target, sessionName, currentFile, currentLine, simpleClassName, detectedClassName, error: String(error), input.reason. - Replace bare String(error) propagation with classifyError() -> ErrorCategory enum (mainClassMissing, classpathUnresolved, buildFailure, projectNotDetected, sessionAlreadyRunning, timeout, lsNotReady, noActiveSession, noSuspendedThread, noStackFrame, cancelled, other). - Add per-invoke recording for all 10 tools with outcome, errorCategory, durationMs, and a tool-specific enum (targetType / breakpointKind / stepKind / scopeType / evalContext / removeScope). The previous build only emitted telemetry on the launch tool and the session-info tool. - Add chatActivationSnapshot one-shot at registration time so we can measure adoption of the chat surfaces without per-turn cost (counts only). - evaluate_debug_expression: the expression text is NEVER logged. Only the evalContext enum and outcome are emitted. Policy: - src/lmToolTelemetry.ts is now the only file in the LMT code path allowed to call sendInfo. The top-of-file policy comment is the single source of truth for what may be logged. - The recorder is typed against ToolInvocationRecord so excess raw strings are rejected at compile time. Validated with: npm run tslint, npm run compile. --- src/extension.ts | 24 +++ src/languageModelTool.ts | 288 ++++++++++++++++++++++++------ src/lmToolTelemetry.ts | 374 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 627 insertions(+), 59 deletions(-) create mode 100644 src/lmToolTelemetry.ts diff --git a/src/extension.ts b/src/extension.ts index e3a75eb0..36025559 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -19,6 +19,7 @@ import { JavaDebugAdapterDescriptorFactory } from "./javaDebugAdapterDescriptorF import { JavaInlineValuesProvider } from "./JavaInlineValueProvider"; import { logJavaException, logJavaInfo } from "./javaLogger"; import { registerLanguageModelTool, registerDebugSessionTools } from "./languageModelTool"; +import { recordChatActivation } from "./lmToolTelemetry"; import { IMainClassOption, IMainMethod, resolveMainMethod } from "./languageServerPlugin"; import { mainClassPicker } from "./mainClassPicker"; import { pickJavaProcess } from "./processPicker"; @@ -124,6 +125,29 @@ async function registerLanguageModelToolsWhenReady(context: vscode.ExtensionCont registerLanguageModelTool(context); const debugToolsDisposables = registerDebugSessionTools(context); context.subscriptions.push(...debugToolsDisposables); + + // One-shot activation snapshot so we can track coverage of the new chat surfaces over time. + // Counts only — no user data, no file paths, no class names. + try { + const pkg = context.extension?.packageJSON as { + version?: string; + contributes?: { + languageModelTools?: unknown[]; + chatSkills?: unknown[]; + chatInstructions?: unknown[]; + }; + } | undefined; + const contrib = pkg?.contributes ?? {}; + recordChatActivation({ + javaLSReadyAtActivation: !!javaExt.isActive, + lmtCount: contrib.languageModelTools?.length ?? 0, + chatSkillsCount: contrib.chatSkills?.length ?? 0, + chatInstructionsCount: contrib.chatInstructions?.length ?? 0, + extensionVersion: pkg?.version ?? "unknown", + }); + } catch { + // Telemetry must never break activation. + } } async function subscribeToJavaExtensionEvents(): Promise { diff --git a/src/languageModelTool.ts b/src/languageModelTool.ts index ad803d25..87fd9539 100644 --- a/src/languageModelTool.ts +++ b/src/languageModelTool.ts @@ -4,7 +4,20 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; -import { sendError, sendInfo } from "vscode-extension-telemetry-wrapper"; +import { + classifyBreakpoint, + classifyError, + classifyEvalContext, + classifyRemoveScope, + classifyScopeType, + classifyStep, + classifyTarget, + ErrorCategory, + recordLaunchInternal, + recordToolInvocation, + TOOL_NAMES, + ToolOutcome, +} from "./lmToolTelemetry"; // ============================================================================ // Constants @@ -63,14 +76,20 @@ export function registerLanguageModelTool(context: vscode.ExtensionContext): vsc const tool: LanguageModelTool = { async invoke(options: { input: DebugJavaApplicationInput }, token: vscode.CancellationToken): Promise { - sendInfo('', { - operationName: 'languageModelTool.debugJavaApplication.invoke', - target: options.input.target, - skipBuild: options.input.skipBuild?.toString() || 'false', - }); + const startedAt = Date.now(); + const targetType = classifyTarget(options.input.target); + let outcome: ToolOutcome = 'success'; + let errorCategory: ErrorCategory | undefined; try { const result = await debugJavaApplication(options.input, token); + if (!result.success) { + outcome = result.status === 'timeout' ? 'timeout' : 'failure'; + errorCategory = result.success ? undefined : classifyError(result.message); + } else if (result.status === 'timeout') { + outcome = 'timeout'; + errorCategory = 'timeout'; + } // Format the message for AI - use simple text, not JSON const message = result.success @@ -82,13 +101,23 @@ export function registerLanguageModelTool(context: vscode.ExtensionContext): vsc new (vscode as any).LanguageModelTextPart(message) ]); } catch (error) { - sendError(error as Error); + outcome = token.isCancellationRequested ? 'cancelled' : 'failure'; + errorCategory = classifyError(error); const errorMessage = error instanceof Error ? error.message : String(error); return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart(`✗ Debug failed: ${errorMessage}`) ]); + } finally { + recordToolInvocation({ + tool: TOOL_NAMES.DEBUG_JAVA_APPLICATION, + outcome, + errorCategory, + targetType, + skipBuild: !!options.input.skipBuild, + durationMs: Date.now() - startedAt, + }); } } }; @@ -120,10 +149,8 @@ async function debugJavaApplication( // Step 0: Cleanup any existing Java debug session to avoid port conflicts const existingSession = vscode.debug.activeDebugSession; if (existingSession && existingSession.type === 'java') { - sendInfo('', { - operationName: 'languageModelTool.cleanupExistingSession', + recordLaunchInternal('cleanupExistingSession', { sessionId: existingSession.id, - sessionName: existingSession.name }); try { await vscode.debug.stopDebugging(existingSession); @@ -131,9 +158,8 @@ async function debugJavaApplication( await new Promise(resolve => setTimeout(resolve, 500)); } catch (error) { // Log but continue - the old session might already be dead - sendInfo('', { - operationName: 'languageModelTool.cleanupExistingSessionFailed', - error: String(error) + recordLaunchInternal('cleanupExistingSessionFailed', { + errorCategory: classifyError(error), }); } } @@ -219,10 +245,8 @@ async function debugJavaApplication( clearTimeout(timeoutHandle); } - sendInfo('', { - operationName: 'languageModelTool.debugSessionStarted.eventBased', + recordLaunchInternal('debugSessionStarted.eventBased', { sessionId: session.id, - sessionName: session.name }); resolve({ @@ -243,10 +267,7 @@ async function debugJavaApplication( if (!sessionStarted) { sessionDisposable.dispose(); - sendInfo('', { - operationName: 'languageModelTool.debugSessionTimeout.eventBased', - target: targetInfo - }); + recordLaunchInternal('debugSessionTimeout.eventBased', {}); resolve({ success: false, @@ -282,10 +303,9 @@ async function debugJavaApplication( if (session && session.type === 'java') { const elapsedTime = ((Date.now() - startTime) / 1000).toFixed(1); - sendInfo('', { - operationName: 'languageModelTool.debugSessionDetected', + recordLaunchInternal('debugSessionDetected', { sessionId: session.id, - elapsedTime + elapsedTime, }); return { @@ -302,10 +322,8 @@ async function debugJavaApplication( } // Timeout: session not detected within 15 seconds - sendInfo('', { - operationName: 'languageModelTool.debugSessionTimeout.smartPolling', - target: targetInfo, - maxWaitTime + recordLaunchInternal('debugSessionTimeout.smartPolling', { + maxWaitTime, }); return { @@ -584,19 +602,16 @@ function constructDebugCommand( if (!input.target.includes('.')) { const detectedClassName = findFullyQualifiedClassName(input.workspacePath, input.target, projectType); if (detectedClassName) { - sendInfo('', { - operationName: 'languageModelTool.classNameDetection', - simpleClassName: input.target, - detectedClassName, - projectType + recordLaunchInternal('classNameDetection', { + projectType, + detected: true, }); className = detectedClassName; } else { // No package detected - class is in default package - sendInfo('', { - operationName: 'languageModelTool.classNameDetection.noPackage', - simpleClassName: input.target, - projectType + recordLaunchInternal('classNameDetection', { + projectType, + detected: false, }); } } @@ -917,6 +932,11 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs // Tool 1: Set Breakpoint const setBreakpointTool: LanguageModelTool = { async invoke(options: { input: SetBreakpointInput }, _token: vscode.CancellationToken): Promise { + const startedAt = Date.now(); + const breakpointKind = classifyBreakpoint(options.input); + let outcome: ToolOutcome = 'success'; + let errorCategory: ErrorCategory | undefined; + try { const { filePath, lineNumber, condition, hitCondition, logMessage } = options.input; @@ -944,9 +964,19 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs ) ]); } catch (error) { + outcome = 'failure'; + errorCategory = classifyError(error); return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart(`✗ Failed to set breakpoint: ${error}`) ]); + } finally { + recordToolInvocation({ + tool: TOOL_NAMES.SET_JAVA_BREAKPOINT, + outcome, + errorCategory, + breakpointKind, + durationMs: Date.now() - startedAt, + }); } } }; @@ -955,9 +985,16 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs // Tool 2: Step Operations const stepOperationTool: LanguageModelTool = { async invoke(options: { input: StepOperationInput }, _token: vscode.CancellationToken): Promise { + const startedAt = Date.now(); + const stepKind = classifyStep(options.input.operation); + let outcome: ToolOutcome = 'success'; + let errorCategory: ErrorCategory | undefined; + try { const session = vscode.debug.activeDebugSession; if (!session || session.type !== 'java') { + outcome = 'noActiveSession'; + errorCategory = 'noActiveSession'; return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart('✗ No active Java debug session.') ]); @@ -987,9 +1024,19 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs new (vscode as any).LanguageModelTextPart(`✓ Executed ${operation}`) ]); } catch (error) { + outcome = 'failure'; + errorCategory = classifyError(error); return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart(`✗ Step operation failed: ${error}`) ]); + } finally { + recordToolInvocation({ + tool: TOOL_NAMES.DEBUG_STEP_OPERATION, + outcome, + errorCategory, + stepKind, + durationMs: Date.now() - startedAt, + }); } } }; @@ -998,9 +1045,17 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs // Tool 3: Get Variables const getVariablesTool: LanguageModelTool = { async invoke(options: { input: GetVariablesInput }, _token: vscode.CancellationToken): Promise { + const startedAt = Date.now(); + const scopeTypeEnum = classifyScopeType(options.input.scopeType); + const hasFilter = !!options.input.filter; + let outcome: ToolOutcome = 'success'; + let errorCategory: ErrorCategory | undefined; + try { const session = vscode.debug.activeDebugSession; if (!session || session.type !== 'java') { + outcome = 'noActiveSession'; + errorCategory = 'noActiveSession'; return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart('✗ No active Java debug session.') ]); @@ -1018,6 +1073,8 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs } if (!targetThreadId) { + outcome = 'noSuspendedThread'; + errorCategory = 'noSuspendedThread'; return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart('✗ No suspended thread found. Use get_debug_threads() to see thread states.') ]); @@ -1031,6 +1088,8 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs }); if (!stackResponse.stackFrames || stackResponse.stackFrames.length === 0) { + outcome = 'noStackFrame'; + errorCategory = 'noStackFrame'; return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart('✗ No stack frame available.') ]); @@ -1075,9 +1134,20 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs ) ]); } catch (error) { + outcome = 'failure'; + errorCategory = classifyError(error); return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart(`✗ Failed to get variables: ${error}`) ]); + } finally { + recordToolInvocation({ + tool: TOOL_NAMES.GET_DEBUG_VARIABLES, + outcome, + errorCategory, + scopeType: scopeTypeEnum, + hasFilter, + durationMs: Date.now() - startedAt, + }); } } }; @@ -1086,9 +1156,16 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs // Tool 4: Get Stack Trace const getStackTraceTool: LanguageModelTool = { async invoke(options: { input: GetStackTraceInput }, _token: vscode.CancellationToken): Promise { + const startedAt = Date.now(); + let outcome: ToolOutcome = 'success'; + let errorCategory: ErrorCategory | undefined; + let frameCount = 0; + try { const session = vscode.debug.activeDebugSession; if (!session || session.type !== 'java') { + outcome = 'noActiveSession'; + errorCategory = 'noActiveSession'; return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart('✗ No active Java debug session.') ]); @@ -1103,11 +1180,14 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs }); if (!stackResponse.stackFrames || stackResponse.stackFrames.length === 0) { + outcome = 'noStackFrame'; return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart('No stack frames available.') ]); } + frameCount = stackResponse.stackFrames.length; + const frames = stackResponse.stackFrames.map((frame: any, index: number) => { const location = frame.source ? `${frame.source.name}:${frame.line}` : @@ -1121,9 +1201,19 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs ) ]); } catch (error) { + outcome = 'failure'; + errorCategory = classifyError(error); return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart(`✗ Failed to get stack trace: ${error}`) ]); + } finally { + recordToolInvocation({ + tool: TOOL_NAMES.GET_DEBUG_STACK_TRACE, + outcome, + errorCategory, + frameCount, + durationMs: Date.now() - startedAt, + }); } } }; @@ -1132,9 +1222,16 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs // Tool 5: Evaluate Expression const evaluateExpressionTool: LanguageModelTool = { async invoke(options: { input: EvaluateExpressionInput }, _token: vscode.CancellationToken): Promise { + const startedAt = Date.now(); + const evalContext = classifyEvalContext(options.input.context); + let outcome: ToolOutcome = 'success'; + let errorCategory: ErrorCategory | undefined; + try { const session = vscode.debug.activeDebugSession; if (!session || session.type !== 'java') { + outcome = 'noActiveSession'; + errorCategory = 'noActiveSession'; return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart('✗ No active Java debug session.') ]); @@ -1168,6 +1265,8 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs targetFrameId = stackResponse.stackFrames[0].id; } } catch { + outcome = 'noSuspendedThread'; + errorCategory = 'noSuspendedThread'; return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart(`✗ Thread #${targetThreadId} is not suspended. Cannot evaluate expression.`) ]); @@ -1175,6 +1274,8 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs } if (!targetThreadId) { + outcome = 'noSuspendedThread'; + errorCategory = 'noSuspendedThread'; return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart('✗ No suspended thread found. Use get_debug_threads() to see thread states.') ]); @@ -1194,9 +1295,20 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs ) ]); } catch (error) { + outcome = 'failure'; + errorCategory = classifyError(error); return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart(`✗ Evaluation failed: ${error}`) ]); + } finally { + // NEVER log expression text (may contain user code / secrets) + recordToolInvocation({ + tool: TOOL_NAMES.EVALUATE_DEBUG_EXPRESSION, + outcome, + errorCategory, + evalContext, + durationMs: Date.now() - startedAt, + }); } } }; @@ -1205,9 +1317,17 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs // Tool 6: Get Threads const getThreadsTool: LanguageModelTool<{}> = { async invoke(_options: { input: {} }, _token: vscode.CancellationToken): Promise { + const startedAt = Date.now(); + let outcome: ToolOutcome = 'success'; + let errorCategory: ErrorCategory | undefined; + let threadCount = 0; + let suspendedCount = 0; + try { const session = vscode.debug.activeDebugSession; if (!session || session.type !== 'java') { + outcome = 'noActiveSession'; + errorCategory = 'noActiveSession'; return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart('✗ No active Java debug session.') ]); @@ -1221,6 +1341,8 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs ]); } + threadCount = threadsResponse.threads.length; + // Check each thread's state by trying to get its stack trace const threadInfos: string[] = []; for (const thread of threadsResponse.threads) { @@ -1236,6 +1358,7 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs if (stackResponse?.stackFrames?.length > 0) { state = '🔴 SUSPENDED'; + suspendedCount++; const topFrame = stackResponse.stackFrames[0]; if (topFrame.source) { location = ` at ${topFrame.source.name}:${topFrame.line}`; @@ -1264,9 +1387,20 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs ) ]); } catch (error) { + outcome = 'failure'; + errorCategory = classifyError(error); return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart(`✗ Failed to get threads: ${error}`) ]); + } finally { + recordToolInvocation({ + tool: TOOL_NAMES.GET_DEBUG_THREADS, + outcome, + errorCategory, + threadCount, + suspendedCount, + durationMs: Date.now() - startedAt, + }); } } }; @@ -1275,6 +1409,12 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs // Tool 7: Remove Breakpoints const removeBreakpointsTool: LanguageModelTool = { async invoke(options: { input: RemoveBreakpointsInput }, _token: vscode.CancellationToken): Promise { + const startedAt = Date.now(); + const removeScope = classifyRemoveScope(options.input); + let outcome: ToolOutcome = 'success'; + let errorCategory: ErrorCategory | undefined; + let removedCount = 0; + try { const { filePath, lineNumber } = options.input; @@ -1283,6 +1423,7 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs if (!filePath) { // Remove all breakpoints (no active session required) const count = breakpoints.length; + removedCount = count; vscode.debug.removeBreakpoints(breakpoints); return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart(`✓ Removed all ${count} breakpoint(s).`) @@ -1304,6 +1445,7 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs if (toRemove.length > 0) { vscode.debug.removeBreakpoints(toRemove); } + removedCount = toRemove.length; return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart( @@ -1313,9 +1455,20 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs ) ]); } catch (error) { + outcome = 'failure'; + errorCategory = classifyError(error); return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart(`✗ Failed to remove breakpoints: ${error}`) ]); + } finally { + recordToolInvocation({ + tool: TOOL_NAMES.REMOVE_JAVA_BREAKPOINTS, + outcome, + errorCategory, + removeScope, + removedCount, + durationMs: Date.now() - startedAt, + }); } } }; @@ -1323,38 +1476,46 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs // Tool 9: Stop Debug Session const stopDebugSessionTool: LanguageModelTool = { - async invoke(options: { input: StopDebugSessionInput }, _token: vscode.CancellationToken): Promise { + async invoke(_options: { input: StopDebugSessionInput }, _token: vscode.CancellationToken): Promise { + const startedAt = Date.now(); + let outcome: ToolOutcome = 'success'; + let errorCategory: ErrorCategory | undefined; + try { const session = vscode.debug.activeDebugSession; if (!session) { + outcome = 'noActiveSession'; + errorCategory = 'noActiveSession'; return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart('No active debug session to stop.') ]); } - const sessionInfo = `${session.name} (${session.type})`; - const reason = options.input.reason || 'Investigation complete'; + const sessionType = session.type; // Stop the debug session await vscode.debug.stopDebugging(session); - sendInfo('', { - operationName: 'languageModelTool.stopDebugSession', - sessionId: session.id, - sessionName: session.name, - reason - }); - return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart( - `✓ Stopped debug session: ${sessionInfo}. Reason: ${reason}` + `✓ Stopped debug session (${sessionType}).` ) ]); } catch (error) { + outcome = 'failure'; + errorCategory = classifyError(error); return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart(`✗ Failed to stop debug session: ${error}`) ]); + } finally { + // Do NOT log session.name (may include user file path) or input.reason (free text) + recordToolInvocation({ + tool: TOOL_NAMES.STOP_DEBUG_SESSION, + outcome, + errorCategory, + durationMs: Date.now() - startedAt, + }); } } }; @@ -1363,10 +1524,17 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs // Tool 10: Get Debug Session Info const getDebugSessionInfoTool: LanguageModelTool = { async invoke(_options: { input: GetDebugSessionInfoInput }, _token: vscode.CancellationToken): Promise { + const startedAt = Date.now(); + let outcome: ToolOutcome = 'success'; + let errorCategory: ErrorCategory | undefined; + let isPausedFlag = false; + try { const session = vscode.debug.activeDebugSession; if (!session) { + outcome = 'noActiveSession'; + errorCategory = 'noActiveSession'; return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart( '❌ No active debug session found.\n\n' + @@ -1457,9 +1625,8 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs // If we can't even get threads, something is wrong // But session exists, so mark as running isPaused = false; - sendInfo('', { - operationName: 'languageModelTool.getDebugSessionInfo.threadError', - error: String(error) + recordLaunchInternal('getDebugSessionInfo.threadError', { + errorCategory: classifyError(error), }); } @@ -1539,23 +1706,26 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs '═══════════════════════════════════════════' ].join('\n'); - sendInfo('', { - operationName: 'languageModelTool.getDebugSessionInfo', - sessionId: session.id, - sessionType: session.type, - isPaused: String(isPaused), - stoppedThreadId: String(stoppedThreadId || ''), - currentFile, - currentLine: String(currentLine) - }); + isPausedFlag = isPaused; return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart(message) ]); } catch (error) { + outcome = 'failure'; + errorCategory = classifyError(error); return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart(`✗ Failed to get debug session info: ${error}`) ]); + } finally { + // Do NOT log currentFile, currentLine, sessionName, stoppedThreadName — those are user data + recordToolInvocation({ + tool: TOOL_NAMES.GET_DEBUG_SESSION_INFO, + outcome, + errorCategory, + isPaused: isPausedFlag, + durationMs: Date.now() - startedAt, + }); } } }; diff --git a/src/lmToolTelemetry.ts b/src/lmToolTelemetry.ts new file mode 100644 index 00000000..959ffba5 --- /dev/null +++ b/src/lmToolTelemetry.ts @@ -0,0 +1,374 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +/** + * Telemetry helpers for the language-model-tool surface. + * + * POLICY: this module is the ONLY place inside the LMT code path that is + * allowed to call `sendInfo` / `sendError`. Direct calls from individual + * tool implementations are forbidden so that PII risk can be audited in + * one file. + * + * Strict rules — every contributor MUST follow these: + * + * 1. Do NOT pass user-provided strings as telemetry properties. This + * includes (non-exhaustive): + * - `target` (main class / JAR path / raw -cp args) + * - `expression` (debug expression to evaluate) + * - `condition` / `hitCondition` / `logMessage` (breakpoint inputs) + * - `filePath` / `currentFile` / source file paths + * - `currentLine` / `lineNumber` + * - `sessionName` (`launch.json` `name` field; often contains class + * or project names) + * - `reason` (user-supplied stop reason) + * - `error.message` / `error.stack` (JVM stack traces leak user + * class and method names) + * - any class name, method name, package name, or source path + * + * 2. Only enums, booleans, durations, counts, opaque session IDs (GUIDs) + * and our own extension version are allowed. + * + * 3. When classifying free-form input (e.g. error text -> errorCategory) + * the classifier function inspects the input in-memory and emits ONLY + * the enum. Unmatched values map to `'other'` / `'unknown'`. The + * original text is NEVER attached to the event. + * + * 4. New telemetry events SHOULD go through `recordToolInvocation` / + * `recordChatActivation` or a new dedicated recorder added below. + * The raw `sendInfo` API is wrapped by `sanitizedSend` here. + */ + +import { sendInfo } from "vscode-extension-telemetry-wrapper"; + +// ============================================================================ +// Enum types (the only shape telemetry properties may take) +// ============================================================================ + +export type ToolOutcome = + | 'success' + | 'failure' + | 'timeout' + | 'cancelled' + | 'lsNotReady' + | 'noActiveSession' + | 'noSuspendedThread' + | 'noStackFrame'; + +export type ErrorCategory = + | 'mainClassMissing' + | 'classpathUnresolved' + | 'buildFailure' + | 'projectNotDetected' + | 'sessionAlreadyRunning' + | 'timeout' + | 'lsNotReady' + | 'noActiveSession' + | 'noSuspendedThread' + | 'noStackFrame' + | 'cancelled' + | 'other'; + +export type TargetType = 'mainClass' | 'jar' | 'rawArgs' | 'unknown'; + +export type BreakpointKind = + | 'line' + | 'conditional' + | 'hitCount' + | 'logpoint'; + +export type StepKind = 'in' | 'out' | 'over' | 'continue' | 'pause'; + +export type EvalContext = 'watch' | 'repl' | 'hover' | 'unknown'; + +export type RemoveBreakpointScope = 'all' | 'file' | 'line'; + +export type ScopeType = 'local' | 'static' | 'all' | 'unknown'; + +export const TOOL_NAMES = { + DEBUG_JAVA_APPLICATION: 'debug_java_application', + SET_JAVA_BREAKPOINT: 'set_java_breakpoint', + DEBUG_STEP_OPERATION: 'debug_step_operation', + GET_DEBUG_VARIABLES: 'get_debug_variables', + GET_DEBUG_STACK_TRACE: 'get_debug_stack_trace', + EVALUATE_DEBUG_EXPRESSION: 'evaluate_debug_expression', + GET_DEBUG_THREADS: 'get_debug_threads', + REMOVE_JAVA_BREAKPOINTS: 'remove_java_breakpoints', + STOP_DEBUG_SESSION: 'stop_debug_session', + GET_DEBUG_SESSION_INFO: 'get_debug_session_info', +} as const; + +export type ToolName = typeof TOOL_NAMES[keyof typeof TOOL_NAMES]; + +// ============================================================================ +// Classifiers — pure functions; emit ONLY enums +// ============================================================================ + +/** + * Classify the `target` parameter of `debug_java_application` into a coarse + * shape category. The original string is consumed in-memory only; the + * returned enum is the only thing that may be logged. + */ +export function classifyTarget(target: string | undefined | null): TargetType { + if (!target) { + return 'unknown'; + } + const trimmed = target.trim(); + if (!trimmed) { + return 'unknown'; + } + if (trimmed.startsWith('-')) { + return 'rawArgs'; + } + if (/\.jar(\s|$)/i.test(trimmed) || trimmed.toLowerCase().endsWith('.jar')) { + return 'jar'; + } + if (/^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/.test(trimmed)) { + return 'mainClass'; + } + return 'unknown'; +} + +/** + * Map an arbitrary error (Error, string, or unknown) to an ErrorCategory. + * The original message and stack trace are consumed in-memory and never + * returned. Unrecognised errors map to `'other'`. + */ +export function classifyError(err: unknown): ErrorCategory { + if (err === undefined || err === null) { + return 'other'; + } + const msg = (err instanceof Error ? err.message : String(err)).toLowerCase(); + if (!msg) { + return 'other'; + } + if (msg.includes('mainclass') && (msg.includes('not set') || msg.includes('missing') || msg.includes('not configured'))) { + return 'mainClassMissing'; + } + if (msg.includes('could not find or load main class') || msg.includes('classnotfound')) { + return 'mainClassMissing'; + } + if (msg.includes('classpath') && (msg.includes('not resolve') || msg.includes('unresolved') || msg.includes('cannot resolve'))) { + return 'classpathUnresolved'; + } + if (msg.includes('compilation') && msg.includes('fail')) { + return 'buildFailure'; + } + if (msg.includes('build failed') || msg.includes('build error')) { + return 'buildFailure'; + } + if (msg.includes('project not detected') || msg.includes('no project found')) { + return 'projectNotDetected'; + } + if (msg.includes('already running') || msg.includes('session is active')) { + return 'sessionAlreadyRunning'; + } + if (msg.includes('timeout') || msg.includes('timed out')) { + return 'timeout'; + } + if (msg.includes('language server not ready') || msg.includes('jdt.ls')) { + return 'lsNotReady'; + } + if (msg.includes('no active debug session') || msg.includes('no debug session')) { + return 'noActiveSession'; + } + if (msg.includes('not suspended') || msg.includes('thread is not paused')) { + return 'noSuspendedThread'; + } + if (msg.includes('cancel')) { + return 'cancelled'; + } + return 'other'; +} + +/** + * Classify a `set_java_breakpoint` invocation into a coarse breakpoint kind. + * The actual filePath / lineNumber / condition strings are NOT logged; this + * classifier only checks which optional inputs are present. + */ +export function classifyBreakpoint(input: { + condition?: string; + hitCondition?: string; + logMessage?: string; +}): BreakpointKind { + if (input.logMessage && input.logMessage.length > 0) { + return 'logpoint'; + } + if (input.hitCondition && input.hitCondition.length > 0) { + return 'hitCount'; + } + if (input.condition && input.condition.length > 0) { + return 'conditional'; + } + return 'line'; +} + +export function classifyStep(operation: string | undefined): StepKind { + switch (operation) { + case 'stepIn': + return 'in'; + case 'stepOut': + return 'out'; + case 'stepOver': + return 'over'; + case 'continue': + return 'continue'; + case 'pause': + return 'pause'; + default: + return 'over'; + } +} + +export function classifyEvalContext(context: string | undefined): EvalContext { + switch (context) { + case 'watch': + case 'repl': + case 'hover': + return context; + default: + return 'unknown'; + } +} + +export function classifyRemoveScope(input: { + filePath?: string; + lineNumber?: number; +}): RemoveBreakpointScope { + if (!input.filePath) { + return 'all'; + } + if (input.lineNumber !== undefined) { + return 'line'; + } + return 'file'; +} + +export function classifyScopeType(scopeType: string | undefined): ScopeType { + switch (scopeType) { + case 'local': + case 'static': + case 'all': + return scopeType; + default: + return 'unknown'; + } +} + +// ============================================================================ +// Recording helpers — the only entrypoints to `sendInfo` inside LMT code +// ============================================================================ + +/** Safe value types allowed as telemetry properties. */ +type SafeValue = string | number | boolean | undefined; + +/** + * Tighten what sendInfo accepts. All values must be primitive enums / + * booleans / numbers / well-known opaque IDs. Objects and arrays are + * rejected at the type level so we cannot accidentally serialise a payload + * containing user data. + */ +function sanitizedSend(properties: Record): void { + const clean: { [key: string]: string } = {}; + for (const [k, v] of Object.entries(properties)) { + if (v === undefined) { + continue; + } + clean[k] = typeof v === 'string' ? v : String(v); + } + sendInfo('', clean); +} + +export interface ToolInvocationRecord { + tool: ToolName; + outcome: ToolOutcome; + errorCategory?: ErrorCategory; + durationMs?: number; + /** + * Optional tool-specific enum fields. ONLY enums / booleans / numbers + * are accepted; the recorder itself is typed to forbid raw strings. + */ + targetType?: TargetType; + breakpointKind?: BreakpointKind; + stepKind?: StepKind; + evalContext?: EvalContext; + removeScope?: RemoveBreakpointScope; + scopeType?: ScopeType; + isPaused?: boolean; + skipBuild?: boolean; + hasFilter?: boolean; + frameCount?: number; + threadCount?: number; + suspendedCount?: number; + removedCount?: number; + /** Opaque GUID assigned by VS Code; safe to log. */ + sessionId?: string; + /** vscode-java-debug's own adapter type — value is constant `'java'`. */ + sessionType?: string; +} + +/** + * Record a single tool-invocation outcome. Replaces ad-hoc `sendInfo` + * calls inside individual tools. + */ +export function recordToolInvocation(record: ToolInvocationRecord): void { + sanitizedSend({ + operationName: `languageModelTool.${record.tool}.invoke`, + outcome: record.outcome, + errorCategory: record.errorCategory, + durationMs: record.durationMs, + targetType: record.targetType, + breakpointKind: record.breakpointKind, + stepKind: record.stepKind, + evalContext: record.evalContext, + removeScope: record.removeScope, + scopeType: record.scopeType, + isPaused: record.isPaused, + skipBuild: record.skipBuild, + hasFilter: record.hasFilter, + frameCount: record.frameCount, + threadCount: record.threadCount, + suspendedCount: record.suspendedCount, + removedCount: record.removedCount, + sessionId: record.sessionId, + sessionType: record.sessionType, + }); +} + +export interface ChatActivationRecord { + javaLSReadyAtActivation: boolean; + lmtCount: number; + chatSkillsCount: number; + chatInstructionsCount: number; + extensionVersion: string; +} + +/** + * Record a one-shot snapshot of the chat-activation surface at the moment + * Language Model Tools are registered. Lets us measure adoption coverage + * post-ship without per-turn cost. + */ +export function recordChatActivation(record: ChatActivationRecord): void { + sanitizedSend({ + operationName: 'languageModelTool.chatActivationSnapshot', + javaLSReadyAtActivation: record.javaLSReadyAtActivation, + lmtCount: record.lmtCount, + chatSkillsCount: record.chatSkillsCount, + chatInstructionsCount: record.chatInstructionsCount, + extensionVersion: record.extensionVersion, + }); +} + +/** + * Internal-debug event for the launch-flow nested instrumentation + * (session-detected / cleanup / timeout). Re-uses the sanitised sender so + * no PII can slip in. + */ +export function recordLaunchInternal( + operationName: string, + properties: Record, +): void { + sanitizedSend({ + operationName: `languageModelTool.${operationName}`, + ...properties, + }); +} From 0660137223ecc53ba0ceceb2276941f129124025 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Wed, 20 May 2026 17:04:22 +0800 Subject: [PATCH 2/2] Address Copilot review on PR #1644 - classifyStep: unknown step operations now report 'unknown' instead of being silently mislabeled as 'over'. Also adds a runtime guard in debug_step_operation so an unknown operation no longer reaches commandMap[op]/executeCommand(undefined) or session.customRequest with an arbitrary string. - recordToolInvocation: introduces a private normalizeToolInvocationRecord that keeps 'outcome' and 'errorCategory' in lock-step for the six shared terminal values (cancelled / timeout / lsNotReady / noActiveSession / noSuspendedThread / noStackFrame). Fixes the case where debug_java_application returns {success:false,message:'Operation cancelled by user'} but outcome was 'failure' while errorCategory was 'cancelled'. - get_debug_stack_trace: empty-stack-frame early return now sets errorCategory='noStackFrame' alongside outcome (was only setting outcome). - recordLaunchInternal: signature is now a discriminated union (LaunchInternalEvent) instead of (operationName: string, properties: Record). Unknown event names and unexpected property keys are now rejected at compile time. Updated all 8 call sites. - elapsedTime (string from .toFixed) split from elapsedMs (number) so the telemetry value is numeric and aggregable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/languageModelTool.ts | 40 ++++++++---- src/lmToolTelemetry.ts | 130 +++++++++++++++++++++++++++++++-------- 2 files changed, 132 insertions(+), 38 deletions(-) diff --git a/src/languageModelTool.ts b/src/languageModelTool.ts index 87fd9539..85b28855 100644 --- a/src/languageModelTool.ts +++ b/src/languageModelTool.ts @@ -149,7 +149,8 @@ async function debugJavaApplication( // Step 0: Cleanup any existing Java debug session to avoid port conflicts const existingSession = vscode.debug.activeDebugSession; if (existingSession && existingSession.type === 'java') { - recordLaunchInternal('cleanupExistingSession', { + recordLaunchInternal({ + name: 'cleanupExistingSession', sessionId: existingSession.id, }); try { @@ -158,7 +159,8 @@ async function debugJavaApplication( await new Promise(resolve => setTimeout(resolve, 500)); } catch (error) { // Log but continue - the old session might already be dead - recordLaunchInternal('cleanupExistingSessionFailed', { + recordLaunchInternal({ + name: 'cleanupExistingSessionFailed', errorCategory: classifyError(error), }); } @@ -245,7 +247,8 @@ async function debugJavaApplication( clearTimeout(timeoutHandle); } - recordLaunchInternal('debugSessionStarted.eventBased', { + recordLaunchInternal({ + name: 'debugSessionStarted.eventBased', sessionId: session.id, }); @@ -267,7 +270,7 @@ async function debugJavaApplication( if (!sessionStarted) { sessionDisposable.dispose(); - recordLaunchInternal('debugSessionTimeout.eventBased', {}); + recordLaunchInternal({ name: 'debugSessionTimeout.eventBased' }); resolve({ success: false, @@ -301,11 +304,13 @@ async function debugJavaApplication( // Check if debug session has started const session = vscode.debug.activeDebugSession; if (session && session.type === 'java') { - const elapsedTime = ((Date.now() - startTime) / 1000).toFixed(1); + const elapsedMs = Date.now() - startTime; + const elapsedTime = (elapsedMs / 1000).toFixed(1); - recordLaunchInternal('debugSessionDetected', { + recordLaunchInternal({ + name: 'debugSessionDetected', sessionId: session.id, - elapsedTime, + elapsedMs, }); return { @@ -322,7 +327,8 @@ async function debugJavaApplication( } // Timeout: session not detected within 15 seconds - recordLaunchInternal('debugSessionTimeout.smartPolling', { + recordLaunchInternal({ + name: 'debugSessionTimeout.smartPolling', maxWaitTime, }); @@ -602,14 +608,16 @@ function constructDebugCommand( if (!input.target.includes('.')) { const detectedClassName = findFullyQualifiedClassName(input.workspacePath, input.target, projectType); if (detectedClassName) { - recordLaunchInternal('classNameDetection', { + recordLaunchInternal({ + name: 'classNameDetection', projectType, detected: true, }); className = detectedClassName; } else { // No package detected - class is in default package - recordLaunchInternal('classNameDetection', { + recordLaunchInternal({ + name: 'classNameDetection', projectType, detected: false, }); @@ -1012,6 +1020,14 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs }; const command = commandMap[operation]; + if (!command) { + outcome = 'failure'; + errorCategory = 'other'; + return new (vscode as any).LanguageModelToolResult([ + new (vscode as any).LanguageModelTextPart(`✗ Unknown step operation: ${operation}`) + ]); + } + if (threadId !== undefined) { // For thread-specific operations, use custom request await session.customRequest(operation, { threadId }); @@ -1181,6 +1197,7 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs if (!stackResponse.stackFrames || stackResponse.stackFrames.length === 0) { outcome = 'noStackFrame'; + errorCategory = 'noStackFrame'; return new (vscode as any).LanguageModelToolResult([ new (vscode as any).LanguageModelTextPart('No stack frames available.') ]); @@ -1625,7 +1642,8 @@ export function registerDebugSessionTools(_context: vscode.ExtensionContext): vs // If we can't even get threads, something is wrong // But session exists, so mark as running isPaused = false; - recordLaunchInternal('getDebugSessionInfo.threadError', { + recordLaunchInternal({ + name: 'getDebugSessionInfo.threadError', errorCategory: classifyError(error), }); } diff --git a/src/lmToolTelemetry.ts b/src/lmToolTelemetry.ts index 959ffba5..617e0b30 100644 --- a/src/lmToolTelemetry.ts +++ b/src/lmToolTelemetry.ts @@ -76,7 +76,7 @@ export type BreakpointKind = | 'hitCount' | 'logpoint'; -export type StepKind = 'in' | 'out' | 'over' | 'continue' | 'pause'; +export type StepKind = 'in' | 'out' | 'over' | 'continue' | 'pause' | 'unknown'; export type EvalContext = 'watch' | 'repl' | 'hover' | 'unknown'; @@ -215,7 +215,7 @@ export function classifyStep(operation: string | undefined): StepKind { case 'pause': return 'pause'; default: - return 'over'; + return 'unknown'; } } @@ -309,31 +309,83 @@ export interface ToolInvocationRecord { /** * Record a single tool-invocation outcome. Replaces ad-hoc `sendInfo` * calls inside individual tools. + * + * Before sending, the record is normalized so that `outcome` and + * `errorCategory` stay aligned for the six shared terminal values + * (cancelled / timeout / lsNotReady / noActiveSession / noSuspendedThread / + * noStackFrame). See {@link normalizeToolInvocationRecord}. */ export function recordToolInvocation(record: ToolInvocationRecord): void { + const normalized = normalizeToolInvocationRecord(record); sanitizedSend({ - operationName: `languageModelTool.${record.tool}.invoke`, - outcome: record.outcome, - errorCategory: record.errorCategory, - durationMs: record.durationMs, - targetType: record.targetType, - breakpointKind: record.breakpointKind, - stepKind: record.stepKind, - evalContext: record.evalContext, - removeScope: record.removeScope, - scopeType: record.scopeType, - isPaused: record.isPaused, - skipBuild: record.skipBuild, - hasFilter: record.hasFilter, - frameCount: record.frameCount, - threadCount: record.threadCount, - suspendedCount: record.suspendedCount, - removedCount: record.removedCount, - sessionId: record.sessionId, - sessionType: record.sessionType, + operationName: `languageModelTool.${normalized.tool}.invoke`, + outcome: normalized.outcome, + errorCategory: normalized.errorCategory, + durationMs: normalized.durationMs, + targetType: normalized.targetType, + breakpointKind: normalized.breakpointKind, + stepKind: normalized.stepKind, + evalContext: normalized.evalContext, + removeScope: normalized.removeScope, + scopeType: normalized.scopeType, + isPaused: normalized.isPaused, + skipBuild: normalized.skipBuild, + hasFilter: normalized.hasFilter, + frameCount: normalized.frameCount, + threadCount: normalized.threadCount, + suspendedCount: normalized.suspendedCount, + removedCount: normalized.removedCount, + sessionId: normalized.sessionId, + sessionType: normalized.sessionType, }); } +/** + * Values that exist in both {@link ToolOutcome} and {@link ErrorCategory}. + * For these, the two fields must stay in lock-step so dashboard queries + * filtering on either one produce identical results. + */ +const SHARED_TERMINAL_VALUES = [ + 'cancelled', + 'timeout', + 'lsNotReady', + 'noActiveSession', + 'noSuspendedThread', + 'noStackFrame', +] as const; + +type SharedTerminal = typeof SHARED_TERMINAL_VALUES[number]; + +function isSharedTerminal(value: string | undefined): value is SharedTerminal { + return value !== undefined && (SHARED_TERMINAL_VALUES as readonly string[]).includes(value); +} + +/** + * Reconcile `outcome` and `errorCategory` for the six shared terminal + * values so downstream queries can rely on either field. Returns a NEW + * record; the input is not mutated. + * + * Rules: + * - If `errorCategory` is a shared terminal value, promote `outcome` to + * that value (callers that only set `errorCategory` get a consistent + * `outcome` for free). + * - If `outcome` is a shared terminal value and `errorCategory` is + * absent, fill it with the matching value (callers that only set + * `outcome` get a consistent `errorCategory`). + */ +function normalizeToolInvocationRecord(record: ToolInvocationRecord): ToolInvocationRecord { + let outcome: ToolOutcome = record.outcome; + let errorCategory: ErrorCategory | undefined = record.errorCategory; + + if (isSharedTerminal(errorCategory)) { + outcome = errorCategory; + } else if (isSharedTerminal(outcome) && errorCategory === undefined) { + errorCategory = outcome; + } + + return { ...record, outcome, errorCategory }; +} + export interface ChatActivationRecord { javaLSReadyAtActivation: boolean; lmtCount: number; @@ -358,17 +410,41 @@ export function recordChatActivation(record: ChatActivationRecord): void { }); } +/** + * Project type detected by the launch flow. Free-form values are + * forbidden so this stays a closed enum. + */ +export type LaunchProjectType = 'maven' | 'gradle' | 'vscode' | 'unknown'; + +/** + * Discriminated union of every launch-flow internal event the recorder + * is allowed to emit. Each variant lists its allowed properties so the + * type system rejects unknown event names and unknown property keys. + * + * Note: `sessionId` here is VS Code's opaque debug-session GUID, never + * the user-visible `launch.json` session name. + */ +export type LaunchInternalEvent = + | { name: 'cleanupExistingSession'; sessionId: string } + | { name: 'cleanupExistingSessionFailed'; errorCategory: ErrorCategory } + | { name: 'debugSessionStarted.eventBased'; sessionId: string } + | { name: 'debugSessionTimeout.eventBased' } + | { name: 'debugSessionDetected'; sessionId: string; elapsedMs: number } + | { name: 'debugSessionTimeout.smartPolling'; maxWaitTime: number } + | { name: 'classNameDetection'; projectType: LaunchProjectType; detected: boolean } + | { name: 'getDebugSessionInfo.threadError'; errorCategory: ErrorCategory }; + /** * Internal-debug event for the launch-flow nested instrumentation * (session-detected / cleanup / timeout). Re-uses the sanitised sender so - * no PII can slip in. + * no PII can slip in. Accepts only the discriminated-union shapes defined + * in {@link LaunchInternalEvent} — unknown event names or unexpected + * property keys are rejected at compile time. */ -export function recordLaunchInternal( - operationName: string, - properties: Record, -): void { +export function recordLaunchInternal(event: LaunchInternalEvent): void { + const { name, ...properties } = event; sanitizedSend({ - operationName: `languageModelTool.${operationName}`, + operationName: `languageModelTool.${name}`, ...properties, }); }