Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCliBridgeSpanProcessor.ts
13405 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { CopilotChatAttr, CopilotCliSdkAttr, GenAiAttr, GenAiOperationName } from '../../../../platform/otel/common/genAiAttributes';
7
import { type ICompletedSpanData, type IOTelService, type ISpanEventRecord, SpanStatusCode } from '../../../../platform/otel/common/otelService';
8
9
/**
10
* Hook event data stashed by copilotcliSession for bridge enrichment.
11
*/
12
export interface HookEventData {
13
readonly hookType: string;
14
readonly input?: string;
15
readonly output?: string;
16
readonly resultKind?: 'success' | 'error';
17
readonly errorMessage?: string;
18
}
19
20
/**
21
* Minimal type for the OTel SDK's ReadableSpan — avoids importing the full
22
* @opentelemetry/sdk-trace-base package into the extension bundle.
23
*/
24
interface ReadableSpan {
25
readonly name: string;
26
readonly startTime: readonly [number, number]; // [seconds, nanoseconds]
27
readonly endTime: readonly [number, number];
28
readonly attributes: Readonly<Record<string, unknown>>;
29
readonly events: readonly { readonly name: string; readonly time: readonly [number, number]; readonly attributes?: Readonly<Record<string, unknown>> }[];
30
readonly status: { readonly code: number; readonly message?: string };
31
/** OTel SDK v2: parent span context object (replaces v1's parentSpanId string) */
32
readonly parentSpanContext?: { readonly traceId: string; readonly spanId: string };
33
spanContext(): { readonly traceId: string; readonly spanId: string };
34
}
35
36
/**
37
* Minimal SpanProcessor interface — matches the OTel SDK's SpanProcessor
38
* without requiring the package as a dependency.
39
*/
40
export interface SpanProcessor {
41
onStart(span: unknown, parentContext: unknown): void;
42
onEnd(span: ReadableSpan): void;
43
shutdown(): Promise<void>;
44
forceFlush(): Promise<void>;
45
}
46
47
/** Convert OTel [seconds, nanoseconds] HrTime to epoch milliseconds. */
48
function hrTimeToMs(hrTime: readonly [number, number]): number {
49
return hrTime[0] * 1000 + hrTime[1] / 1_000_000;
50
}
51
52
/** Flatten OTel attribute values to the types ICompletedSpanData accepts. */
53
function flattenAttributes(attrs: Readonly<Record<string, unknown>>): Record<string, string | number | boolean | string[]> {
54
const result: Record<string, string | number | boolean | string[]> = {};
55
for (const [key, value] of Object.entries(attrs)) {
56
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
57
result[key] = value;
58
} else if (Array.isArray(value) && value.every(v => typeof v === 'string')) {
59
result[key] = value as string[];
60
} else if (value !== null && value !== undefined) {
61
result[key] = String(value);
62
}
63
}
64
return result;
65
}
66
67
/**
68
* Bridge SpanProcessor that forwards completed spans from the Copilot CLI SDK's
69
* OTel TracerProvider into the extension's IOTelService event stream.
70
*
71
* This allows SDK-native spans (invoke_agent, chat, execute_tool, subagent,
72
* permission, hook, etc.) to appear in the Agent Debug Log panel without
73
* creating duplicate synthetic spans in the extension.
74
*
75
* The processor injects `copilot_chat.chat_session_id` on each forwarded span
76
* using a traceId → sessionId mapping maintained by the extension.
77
*/
78
export class CopilotCliBridgeSpanProcessor implements SpanProcessor {
79
/**
80
* Maps OTel traceId → VS Code chat session ID.
81
* Populated when copilotcliSession.ts creates its root `invoke_agent copilotcli` span.
82
*/
83
private readonly _traceIdToSessionId = new Map<string, string>();
84
private _disposed = false;
85
86
/**
87
* Hook event data stashed by copilotcliSession for enriching SDK hook spans.
88
* Keyed by hookInvocationId. Input is stashed on hook.start, output on hook.end.
89
*/
90
private readonly _hookData = new Map<string, HookEventData>();
91
92
/**
93
* SDK hook spans that arrived before hook.end data was stashed.
94
* Held until enrichment data arrives, then injected.
95
*/
96
private readonly _pendingHookSpans = new Map<string, ICompletedSpanData>();
97
98
constructor(private readonly _otelService: IOTelService) { }
99
100
/** Register a traceId → sessionId mapping for CHAT_SESSION_ID injection. */
101
registerTrace(traceId: string, sessionId: string): void {
102
this._traceIdToSessionId.set(traceId, sessionId);
103
}
104
105
/** Remove a traceId mapping (called when the session request completes). */
106
unregisterTrace(traceId: string): void {
107
this._traceIdToSessionId.delete(traceId);
108
}
109
110
/**
111
* Stash hook input data from a hook.start session event.
112
* Called by copilotcliSession before the SDK span ends.
113
*/
114
stashHookInput(hookInvocationId: string, hookType: string, input: string | undefined): void {
115
this._hookData.set(hookInvocationId, { hookType, input });
116
}
117
118
/**
119
* Stash hook completion data from a hook.end session event.
120
* If the SDK span already arrived (held in _pendingHookSpans), enriches and injects it now.
121
*/
122
stashHookEnd(hookInvocationId: string, hookType: string, output: string | undefined, resultKind: 'success' | 'error', errorMessage: string | undefined): void {
123
const existing = this._hookData.get(hookInvocationId);
124
if (existing) {
125
this._hookData.set(hookInvocationId, { ...existing, output, resultKind, errorMessage });
126
} else {
127
this._hookData.set(hookInvocationId, { hookType, output, resultKind, errorMessage });
128
}
129
130
// If the SDK span arrived before this data, inject it now
131
const pendingSpan = this._pendingHookSpans.get(hookInvocationId);
132
if (pendingSpan) {
133
this._pendingHookSpans.delete(hookInvocationId);
134
this._injectEnrichedHookSpan(pendingSpan, hookInvocationId);
135
}
136
}
137
138
// SpanProcessor interface
139
140
onStart(_span: unknown, _parentContext: unknown): void {
141
// Nothing to do on start — we only care about completed spans.
142
}
143
144
onEnd(span: ReadableSpan): void {
145
if (this._disposed) {
146
return;
147
}
148
149
const ctx = span.spanContext();
150
const sessionId = this._traceIdToSessionId.get(ctx.traceId);
151
152
// Only forward spans that belong to a registered CLI session.
153
// This prevents foreground agent spans or other sources from leaking
154
// into the CLI session's debug panel bucket.
155
if (!sessionId) {
156
return;
157
}
158
159
const completedSpan = this._toCompletedSpan(span, sessionId);
160
161
// SDK native hook spans: enrich with data from session events and
162
// remap to execute_hook so the debug panel shows full details.
163
const invocationId = span.attributes[CopilotCliSdkAttr.HOOK_INVOCATION_ID];
164
if (span.name.startsWith('hook ') && span.attributes[CopilotCliSdkAttr.HOOK_TYPE] && typeof invocationId === 'string') {
165
const hookEndData = this._hookData.get(invocationId);
166
if (hookEndData?.resultKind) {
167
// hook.end data already arrived — enrich and inject immediately
168
this._injectEnrichedHookSpan(completedSpan, invocationId);
169
} else {
170
// hook.end data not yet available — hold the span until it arrives
171
this._pendingHookSpans.set(invocationId, completedSpan);
172
}
173
return;
174
}
175
176
this._otelService.injectCompletedSpan(completedSpan);
177
}
178
179
private _injectEnrichedHookSpan(span: ICompletedSpanData, hookInvocationId: string): void {
180
const data = this._hookData.get(hookInvocationId);
181
this._hookData.delete(hookInvocationId);
182
if (!data) {
183
this._otelService.injectCompletedSpan(span);
184
return;
185
}
186
187
const attrs = { ...span.attributes };
188
attrs[GenAiAttr.OPERATION_NAME] = GenAiOperationName.EXECUTE_HOOK;
189
attrs[CopilotChatAttr.HOOK_TYPE] = data.hookType;
190
if (data.input) {
191
attrs[CopilotChatAttr.HOOK_INPUT] = data.input;
192
}
193
if (data.output) {
194
attrs[CopilotChatAttr.HOOK_OUTPUT] = data.output;
195
}
196
if (data.resultKind) {
197
attrs[CopilotChatAttr.HOOK_RESULT_KIND] = data.resultKind;
198
}
199
200
const enrichedSpan: ICompletedSpanData = {
201
...span,
202
name: `execute_hook ${data.hookType}`,
203
attributes: attrs,
204
status: data.resultKind === 'error'
205
? { code: SpanStatusCode.ERROR, message: data.errorMessage }
206
: { code: SpanStatusCode.OK },
207
};
208
this._otelService.injectCompletedSpan(enrichedSpan);
209
}
210
211
private _toCompletedSpan(span: ReadableSpan, sessionId: string): ICompletedSpanData {
212
const ctx = span.spanContext();
213
const events: ISpanEventRecord[] = span.events.map(event => ({
214
name: event.name,
215
timestamp: hrTimeToMs(event.time),
216
attributes: event.attributes ? flattenAttributes(event.attributes) : undefined,
217
}));
218
219
const baseAttributes = flattenAttributes(span.attributes);
220
221
// Inject CHAT_SESSION_ID so the debug panel can bucket this span correctly
222
if (sessionId && !baseAttributes[CopilotChatAttr.CHAT_SESSION_ID]) {
223
baseAttributes[CopilotChatAttr.CHAT_SESSION_ID] = sessionId;
224
}
225
226
return {
227
name: span.name,
228
spanId: ctx.spanId,
229
traceId: ctx.traceId,
230
parentSpanId: span.parentSpanContext?.spanId,
231
startTime: hrTimeToMs(span.startTime),
232
endTime: hrTimeToMs(span.endTime),
233
status: {
234
code: span.status.code as SpanStatusCode,
235
message: span.status.message,
236
},
237
attributes: baseAttributes,
238
events,
239
};
240
}
241
242
async shutdown(): Promise<void> {
243
this._disposed = true;
244
this._traceIdToSessionId.clear();
245
this._hookData.clear();
246
this._pendingHookSpans.clear();
247
}
248
249
async forceFlush(): Promise<void> {
250
// No buffering — spans are forwarded synchronously on end.
251
}
252
}
253
254