Merge pull request #318031 from microsoft/zhichli/oteladdition

Chat: OTel enrichment for agent context
This commit is contained in:
Zhichao Li
2026-05-24 10:18:06 -07:00
committed by GitHub
13 changed files with 448 additions and 7 deletions
+17
View File
@@ -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');
}