Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/skills/otel/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
39 changes: 39 additions & 0 deletions extensions/copilot/docs/monitoring/agent_monitoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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",...}]` |
Expand All @@ -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` |
Expand All @@ -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)` |
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -153,13 +153,17 @@ 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: {
[GenAiAttr.OPERATION_NAME]: GenAiOperationName.EXECUTE_HOOK,
[CopilotChatAttr.HOOK_TYPE]: hookType,
'copilot_chat.hook_command': hookCommand.command,
...(chatSessionId ? { [CopilotChatAttr.CHAT_SESSION_ID]: chatSessionId } : {}),
...(hookToolNamesJson ? { [GitHubCopilotAttr.HOOK_TOOL_NAMES]: hookToolNamesJson } : {}),
},
});

Expand All @@ -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);

Expand All @@ -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
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
15 changes: 15 additions & 0 deletions extensions/copilot/src/extension/tools/vscode-node/toolsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading