npx skills add ...
npx skills add microsoft/vscode --skill otel
npx skills add microsoft/vscode --skill otel
OpenTelemetry instrumentation for the Copilot Chat extension — covers the four agent execution paths, the IOTelService abstraction, span/metric/event conventions, and the relationship between code and the user/developer monitoring docs. Use when adding/changing OTel spans, metrics, or events; instrumenting a new agent surface; touching the Copilot CLI bridge or Claude span emission; or updating `extensions/copilot/docs/monitoring/agent_monitoring*.md`.
When adding, changing, or reviewing OTel telemetry in the Copilot Chat extension, always read the two source-of-truth docs first and always keep them in sync with the code you change.
The extensions/copilot/docs/monitoring/ directory contains the two specs that define the OTel contract for the extension. Treat them like the layout / layer specs in vs/sessions.
| Document | Path | Audience | Covers |
|---|---|---|---|
| User-facing | extensions/copilot/docs/monitoring/agent_monitoring.md | Extension users | Quick start, settings, env vars, exported spans/metrics/events, backend setup guides |
| Architecture | extensions/copilot/docs/monitoring/agent_monitoring_arch.md | Developers | Multi-agent strategies, span hierarchies, file structure, instrumentation points, IOTelService, configuration channels |
| Visual flow | extensions/copilot/docs/monitoring/otel-data-flow.html | Developers | Renders the bridge data flow for the in-process Copilot CLI agent |
If the implementation changes, you must update the relevant doc in the same PR. The arch doc is the most likely to drift; treat divergence as a bug.
The extension has four agent execution paths, each with a different OTel strategy:
| Agent | Process Model | Strategy | Debug Panel Source |
|---|---|---|---|
Foreground (toolCallingLoop) | Extension host | Direct IOTelService spans | Extension spans |
| Copilot CLI in-process | Extension host (same process) | Bridge SpanProcessor — SDK creates spans natively; bridge forwards to debug panel | SDK native spans via bridge |
| Copilot CLI terminal | Separate terminal process | Forward OTel env vars | N/A (separate process) |
| Claude Code | Child process (Node fork) | Synthesized from SDK messages — extension intercepts the Claude SDK message stream in claudeMessageDispatch.ts and emits GenAI spans; LLM calls are proxied through claudeLanguageModelServer.ts (which calls chatMLFetcher, producing standard chat spans). | Extension spans |
Why asymmetric? The CLI SDK runs in-process with full trace hierarchy (subagents, permissions, hooks). A bridge captures this directly. Claude runs as a separate process — internal spans are inaccessible, so the extension synthesizes spans by translating SDK messages and proxying the model API.
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. |
github.copilot.* only — do not introduce a copilot_chat.* twin.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.hashTelemetryValue from util/node/crypto.ts. Emit hashes unconditionally; raw values only when captureContent is enabled.IOTelService (otelService.ts) is the only abstraction consumers should depend on — never import the OTel SDK directly outside node/otelServiceImpl.ts. Three implementations:
| Class | When Used |
|---|---|
NoopOTelService | chatLib and tests where no telemetry pipeline is needed — zero cost |
NodeOTelService | OTel enabled — full SDK, OTLP/file/console export, optional SQLite span exporter |
InMemoryOTelService | Registered when OTel is disabled — no SDK is loaded, but spans/metrics/logs are still captured in-memory so the Agent Debug Log panel keeps working |
Selection happens in src/extension/extension/vscode-node/services.ts: exactly one of NodeOTelService or InMemoryOTelService is bound to IOTelService per extension host based on resolveOTelConfig().enabled.
Follow the OTel GenAI semantic conventions. Always use the constants from genAiAttributes.ts — never raw string literals.
| Operation | Span Name | Kind | Constant |
|---|---|---|---|
| Agent orchestration | invoke_agent {agent_name} | INTERNAL | GenAiOperationName.INVOKE_AGENT |
| LLM API call | chat {model} | CLIENT | GenAiOperationName.CHAT |
| Tool execution | execute_tool {tool_name} | INTERNAL | GenAiOperationName.EXECUTE_TOOL |
| Hook execution | execute_hook {hook_type} | INTERNAL | GenAiOperationName.EXECUTE_HOOK |
Attribute namespaces:
| Namespace | Constant module | Examples |
|---|---|---|
gen_ai.* | GenAiAttr | gen_ai.operation.name, gen_ai.usage.input_tokens |
copilot_chat.* | CopilotChatAttr | copilot_chat.session_id, copilot_chat.chat_session_id, copilot_chat.hook_* |
github.copilot.* | CopilotCliSdkAttr | SDK-emitted hook attributes (read-only — bridge & debug panel) |
claude_code.* | (raw) | Claude subprocess SDK attributes — only ever observed in OTLP, not produced by the extension |
The extension uses two conventions side-by-side; pick the right one for the attribute you're adding.
gen_ai.tool.call.arguments in toolsService.ts, and copilot_chat.hook_input / hook_output in chatHookService.ts). The attribute is captured unconditionally but always passed through truncateForOTel. Use this for moderate-sized, generally-non-secret arguments / results.config.captureContent — used for full prompt / response / system-instruction bodies (e.g. gen_ai.input.messages, gen_ai.output.messages, gen_ai.system_instructions, gen_ai.tool.definitions in chatMLFetcher.ts and the BYOK providers). These are larger and more likely to contain user secrets.Spans whose gen_ai.operation.name is not in EXPORTABLE_OPERATION_NAMES (defined in otelServiceImpl.ts) are visible to the debug panel via onDidCompleteSpan but excluded from OTLP and SQLite exporters by DiagnosticSpanExporter and FilteredSpanExporter. Currently exportable: chat, invoke_agent, execute_tool, embeddings, execute_hook. If you add a new operation name that should reach the user's collector, update EXPORTABLE_OPERATION_NAMES and document it in agent_monitoring.md.
When you add or change a setting/env var/command, update all three of:
extensions/copilot/package.json (search for github.copilot.chat.otel).resolveOTelConfig in otelConfig.ts — if the setting affects runtime config — and the enabledVia channel if it can implicitly enable OTel.agent_monitoring.md ("VS Code Settings", "Environment Variables", "Activation", "Commands" tables) and agent_monitoring_arch.md ("Activation Channels", "Agent-Specific Env Var Translation" tables).For sub-process env vars, also update:
deriveCopilotCliOTelEnv / deriveClaudeOTelEnv in agentOTelEnv.ts.src/platform/otel/common/test/agentOTelEnv.spec.ts.genAiAttributes.ts (under GenAiAttr, CopilotChatAttr, or a new domain group). Never inline a raw 'copilot_chat.foo' literal.index.ts if it lives in a new group.IOTelService.startActiveSpan (preferred) or startSpan — never BasicTracerProvider / getTracer directly.truncateForOTel (mandatory for any free-form content attribute — prevents OTLP batch failures). Decide whether the attribute should be always-emitted (debug-panel-essential, e.g. tool args, hook input/output) or gated on config.captureContent (large prompt/response bodies, system instructions); follow the existing convention for similar data.EXPORTABLE_OPERATION_NAMES in otelServiceImpl.ts.agent_monitoring.md (under the relevant span table) and add a test in src/platform/otel/common/test/.genAiMetrics.ts or genAiEvents.ts (mirror existing static / functional patterns).index.ts.agent_monitoring.md ("Metrics" / "Events" sections) with all attributes documented.src/platform/otel/common/test/genAiMetrics.spec.ts or genAiEvents.spec.ts (assert the exact name + attribute keys).agent_monitoring_arch.md and the Span Hierarchies diagrams.derive*OTelEnv helper in agentOTelEnv.ts and add a row to the Agent-Specific Env Var Translation table.storeTraceContext / parentTraceContext for any subagent or async boundary; do not rely on global active context across processes.The bridge (copilotCliBridgeSpanProcessor.ts) reaches into _delegate._activeSpanProcessor._spanProcessors — internal OTel SDK v2 state. This is documented as a known risk. If you touch it:
agent_monitoring_arch.md if the access pattern changes.copilotCliBridgeSpanProcessor.spec.ts.Before sending a PR that touches OTel code:
Manual sanity checks:
agent_monitoring.md still works end-to-end (one agent message → invoke_agent + chat + execute_tool spans visible at http://localhost:18888).These are documented in agent_monitoring_arch.md — preserve them:
_spanProcessors internal access (graceful runtime guard).process.env mutation for the CLI SDK (only OTel-specific vars, set before LocalSessionManager ctor).captureContent flag for the CLI SDK applies to both debug panel and OTLP — document any user-visible change clearly.otlp-http.@opentelemetry/api (or any @opentelemetry/* package) from anywhere other than node/otelServiceImpl.ts, fileExporters.ts, or the CLI bridge processor type imports.'copilot_chat.hook_type' instead of CopilotChatAttr.HOOK_TYPE.'github' / 'anthropic' / 'gemini' instead of GenAiProviderName.*.SpanStatusCode numbers (code: 1, code: 2) — use the enum.truncateForOTel — OTLP batches will silently drop or fail.config.captureContent gating (these are pattern 2 above).EXPORTABLE_OPERATION_NAMES).agent_monitoring.md / agent_monitoring_arch.md in the same change.return this._otelService.startActiveSpan(
`execute_tool ${name}`,
{
kind: SpanKind.INTERNAL,
attributes: {
[GenAiAttr.OPERATION_NAME]: GenAiOperationName.EXECUTE_TOOL,
[GenAiAttr.TOOL_NAME]: name,
// …
},
},
async (span) => {
try {
const result = await this._actualWork();
span.setStatus(SpanStatusCode.OK);
return result;
} catch (err) {
span.setStatus(SpanStatusCode.ERROR, err instanceof Error ? err.message : String(err));
span.setAttribute(StdAttr.ERROR_TYPE, err instanceof Error ? err.constructor.name : 'Error');
throw err;
}
},
);// Parent: store context keyed by something the child knows
const ctx = this._otelService.getActiveTraceContext();
if (ctx) { this._otelService.storeTraceContext(`subagent:invocation:${id}`, ctx); }
// Child: retrieve and use as parent
const parentCtx = this._otelService.getStoredTraceContext(`subagent:invocation:${id}`);
return this._otelService.startActiveSpan('invoke_agent child', { parentTraceContext: parentCtx, … }, fn);// Pattern 1 — always emit, always truncate
span.setAttribute(GenAiAttr.TOOL_CALL_ARGUMENTS, truncateForOTel(JSON.stringify(args)));
// Pattern 2 — gated on captureContent
if (this._otelService.config.captureContent) {
span.setAttribute(GenAiAttr.INPUT_MESSAGES, truncateForOTel(JSON.stringify(messages)));
}# From extensions/copilot/
npx tsc --noEmit --project tsconfig.json
# OTel + Bridge unit tests
npm test -- --grep "OTel\|Bridge"