feat(copilot/otel): add Agent Traces viewer webview#312030
feat(copilot/otel): add Agent Traces viewer webview#312030
Conversation
New UI to browse OpenTelemetry spans collected from the foreground chat
agent and background CLI agent.
- Always-on SQLite persistence: OTelContrib now subscribes to
onDidCompleteSpan and writes to OTelSqliteStore regardless of the
dbSpanExporter flag (skipped when the SDK BSP path is already active
to avoid double writes).
- OTelSqliteStore: new getTraces() aggregation returning one row per
trace_id (root name, agent, model, duration, span/error counts,
token totals) ordered by most recent first.
- otelViewer module:
* OTelViewerQueries — typed query facade (listTraces, getTrace,
getSpanDetails) wrapping the store.
* OTelViewerPanel — single-instance webview with CSP/nonce, strict
message protocol { id, query } -> { id, result }.
* otelViewerHtml — inline HTML/CSS/vanilla-JS client: sortable,
filterable trace list; click-through to a % CSS-positioned
waterfall; right-side detail pane with overview/attributes/events.
Theme via VS Code CSS vars.
- Commands: 'Chat: Open Agent Traces View' and 'Chat: Open Trace…'.
Does not touch the existing chat debug panel.
There was a problem hiding this comment.
Pull request overview
Adds an MVP “Agent Traces” viewer to the Copilot extension to browse persisted OpenTelemetry span data from foreground chat and background agents, exposed via new Chat commands and backed by the existing SQLite span store.
Changes:
- Extend
OTelSqliteStorewith a newTraceRowaggregation +getTraces()to power a trace list view. - Introduce a new
otelViewermodule (query facade, webview panel lifecycle, inline HTML/JS UI) for listing traces and drilling into spans. - Update OTel contribution wiring to persist completed spans into SQLite for the viewer (and register new viewer commands).
Show a summary per file
| File | Description |
|---|---|
| extensions/copilot/src/extension/otel/vscode-node/otelContrib.ts | Adds an onDidCompleteSpan subscription to persist spans to SQLite for the viewer. |
| extensions/copilot/src/platform/otel/node/sqlite/otelSqliteStore.ts | Adds TraceRow and getTraces() SQL aggregation. |
| extensions/copilot/src/extension/otelViewer/vscode-node/otelViewerQueries.ts | Adds typed query protocol + store wrapper for the webview. |
| extensions/copilot/src/extension/otelViewer/vscode-node/otelViewerPanel.ts | Adds single-instance webview panel + message handling. |
| extensions/copilot/src/extension/otelViewer/vscode-node/otelViewerHtml.ts | Adds inline HTML/CSS/JS UI for trace list, waterfall, and details. |
| extensions/copilot/src/extension/otelViewer/vscode-node/otelViewerContrib.ts | Registers commands to open the viewer / open a specific trace. |
| extensions/copilot/src/extension/extension/vscode-node/contributions.ts | Wires the new viewer contribution into extension activation. |
| extensions/copilot/package.json | Adds command contributions for opening the trace viewer. |
Copilot's findings
Comments suppressed due to low confidence (2)
extensions/copilot/src/platform/otel/node/sqlite/otelSqliteStore.ts:289
- In
getTraces(),SUM(CASE WHEN s.operation_name = 'chat' THEN s.input_tokens ELSE 0 END)(and output_tokens) can yieldNULLfor traces that only contain chat spans withinput_tokens/output_tokensunset (SQLiteSUMover all-NULL returns NULL). SinceTraceRowtypes these as numbers, this can leaknullinto the UI. ConsiderSUM(CASE WHEN ... THEN COALESCE(s.input_tokens, 0) ELSE 0 END)(and same for output) to guarantee numeric totals.
SUM(CASE WHEN s.operation_name = 'chat' THEN 1 ELSE 0 END) AS llm_calls,
SUM(CASE WHEN s.operation_name = 'execute_tool' THEN 1 ELSE 0 END) AS tool_calls,
SUM(CASE WHEN s.operation_name = 'chat' THEN s.input_tokens ELSE 0 END) AS total_input_tokens,
SUM(CASE WHEN s.operation_name = 'chat' THEN s.output_tokens ELSE 0 END) AS total_output_tokens
extensions/copilot/src/platform/otel/node/sqlite/otelSqliteStore.ts:281
- The comment says the root span “provides the name/agent”, but the query uses
MAX(s.agent_name)/MAX(s.response_model)/MAX(COALESCE(...)), which can pick unrelated values if a trace contains spans with multiple agents/models/sessions. Consider selecting these fields from the root span (similar toroot_name) or from a deterministic span (e.g., first invoke_agent / first chat) to avoid misleading aggregation.
s.trace_id AS trace_id,
(SELECT name FROM spans WHERE trace_id = s.trace_id AND parent_span_id IS NULL ORDER BY start_time_ms LIMIT 1) AS root_name,
MAX(s.agent_name) AS agent_name,
MAX(s.response_model) AS model,
MAX(COALESCE(s.conversation_id, s.chat_session_id)) AS session_id,
MIN(s.start_time_ms) AS started_at,
- Files reviewed: 8/8 changed files
- Comments generated: 6
| getTraces(limit?: number): TraceRow[] { | ||
| const sql = ` | ||
| SELECT | ||
| s.trace_id AS trace_id, | ||
| (SELECT name FROM spans WHERE trace_id = s.trace_id AND parent_span_id IS NULL ORDER BY start_time_ms LIMIT 1) AS root_name, | ||
| MAX(s.agent_name) AS agent_name, | ||
| MAX(s.response_model) AS model, | ||
| MAX(COALESCE(s.conversation_id, s.chat_session_id)) AS session_id, | ||
| MIN(s.start_time_ms) AS started_at, | ||
| MAX(s.end_time_ms) AS ended_at, | ||
| MAX(s.end_time_ms) - MIN(s.start_time_ms) AS duration_ms, | ||
| COUNT(*) AS span_count, | ||
| SUM(CASE WHEN s.status_code = 2 THEN 1 ELSE 0 END) AS error_count, | ||
| SUM(CASE WHEN s.operation_name = 'chat' THEN 1 ELSE 0 END) AS llm_calls, | ||
| SUM(CASE WHEN s.operation_name = 'execute_tool' THEN 1 ELSE 0 END) AS tool_calls, | ||
| SUM(CASE WHEN s.operation_name = 'chat' THEN s.input_tokens ELSE 0 END) AS total_input_tokens, | ||
| SUM(CASE WHEN s.operation_name = 'chat' THEN s.output_tokens ELSE 0 END) AS total_output_tokens | ||
| FROM spans s | ||
| GROUP BY s.trace_id | ||
| ORDER BY started_at DESC | ||
| ${limit ? 'LIMIT ?' : ''} | ||
| `; | ||
| const stmt = this._ensureDb().prepare(sql); | ||
| return (limit ? stmt.all(limit) : stmt.all()) as unknown as TraceRow[]; | ||
| } |
There was a problem hiding this comment.
getTraces() uses ${limit ? 'LIMIT ?' : ''} and (limit ? stmt.all(limit) : stmt.all()), which treats 0 as “no limit”. If callers ever pass 0 to mean “return none”, it will instead return all traces. Consider checking limit !== undefined (and optionally validating non-negative integers) instead of truthiness.
This issue also appears in the following locations of the same file:
- line 276
- line 286
| /** | ||
| * List all traces with aggregated metrics, ordered by most recent first. | ||
| * Root span (the one with no parent in the trace) provides the name/agent. | ||
| */ | ||
| getTraces(limit?: number): TraceRow[] { | ||
| const sql = ` | ||
| SELECT | ||
| s.trace_id AS trace_id, | ||
| (SELECT name FROM spans WHERE trace_id = s.trace_id AND parent_span_id IS NULL ORDER BY start_time_ms LIMIT 1) AS root_name, | ||
| MAX(s.agent_name) AS agent_name, | ||
| MAX(s.response_model) AS model, | ||
| MAX(COALESCE(s.conversation_id, s.chat_session_id)) AS session_id, | ||
| MIN(s.start_time_ms) AS started_at, | ||
| MAX(s.end_time_ms) AS ended_at, | ||
| MAX(s.end_time_ms) - MIN(s.start_time_ms) AS duration_ms, | ||
| COUNT(*) AS span_count, | ||
| SUM(CASE WHEN s.status_code = 2 THEN 1 ELSE 0 END) AS error_count, | ||
| SUM(CASE WHEN s.operation_name = 'chat' THEN 1 ELSE 0 END) AS llm_calls, | ||
| SUM(CASE WHEN s.operation_name = 'execute_tool' THEN 1 ELSE 0 END) AS tool_calls, | ||
| SUM(CASE WHEN s.operation_name = 'chat' THEN s.input_tokens ELSE 0 END) AS total_input_tokens, | ||
| SUM(CASE WHEN s.operation_name = 'chat' THEN s.output_tokens ELSE 0 END) AS total_output_tokens | ||
| FROM spans s | ||
| GROUP BY s.trace_id | ||
| ORDER BY started_at DESC | ||
| ${limit ? 'LIMIT ?' : ''} | ||
| `; | ||
| const stmt = this._ensureDb().prepare(sql); | ||
| return (limit ? stmt.all(limit) : stmt.all()) as unknown as TraceRow[]; | ||
| } |
There was a problem hiding this comment.
New getTraces() aggregation isn’t covered by the existing OTelSqliteStore test suite. Adding a unit test that inserts a multi-span trace (with errors + tokens) and asserts the aggregated TraceRow fields (duration, counts, root_name, token totals) would help prevent regressions in the SQL aggregation logic.
| switch (query.type) { | ||
| case 'listTraces': return this.listTraces(query); | ||
| case 'getTrace': return this.getTrace(query); | ||
| case 'getSpanDetails': return this.getSpanDetails(query); |
There was a problem hiding this comment.
handle() has no default case; if the webview ever sends an unexpected query.type at runtime, this will return undefined and the panel will post back { id, result: undefined } instead of a structured error. Consider adding a default branch that throws/returns an explicit error result for unknown query types to keep the message protocol robust.
| case 'getSpanDetails': return this.getSpanDetails(query); | |
| case 'getSpanDetails': return this.getSpanDetails(query); | |
| default: throw new Error(`Unknown OTel viewer query type: ${query.type}`); |
| getTrace(q: IGetTraceQuery): IGetTraceResult { | ||
| return { | ||
| type: 'getTrace', | ||
| traceId: q.traceId, | ||
| spans: this._store.getSpansByTraceId(q.traceId), | ||
| }; | ||
| } | ||
|
|
||
| getSpanDetails(q: IGetSpanDetailsQuery): IGetSpanDetailsResult { | ||
| return { | ||
| type: 'getSpanDetails', | ||
| spanId: q.spanId, | ||
| attributes: this._store.getSpanAttributes(q.spanId), | ||
| events: this._store.getSpanEvents(q.spanId), | ||
| }; |
There was a problem hiding this comment.
The query facade currently returns raw span attributes/events directly to the webview (e.g., getSpanAttributes, getSpanEvents). The SQLite store includes potentially sensitive values like gen_ai.tool.call.arguments, gen_ai.tool.call.result, and (when enabled) gen_ai.*messages; events can also contain message content. Consider implementing the redaction/sanitization mentioned in the comment (e.g., allowlist safe keys, truncate large values, and/or gate content fields behind captureContent) before crossing the postMessage boundary.
| return ( | ||
| '<div class="wf-row" data-span="' + escapeHtml(span.span_id) + '"' + (state.selectedSpanId === span.span_id ? ' class="wf-row selected"' : '') + '>' + | ||
| '<div class="wf-name" style="padding-left:' + (depth * 14) + 'px">' + | ||
| '<span class="caret">' + (tree.byParent.has(span.span_id) ? '▸' : ' ') + '</span>' + | ||
| '<span class="chip cat-' + cat + '" style="color:var(--vscode-foreground);background:transparent;border:1px solid currentColor;">' + escapeHtml(cat) + '</span>' + | ||
| '<span class="label" title="' + escapeHtml(span.name) + '">' + escapeHtml(span.name) + (label && label !== span.name ? ' — ' + escapeHtml(label) : '') + '</span>' + | ||
| '</div>' + | ||
| '<div class="wf-bar-track">' + | ||
| '<div class="wf-bar cat-' + cat + (isError ? ' bar-error' : '') + '" style="left:' + left + '%;width:' + width + '%" title="' + escapeHtml(duration) + '"></div>' + | ||
| '<div class="wf-bar-label" style="left:calc(' + left + '% + ' + width + '% + 4px)">' + escapeHtml(duration) + '</div>' + |
There was a problem hiding this comment.
The generated HTML for waterfall rows can emit invalid markup when a span is selected: it starts with class="wf-row" and then conditionally appends a second class="wf-row selected", resulting in two class attributes on the same element. This can break selection styling and DOM querying. Consider building the class list once (e.g., wf-row${selected ? ' selected' : ''}) instead of appending a second attribute.
| // Always-on SQLite persistence for the trace viewer UI. | ||
| // When NodeOTelService is active with dbSpanExporter.enabled=true, the SDK-side | ||
| // SqliteSpanExporter already writes via BSP. This subscription is the InMemoryOTelService | ||
| // path (default config) — InMemoryOTelService fires onDidCompleteSpan synchronously | ||
| // and we write through to SQLite so the trace viewer always has data. | ||
| // The NodeOTelService also fires onDidCompleteSpan for in-memory tracking; to avoid | ||
| // duplicate writes when dbSpanExporter is enabled, skip the subscription in that case. | ||
| if (!(this._otelService.config.enabled && this._otelService.config.dbSpanExporter)) { | ||
| this._register(this._otelService.onDidCompleteSpan(span => { | ||
| try { | ||
| this._sqliteStore.insertSpan(span); | ||
| } catch (err) { | ||
| // Best-effort persistence — don't let DB failures affect agent execution. | ||
| this._logService.trace(`[OTel] Trace viewer SQLite write failed: ${String(err)}`); | ||
| } | ||
| })); | ||
| } |
There was a problem hiding this comment.
The new onDidCompleteSpan subscription writes spans to SQLite even when this._otelService.config.enabled is false. InMemoryOTelService (used when enabled=false) still emits spans for the debug panel, so this change will create/write the DB by default and persist sensitive attributes (e.g., tool call args/results) to disk despite OTel being “disabled”. Consider gating persistence behind an explicit user setting (or at least config.enabledExplicitly), and/or redacting high-risk attributes before persistence so default behavior doesn’t persist potentially sensitive data.
Summary
New webview UI to browse OpenTelemetry spans collected from the foreground chat agent and background CLI agent. Accessible via Chat: Open Agent Traces View command palette.
What's in this PR
Always-on SQLite persistence
OTelContribnow subscribes toonDidCompleteSpanand writes toOTelSqliteStoreregardless of thedbSpanExporterflag (skipped when the SDK BSP path is already active to avoid double writes).Data layer
OTelSqliteStore.getTraces()— new aggregation returning one row pertrace_idwith root name, agent, model, duration, span/error counts, and token totals.otelViewer module (
extensions/copilot/src/extension/otelViewer/)OTelViewerQueries— typed query facade (listTraces,getTrace,getSpanDetails) wrapping the store.OTelViewerPanel— single-instance webview with CSP/nonce, strict message protocol{ id, query } → { id, result }.otelViewerHtml— inline HTML/CSS/vanilla-JS client:Commands
github.copilot.chat.openTraces— "Chat: Open Agent Traces View"github.copilot.chat.openTrace— "Chat: Open Trace…"What's NOT in this PR
onDidCompleteSpan)Files changed
extensions/copilot/src/extension/otel/vscode-node/otelContrib.tsextensions/copilot/src/platform/otel/node/sqlite/otelSqliteStore.tsTraceRow+getTraces()extensions/copilot/src/extension/otelViewer/vscode-node/otelViewerQueries.tsextensions/copilot/src/extension/otelViewer/vscode-node/otelViewerPanel.tsextensions/copilot/src/extension/otelViewer/vscode-node/otelViewerHtml.tsextensions/copilot/src/extension/otelViewer/vscode-node/otelViewerContrib.tsextensions/copilot/src/extension/extension/vscode-node/contributions.tsextensions/copilot/package.json