Path: blob/main/extensions/copilot/src/platform/otel/node/otelServiceImpl.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 { Emitter, type Event } from '../../../util/vs/base/common/event';6import { GenAiAttr, GenAiOperationName } from '../common/genAiAttributes';7import type { OTelConfig } from '../common/otelConfig';8import { type ICompletedSpanData, type IOTelService, type ISpanEventData, type ISpanEventRecord, type ISpanHandle, SpanKind, type SpanOptions, SpanStatusCode, type TraceContext } from '../common/otelService';910// Type-only imports — erased by esbuild, zero bundle impact11import type { Attributes, Context, Meter, MetricOptions, Span, SpanContext, Tracer } from '@opentelemetry/api';12import type { AnyValueMap, Logger } from '@opentelemetry/api-logs';13import type { ExportResult } from '@opentelemetry/core';14import type { BatchLogRecordProcessor, LogRecordExporter } from '@opentelemetry/sdk-logs';15import type { PeriodicExportingMetricReader, PushMetricExporter } from '@opentelemetry/sdk-metrics';16import type { BatchSpanProcessor, ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-node';17import type { OTelSqliteStore } from './sqlite/otelSqliteStore';1819interface ExporterSet {20spanExporter: SpanExporter;21logExporter: LogRecordExporter;22metricExporter: PushMetricExporter;23}2425const noopSpanHandle: ISpanHandle = {26setAttribute() { },27setAttributes() { },28setStatus() { },29recordException() { },30addEvent() { },31getSpanContext() { return undefined; },32end() { },33};3435/**36* Callback for routing OTel service log messages to the extension's output channel.37*/38export type OTelLogFn = (level: 'info' | 'warn' | 'error', message: string) => void;3940/**41* Real OTel service implementation, only instantiated when OTel is enabled.42* Uses dynamic imports so the OTel SDK is not loaded when disabled.43*/44export class NodeOTelService implements IOTelService {45declare readonly _serviceBrand: undefined;46readonly config: OTelConfig;4748private _tracer: Tracer | undefined;49private _meter: Meter | undefined;50private _logger: Logger | undefined;51private _spanProcessors: BatchSpanProcessor[] = [];52private _logProcessor: BatchLogRecordProcessor | undefined;53private _metricReader: PeriodicExportingMetricReader | undefined;54// OTel API reference for context propagation (stored after dynamic import)55private _otelApi: typeof import('@opentelemetry/api') | undefined;56private _initialized = false;57private _initFailed = false;58private static readonly _MAX_BUFFER_SIZE = 1000;59private readonly _log: OTelLogFn;60private readonly _sqliteStore: OTelSqliteStore | undefined;6162// Event emitters for span lifecycle63private readonly _onDidCompleteSpan = new Emitter<ICompletedSpanData>();64readonly onDidCompleteSpan: Event<ICompletedSpanData> = this._onDidCompleteSpan.event;65private readonly _onDidEmitSpanEvent = new Emitter<ISpanEventData>();66readonly onDidEmitSpanEvent: Event<ISpanEventData> = this._onDidEmitSpanEvent.event;6768injectCompletedSpan(span: ICompletedSpanData): void {69try { this._onDidCompleteSpan.fire(span); } catch { /* emitter may be disposed */ }70}7172// Buffer events until SDK is ready73private readonly _buffer: Array<() => void> = [];7475constructor(config: OTelConfig, logFn?: OTelLogFn, sqliteStore?: OTelSqliteStore) {76this.config = config;77this._log = logFn ?? ((_level, _msg) => { /* silent when no logger wired */ });78this._sqliteStore = sqliteStore;79// Start async initialization immediately80void this._initialize();81}8283private async _initialize(): Promise<void> {84if (this._initialized || !this.config.enabled) {85return;86}8788try {89// Dynamic imports — only loaded when OTel is enabled90const [91api,92apiLogs,93traceSDK,94logsSDK,95metricsSDK,96resourcesMod,97] = await Promise.all([98import('@opentelemetry/api'),99import('@opentelemetry/api-logs'),100import('@opentelemetry/sdk-trace-node'),101import('@opentelemetry/sdk-logs'),102import('@opentelemetry/sdk-metrics'),103import('@opentelemetry/resources'),104]);105106const BSP = traceSDK.BatchSpanProcessor;107const BLRP = logsSDK.BatchLogRecordProcessor;108const PEMR = metricsSDK.PeriodicExportingMetricReader;109const NodeTracerProvider = traceSDK.NodeTracerProvider;110const MeterProvider = metricsSDK.MeterProvider;111const LoggerProvider = logsSDK.LoggerProvider;112113// Use resourceFromAttributes (available in @opentelemetry/resources v2+)114const resource = resourcesMod.resourceFromAttributes({115'service.name': this.config.serviceName,116'service.version': this.config.serviceVersion,117'session.id': this.config.sessionId,118...this.config.resourceAttributes,119});120121// Create exporters based on config122const { spanExporter, logExporter, metricExporter } = await this._createExporters();123124// Primary span processor: filters debug-panel-only spans for the user's exporter125const diagnosticSpanExporter = new DiagnosticSpanExporter(spanExporter, this.config.exporterType, this._log);126this._spanProcessors.push(new BSP(diagnosticSpanExporter));127128// SQLite DB span processor: standard GenAI spans only (same filter as primary).129// Registered as a separate processor so it works in parallel with any user exporter.130if (this.config.dbSpanExporter && this._sqliteStore) {131const { SqliteSpanExporter } = await import('./sqlite/sqliteSpanExporter');132const sqliteExporter = new FilteredSpanExporter(new SqliteSpanExporter(this._sqliteStore));133this._spanProcessors.push(new BSP(sqliteExporter));134}135136// Trace provider — pass spanProcessors in constructor (SDK v2 API)137const tracerProvider = new NodeTracerProvider({138resource,139spanProcessors: this._spanProcessors,140});141tracerProvider.register();142this._tracer = api.trace.getTracer(this.config.serviceName, this.config.serviceVersion);143this._otelApi = api;144145// Log provider — pass processors in constructor (SDK v2 uses 'processors' key)146this._logProcessor = new BLRP(logExporter, {147scheduledDelayMillis: 1000,148maxExportBatchSize: 512,149});150const loggerProvider = new LoggerProvider({151resource,152processors: [this._logProcessor],153} as ConstructorParameters<typeof LoggerProvider>[0]);154apiLogs.logs.setGlobalLoggerProvider(loggerProvider);155this._logger = apiLogs.logs.getLogger(this.config.serviceName, this.config.serviceVersion);156157// Metric provider158this._metricReader = new PEMR({159exporter: metricExporter,160exportIntervalMillis: 10000,161});162const meterProvider = new MeterProvider({163resource,164readers: [this._metricReader],165});166api.metrics.setGlobalMeterProvider(meterProvider);167this._meter = api.metrics.getMeter(this.config.serviceName, this.config.serviceVersion);168169this._initialized = true;170171// Flush buffered events in batches to avoid blocking the event loop172const batch = this._buffer.splice(0);173const BATCH_SIZE = 50;174for (let i = 0; i < batch.length; i += BATCH_SIZE) {175const chunk = batch.slice(i, i + BATCH_SIZE);176for (const fn of chunk) {177try { fn(); } catch { /* swallow */ }178}179if (i + BATCH_SIZE < batch.length) {180// Yield to event loop between batches181await new Promise<void>(resolve => setTimeout(resolve, 0));182}183}184185} catch (err) {186// OTel init failure should never break the extension187this._initFailed = true;188this._buffer.length = 0; // Discard buffered events on failure189this._log('error', `[OTel] Failed to initialize: ${err}`);190}191}192193private async _createExporters(): Promise<ExporterSet> {194const { config } = this;195196// When OTel is enabled only for dbSpanExporter (no OTLP endpoint/file/console configured),197// use a noop exporter as the primary span exporter so the pipeline still runs.198// If the user also explicitly enabled OTel (via setting or env var), honour their199// exporter config and don't switch to noop.200const dbOnlyMode = config.dbSpanExporter201&& !config.enabledExplicitly202&& !config.fileExporterPath203&& config.exporterType !== 'console';204205if (config.exporterType === 'file' && config.fileExporterPath) {206const { FileSpanExporter, FileLogExporter, FileMetricExporter } = await import('./fileExporters');207return {208spanExporter: new FileSpanExporter(config.fileExporterPath),209logExporter: new FileLogExporter(config.fileExporterPath),210metricExporter: new FileMetricExporter(config.fileExporterPath),211};212}213214if (config.exporterType === 'console' && !dbOnlyMode) {215const [traceSDK, logsSDK, metricsSDK] = await Promise.all([216import('@opentelemetry/sdk-trace-node'),217import('@opentelemetry/sdk-logs'),218import('@opentelemetry/sdk-metrics'),219]);220return {221spanExporter: new traceSDK.ConsoleSpanExporter(),222logExporter: new logsSDK.ConsoleLogRecordExporter(),223metricExporter: new metricsSDK.ConsoleMetricExporter(),224};225}226227if (config.exporterType === 'otlp-grpc' && !dbOnlyMode) {228const [229{ OTLPTraceExporter },230{ OTLPLogExporter },231{ OTLPMetricExporter },232] = await Promise.all([233import('@opentelemetry/exporter-trace-otlp-grpc'),234import('@opentelemetry/exporter-logs-otlp-grpc'),235import('@opentelemetry/exporter-metrics-otlp-grpc'),236]);237const opts = { url: config.otlpEndpoint };238return {239spanExporter: new OTLPTraceExporter(opts),240logExporter: new OTLPLogExporter(opts),241metricExporter: new OTLPMetricExporter(opts),242};243}244245// Default: otlp-http (or noop when in db-only mode)246if (dbOnlyMode) {247const metricsSDK = await import('@opentelemetry/sdk-metrics');248const logsSDK = await import('@opentelemetry/sdk-logs');249return {250spanExporter: new NoopSpanExporter(),251logExporter: new logsSDK.ConsoleLogRecordExporter(),252metricExporter: new metricsSDK.ConsoleMetricExporter(),253};254}255const [256{ OTLPTraceExporter },257{ OTLPLogExporter },258{ OTLPMetricExporter },259] = await Promise.all([260import('@opentelemetry/exporter-trace-otlp-http'),261import('@opentelemetry/exporter-logs-otlp-http'),262import('@opentelemetry/exporter-metrics-otlp-http'),263]);264const base = config.otlpEndpoint.replace(/\/$/, '');265return {266spanExporter: new OTLPTraceExporter({ url: `${base}/v1/traces` }),267logExporter: new OTLPLogExporter({ url: `${base}/v1/logs` }),268metricExporter: new OTLPMetricExporter({ url: `${base}/v1/metrics` }),269};270}271272// ── Span API ──273274startSpan(name: string, options?: SpanOptions): ISpanHandle {275if (!this._tracer) {276if (this._initFailed || this._buffer.length >= NodeOTelService._MAX_BUFFER_SIZE) {277return noopSpanHandle;278}279const handle = new BufferedSpanHandle();280this._buffer.push(() => {281const real = this._createSpan(name, options);282handle.replay(real);283});284return handle;285}286return this._createSpan(name, options);287}288289async startActiveSpan<T>(name: string, options: SpanOptions, fn: (span: ISpanHandle) => Promise<T>): Promise<T> {290if (!this._tracer) {291const handle = this.startSpan(name, options);292try {293return await fn(handle);294} finally {295handle.end();296}297}298299const spanOpts = { kind: toOTelSpanKind(options?.kind), attributes: options?.attributes as Attributes };300301// If a parent trace context is provided, create a remote context and start span within it302if (options.parentTraceContext && this._otelApi) {303const parentCtx = this._createRemoteContext(options.parentTraceContext);304return this._tracer.startActiveSpan(305name,306spanOpts,307parentCtx,308async (span: Span) => {309const handle = new RealSpanHandle(span, this._onDidCompleteSpan, this._onDidEmitSpanEvent, options?.attributes, options.parentTraceContext!.spanId);310try {311return await fn(handle);312} finally {313handle.end();314}315}316);317}318319const activeParentId = this._getActiveParentSpanId();320return this._tracer.startActiveSpan(321name,322spanOpts,323async (span: Span) => {324const handle = new RealSpanHandle(span, this._onDidCompleteSpan, this._onDidEmitSpanEvent, options?.attributes, activeParentId);325try {326return await fn(handle);327} finally {328handle.end();329}330}331);332}333334getActiveTraceContext(): TraceContext | undefined {335if (!this._otelApi) {336return undefined;337}338const activeSpan = this._otelApi.trace.getSpan(this._otelApi.context.active());339if (!activeSpan) {340return undefined;341}342const ctx = activeSpan.spanContext();343if (!ctx.traceId || !ctx.spanId) {344return undefined;345}346return { traceId: ctx.traceId, spanId: ctx.spanId, traceFlags: ctx.traceFlags, traceState: ctx.traceState?.serialize() };347}348349// ── Trace Context Store ── (for cross-boundary propagation)350351private static readonly _MAX_TRACE_CONTEXT_STORE_SIZE = 100;352private readonly _traceContextStore = new Map<string, TraceContext>();353private readonly _traceContextTimers = new Map<string, ReturnType<typeof setTimeout>>();354355storeTraceContext(key: string, context: TraceContext): void {356// Evict oldest entry if at capacity357if (this._traceContextStore.size >= NodeOTelService._MAX_TRACE_CONTEXT_STORE_SIZE) {358const oldestKey = this._traceContextStore.keys().next().value;359if (oldestKey !== undefined) {360this._clearStoredTraceContext(oldestKey);361}362}363this._traceContextStore.set(key, context);364// Auto-cleanup after 5 minutes; tracked for proper disposal365const timer = setTimeout(() => this._clearStoredTraceContext(key), 5 * 60 * 1000);366this._traceContextTimers.set(key, timer);367}368369getStoredTraceContext(key: string): TraceContext | undefined {370const ctx = this._traceContextStore.get(key);371if (ctx) {372this._clearStoredTraceContext(key);373}374return ctx;375}376377private _clearStoredTraceContext(key: string): void {378this._traceContextStore.delete(key);379const timer = this._traceContextTimers.get(key);380if (timer) {381clearTimeout(timer);382this._traceContextTimers.delete(key);383}384}385386/**387* Creates an OTel Context with a remote span context as parent,388* allowing spans created within it to be children of the remote span.389*/390private _createRemoteContext(tc: TraceContext): Context {391const api = this._otelApi!;392const remoteSpanContext: SpanContext = {393traceId: tc.traceId,394spanId: tc.spanId,395traceFlags: 1, // SAMPLED396isRemote: true,397};398const remoteCtx = api.trace.setSpanContext(api.context.active(), remoteSpanContext);399return remoteCtx;400}401402async runWithTraceContext<T>(traceContext: TraceContext, fn: () => Promise<T>): Promise<T> {403if (!this._otelApi) {404return fn();405}406const parentCtx = this._createRemoteContext(traceContext);407return this._otelApi.context.with(parentCtx, fn);408}409410private _createSpan(name: string, options?: SpanOptions): ISpanHandle {411const spanOpts = { kind: toOTelSpanKind(options?.kind), attributes: options?.attributes as Attributes };412413// If an explicit parent trace context is provided, create the span as its child.414// This ensures correct parent-child hierarchy even when async context propagation415// doesn't carry the active span (common in the VS Code extension host).416if (options?.parentTraceContext && this._otelApi) {417const parentCtx = this._createRemoteContext(options.parentTraceContext);418const span = this._tracer!.startSpan(name, spanOpts, parentCtx);419return new RealSpanHandle(span, this._onDidCompleteSpan, this._onDidEmitSpanEvent, options?.attributes, options.parentTraceContext.spanId);420}421422const parentSpanId = this._getActiveParentSpanId();423const span = this._tracer!.startSpan(name, spanOpts);424return new RealSpanHandle(span, this._onDidCompleteSpan, this._onDidEmitSpanEvent, options?.attributes, parentSpanId);425}426427private _getActiveParentSpanId(): string | undefined {428if (!this._otelApi) { return undefined; }429const activeSpan = this._otelApi.trace.getSpan(this._otelApi.context.active());430if (!activeSpan) { return undefined; }431const ctx = activeSpan.spanContext();432return ctx.spanId || undefined;433}434435// ── Metric API ──436437/**438* Explicit bucket boundaries per the OTel GenAI semantic conventions.439* @see https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-metrics/440*/441private static readonly _histogramOptions: ReadonlyMap<string, MetricOptions> = new Map([442['gen_ai.client.operation.duration', { advice: { explicitBucketBoundaries: [0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, 20.48, 40.96, 81.92] } }],443['gen_ai.client.token.usage', { advice: { explicitBucketBoundaries: [1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864] } }],444]);445446private readonly _histograms = new Map<string, ReturnType<Meter['createHistogram']>>();447private readonly _counters = new Map<string, ReturnType<Meter['createCounter']>>();448449recordMetric(name: string, value: number, attributes?: Record<string, string | number | boolean>): void {450if (!this._meter) {451if (!this._initFailed && this._buffer.length < NodeOTelService._MAX_BUFFER_SIZE) {452this._buffer.push(() => this.recordMetric(name, value, attributes));453}454return;455}456let histogram = this._histograms.get(name);457if (!histogram) {458histogram = this._meter.createHistogram(name, NodeOTelService._histogramOptions.get(name));459this._histograms.set(name, histogram);460}461histogram.record(value, attributes);462}463464incrementCounter(name: string, value = 1, attributes?: Record<string, string | number | boolean>): void {465if (!this._meter) {466if (!this._initFailed && this._buffer.length < NodeOTelService._MAX_BUFFER_SIZE) {467this._buffer.push(() => this.incrementCounter(name, value, attributes));468}469return;470}471let counter = this._counters.get(name);472if (!counter) {473counter = this._meter.createCounter(name);474this._counters.set(name, counter);475}476counter.add(value, attributes);477}478479// ── Log API ──480481private _logEmitCount = 0;482483emitLogRecord(body: string, attributes?: Record<string, unknown>): void {484if (!this._logger) {485if (!this._initFailed && this._buffer.length < NodeOTelService._MAX_BUFFER_SIZE) {486this._buffer.push(() => this.emitLogRecord(body, attributes));487}488return;489}490// Pass the active context so the log record inherits the trace ID from491// the current span (if any). Without this, logs emitted inside a span492// created via startSpan() (rather than startActiveSpan()) lack trace context.493const ctx = this._otelApi?.context.active();494this._logger.emit({ body, attributes: attributes as AnyValueMap, ...(ctx ? { context: ctx } : {}) });495this._logEmitCount++;496if (this._logEmitCount === 1) {497this._log('info', `[OTel] First log record emitted: ${body}`);498}499}500501// ── Lifecycle ──502503async flush(): Promise<void> {504await Promise.all([505...this._spanProcessors.map(p => p.forceFlush()),506this._logProcessor?.forceFlush(),507this._metricReader?.forceFlush(),508]);509}510511async shutdown(): Promise<void> {512try {513// Clear all trace context timers514for (const timer of this._traceContextTimers.values()) {515clearTimeout(timer);516}517this._traceContextTimers.clear();518this._traceContextStore.clear();519520await this.flush();521await Promise.all([522...this._spanProcessors.map(p => p.shutdown()),523this._logProcessor?.shutdown(),524this._metricReader?.shutdown(),525]);526const api = await import('@opentelemetry/api');527const apiLogs = await import('@opentelemetry/api-logs');528api.trace.disable();529api.metrics.disable();530apiLogs.logs.disable();531} catch {532// Swallow shutdown errors533} finally {534this._onDidCompleteSpan.dispose();535this._onDidEmitSpanEvent.dispose();536}537}538}539540// ── Span Handle Implementations ──541542class RealSpanHandle implements ISpanHandle {543private readonly _attributes: Record<string, string | number | boolean | string[]> = {};544private readonly _events: ISpanEventRecord[] = [];545private _statusCode = SpanStatusCode.UNSET;546private _statusMessage?: string;547private readonly _startTime = Date.now();548private _ended = false;549private readonly _parentSpanId: string | undefined;550551constructor(552private readonly _span: Span,553private readonly _onDidCompleteSpan: Emitter<ICompletedSpanData>,554private readonly _onDidEmitSpanEvent: Emitter<ISpanEventData>,555initialAttributes?: Record<string, string | number | boolean | string[]>,556parentSpanId?: string,557) {558this._parentSpanId = parentSpanId;559if (initialAttributes) {560for (const k in initialAttributes) {561if (Object.prototype.hasOwnProperty.call(initialAttributes, k)) {562this._attributes[k] = initialAttributes[k];563}564}565}566}567568setAttribute(key: string, value: string | number | boolean | string[]): void {569this._attributes[key] = value;570this._span.setAttribute(key, value);571}572573setAttributes(attrs: Record<string, string | number | boolean | string[] | undefined>): void {574for (const k in attrs) {575if (Object.prototype.hasOwnProperty.call(attrs, k)) {576const v = attrs[k];577if (v !== undefined) {578this._attributes[k] = v;579this._span.setAttribute(k, v);580}581}582}583}584585setStatus(code: SpanStatusCode, message?: string): void {586this._statusCode = code;587this._statusMessage = message;588const otelCode = code === SpanStatusCode.OK ? 1 : code === SpanStatusCode.ERROR ? 2 : 0;589this._span.setStatus({ code: otelCode, message });590}591592recordException(error: unknown): void {593if (error instanceof Error) {594this._span.recordException(error);595} else {596this._span.recordException(new Error(String(error)));597}598}599600addEvent(name: string, attributes?: Record<string, string | number | boolean | string[]>): void {601const timestamp = Date.now();602const record: ISpanEventRecord = { name, timestamp, attributes };603this._events.push(record);604this._span.addEvent(name, attributes);605606// Fire real-time span event notification (guard against disposed emitter)607try {608const ctx = this._span.spanContext();609this._onDidEmitSpanEvent.fire({610spanId: ctx.spanId,611traceId: ctx.traceId,612parentSpanId: this._parentSpanId,613eventName: name,614attributes: attributes ?? {},615timestamp,616});617} catch { /* emitter may be disposed after shutdown */ }618}619620getSpanContext(): TraceContext | undefined {621const ctx = this._span.spanContext();622return ctx.traceId && ctx.spanId623? { traceId: ctx.traceId, spanId: ctx.spanId, traceFlags: ctx.traceFlags, traceState: ctx.traceState?.serialize() }624: undefined;625}626627end(): void {628if (this._ended) {629return;630}631this._ended = true;632this._span.end();633634try {635const ctx = this._span.spanContext();636this._onDidCompleteSpan.fire({637name: (this._span as unknown as { name?: string }).name ?? '',638spanId: ctx.spanId,639traceId: ctx.traceId,640parentSpanId: this._parentSpanId,641startTime: this._startTime,642endTime: Date.now(),643status: { code: this._statusCode, message: this._statusMessage },644attributes: { ...this._attributes },645events: [...this._events],646});647} catch { /* emitter may be disposed after shutdown */ }648}649}650651/**652* Buffers span operations until the SDK is initialized, then replays them.653*/654class BufferedSpanHandle implements ISpanHandle {655private static readonly _MAX_OPS = 200;656private readonly _ops: Array<(span: ISpanHandle) => void> = [];657private _real: ISpanHandle | undefined;658659setAttribute(key: string, value: string | number | boolean | string[]): void {660if (this._real) { this._real.setAttribute(key, value); return; }661if (this._ops.length < BufferedSpanHandle._MAX_OPS) {662this._ops.push(s => s.setAttribute(key, value));663}664}665666setAttributes(attrs: Record<string, string | number | boolean | string[] | undefined>): void {667if (this._real) { this._real.setAttributes(attrs); return; }668if (this._ops.length < BufferedSpanHandle._MAX_OPS) {669this._ops.push(s => s.setAttributes(attrs));670}671}672673setStatus(code: SpanStatusCode, message?: string): void {674if (this._real) { this._real.setStatus(code, message); return; }675if (this._ops.length < BufferedSpanHandle._MAX_OPS) {676this._ops.push(s => s.setStatus(code, message));677}678}679680recordException(error: unknown): void {681if (this._real) { this._real.recordException(error); return; }682if (this._ops.length < BufferedSpanHandle._MAX_OPS) {683this._ops.push(s => s.recordException(error));684}685}686687addEvent(name: string, attributes?: Record<string, string | number | boolean | string[]>): void {688if (this._real) { this._real.addEvent(name, attributes); return; }689if (this._ops.length < BufferedSpanHandle._MAX_OPS) {690this._ops.push(s => s.addEvent(name, attributes));691}692}693694getSpanContext(): TraceContext | undefined {695return this._real?.getSpanContext();696}697698end(): void {699if (this._real) { this._real.end(); return; }700// Always buffer end() regardless of cap — it's critical for span lifecycle701this._ops.push(s => s.end());702}703704replay(real: ISpanHandle): void {705this._real = real;706for (const op of this._ops) {707op(real);708}709this._ops.length = 0;710}711}712713function toOTelSpanKind(kind: SpanKind | undefined): number {714switch (kind) {715case SpanKind.CLIENT: return 2; // OTel SpanKind.CLIENT716case SpanKind.INTERNAL: return 0; // OTel SpanKind.INTERNAL717default: return 0; // INTERNAL718}719}720721/**722* Operation names that follow the GenAI semantic conventions and should be723* exported to the user's OTLP endpoint. Debug-panel-only spans (e.g.,724* `content_event`, `user_message`) are excluded from external export but725* still visible in the in-memory span store for the Agent Debug Log panel.726*/727const EXPORTABLE_OPERATION_NAMES: ReadonlySet<string> = new Set([728GenAiOperationName.CHAT,729GenAiOperationName.INVOKE_AGENT,730GenAiOperationName.EXECUTE_TOOL,731GenAiOperationName.EMBEDDINGS,732GenAiOperationName.EXECUTE_HOOK,733]);734735/**736* Wraps a SpanExporter to log diagnostic info about export results.737* Logs once on first successful export (info), and on every failure (warn).738*739* Also filters out debug-panel-only spans (those with non-standard740* `gen_ai.operation.name` values) so they don't appear in the user's741* configured OTel collector.742*/743class DiagnosticSpanExporter implements SpanExporter {744private _firstSuccessLogged = false;745private _lastFailureLogTime = 0;746private static readonly _FAILURE_LOG_INTERVAL_MS = 60_000;747private readonly _inner: SpanExporter;748private readonly _exporterType: string;749private readonly _log: OTelLogFn;750751constructor(inner: SpanExporter, exporterType: string, logFn: OTelLogFn) {752this._inner = inner;753this._exporterType = exporterType;754this._log = logFn;755}756757export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void {758// Filter out debug-panel-only spans — only export standard GenAI operations759const exportable = spans.filter(span => {760const opName = span.attributes[GenAiAttr.OPERATION_NAME];761// If no operation name set, export it (safety: don't drop unknown spans)762if (opName === undefined) {763return true;764}765return EXPORTABLE_OPERATION_NAMES.has(String(opName));766});767if (exportable.length === 0) {768resultCallback({ code: 0 }); // ExportResultCode.SUCCESS769return;770}771this._inner.export(exportable, result => {772// ExportResultCode.SUCCESS === 0773if (result.code === 0) {774if (!this._firstSuccessLogged) {775this._firstSuccessLogged = true;776this._log('info', `[OTel] First span batch exported successfully via ${this._exporterType} (${exportable.length} spans)`);777}778} else {779// Rate-limit failure logging to avoid flooding stdout780const now = Date.now();781if (now - this._lastFailureLogTime >= DiagnosticSpanExporter._FAILURE_LOG_INTERVAL_MS) {782this._lastFailureLogTime = now;783this._log('warn', `[OTel] Span export failed via ${this._exporterType}: ${result.error ?? 'unknown error'}`);784}785}786resultCallback(result);787});788}789790shutdown(): Promise<void> {791return this._inner.shutdown?.() ?? Promise.resolve();792}793794forceFlush(): Promise<void> {795return this._inner.forceFlush?.() ?? Promise.resolve();796}797}798799/**800* Wraps a SpanExporter to filter out debug-panel-only spans,801* passing only standard GenAI operations through to the inner exporter.802*/803class FilteredSpanExporter implements SpanExporter {804constructor(private readonly _inner: SpanExporter) { }805806export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void {807const filtered = spans.filter(span => {808const opName = span.attributes[GenAiAttr.OPERATION_NAME];809return opName === undefined || EXPORTABLE_OPERATION_NAMES.has(String(opName));810});811if (filtered.length === 0) {812resultCallback({ code: 0 });813return;814}815this._inner.export(filtered, resultCallback);816}817818shutdown(): Promise<void> { return this._inner.shutdown?.() ?? Promise.resolve(); }819forceFlush(): Promise<void> { return this._inner.forceFlush?.() ?? Promise.resolve(); }820}821822/**823* No-op span exporter for db-only mode — the SQLite exporter handles persistence,824* this satisfies the OTel pipeline requirement for a primary exporter.825*/826class NoopSpanExporter implements SpanExporter {827export(_spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void {828resultCallback({ code: 0 }); // ExportResultCode.SUCCESS829}830shutdown(): Promise<void> { return Promise.resolve(); }831forceFlush(): Promise<void> { return Promise.resolve(); }832}833834835