Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCliBridgeSpanProcessor.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { CopilotChatAttr, CopilotCliSdkAttr, GenAiAttr, GenAiOperationName } from '../../../../platform/otel/common/genAiAttributes';6import { type ICompletedSpanData, type IOTelService, type ISpanEventRecord, SpanStatusCode } from '../../../../platform/otel/common/otelService';78/**9* Hook event data stashed by copilotcliSession for bridge enrichment.10*/11export interface HookEventData {12readonly hookType: string;13readonly input?: string;14readonly output?: string;15readonly resultKind?: 'success' | 'error';16readonly errorMessage?: string;17}1819/**20* Minimal type for the OTel SDK's ReadableSpan — avoids importing the full21* @opentelemetry/sdk-trace-base package into the extension bundle.22*/23interface ReadableSpan {24readonly name: string;25readonly startTime: readonly [number, number]; // [seconds, nanoseconds]26readonly endTime: readonly [number, number];27readonly attributes: Readonly<Record<string, unknown>>;28readonly events: readonly { readonly name: string; readonly time: readonly [number, number]; readonly attributes?: Readonly<Record<string, unknown>> }[];29readonly status: { readonly code: number; readonly message?: string };30/** OTel SDK v2: parent span context object (replaces v1's parentSpanId string) */31readonly parentSpanContext?: { readonly traceId: string; readonly spanId: string };32spanContext(): { readonly traceId: string; readonly spanId: string };33}3435/**36* Minimal SpanProcessor interface — matches the OTel SDK's SpanProcessor37* without requiring the package as a dependency.38*/39export interface SpanProcessor {40onStart(span: unknown, parentContext: unknown): void;41onEnd(span: ReadableSpan): void;42shutdown(): Promise<void>;43forceFlush(): Promise<void>;44}4546/** Convert OTel [seconds, nanoseconds] HrTime to epoch milliseconds. */47function hrTimeToMs(hrTime: readonly [number, number]): number {48return hrTime[0] * 1000 + hrTime[1] / 1_000_000;49}5051/** Flatten OTel attribute values to the types ICompletedSpanData accepts. */52function flattenAttributes(attrs: Readonly<Record<string, unknown>>): Record<string, string | number | boolean | string[]> {53const result: Record<string, string | number | boolean | string[]> = {};54for (const [key, value] of Object.entries(attrs)) {55if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {56result[key] = value;57} else if (Array.isArray(value) && value.every(v => typeof v === 'string')) {58result[key] = value as string[];59} else if (value !== null && value !== undefined) {60result[key] = String(value);61}62}63return result;64}6566/**67* Bridge SpanProcessor that forwards completed spans from the Copilot CLI SDK's68* OTel TracerProvider into the extension's IOTelService event stream.69*70* This allows SDK-native spans (invoke_agent, chat, execute_tool, subagent,71* permission, hook, etc.) to appear in the Agent Debug Log panel without72* creating duplicate synthetic spans in the extension.73*74* The processor injects `copilot_chat.chat_session_id` on each forwarded span75* using a traceId → sessionId mapping maintained by the extension.76*/77export class CopilotCliBridgeSpanProcessor implements SpanProcessor {78/**79* Maps OTel traceId → VS Code chat session ID.80* Populated when copilotcliSession.ts creates its root `invoke_agent copilotcli` span.81*/82private readonly _traceIdToSessionId = new Map<string, string>();83private _disposed = false;8485/**86* Hook event data stashed by copilotcliSession for enriching SDK hook spans.87* Keyed by hookInvocationId. Input is stashed on hook.start, output on hook.end.88*/89private readonly _hookData = new Map<string, HookEventData>();9091/**92* SDK hook spans that arrived before hook.end data was stashed.93* Held until enrichment data arrives, then injected.94*/95private readonly _pendingHookSpans = new Map<string, ICompletedSpanData>();9697constructor(private readonly _otelService: IOTelService) { }9899/** Register a traceId → sessionId mapping for CHAT_SESSION_ID injection. */100registerTrace(traceId: string, sessionId: string): void {101this._traceIdToSessionId.set(traceId, sessionId);102}103104/** Remove a traceId mapping (called when the session request completes). */105unregisterTrace(traceId: string): void {106this._traceIdToSessionId.delete(traceId);107}108109/**110* Stash hook input data from a hook.start session event.111* Called by copilotcliSession before the SDK span ends.112*/113stashHookInput(hookInvocationId: string, hookType: string, input: string | undefined): void {114this._hookData.set(hookInvocationId, { hookType, input });115}116117/**118* Stash hook completion data from a hook.end session event.119* If the SDK span already arrived (held in _pendingHookSpans), enriches and injects it now.120*/121stashHookEnd(hookInvocationId: string, hookType: string, output: string | undefined, resultKind: 'success' | 'error', errorMessage: string | undefined): void {122const existing = this._hookData.get(hookInvocationId);123if (existing) {124this._hookData.set(hookInvocationId, { ...existing, output, resultKind, errorMessage });125} else {126this._hookData.set(hookInvocationId, { hookType, output, resultKind, errorMessage });127}128129// If the SDK span arrived before this data, inject it now130const pendingSpan = this._pendingHookSpans.get(hookInvocationId);131if (pendingSpan) {132this._pendingHookSpans.delete(hookInvocationId);133this._injectEnrichedHookSpan(pendingSpan, hookInvocationId);134}135}136137// SpanProcessor interface138139onStart(_span: unknown, _parentContext: unknown): void {140// Nothing to do on start — we only care about completed spans.141}142143onEnd(span: ReadableSpan): void {144if (this._disposed) {145return;146}147148const ctx = span.spanContext();149const sessionId = this._traceIdToSessionId.get(ctx.traceId);150151// Only forward spans that belong to a registered CLI session.152// This prevents foreground agent spans or other sources from leaking153// into the CLI session's debug panel bucket.154if (!sessionId) {155return;156}157158const completedSpan = this._toCompletedSpan(span, sessionId);159160// SDK native hook spans: enrich with data from session events and161// remap to execute_hook so the debug panel shows full details.162const invocationId = span.attributes[CopilotCliSdkAttr.HOOK_INVOCATION_ID];163if (span.name.startsWith('hook ') && span.attributes[CopilotCliSdkAttr.HOOK_TYPE] && typeof invocationId === 'string') {164const hookEndData = this._hookData.get(invocationId);165if (hookEndData?.resultKind) {166// hook.end data already arrived — enrich and inject immediately167this._injectEnrichedHookSpan(completedSpan, invocationId);168} else {169// hook.end data not yet available — hold the span until it arrives170this._pendingHookSpans.set(invocationId, completedSpan);171}172return;173}174175this._otelService.injectCompletedSpan(completedSpan);176}177178private _injectEnrichedHookSpan(span: ICompletedSpanData, hookInvocationId: string): void {179const data = this._hookData.get(hookInvocationId);180this._hookData.delete(hookInvocationId);181if (!data) {182this._otelService.injectCompletedSpan(span);183return;184}185186const attrs = { ...span.attributes };187attrs[GenAiAttr.OPERATION_NAME] = GenAiOperationName.EXECUTE_HOOK;188attrs[CopilotChatAttr.HOOK_TYPE] = data.hookType;189if (data.input) {190attrs[CopilotChatAttr.HOOK_INPUT] = data.input;191}192if (data.output) {193attrs[CopilotChatAttr.HOOK_OUTPUT] = data.output;194}195if (data.resultKind) {196attrs[CopilotChatAttr.HOOK_RESULT_KIND] = data.resultKind;197}198199const enrichedSpan: ICompletedSpanData = {200...span,201name: `execute_hook ${data.hookType}`,202attributes: attrs,203status: data.resultKind === 'error'204? { code: SpanStatusCode.ERROR, message: data.errorMessage }205: { code: SpanStatusCode.OK },206};207this._otelService.injectCompletedSpan(enrichedSpan);208}209210private _toCompletedSpan(span: ReadableSpan, sessionId: string): ICompletedSpanData {211const ctx = span.spanContext();212const events: ISpanEventRecord[] = span.events.map(event => ({213name: event.name,214timestamp: hrTimeToMs(event.time),215attributes: event.attributes ? flattenAttributes(event.attributes) : undefined,216}));217218const baseAttributes = flattenAttributes(span.attributes);219220// Inject CHAT_SESSION_ID so the debug panel can bucket this span correctly221if (sessionId && !baseAttributes[CopilotChatAttr.CHAT_SESSION_ID]) {222baseAttributes[CopilotChatAttr.CHAT_SESSION_ID] = sessionId;223}224225return {226name: span.name,227spanId: ctx.spanId,228traceId: ctx.traceId,229parentSpanId: span.parentSpanContext?.spanId,230startTime: hrTimeToMs(span.startTime),231endTime: hrTimeToMs(span.endTime),232status: {233code: span.status.code as SpanStatusCode,234message: span.status.message,235},236attributes: baseAttributes,237events,238};239}240241async shutdown(): Promise<void> {242this._disposed = true;243this._traceIdToSessionId.clear();244this._hookData.clear();245this._pendingHookSpans.clear();246}247248async forceFlush(): Promise<void> {249// No buffering — spans are forwarded synchronously on end.250}251}252253254