diff --git a/.github/skills/otel/SKILL.md b/.github/skills/otel/SKILL.md index af5c826ed14..c952f23b353 100644 --- a/.github/skills/otel/SKILL.md +++ b/.github/skills/otel/SKILL.md @@ -78,6 +78,23 @@ extensions/copilot/src/extension/ └── otlpFormatConversion.ts # OTLP ↔ in-memory span format ``` +## 3a. Attribute namespaces & dual-emit policy + +Three namespaces coexist on extension-emitted spans: + +| Namespace | Purpose | Status | +|---|---|---| +| `gen_ai.*` | OTel GenAI Semantic Conventions. Use whenever a standard key exists. | Canonical | +| `github.copilot.*` | Copilot-specific vendor namespace. | **Preferred — new attributes go here.** | +| `copilot_chat.*` | Original VS Code-only namespace. Several keys remain for backwards compatibility. | **Legacy — keep emitting; do not add new keys here.** | + +### Dual-emit rules + +- When adding a new attribute that belongs to Copilot's vendor namespace, emit it under `github.copilot.*` only — do **not** introduce a `copilot_chat.*` twin. +- When **renaming** an existing `copilot_chat.*` attribute to its `github.copilot.*` equivalent (e.g., `copilot_chat.repo.*` → `github.copilot.git.*`, `gen_ai.usage.reasoning_tokens` → `gen_ai.usage.reasoning.output_tokens`), **dual-emit both keys indefinitely**. Downstream readers (Agent Debug Log, Chronicle, SQLite span store, OTLP collectors) may depend on the legacy key. +- Mark the legacy row in [agent_monitoring.md](../../../extensions/copilot/docs/monitoring/agent_monitoring.md) with **Legacy** in the "Requirement" column and a pointer to the preferred key. No sunset date — legacy keys live on indefinitely. +- Hash sensitive identifiers (e.g., MCP server names) with `hashTelemetryValue` from [`util/node/crypto.ts`](../../../extensions/copilot/src/util/node/crypto.ts). Emit hashes unconditionally; raw values only when `captureContent` is enabled. + ## 4. Service Layer & Selection `IOTelService` ([otelService.ts](../../../extensions/copilot/src/platform/otel/common/otelService.ts)) is the only abstraction consumers should depend on — never import the OTel SDK directly outside `node/otelServiceImpl.ts`. Three implementations: diff --git a/extensions/copilot/docs/monitoring/agent_monitoring.md b/extensions/copilot/docs/monitoring/agent_monitoring.md index 9ccaefef64f..fe8c2c08fbe 100644 --- a/extensions/copilot/docs/monitoring/agent_monitoring.md +++ b/extensions/copilot/docs/monitoring/agent_monitoring.md @@ -114,6 +114,16 @@ OTel is **off by default** with zero overhead. It activates when: ## What Gets Exported +> ### Attribute namespaces & dual-emit policy +> +> Copilot Chat emits OTel attributes under three namespaces: +> +> - **`gen_ai.*`** — [OTel GenAI Semantic Conventions](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/). Use these whenever a standard key exists. +> - **`github.copilot.*`** — Canonical Copilot-specific namespace. Prefer this for new dashboards and alerts. +> - **`copilot_chat.*`** — Original VS Code extension namespace. Several keys (notably `copilot_chat.repo.*` and `gen_ai.usage.reasoning_tokens`) are now **dual-emitted alongside the `github.copilot.*` equivalents**. Tables below mark these rows as **Legacy** with a pointer to the preferred key. +> +> Legacy keys continue to emit indefinitely so existing collectors, dashboards, and downstream consumers (Agent Debug Log, Chronicle, SQLite span store) keep working without changes. There is no sunset date. + ### Traces Copilot Chat emits a hierarchical span tree for each agent interaction: @@ -141,6 +151,14 @@ invoke_agent copilot [~15s] | `gen_ai.usage.output_tokens` | Recommended | `3200` | | `gen_ai.usage.cache_read.input_tokens` | When available | `8000` | | `gen_ai.usage.cache_creation.input_tokens` | When available | `4200` | +| `github.copilot.agent.type` | Always | `builtin` \| `custom` \| `plugin` | +| `github.copilot.git.repository` | When in a repo | `https://github.com/microsoft/vscode.git` | +| `github.copilot.git.branch` | When in a repo | `main` | +| `github.copilot.git.commit_sha` | When in a repo | `deadbeef...` | +| `github.copilot.github.org` | GitHub remotes only | `microsoft` | +| `copilot_chat.repo.remote_url` | **Legacy** — prefer `github.copilot.git.repository` | `https://github.com/...` | +| `copilot_chat.repo.head_branch_name` | **Legacy** — prefer `github.copilot.git.branch` | `main` | +| `copilot_chat.repo.head_commit_hash` | **Legacy** — prefer `github.copilot.git.commit_sha` | `deadbeef...` | | `copilot_chat.turn_count` | Always | `4` | | `error.type` | On error | `Error` | | `gen_ai.input.messages` | Opt-in (captureContent) | `[{"role":"user",...}]` | @@ -166,6 +184,8 @@ invoke_agent copilot [~15s] | `gen_ai.usage.output_tokens` | On response | `250` | | `gen_ai.usage.cache_read.input_tokens` | When available | `1200` | | `gen_ai.usage.cache_creation.input_tokens` | When available | `300` | +| `gen_ai.usage.reasoning.output_tokens` | When available | `512` | +| `gen_ai.usage.reasoning_tokens` | **Legacy** — prefer `gen_ai.usage.reasoning.output_tokens` | `512` | | `copilot_chat.time_to_first_token` | On response | `450` | | `server.address` | When available | `api.github.com` | | `copilot_chat.debug_name` | When available | `agentMode` | @@ -182,6 +202,13 @@ invoke_agent copilot [~15s] | `gen_ai.tool.type` | Required | `function` or `extension` (MCP tools) | | `gen_ai.tool.call.id` | Recommended | `call_abc123` | | `gen_ai.tool.description` | When available | `Read the contents of a file` | +| `github.copilot.tool.parameters.edit_type` | Edit tools | `create` \| `update` \| `str_replace` \| `insert` | +| `github.copilot.tool.parameters.skill_name` | When invoking a skill | `auto-perf-optimize` | +| `github.copilot.tool.parameters.mcp_server_name_hash` | MCP tools | SHA-256 hex of server name | +| `github.copilot.tool.parameters.mcp_tool_name` | MCP tools | `search_issues` | +| `github.copilot.tool.parameters.command` | Shell tools, opt-in (captureContent) | `npm test` (truncated to 256 chars) | +| `github.copilot.tool.parameters.file_path` | File tools, opt-in (captureContent) | `/src/app.ts` | +| `github.copilot.tool.parameters.mcp_server_name` | MCP tools, opt-in (captureContent) | `github` | | `error.type` | On error | `FileNotFoundError` | | `gen_ai.tool.call.arguments` | Opt-in (captureContent) | `{"filePath":"/src/index.ts"}` | | `gen_ai.tool.call.result` | Opt-in (captureContent) | `(file contents or summary)` | @@ -628,6 +655,18 @@ copilot-chat invoke_agent claude [~33s] **`execute_hook`** — one span per Claude hook execution (e.g., `Stop` hooks). +| Attribute | Requirement | Example | +|---|---|---| +| `gen_ai.operation.name` | Required | `execute_hook` | +| `copilot_chat.hook_type` | Required | `PreToolUse` | +| `copilot_chat.hook_result_kind` | Always | `success` \| `error` \| `non_blocking_error` | +| `github.copilot.hook.decision` | Always | `pass` \| `block` \| `non_blocking_error` | +| `github.copilot.hook.duration` | Always | `0.142` (seconds) | +| `github.copilot.hook.tool_names` | When tool-scoped | `["bash"]` (JSON array) | +| `copilot_chat.hook_input` | Always | hook input payload (truncated) | +| `copilot_chat.hook_output` | On success | hook stdout (truncated) | +| `error.type` | On error | `Error` | + --- ## Interpreting the Data diff --git a/extensions/copilot/src/extension/chat/vscode-node/chatHookService.ts b/extensions/copilot/src/extension/chat/vscode-node/chatHookService.ts index 25a6b059aaa..d65bf8ac5fd 100644 --- a/extensions/copilot/src/extension/chat/vscode-node/chatHookService.ts +++ b/extensions/copilot/src/extension/chat/vscode-node/chatHookService.ts @@ -11,7 +11,7 @@ import { HookCommandResultKind, IHookCommandResult, IHookExecutor } from '../../ import { IHooksOutputChannel } from '../../../platform/chat/common/hooksOutputChannel'; import { ISessionTranscriptService } from '../../../platform/chat/common/sessionTranscriptService'; import { ILogService } from '../../../platform/log/common/logService'; -import { CopilotChatAttr, GenAiAttr, GenAiOperationName, IOTelService, SpanKind, SpanStatusCode, truncateForOTel } from '../../../platform/otel/common/index'; +import { CopilotChatAttr, GenAiAttr, GenAiOperationName, GitHubCopilotAttr, IOTelService, SpanKind, SpanStatusCode, truncateForOTel } from '../../../platform/otel/common/index'; import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'; import { raceTimeout } from '../../../util/vs/base/common/async'; import { CancellationToken } from '../../../util/vs/base/common/cancellation'; @@ -153,6 +153,9 @@ export class ChatHookService implements IChatHookService { const inputForLog = this._redactForLogging(commandInput as Record); this._log(requestId, hookType, `Input: ${JSON.stringify(inputForLog)}`); + const hookToolName = (commandInput as { tool_name?: unknown }).tool_name; + const hookToolNamesJson = typeof hookToolName === 'string' ? JSON.stringify([hookToolName]) : undefined; + const span = this._otelService.startSpan(`execute_hook ${hookType}`, { kind: SpanKind.INTERNAL, attributes: { @@ -160,6 +163,7 @@ export class ChatHookService implements IChatHookService { [CopilotChatAttr.HOOK_TYPE]: hookType, 'copilot_chat.hook_command': hookCommand.command, ...(chatSessionId ? { [CopilotChatAttr.CHAT_SESSION_ID]: chatSessionId } : {}), + ...(hookToolNamesJson ? { [GitHubCopilotAttr.HOOK_TOOL_NAMES]: hookToolNamesJson } : {}), }, }); @@ -172,6 +176,7 @@ export class ChatHookService implements IChatHookService { const sw = StopWatch.create(); const commandResult = await this._hookExecutor.executeCommand(hookCommand, commandInput, effectiveToken); const elapsed = sw.elapsed(); + span.setAttribute(GitHubCopilotAttr.HOOK_DURATION_SECONDS, elapsed / 1000); this._logCommandResult(requestId, hookType, commandResult, elapsed); @@ -181,6 +186,13 @@ export class ChatHookService implements IChatHookService { : 'error'; span.setAttribute(CopilotChatAttr.HOOK_RESULT_KIND, resultKind); + const hookDecision = commandResult.kind === HookCommandResultKind.Error + ? 'block' + : commandResult.kind === HookCommandResultKind.NonBlockingError + ? 'non_blocking_error' + : 'pass'; + span.setAttribute(GitHubCopilotAttr.HOOK_DECISION, hookDecision); + if (commandResult.kind === HookCommandResultKind.Error || commandResult.kind === HookCommandResultKind.NonBlockingError) { hasError = true; // Record exit code on error @@ -205,6 +217,10 @@ export class ChatHookService implements IChatHookService { // If stopReason is set (including empty string for "stop without message"), stop processing remaining hooks if (result.stopReason !== undefined) { + // A stop signal from a successful hook still counts as a block. + if (hookDecision === 'pass') { + span.setAttribute(GitHubCopilotAttr.HOOK_DECISION, 'block'); + } this._log(requestId, hookType, `Stopping: ${result.stopReason}`); this._logService.debug(`[ChatHookService] Stopping after hook: ${result.stopReason}`); break; diff --git a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts index 1399958d9c4..b054cde73cc 100644 --- a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts +++ b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts @@ -22,7 +22,7 @@ import { ILogService } from '../../../platform/log/common/logService'; import { isOpenAIContextManagementResponse, OpenAiFunctionDef } from '../../../platform/networking/common/fetch'; import { IMakeChatRequestOptions } from '../../../platform/networking/common/networking'; import { OpenAIContextManagementResponse } from '../../../platform/networking/common/openai'; -import { CopilotChatAttr, emitAgentTurnEvent, emitSessionStartEvent, GenAiAttr, GenAiMetrics, GenAiOperationName, GenAiProviderName, resolveWorkspaceOTelMetadata, StdAttr, truncateForOTel, workspaceMetadataToOTelAttributes } from '../../../platform/otel/common/index'; +import { CopilotChatAttr, emitAgentTurnEvent, emitSessionStartEvent, GenAiAttr, GenAiMetrics, GenAiOperationName, GenAiProviderName, GitHubCopilotAttr, resolveWorkspaceOTelMetadata, StdAttr, truncateForOTel, workspaceMetadataToOTelAttributes } from '../../../platform/otel/common/index'; import { IOTelService, ISpanHandle, SpanKind, SpanStatusCode } from '../../../platform/otel/common/otelService'; import { IRequestLogger } from '../../../platform/requestLogger/common/requestLogger'; import { getCurrentCapturingToken } from '../../../platform/requestLogger/node/requestLogger'; @@ -738,6 +738,7 @@ export abstract class ToolCallingLoop_` prefix). */ + TOOL_PARAM_MCP_TOOL_NAME: 'github.copilot.tool.parameters.mcp_tool_name', +} as const; + +export type AgentType = 'builtin' | 'plugin' | 'custom'; +export type HookDecision = 'block' | 'approve' | 'non_blocking_error' | 'pass'; +export type EditOperationType = 'create' | 'update' | 'str_replace' | 'insert'; + +/** Max length for the `tool.parameters.command` attribute. */ +export const TOOL_PARAM_COMMAND_MAX_LEN = 256; + +/** Tool names treated as shell-command tools for parameter extraction. */ +export const SHELL_TOOL_NAMES: ReadonlySet = new Set([ + 'bash', + 'powershell', + 'local_shell', + 'runInTerminal', + 'run_in_terminal', +]); + +/** + * Tool names treated as file tools for parameter extraction. Covers VS Code's + * `ToolName` enum values (snake_case, see `extension/tools/common/toolNames.ts`) + * and the camelCase / Claude-style variants seen on external surfaces. + */ +export const FILE_TOOL_NAMES: ReadonlySet = new Set([ + // camelCase / Claude-style names + 'view', + 'create', + 'edit', + 'str_replace', + 'str_replace_editor', + 'insert', + 'readFile', + 'createFile', + 'replaceString', + 'applyPatch', + // VS Code tool names (ToolName enum) + 'read_file', + 'create_file', + 'apply_patch', + 'insert_edit_into_file', + 'replace_string_in_file', + 'multi_replace_string_in_file', + 'edit_notebook_file', +]); diff --git a/extensions/copilot/src/platform/otel/common/index.ts b/extensions/copilot/src/platform/otel/common/index.ts index ef4177129ea..99116ab6b96 100644 --- a/extensions/copilot/src/platform/otel/common/index.ts +++ b/extensions/copilot/src/platform/otel/common/index.ts @@ -3,7 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -export { CopilotChatAttr, CopilotCliSdkAttr, GenAiAttr, GenAiOperationName, GenAiProviderName, GenAiTokenType, GenAiToolType, StdAttr } from './genAiAttributes'; +export { CopilotChatAttr, CopilotCliSdkAttr, FILE_TOOL_NAMES, GenAiAttr, GenAiOperationName, GenAiProviderName, GenAiTokenType, GenAiToolType, GitHubCopilotAttr, SHELL_TOOL_NAMES, StdAttr, TOOL_PARAM_COMMAND_MAX_LEN } from './genAiAttributes'; +export type { AgentType, EditOperationType, HookDecision } from './genAiAttributes'; export { emitAgentTurnEvent, emitCloudSessionInvokeEvent, emitEditFeedbackEvent, emitEditHunkActionEvent, emitEditSurvivalEvent, emitInferenceDetailsEvent, emitInlineDoneEvent, emitSessionStartEvent, emitToolCallEvent, emitUserFeedbackEvent } from './genAiEvents'; export { GenAiMetrics } from './genAiMetrics'; export { collectSystemTextsFromRequestBody, extractTextFromContent, normalizeProviderMessages, toInputMessages, toOutputMessages, toSystemInstructions, toToolDefinitions, truncateForOTel } from './messageFormatters'; diff --git a/extensions/copilot/src/platform/otel/common/test/workspaceOTelMetadata.spec.ts b/extensions/copilot/src/platform/otel/common/test/workspaceOTelMetadata.spec.ts index c611aa3bbd6..00e93ab2766 100644 --- a/extensions/copilot/src/platform/otel/common/test/workspaceOTelMetadata.spec.ts +++ b/extensions/copilot/src/platform/otel/common/test/workspaceOTelMetadata.spec.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest'; import { URI } from '../../../../util/vs/base/common/uri'; import type { IGitService, RepoContext } from '../../../git/common/gitService'; -import { CopilotChatAttr } from '../genAiAttributes'; +import { CopilotChatAttr, GitHubCopilotAttr } from '../genAiAttributes'; import { resolveWorkspaceOTelMetadata, workspaceMetadataToOTelAttributes } from '../workspaceOTelMetadata'; function createMockGitService(repoContext?: Partial): IGitService { @@ -108,4 +108,26 @@ describe('workspaceMetadataToOTelAttributes', () => { expect(attrs[CopilotChatAttr.REPO_REMOTE_URL]).toBe('github.com/org/repo'); expect(attrs[CopilotChatAttr.FILE_RELATIVE_PATH]).toBe('src/index.ts'); }); + + it('dual-emits github.copilot.git.* alongside copilot_chat.repo.* and derives github.org', () => { + const attrs = workspaceMetadataToOTelAttributes({ + headBranchName: 'feature/x', + headCommitHash: 'cafef00d', + remoteUrl: 'https://github.com/microsoft/vscode.git', + }); + expect(attrs[GitHubCopilotAttr.GIT_BRANCH]).toBe('feature/x'); + expect(attrs[GitHubCopilotAttr.GIT_COMMIT_SHA]).toBe('cafef00d'); + expect(attrs[GitHubCopilotAttr.GIT_REPOSITORY]).toBe('https://github.com/microsoft/vscode.git'); + expect(attrs[GitHubCopilotAttr.GITHUB_ORG]).toBe('microsoft'); + // Legacy keys must still be present. + expect(attrs[CopilotChatAttr.REPO_HEAD_BRANCH_NAME]).toBe('feature/x'); + }); + + it('omits github.org for non-github remotes', () => { + const attrs = workspaceMetadataToOTelAttributes({ + remoteUrl: 'https://gitlab.com/org/repo.git', + }); + expect(attrs[GitHubCopilotAttr.GIT_REPOSITORY]).toBe('https://gitlab.com/org/repo.git'); + expect(attrs[GitHubCopilotAttr.GITHUB_ORG]).toBeUndefined(); + }); }); diff --git a/extensions/copilot/src/platform/otel/common/workspaceOTelMetadata.ts b/extensions/copilot/src/platform/otel/common/workspaceOTelMetadata.ts index 72677905869..81e46aa16b4 100644 --- a/extensions/copilot/src/platform/otel/common/workspaceOTelMetadata.ts +++ b/extensions/copilot/src/platform/otel/common/workspaceOTelMetadata.ts @@ -6,7 +6,7 @@ import { URI } from '../../../util/vs/base/common/uri'; import { isEqualOrParent, relativePath } from '../../../util/vs/base/common/resources'; import { getOrderedRepoInfosFromContext, type IGitService, normalizeFetchUrl, type RepoContext } from '../../git/common/gitService'; -import { CopilotChatAttr } from './genAiAttributes'; +import { CopilotChatAttr, GitHubCopilotAttr } from './genAiAttributes'; export interface WorkspaceOTelMetadata { readonly headBranchName?: string; @@ -52,6 +52,8 @@ function buildWorkspaceMetadata(repoContext: RepoContext, fileUri?: URI): Worksp /** * Convert workspace metadata to OTel attributes, omitting undefined values. + * Emits both the legacy `copilot_chat.repo.*` namespace and the canonical + * `github.copilot.git.*` namespace. */ export function workspaceMetadataToOTelAttributes( metadata?: WorkspaceOTelMetadata, @@ -62,15 +64,33 @@ export function workspaceMetadataToOTelAttributes( const attrs: Record = {}; if (metadata.headBranchName) { attrs[CopilotChatAttr.REPO_HEAD_BRANCH_NAME] = metadata.headBranchName; + attrs[GitHubCopilotAttr.GIT_BRANCH] = metadata.headBranchName; } if (metadata.headCommitHash) { attrs[CopilotChatAttr.REPO_HEAD_COMMIT_HASH] = metadata.headCommitHash; + attrs[GitHubCopilotAttr.GIT_COMMIT_SHA] = metadata.headCommitHash; } if (metadata.remoteUrl) { attrs[CopilotChatAttr.REPO_REMOTE_URL] = metadata.remoteUrl; + attrs[GitHubCopilotAttr.GIT_REPOSITORY] = metadata.remoteUrl; + const org = extractGitHubOrg(metadata.remoteUrl); + if (org) { + attrs[GitHubCopilotAttr.GITHUB_ORG] = org; + } } if (metadata.fileRelativePath) { attrs[CopilotChatAttr.FILE_RELATIVE_PATH] = metadata.fileRelativePath; } return attrs; } + +/** + * Extract the `owner` segment from a normalized GitHub remote URL. + * Returns undefined for non-GitHub hosts or malformed inputs. + */ +function extractGitHubOrg(remoteUrl: string): string | undefined { + // Match `(https://|git@)[:/]/` — normalizeFetchUrl already + // strips credentials and `.git` suffixes. + const m = remoteUrl.match(/github\.com[/:]([^/]+)\/[^/]+\/?$/i); + return m?.[1]; +} diff --git a/extensions/copilot/src/platform/otel/node/extractToolParameters.ts b/extensions/copilot/src/platform/otel/node/extractToolParameters.ts new file mode 100644 index 00000000000..c3d2303767d --- /dev/null +++ b/extensions/copilot/src/platform/otel/node/extractToolParameters.ts @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { hashTelemetryValue } from '../../../util/node/crypto'; +import { + EditOperationType, + FILE_TOOL_NAMES, + GitHubCopilotAttr, + SHELL_TOOL_NAMES, + TOOL_PARAM_COMMAND_MAX_LEN, +} from '../common/genAiAttributes'; + +/** + * Result of extracting structured parameter attributes from a tool invocation. + * `gatedAttrs` are content-sensitive (raw paths, command text, MCP server + * names) and must only be emitted when `captureContent` is enabled. `attrs` + * are always safe (e.g. SHA-256 hashes, fixed enums). + */ +export interface ToolParameterAttributes { + attrs: Record; + gatedAttrs: Record; +} + +/** + * Produces structured `github.copilot.tool.parameters.*` attributes for shell, + * file, skill, and MCP tool calls. `gatedAttrs` are content-sensitive (raw + * paths, commands, MCP server names) and emit only when `captureContent` is + * enabled; `attrs` are always safe (hashes, fixed enums). + */ +export function extractToolParameters(toolName: string, input: unknown): ToolParameterAttributes { + const attrs: Record = {}; + const gatedAttrs: Record = {}; + + if (typeof input !== 'object' || input === null) { + return { attrs, gatedAttrs }; + } + const obj = input as Record; + + if (SHELL_TOOL_NAMES.has(toolName)) { + const command = pickFirstString(obj, ['command', 'cmd', 'commandLine']); + if (command) { + gatedAttrs[GitHubCopilotAttr.TOOL_PARAM_COMMAND] = + command.length > TOOL_PARAM_COMMAND_MAX_LEN + ? command.slice(0, TOOL_PARAM_COMMAND_MAX_LEN) + : command; + } + } + + if (FILE_TOOL_NAMES.has(toolName)) { + const filePath = pickFirstString(obj, ['file_path', 'filePath', 'path', 'uri']); + if (filePath) { + gatedAttrs[GitHubCopilotAttr.TOOL_PARAM_FILE_PATH] = filePath; + } + const editType = classifyEditType(toolName, obj); + if (editType) { + attrs[GitHubCopilotAttr.TOOL_PARAM_EDIT_TYPE] = editType; + } + } + + const skillName = pickFirstString(obj, ['skill_name', 'skillName', 'skill']); + if (skillName) { + attrs[GitHubCopilotAttr.TOOL_PARAM_SKILL_NAME] = skillName; + } + + // MCP-style tool names: VS Code emits `mcp__`; Anthropic-style + // references use `mcp____`. Accept both. + if (toolName.startsWith('mcp__')) { + const rest = toolName.slice('mcp__'.length); + const sep = rest.indexOf('__'); + if (sep > 0) { + const serverName = rest.slice(0, sep); + const mcpToolName = rest.slice(sep + 2); + attrs[GitHubCopilotAttr.TOOL_PARAM_MCP_SERVER_NAME_HASH] = hashTelemetryValue(serverName); + attrs[GitHubCopilotAttr.TOOL_PARAM_MCP_TOOL_NAME] = mcpToolName; + gatedAttrs[GitHubCopilotAttr.TOOL_PARAM_MCP_SERVER_NAME] = serverName; + } + } else if (toolName.startsWith('mcp_')) { + const rest = toolName.slice('mcp_'.length); + const underscore = rest.indexOf('_'); + if (underscore > 0) { + const serverName = rest.slice(0, underscore); + const mcpToolName = rest.slice(underscore + 1); + attrs[GitHubCopilotAttr.TOOL_PARAM_MCP_SERVER_NAME_HASH] = hashTelemetryValue(serverName); + attrs[GitHubCopilotAttr.TOOL_PARAM_MCP_TOOL_NAME] = mcpToolName; + gatedAttrs[GitHubCopilotAttr.TOOL_PARAM_MCP_SERVER_NAME] = serverName; + } + } + + return { attrs, gatedAttrs }; +} + +function pickFirstString(obj: Record, keys: readonly string[]): string | undefined { + for (const k of keys) { + const v = obj[k]; + if (typeof v === 'string' && v.length > 0) { + return v; + } + } + return undefined; +} + +function classifyEditType(toolName: string, obj: Record): EditOperationType | undefined { + if (toolName === 'create' || toolName === 'createFile' || toolName === 'create_file') { + return 'create'; + } + if (toolName === 'insert') { + return 'insert'; + } + if ( + toolName === 'str_replace' || + toolName === 'str_replace_editor' || + toolName === 'replaceString' || + toolName === 'replace_string_in_file' || + toolName === 'multi_replace_string_in_file' + ) { + return 'str_replace'; + } + if ( + toolName === 'edit' || + toolName === 'applyPatch' || + toolName === 'apply_patch' || + toolName === 'insert_edit_into_file' || + toolName === 'edit_notebook_file' + ) { + return 'update'; + } + // `view`/`readFile`/`read_file` have no edit_type. + if (toolName === 'view' || toolName === 'readFile' || toolName === 'read_file') { + return undefined; + } + // Fallback: heuristic on common arg names. + if (typeof obj.old_str === 'string' || typeof obj.oldString === 'string') { + return 'str_replace'; + } + if (typeof obj.content === 'string' && typeof obj.file_text === 'undefined') { + return 'update'; + } + return undefined; +} diff --git a/extensions/copilot/src/platform/otel/node/test/extractToolParameters.spec.ts b/extensions/copilot/src/platform/otel/node/test/extractToolParameters.spec.ts new file mode 100644 index 00000000000..844b0013c75 --- /dev/null +++ b/extensions/copilot/src/platform/otel/node/test/extractToolParameters.spec.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { GitHubCopilotAttr, TOOL_PARAM_COMMAND_MAX_LEN } from '../../common/genAiAttributes'; +import { extractToolParameters } from '../extractToolParameters'; + +describe('extractToolParameters', () => { + it('returns empty attrs for non-object input', () => { + expect(extractToolParameters('bash', undefined).attrs).toEqual({}); + expect(extractToolParameters('bash', null).gatedAttrs).toEqual({}); + }); + + it('gates shell command behind captureContent and truncates to 256 chars', () => { + const long = 'echo ' + 'x'.repeat(500); + const { attrs, gatedAttrs } = extractToolParameters('bash', { command: long }); + expect(attrs).toEqual({}); + expect(gatedAttrs[GitHubCopilotAttr.TOOL_PARAM_COMMAND]).toBeDefined(); + expect(gatedAttrs[GitHubCopilotAttr.TOOL_PARAM_COMMAND].length).toBe(TOOL_PARAM_COMMAND_MAX_LEN); + }); + + it('emits MCP server hash unconditionally and raw name gated', () => { + const { attrs, gatedAttrs } = extractToolParameters('mcp_github_search_issues', {}); + expect(attrs[GitHubCopilotAttr.TOOL_PARAM_MCP_SERVER_NAME_HASH]).toMatch(/^[a-f0-9]{64}$/); + expect(attrs[GitHubCopilotAttr.TOOL_PARAM_MCP_TOOL_NAME]).toBe('search_issues'); + expect(gatedAttrs[GitHubCopilotAttr.TOOL_PARAM_MCP_SERVER_NAME]).toBe('github'); + }); + + it('also handles Anthropic-style mcp__server__tool double-underscore format', () => { + const { attrs, gatedAttrs } = extractToolParameters('mcp__github__list_issues', {}); + expect(attrs[GitHubCopilotAttr.TOOL_PARAM_MCP_SERVER_NAME_HASH]).toMatch(/^[a-f0-9]{64}$/); + expect(attrs[GitHubCopilotAttr.TOOL_PARAM_MCP_TOOL_NAME]).toBe('list_issues'); + expect(gatedAttrs[GitHubCopilotAttr.TOOL_PARAM_MCP_SERVER_NAME]).toBe('github'); + }); + + it('classifies file edit operations and gates file_path', () => { + const { attrs, gatedAttrs } = extractToolParameters('str_replace', { + file_path: '/src/app.ts', + old_str: 'foo', + }); + expect(attrs[GitHubCopilotAttr.TOOL_PARAM_EDIT_TYPE]).toBe('str_replace'); + expect(gatedAttrs[GitHubCopilotAttr.TOOL_PARAM_FILE_PATH]).toBe('/src/app.ts'); + }); + + it('returns nothing for tools that do not match any extractor', () => { + const { attrs, gatedAttrs } = extractToolParameters('unknown_tool', { foo: 'bar' }); + expect(attrs).toEqual({}); + expect(gatedAttrs).toEqual({}); + }); + + it('classifies VS Code snake_case edit tool names', () => { + expect(extractToolParameters('create_file', { filePath: '/a.ts' }).attrs[GitHubCopilotAttr.TOOL_PARAM_EDIT_TYPE]).toBe('create'); + expect(extractToolParameters('replace_string_in_file', { filePath: '/a.ts' }).attrs[GitHubCopilotAttr.TOOL_PARAM_EDIT_TYPE]).toBe('str_replace'); + expect(extractToolParameters('multi_replace_string_in_file', { filePath: '/a.ts' }).attrs[GitHubCopilotAttr.TOOL_PARAM_EDIT_TYPE]).toBe('str_replace'); + expect(extractToolParameters('apply_patch', { filePath: '/a.ts' }).attrs[GitHubCopilotAttr.TOOL_PARAM_EDIT_TYPE]).toBe('update'); + expect(extractToolParameters('insert_edit_into_file', { filePath: '/a.ts' }).attrs[GitHubCopilotAttr.TOOL_PARAM_EDIT_TYPE]).toBe('update'); + expect(extractToolParameters('edit_notebook_file', { filePath: '/a.ipynb' }).attrs[GitHubCopilotAttr.TOOL_PARAM_EDIT_TYPE]).toBe('update'); + expect(extractToolParameters('read_file', { filePath: '/a.ts' }).attrs[GitHubCopilotAttr.TOOL_PARAM_EDIT_TYPE]).toBeUndefined(); + }); +}); diff --git a/extensions/copilot/src/util/node/crypto.ts b/extensions/copilot/src/util/node/crypto.ts index 84cde33c748..d6987311f40 100644 --- a/extensions/copilot/src/util/node/crypto.ts +++ b/extensions/copilot/src/util/node/crypto.ts @@ -13,3 +13,15 @@ export async function createSha256FromStream(stream: Readable): Promise } return hash.digest('hex'); } + +/** + * Synchronous SHA-256 hex digest used to redact identifiers in OTel telemetry + * (e.g., MCP server names). Returns `''` for empty/undefined input so callers + * can short-circuit on `!result`. + */ +export function hashTelemetryValue(value: string | undefined | null): string { + if (!value) { + return ''; + } + return createHash('sha256').update(value).digest('hex'); +}