Path: blob/main/extensions/copilot/src/platform/otel/node/inMemoryOTelService.ts
13401 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 { AsyncLocalStorage } from 'async_hooks';6import { Emitter, type Event } from '../../../util/vs/base/common/event';7import type { OTelConfig } from '../common/otelConfig';8import { SpanStatusCode, type ICompletedSpanData, type IOTelService, type ISpanEventData, type ISpanEventRecord, type ISpanHandle, type SpanOptions, type TraceContext } from '../common/otelService';910let nextId = 1;11function hexId(len: number): string {12return (nextId++).toString(16).padStart(len, '0');13}1415/**16* Span context stored in AsyncLocalStorage for correct parent-child propagation17* across concurrent async operations.18*/19interface SpanContext {20readonly spanId: string;21readonly traceId: string;22}2324/**25* Full-fidelity span handle that tracks all attributes, events, and status in-memory.26* Fires onDidCompleteSpan on end() and onDidEmitSpanEvent on addEvent().27*/28class InMemorySpanHandle implements ISpanHandle {29private readonly _attributes: Record<string, string | number | boolean | string[]> = {};30private readonly _events: ISpanEventRecord[] = [];31private _statusCode = SpanStatusCode.UNSET;32private _statusMessage?: string;33private readonly _startTime = Date.now();34private _ended = false;3536readonly spanId: string;37readonly traceId: string;38readonly parentSpanId: string | undefined;3940constructor(41readonly name: string,42private readonly _onDidCompleteSpan: Emitter<ICompletedSpanData>,43private readonly _onDidEmitSpanEvent: Emitter<ISpanEventData>,44parentContext?: SpanContext,45initialAttributes?: Record<string, string | number | boolean | string[]>,46) {47this.spanId = hexId(16);48this.traceId = parentContext?.traceId ?? hexId(32);49this.parentSpanId = parentContext?.spanId;50if (initialAttributes) {51Object.assign(this._attributes, initialAttributes);52}53}5455setAttribute(key: string, value: string | number | boolean | string[]): void {56this._attributes[key] = value;57}5859setAttributes(attrs: Record<string, string | number | boolean | string[] | undefined>): void {60for (const k in attrs) {61if (Object.prototype.hasOwnProperty.call(attrs, k)) {62const v = attrs[k];63if (v !== undefined) {64this._attributes[k] = v;65}66}67}68}6970setStatus(code: SpanStatusCode, message?: string): void {71this._statusCode = code;72this._statusMessage = message;73}7475recordException(_error: unknown): void { /* no-op for in-memory */ }7677addEvent(name: string, attributes?: Record<string, string | number | boolean | string[]>): void {78const timestamp = Date.now();79this._events.push({ name, timestamp, attributes });80try {81this._onDidEmitSpanEvent.fire({82spanId: this.spanId,83traceId: this.traceId,84parentSpanId: this.parentSpanId,85eventName: name,86attributes: attributes ?? {},87timestamp,88});89} catch { /* emitter disposed */ }90}9192end(): void {93if (this._ended) { return; }94this._ended = true;95try {96this._onDidCompleteSpan.fire({97name: this.name,98spanId: this.spanId,99traceId: this.traceId,100parentSpanId: this.parentSpanId,101startTime: this._startTime,102endTime: Date.now(),103status: { code: this._statusCode, message: this._statusMessage },104attributes: { ...this._attributes },105events: [...this._events],106});107} catch { /* emitter disposed */ }108}109110get context(): SpanContext {111return { spanId: this.spanId, traceId: this.traceId };112}113114getSpanContext(): TraceContext | undefined {115return { spanId: this.spanId, traceId: this.traceId };116}117}118119/**120* In-memory OTel service for the debug panel.121*122* Uses Node.js AsyncLocalStorage for correct parent-child span propagation123* across concurrent async operations (e.g., parallel tool calls, subagents).124* Does NOT load the OTel SDK or export to any backend.125*126* Used when OTel external export is disabled (the default).127* When OTel export IS enabled, NodeOTelService is used instead (which has128* both in-memory tracking AND SDK-based export).129*/130export class InMemoryOTelService implements IOTelService {131declare readonly _serviceBrand: undefined;132readonly config: OTelConfig;133134private readonly _onDidCompleteSpan = new Emitter<ICompletedSpanData>();135readonly onDidCompleteSpan: Event<ICompletedSpanData> = this._onDidCompleteSpan.event;136private readonly _onDidEmitSpanEvent = new Emitter<ISpanEventData>();137readonly onDidEmitSpanEvent: Event<ISpanEventData> = this._onDidEmitSpanEvent.event;138139injectCompletedSpan(span: ICompletedSpanData): void {140try { this._onDidCompleteSpan.fire(span); } catch { /* emitter may be disposed */ }141}142143/** AsyncLocalStorage for correct context propagation across concurrent async ops */144private readonly _contextStorage = new AsyncLocalStorage<SpanContext>();145146/** Trace context store for cross-boundary propagation (e.g., subagent invocations) */147private static readonly _MAX_TRACE_CONTEXT_STORE_SIZE = 1000;148private readonly _traceContextStore = new Map<string, TraceContext>();149private readonly _traceContextTimers = new Map<string, ReturnType<typeof setTimeout>>();150151constructor(config: OTelConfig) {152this.config = config;153}154155startSpan(name: string, options?: SpanOptions): ISpanHandle {156const parentCtx = this._resolveParentContext(options);157return new InMemorySpanHandle(158name,159this._onDidCompleteSpan,160this._onDidEmitSpanEvent,161parentCtx,162options?.attributes as Record<string, string | number | boolean | string[]>,163);164}165166async startActiveSpan<T>(name: string, options: SpanOptions, fn: (span: ISpanHandle) => Promise<T>): Promise<T> {167const parentCtx = this._resolveParentContext(options);168const handle = new InMemorySpanHandle(169name,170this._onDidCompleteSpan,171this._onDidEmitSpanEvent,172parentCtx,173options?.attributes as Record<string, string | number | boolean | string[]>,174);175return this._contextStorage.run(handle.context, async () => {176try {177return await fn(handle);178} finally {179handle.end();180}181});182}183184getActiveTraceContext(): TraceContext | undefined {185const ctx = this._contextStorage.getStore();186return ctx ? { traceId: ctx.traceId, spanId: ctx.spanId } : undefined;187}188189storeTraceContext(key: string, context: TraceContext): void {190// Evict oldest entry if at capacity191if (this._traceContextStore.size >= InMemoryOTelService._MAX_TRACE_CONTEXT_STORE_SIZE) {192const oldestKey = this._traceContextStore.keys().next().value;193if (oldestKey !== undefined) {194this._clearStoredTraceContext(oldestKey);195}196}197this._traceContextStore.set(key, context);198// Auto-cleanup after 30 minutes (generous for long-running agent sessions)199const timer = setTimeout(() => this._clearStoredTraceContext(key), 30 * 60 * 1000);200this._traceContextTimers.set(key, timer);201}202203getStoredTraceContext(key: string): TraceContext | undefined {204const ctx = this._traceContextStore.get(key);205if (ctx) { this._clearStoredTraceContext(key); }206return ctx;207}208209private _clearStoredTraceContext(key: string): void {210this._traceContextStore.delete(key);211const timer = this._traceContextTimers.get(key);212if (timer) {213clearTimeout(timer);214this._traceContextTimers.delete(key);215}216}217218runWithTraceContext<T>(traceContext: TraceContext, fn: () => Promise<T>): Promise<T> {219return this._contextStorage.run({ spanId: traceContext.spanId, traceId: traceContext.traceId }, fn);220}221222// ── No-ops for metrics/logs (not needed for debug panel for now) ──223224recordMetric(_name: string, _value: number, _attributes?: Record<string, string | number | boolean>): void { }225incrementCounter(_name: string, _value?: number, _attributes?: Record<string, string | number | boolean>): void { }226emitLogRecord(_body: string, _attributes?: Record<string, unknown>): void { }227async flush(): Promise<void> { }228229async shutdown(): Promise<void> {230for (const timer of this._traceContextTimers.values()) {231clearTimeout(timer);232}233this._traceContextTimers.clear();234this._traceContextStore.clear();235this._onDidCompleteSpan.dispose();236this._onDidEmitSpanEvent.dispose();237}238239// ── Private ──240241private _resolveParentContext(options?: SpanOptions): SpanContext | undefined {242// Explicit parent takes priority (e.g., subagent linking)243if (options?.parentTraceContext) {244return {245spanId: options.parentTraceContext.spanId,246traceId: options.parentTraceContext.traceId,247};248}249// Otherwise inherit from async context250return this._contextStorage.getStore();251}252}253254255