Skip to content

feat(copilot/otel): add Agent Traces viewer webview#312030

Draft
zhichli wants to merge 1 commit intomainfrom
zhichli/otelviewer
Draft

feat(copilot/otel): add Agent Traces viewer webview#312030
zhichli wants to merge 1 commit intomainfrom
zhichli/otelviewer

Conversation

@zhichli
Copy link
Copy Markdown
Member

@zhichli zhichli commented Apr 22, 2026

Summary

image

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

  • 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).

Data layer

  • OTelSqliteStore.getTraces() — new aggregation returning one row per trace_id with 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:
    • Sortable, filterable trace list (status, time, duration, agent, name, model, spans, errors, tokens)
    • Click-through to a %-CSS-positioned waterfall, indented by parent chain, colored by category
    • Right-side detail pane with overview / attributes / events

Commands

  • github.copilot.chat.openTraces — "Chat: Open Agent Traces View"
  • github.copilot.chat.openTrace — "Chat: Open Trace…"

What's NOT in this PR

  • Does not touch the existing chat debug panel
  • No virtualization yet (planned follow-up for >500 rows)
  • No live-refresh (planned: push new spans to webview via onDidCompleteSpan)

Files changed

File Change
extensions/copilot/src/extension/otel/vscode-node/otelContrib.ts Always-on span persistence
extensions/copilot/src/platform/otel/node/sqlite/otelSqliteStore.ts TraceRow + getTraces()
extensions/copilot/src/extension/otelViewer/vscode-node/otelViewerQueries.ts Query facade
extensions/copilot/src/extension/otelViewer/vscode-node/otelViewerPanel.ts Webview lifecycle
extensions/copilot/src/extension/otelViewer/vscode-node/otelViewerHtml.ts Inline HTML/JS UI
extensions/copilot/src/extension/otelViewer/vscode-node/otelViewerContrib.ts Command registration
extensions/copilot/src/extension/extension/vscode-node/contributions.ts Wire contrib
extensions/copilot/package.json Command entries

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.
Copilot AI review requested due to automatic review settings April 22, 2026 22:41
@zhichli zhichli changed the title feat(copilot/otel): add Agent Traces viewer webview (MVP) feat(copilot/otel): add Agent Traces viewer webview Apr 22, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 OTelSqliteStore with a new TraceRow aggregation + getTraces() to power a trace list view.
  • Introduce a new otelViewer module (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 yield NULL for traces that only contain chat spans with input_tokens/output_tokens unset (SQLite SUM over all-NULL returns NULL). Since TraceRow types these as numbers, this can leak null into the UI. Consider SUM(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 to root_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

Comment on lines +273 to +297
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[];
}
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copilot uses AI. Check for mistakes.
Comment on lines +269 to +297
/**
* 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[];
}
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
switch (query.type) {
case 'listTraces': return this.listTraces(query);
case 'getTrace': return this.getTrace(query);
case 'getSpanDetails': return this.getSpanDetails(query);
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
case 'getSpanDetails': return this.getSpanDetails(query);
case 'getSpanDetails': return this.getSpanDetails(query);
default: throw new Error(`Unknown OTel viewer query type: ${query.type}`);

Copilot uses AI. Check for mistakes.
Comment on lines +99 to +113
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),
};
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +377 to +386
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>' +
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +36 to +52
// 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)}`);
}
}));
}
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants