Path: blob/main/extensions/copilot/src/platform/otel/common/test/capturingOTelService.ts
13406 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 { Emitter, type Event } from '../../../../util/vs/base/common/event';6import { resolveOTelConfig, type OTelConfig } from '../otelConfig';7import { SpanStatusCode, type ICompletedSpanData, type IOTelService, type ISpanEventData, type ISpanEventRecord, type ISpanHandle, type SpanOptions, type TraceContext } from '../otelService';89/**10* Captured span record for test assertions.11*/12export interface CapturedSpan {13name: string;14kind?: number;15attributes: Record<string, string | number | boolean | string[] | undefined>;16statusCode?: SpanStatusCode;17statusMessage?: string;18exceptions: unknown[];19ended: boolean;20parentTraceContext?: TraceContext;21events: ISpanEventRecord[];22}2324/**25* Captured metric record.26*/27export interface CapturedMetric {28name: string;29value: number;30attributes?: Record<string, string | number | boolean>;31}3233/**34* Captured log record.35*/36export interface CapturedLogRecord {37body: string;38attributes?: Record<string, unknown>;39}4041/**42* IOTelService implementation that captures all operations for test verification.43* Unlike NoopOTelService, this records spans, metrics, and logs so tests can44* assert on the OTel output without a real SDK.45*/46export class CapturingOTelService implements IOTelService {47declare readonly _serviceBrand: undefined;48readonly config: OTelConfig;4950readonly spans: CapturedSpan[] = [];51readonly metrics: CapturedMetric[] = [];52readonly counters: CapturedMetric[] = [];53readonly logRecords: CapturedLogRecord[] = [];54private readonly _traceContextStore = new Map<string, TraceContext>();55private readonly _onDidCompleteSpan = new Emitter<ICompletedSpanData>();56readonly onDidCompleteSpan: Event<ICompletedSpanData> = this._onDidCompleteSpan.event;57private readonly _onDidEmitSpanEvent = new Emitter<ISpanEventData>();58readonly onDidEmitSpanEvent: Event<ISpanEventData> = this._onDidEmitSpanEvent.event;5960injectCompletedSpan(span: ICompletedSpanData): void {61this._onDidCompleteSpan.fire(span);62}6364constructor(config?: Partial<OTelConfig>) {65this.config = {66...resolveOTelConfig({ env: { 'COPILOT_OTEL_ENABLED': 'true' }, extensionVersion: '1.0.0', sessionId: 'test' }),67...config,68};69}7071startSpan(name: string, options?: SpanOptions): ISpanHandle {72const captured: CapturedSpan = {73name,74kind: options?.kind,75attributes: { ...options?.attributes },76exceptions: [],77ended: false,78parentTraceContext: options?.parentTraceContext,79events: [],80};81this.spans.push(captured);82return new CapturingSpanHandle(captured, this._onDidCompleteSpan, this._onDidEmitSpanEvent);83}8485async startActiveSpan<T>(name: string, options: SpanOptions, fn: (span: ISpanHandle) => Promise<T>): Promise<T> {86const span = this.startSpan(name, options);87try {88return await fn(span);89} finally {90span.end();91}92}9394getActiveTraceContext(): TraceContext | undefined {95return undefined;96}9798storeTraceContext(key: string, context: TraceContext): void {99this._traceContextStore.set(key, context);100}101102getStoredTraceContext(key: string): TraceContext | undefined {103const ctx = this._traceContextStore.get(key);104if (ctx) {105this._traceContextStore.delete(key);106}107return ctx;108}109110async runWithTraceContext<T>(_traceContext: TraceContext, fn: () => Promise<T>): Promise<T> {111return fn();112}113114recordMetric(name: string, value: number, attributes?: Record<string, string | number | boolean>): void {115this.metrics.push({ name, value, attributes });116}117118incrementCounter(name: string, value = 1, attributes?: Record<string, string | number | boolean>): void {119this.counters.push({ name, value, attributes });120}121122emitLogRecord(body: string, attributes?: Record<string, unknown>): void {123this.logRecords.push({ body, attributes });124}125126async flush(): Promise<void> { }127async shutdown(): Promise<void> { }128129/** Find spans by name prefix. */130findSpans(namePrefix: string): CapturedSpan[] {131return this.spans.filter(s => s.name.startsWith(namePrefix));132}133134/** Reset all captured data. */135reset(): void {136this.spans.length = 0;137this.metrics.length = 0;138this.counters.length = 0;139this.logRecords.length = 0;140}141}142143class CapturingSpanHandle implements ISpanHandle {144private static _nextSpanId = 1;145private readonly _spanId: string;146147constructor(148private readonly _captured: CapturedSpan,149private readonly _onDidCompleteSpan: Emitter<ICompletedSpanData>,150private readonly _onDidEmitSpanEvent: Emitter<ISpanEventData>,151) {152this._spanId = String(CapturingSpanHandle._nextSpanId++).padStart(16, '0');153}154155setAttribute(key: string, value: string | number | boolean | string[]): void {156this._captured.attributes[key] = value;157}158159setAttributes(attrs: Record<string, string | number | boolean | string[] | undefined>): void {160for (const k in attrs) {161if (Object.prototype.hasOwnProperty.call(attrs, k)) {162this._captured.attributes[k] = attrs[k];163}164}165}166167setStatus(code: SpanStatusCode, message?: string): void {168this._captured.statusCode = code;169this._captured.statusMessage = message;170}171172recordException(error: unknown): void {173this._captured.exceptions.push(error);174}175176addEvent(name: string, attributes?: Record<string, string | number | boolean | string[]>): void {177const timestamp = Date.now();178const record: ISpanEventRecord = { name, timestamp, attributes };179this._captured.events.push(record);180this._onDidEmitSpanEvent.fire({181spanId: this._spanId,182traceId: '00000000000000000000000000000000',183eventName: name,184attributes: attributes ?? {},185timestamp,186});187}188189getSpanContext(): TraceContext | undefined {190return { spanId: this._spanId, traceId: '00000000000000000000000000000000' };191}192193end(): void {194this._captured.ended = true;195const attrs: Record<string, string | number | boolean | string[]> = {};196for (const [k, v] of Object.entries(this._captured.attributes)) {197if (v !== undefined) {198attrs[k] = v;199}200}201this._onDidCompleteSpan.fire({202name: this._captured.name,203spanId: this._spanId,204traceId: '00000000000000000000000000000000',205startTime: Date.now(),206endTime: Date.now(),207status: { code: this._captured.statusCode ?? SpanStatusCode.UNSET, message: this._captured.statusMessage },208attributes: attrs,209events: [...this._captured.events],210});211}212}213214215