mirror of
https://github.com/microsoft/vscode.git
synced 2026-06-07 16:16:58 +01:00
Merge pull request #318031 from microsoft/zhichli/oteladdition
Chat: OTel enrichment for agent context
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>);
|
||||
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;
|
||||
|
||||
@@ -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<TOptions extends IToolCallingLoopOptions =
|
||||
// Extract custom mode name for debug logging (kept separate from agentName to avoid metric cardinality)
|
||||
const modeInstructions = (this.options.request as { modeInstructions2?: { name?: string; isBuiltin?: boolean } }).modeInstructions2;
|
||||
const customModeName = modeInstructions?.name && !modeInstructions.isBuiltin ? modeInstructions.name : undefined;
|
||||
const agentType: 'builtin' | 'custom' = modeInstructions && modeInstructions.isBuiltin === false ? 'custom' : 'builtin';
|
||||
|
||||
// If this is a subagent request, look up the parent trace context stored by the parent agent's execute_tool span
|
||||
// Try subAgentInvocationId first (unique per subagent, supports parallel), then request-level key
|
||||
@@ -773,6 +774,7 @@ export abstract class ToolCallingLoop<TOptions extends IToolCallingLoopOptions =
|
||||
...(parentChatSessionId ? { [CopilotChatAttr.PARENT_CHAT_SESSION_ID]: parentChatSessionId } : {}),
|
||||
...(debugLogLabel ? { [CopilotChatAttr.DEBUG_LOG_LABEL]: debugLogLabel } : {}),
|
||||
...(customModeName ? { [CopilotChatAttr.MODE_NAME]: customModeName } : {}),
|
||||
[GitHubCopilotAttr.AGENT_TYPE]: agentType,
|
||||
...workspaceMetadataToOTelAttributes(resolveWorkspaceOTelMetadata(this._gitService)),
|
||||
},
|
||||
parentTraceContext,
|
||||
|
||||
@@ -419,7 +419,10 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher {
|
||||
[CopilotChatAttr.TIME_TO_FIRST_TOKEN]: timeToFirstToken,
|
||||
...(result.serverRequestId ? { [CopilotChatAttr.SERVER_REQUEST_ID]: result.serverRequestId } : {}),
|
||||
...(result.usage.completion_tokens_details?.reasoning_tokens
|
||||
? { [GenAiAttr.USAGE_REASONING_TOKENS]: result.usage.completion_tokens_details.reasoning_tokens }
|
||||
? {
|
||||
[GenAiAttr.USAGE_REASONING_TOKENS]: result.usage.completion_tokens_details.reasoning_tokens,
|
||||
[GenAiAttr.USAGE_REASONING_OUTPUT_TOKENS]: result.usage.completion_tokens_details.reasoning_tokens,
|
||||
}
|
||||
: {}),
|
||||
...(typeof result.usage.copilot_usage?.total_nano_aiu === 'number'
|
||||
? { [CopilotChatAttr.COPILOT_USAGE_NANO_AIU]: result.usage.copilot_usage.total_nano_aiu }
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ILogService } from '../../../platform/log/common/logService';
|
||||
import { IChatEndpoint } from '../../../platform/networking/common/networking';
|
||||
import { CopilotChatAttr, emitToolCallEvent, GenAiAttr, GenAiMetrics, GenAiOperationName, GenAiToolType, StdAttr, truncateForOTel } from '../../../platform/otel/common/index';
|
||||
import { IOTelService, SpanKind, SpanStatusCode } from '../../../platform/otel/common/otelService';
|
||||
import { extractToolParameters } from '../../../platform/otel/node/extractToolParameters';
|
||||
import { getCurrentCapturingToken } from '../../../platform/requestLogger/node/requestLogger';
|
||||
import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService';
|
||||
import { equals as arraysEqual } from '../../../util/vs/base/common/arrays';
|
||||
@@ -152,6 +153,20 @@ export class ToolsService extends BaseToolsService {
|
||||
} catch { /* swallow serialization errors */ }
|
||||
}
|
||||
|
||||
// Structured `github.copilot.tool.parameters.*`. Hashes and edit_type emit
|
||||
// unconditionally; raw paths, commands, and MCP names are gated.
|
||||
try {
|
||||
const { attrs: paramAttrs, gatedAttrs: gatedParamAttrs } = extractToolParameters(String(name), options.input);
|
||||
for (const [k, v] of Object.entries(paramAttrs)) {
|
||||
span.setAttribute(k, v);
|
||||
}
|
||||
if (this._otelService.config.captureContent) {
|
||||
for (const [k, v] of Object.entries(gatedParamAttrs)) {
|
||||
span.setAttribute(k, v);
|
||||
}
|
||||
}
|
||||
} catch { /* swallow extraction errors */ }
|
||||
|
||||
// For runSubagent tool, store this execute_tool span's trace context so the subagent's
|
||||
// invoke_agent span can be parented to THIS tool call (not the grandparent invoke_agent).
|
||||
const chatStreamToolCallId = (options as { chatStreamToolCallId?: string }).chatStreamToolCallId;
|
||||
|
||||
@@ -65,8 +65,10 @@ export const GenAiAttr = {
|
||||
USAGE_OUTPUT_TOKENS: 'gen_ai.usage.output_tokens',
|
||||
USAGE_CACHE_READ_INPUT_TOKENS: 'gen_ai.usage.cache_read.input_tokens',
|
||||
USAGE_CACHE_CREATION_INPUT_TOKENS: 'gen_ai.usage.cache_creation.input_tokens',
|
||||
/** Custom: reasoning/thinking token count (not yet standardized in GenAI conventions) */
|
||||
/** Legacy: reasoning/thinking token count. Prefer `USAGE_REASONING_OUTPUT_TOKENS`; this key is kept for backwards compatibility. */
|
||||
USAGE_REASONING_TOKENS: 'gen_ai.usage.reasoning_tokens',
|
||||
/** Reasoning/thinking output token count (semantic-convention-aligned). Dual-emitted alongside `USAGE_REASONING_TOKENS`. */
|
||||
USAGE_REASONING_OUTPUT_TOKENS: 'gen_ai.usage.reasoning.output_tokens',
|
||||
|
||||
// Conversation
|
||||
CONVERSATION_ID: 'gen_ai.conversation.id',
|
||||
@@ -189,3 +191,92 @@ export const CopilotCliSdkAttr = {
|
||||
HOOK_TYPE: 'github.copilot.hook.type',
|
||||
HOOK_INVOCATION_ID: 'github.copilot.hook.invocation_id',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Canonical `github.copilot.*` attribute namespace for Copilot Chat. These
|
||||
* attributes are dual-emitted alongside the legacy `copilot_chat.*` keys; new
|
||||
* dashboards should prefer this namespace.
|
||||
*/
|
||||
export const GitHubCopilotAttr = {
|
||||
/** Agent type classifier: `builtin` | `plugin` | `custom`. */
|
||||
AGENT_TYPE: 'github.copilot.agent.type',
|
||||
|
||||
/** Git remote URL (normalized). Dual of `copilot_chat.repo.remote_url`. */
|
||||
GIT_REPOSITORY: 'github.copilot.git.repository',
|
||||
/** Git HEAD branch. Dual of `copilot_chat.repo.head_branch_name`. */
|
||||
GIT_BRANCH: 'github.copilot.git.branch',
|
||||
/** Git HEAD commit. Dual of `copilot_chat.repo.head_commit_hash`. */
|
||||
GIT_COMMIT_SHA: 'github.copilot.git.commit_sha',
|
||||
/** GitHub `owner` segment derived from the remote URL (gated like the URL itself). */
|
||||
GITHUB_ORG: 'github.copilot.github.org',
|
||||
|
||||
/** Hook decision result (`block` | `approve` | `non_blocking_error` | `pass`). */
|
||||
HOOK_DECISION: 'github.copilot.hook.decision',
|
||||
/** Hook duration in seconds (float). */
|
||||
HOOK_DURATION_SECONDS: 'github.copilot.hook.duration',
|
||||
/** JSON-encoded array of tool names a hook applies to (plural). */
|
||||
HOOK_TOOL_NAMES: 'github.copilot.hook.tool_names',
|
||||
|
||||
/** SHA-256 hex of an MCP server name (always emitted). */
|
||||
MCP_SERVER_NAME_HASH: 'github.copilot.mcp.server.name_hash',
|
||||
/** Raw MCP server name (gated on captureContent). */
|
||||
MCP_SERVER_NAME: 'github.copilot.mcp.server.name',
|
||||
|
||||
/** Shell command (truncated to 256 chars; gated on captureContent). */
|
||||
TOOL_PARAM_COMMAND: 'github.copilot.tool.parameters.command',
|
||||
/** File path argument (gated on captureContent). */
|
||||
TOOL_PARAM_FILE_PATH: 'github.copilot.tool.parameters.file_path',
|
||||
/** Edit operation kind (`create`, `update`, `str_replace`, `insert`). */
|
||||
TOOL_PARAM_EDIT_TYPE: 'github.copilot.tool.parameters.edit_type',
|
||||
/** Skill identifier for the invoked tool. */
|
||||
TOOL_PARAM_SKILL_NAME: 'github.copilot.tool.parameters.skill_name',
|
||||
/** SHA-256 hex of the MCP server name for an MCP tool call (always emitted). */
|
||||
TOOL_PARAM_MCP_SERVER_NAME_HASH: 'github.copilot.tool.parameters.mcp_server_name_hash',
|
||||
/** Raw MCP server name for an MCP tool call (gated). */
|
||||
TOOL_PARAM_MCP_SERVER_NAME: 'github.copilot.tool.parameters.mcp_server_name',
|
||||
/** MCP tool name (the part after the `mcp_<server>_` 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<string> = 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<string> = 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',
|
||||
]);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<RepoContext>): 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
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@)<host>[:/]<owner>/<repo>` — normalizeFetchUrl already
|
||||
// strips credentials and `.git` suffixes.
|
||||
const m = remoteUrl.match(/github\.com[/:]([^/]+)\/[^/]+\/?$/i);
|
||||
return m?.[1];
|
||||
}
|
||||
|
||||
@@ -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<string, string>;
|
||||
gatedAttrs: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, string> = {};
|
||||
const gatedAttrs: Record<string, string> = {};
|
||||
|
||||
if (typeof input !== 'object' || input === null) {
|
||||
return { attrs, gatedAttrs };
|
||||
}
|
||||
const obj = input as Record<string, unknown>;
|
||||
|
||||
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_<server>_<tool>`; Anthropic-style
|
||||
// references use `mcp__<server>__<tool>`. 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<string, unknown>, 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<string, unknown>): 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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -13,3 +13,15 @@ export async function createSha256FromStream(stream: Readable): Promise<string>
|
||||
}
|
||||
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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user