Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/platform/otel/node/otelServiceImpl.ts
13401 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 { Emitter, type Event } from '../../../util/vs/base/common/event';
7
import { GenAiAttr, GenAiOperationName } from '../common/genAiAttributes';
8
import type { OTelConfig } from '../common/otelConfig';
9
import { type ICompletedSpanData, type IOTelService, type ISpanEventData, type ISpanEventRecord, type ISpanHandle, SpanKind, type SpanOptions, SpanStatusCode, type TraceContext } from '../common/otelService';
10
11
// Type-only imports — erased by esbuild, zero bundle impact
12
import type { Attributes, Context, Meter, MetricOptions, Span, SpanContext, Tracer } from '@opentelemetry/api';
13
import type { AnyValueMap, Logger } from '@opentelemetry/api-logs';
14
import type { ExportResult } from '@opentelemetry/core';
15
import type { BatchLogRecordProcessor, LogRecordExporter } from '@opentelemetry/sdk-logs';
16
import type { PeriodicExportingMetricReader, PushMetricExporter } from '@opentelemetry/sdk-metrics';
17
import type { BatchSpanProcessor, ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-node';
18
import type { OTelSqliteStore } from './sqlite/otelSqliteStore';
19
20
interface ExporterSet {
21
spanExporter: SpanExporter;
22
logExporter: LogRecordExporter;
23
metricExporter: PushMetricExporter;
24
}
25
26
const noopSpanHandle: ISpanHandle = {
27
setAttribute() { },
28
setAttributes() { },
29
setStatus() { },
30
recordException() { },
31
addEvent() { },
32
getSpanContext() { return undefined; },
33
end() { },
34
};
35
36
/**
37
* Callback for routing OTel service log messages to the extension's output channel.
38
*/
39
export type OTelLogFn = (level: 'info' | 'warn' | 'error', message: string) => void;
40
41
/**
42
* Real OTel service implementation, only instantiated when OTel is enabled.
43
* Uses dynamic imports so the OTel SDK is not loaded when disabled.
44
*/
45
export class NodeOTelService implements IOTelService {
46
declare readonly _serviceBrand: undefined;
47
readonly config: OTelConfig;
48
49
private _tracer: Tracer | undefined;
50
private _meter: Meter | undefined;
51
private _logger: Logger | undefined;
52
private _spanProcessors: BatchSpanProcessor[] = [];
53
private _logProcessor: BatchLogRecordProcessor | undefined;
54
private _metricReader: PeriodicExportingMetricReader | undefined;
55
// OTel API reference for context propagation (stored after dynamic import)
56
private _otelApi: typeof import('@opentelemetry/api') | undefined;
57
private _initialized = false;
58
private _initFailed = false;
59
private static readonly _MAX_BUFFER_SIZE = 1000;
60
private readonly _log: OTelLogFn;
61
private readonly _sqliteStore: OTelSqliteStore | undefined;
62
63
// Event emitters for span lifecycle
64
private readonly _onDidCompleteSpan = new Emitter<ICompletedSpanData>();
65
readonly onDidCompleteSpan: Event<ICompletedSpanData> = this._onDidCompleteSpan.event;
66
private readonly _onDidEmitSpanEvent = new Emitter<ISpanEventData>();
67
readonly onDidEmitSpanEvent: Event<ISpanEventData> = this._onDidEmitSpanEvent.event;
68
69
injectCompletedSpan(span: ICompletedSpanData): void {
70
try { this._onDidCompleteSpan.fire(span); } catch { /* emitter may be disposed */ }
71
}
72
73
// Buffer events until SDK is ready
74
private readonly _buffer: Array<() => void> = [];
75
76
constructor(config: OTelConfig, logFn?: OTelLogFn, sqliteStore?: OTelSqliteStore) {
77
this.config = config;
78
this._log = logFn ?? ((_level, _msg) => { /* silent when no logger wired */ });
79
this._sqliteStore = sqliteStore;
80
// Start async initialization immediately
81
void this._initialize();
82
}
83
84
private async _initialize(): Promise<void> {
85
if (this._initialized || !this.config.enabled) {
86
return;
87
}
88
89
try {
90
// Dynamic imports — only loaded when OTel is enabled
91
const [
92
api,
93
apiLogs,
94
traceSDK,
95
logsSDK,
96
metricsSDK,
97
resourcesMod,
98
] = await Promise.all([
99
import('@opentelemetry/api'),
100
import('@opentelemetry/api-logs'),
101
import('@opentelemetry/sdk-trace-node'),
102
import('@opentelemetry/sdk-logs'),
103
import('@opentelemetry/sdk-metrics'),
104
import('@opentelemetry/resources'),
105
]);
106
107
const BSP = traceSDK.BatchSpanProcessor;
108
const BLRP = logsSDK.BatchLogRecordProcessor;
109
const PEMR = metricsSDK.PeriodicExportingMetricReader;
110
const NodeTracerProvider = traceSDK.NodeTracerProvider;
111
const MeterProvider = metricsSDK.MeterProvider;
112
const LoggerProvider = logsSDK.LoggerProvider;
113
114
// Use resourceFromAttributes (available in @opentelemetry/resources v2+)
115
const resource = resourcesMod.resourceFromAttributes({
116
'service.name': this.config.serviceName,
117
'service.version': this.config.serviceVersion,
118
'session.id': this.config.sessionId,
119
...this.config.resourceAttributes,
120
});
121
122
// Create exporters based on config
123
const { spanExporter, logExporter, metricExporter } = await this._createExporters();
124
125
// Primary span processor: filters debug-panel-only spans for the user's exporter
126
const diagnosticSpanExporter = new DiagnosticSpanExporter(spanExporter, this.config.exporterType, this._log);
127
this._spanProcessors.push(new BSP(diagnosticSpanExporter));
128
129
// SQLite DB span processor: standard GenAI spans only (same filter as primary).
130
// Registered as a separate processor so it works in parallel with any user exporter.
131
if (this.config.dbSpanExporter && this._sqliteStore) {
132
const { SqliteSpanExporter } = await import('./sqlite/sqliteSpanExporter');
133
const sqliteExporter = new FilteredSpanExporter(new SqliteSpanExporter(this._sqliteStore));
134
this._spanProcessors.push(new BSP(sqliteExporter));
135
}
136
137
// Trace provider — pass spanProcessors in constructor (SDK v2 API)
138
const tracerProvider = new NodeTracerProvider({
139
resource,
140
spanProcessors: this._spanProcessors,
141
});
142
tracerProvider.register();
143
this._tracer = api.trace.getTracer(this.config.serviceName, this.config.serviceVersion);
144
this._otelApi = api;
145
146
// Log provider — pass processors in constructor (SDK v2 uses 'processors' key)
147
this._logProcessor = new BLRP(logExporter, {
148
scheduledDelayMillis: 1000,
149
maxExportBatchSize: 512,
150
});
151
const loggerProvider = new LoggerProvider({
152
resource,
153
processors: [this._logProcessor],
154
} as ConstructorParameters<typeof LoggerProvider>[0]);
155
apiLogs.logs.setGlobalLoggerProvider(loggerProvider);
156
this._logger = apiLogs.logs.getLogger(this.config.serviceName, this.config.serviceVersion);
157
158
// Metric provider
159
this._metricReader = new PEMR({
160
exporter: metricExporter,
161
exportIntervalMillis: 10000,
162
});
163
const meterProvider = new MeterProvider({
164
resource,
165
readers: [this._metricReader],
166
});
167
api.metrics.setGlobalMeterProvider(meterProvider);
168
this._meter = api.metrics.getMeter(this.config.serviceName, this.config.serviceVersion);
169
170
this._initialized = true;
171
172
// Flush buffered events in batches to avoid blocking the event loop
173
const batch = this._buffer.splice(0);
174
const BATCH_SIZE = 50;
175
for (let i = 0; i < batch.length; i += BATCH_SIZE) {
176
const chunk = batch.slice(i, i + BATCH_SIZE);
177
for (const fn of chunk) {
178
try { fn(); } catch { /* swallow */ }
179
}
180
if (i + BATCH_SIZE < batch.length) {
181
// Yield to event loop between batches
182
await new Promise<void>(resolve => setTimeout(resolve, 0));
183
}
184
}
185
186
} catch (err) {
187
// OTel init failure should never break the extension
188
this._initFailed = true;
189
this._buffer.length = 0; // Discard buffered events on failure
190
this._log('error', `[OTel] Failed to initialize: ${err}`);
191
}
192
}
193
194
private async _createExporters(): Promise<ExporterSet> {
195
const { config } = this;
196
197
// When OTel is enabled only for dbSpanExporter (no OTLP endpoint/file/console configured),
198
// use a noop exporter as the primary span exporter so the pipeline still runs.
199
// If the user also explicitly enabled OTel (via setting or env var), honour their
200
// exporter config and don't switch to noop.
201
const dbOnlyMode = config.dbSpanExporter
202
&& !config.enabledExplicitly
203
&& !config.fileExporterPath
204
&& config.exporterType !== 'console';
205
206
if (config.exporterType === 'file' && config.fileExporterPath) {
207
const { FileSpanExporter, FileLogExporter, FileMetricExporter } = await import('./fileExporters');
208
return {
209
spanExporter: new FileSpanExporter(config.fileExporterPath),
210
logExporter: new FileLogExporter(config.fileExporterPath),
211
metricExporter: new FileMetricExporter(config.fileExporterPath),
212
};
213
}
214
215
if (config.exporterType === 'console' && !dbOnlyMode) {
216
const [traceSDK, logsSDK, metricsSDK] = await Promise.all([
217
import('@opentelemetry/sdk-trace-node'),
218
import('@opentelemetry/sdk-logs'),
219
import('@opentelemetry/sdk-metrics'),
220
]);
221
return {
222
spanExporter: new traceSDK.ConsoleSpanExporter(),
223
logExporter: new logsSDK.ConsoleLogRecordExporter(),
224
metricExporter: new metricsSDK.ConsoleMetricExporter(),
225
};
226
}
227
228
if (config.exporterType === 'otlp-grpc' && !dbOnlyMode) {
229
const [
230
{ OTLPTraceExporter },
231
{ OTLPLogExporter },
232
{ OTLPMetricExporter },
233
] = await Promise.all([
234
import('@opentelemetry/exporter-trace-otlp-grpc'),
235
import('@opentelemetry/exporter-logs-otlp-grpc'),
236
import('@opentelemetry/exporter-metrics-otlp-grpc'),
237
]);
238
const opts = { url: config.otlpEndpoint };
239
return {
240
spanExporter: new OTLPTraceExporter(opts),
241
logExporter: new OTLPLogExporter(opts),
242
metricExporter: new OTLPMetricExporter(opts),
243
};
244
}
245
246
// Default: otlp-http (or noop when in db-only mode)
247
if (dbOnlyMode) {
248
const metricsSDK = await import('@opentelemetry/sdk-metrics');
249
const logsSDK = await import('@opentelemetry/sdk-logs');
250
return {
251
spanExporter: new NoopSpanExporter(),
252
logExporter: new logsSDK.ConsoleLogRecordExporter(),
253
metricExporter: new metricsSDK.ConsoleMetricExporter(),
254
};
255
}
256
const [
257
{ OTLPTraceExporter },
258
{ OTLPLogExporter },
259
{ OTLPMetricExporter },
260
] = await Promise.all([
261
import('@opentelemetry/exporter-trace-otlp-http'),
262
import('@opentelemetry/exporter-logs-otlp-http'),
263
import('@opentelemetry/exporter-metrics-otlp-http'),
264
]);
265
const base = config.otlpEndpoint.replace(/\/$/, '');
266
return {
267
spanExporter: new OTLPTraceExporter({ url: `${base}/v1/traces` }),
268
logExporter: new OTLPLogExporter({ url: `${base}/v1/logs` }),
269
metricExporter: new OTLPMetricExporter({ url: `${base}/v1/metrics` }),
270
};
271
}
272
273
// ── Span API ──
274
275
startSpan(name: string, options?: SpanOptions): ISpanHandle {
276
if (!this._tracer) {
277
if (this._initFailed || this._buffer.length >= NodeOTelService._MAX_BUFFER_SIZE) {
278
return noopSpanHandle;
279
}
280
const handle = new BufferedSpanHandle();
281
this._buffer.push(() => {
282
const real = this._createSpan(name, options);
283
handle.replay(real);
284
});
285
return handle;
286
}
287
return this._createSpan(name, options);
288
}
289
290
async startActiveSpan<T>(name: string, options: SpanOptions, fn: (span: ISpanHandle) => Promise<T>): Promise<T> {
291
if (!this._tracer) {
292
const handle = this.startSpan(name, options);
293
try {
294
return await fn(handle);
295
} finally {
296
handle.end();
297
}
298
}
299
300
const spanOpts = { kind: toOTelSpanKind(options?.kind), attributes: options?.attributes as Attributes };
301
302
// If a parent trace context is provided, create a remote context and start span within it
303
if (options.parentTraceContext && this._otelApi) {
304
const parentCtx = this._createRemoteContext(options.parentTraceContext);
305
return this._tracer.startActiveSpan(
306
name,
307
spanOpts,
308
parentCtx,
309
async (span: Span) => {
310
const handle = new RealSpanHandle(span, this._onDidCompleteSpan, this._onDidEmitSpanEvent, options?.attributes, options.parentTraceContext!.spanId);
311
try {
312
return await fn(handle);
313
} finally {
314
handle.end();
315
}
316
}
317
);
318
}
319
320
const activeParentId = this._getActiveParentSpanId();
321
return this._tracer.startActiveSpan(
322
name,
323
spanOpts,
324
async (span: Span) => {
325
const handle = new RealSpanHandle(span, this._onDidCompleteSpan, this._onDidEmitSpanEvent, options?.attributes, activeParentId);
326
try {
327
return await fn(handle);
328
} finally {
329
handle.end();
330
}
331
}
332
);
333
}
334
335
getActiveTraceContext(): TraceContext | undefined {
336
if (!this._otelApi) {
337
return undefined;
338
}
339
const activeSpan = this._otelApi.trace.getSpan(this._otelApi.context.active());
340
if (!activeSpan) {
341
return undefined;
342
}
343
const ctx = activeSpan.spanContext();
344
if (!ctx.traceId || !ctx.spanId) {
345
return undefined;
346
}
347
return { traceId: ctx.traceId, spanId: ctx.spanId, traceFlags: ctx.traceFlags, traceState: ctx.traceState?.serialize() };
348
}
349
350
// ── Trace Context Store ── (for cross-boundary propagation)
351
352
private static readonly _MAX_TRACE_CONTEXT_STORE_SIZE = 100;
353
private readonly _traceContextStore = new Map<string, TraceContext>();
354
private readonly _traceContextTimers = new Map<string, ReturnType<typeof setTimeout>>();
355
356
storeTraceContext(key: string, context: TraceContext): void {
357
// Evict oldest entry if at capacity
358
if (this._traceContextStore.size >= NodeOTelService._MAX_TRACE_CONTEXT_STORE_SIZE) {
359
const oldestKey = this._traceContextStore.keys().next().value;
360
if (oldestKey !== undefined) {
361
this._clearStoredTraceContext(oldestKey);
362
}
363
}
364
this._traceContextStore.set(key, context);
365
// Auto-cleanup after 5 minutes; tracked for proper disposal
366
const timer = setTimeout(() => this._clearStoredTraceContext(key), 5 * 60 * 1000);
367
this._traceContextTimers.set(key, timer);
368
}
369
370
getStoredTraceContext(key: string): TraceContext | undefined {
371
const ctx = this._traceContextStore.get(key);
372
if (ctx) {
373
this._clearStoredTraceContext(key);
374
}
375
return ctx;
376
}
377
378
private _clearStoredTraceContext(key: string): void {
379
this._traceContextStore.delete(key);
380
const timer = this._traceContextTimers.get(key);
381
if (timer) {
382
clearTimeout(timer);
383
this._traceContextTimers.delete(key);
384
}
385
}
386
387
/**
388
* Creates an OTel Context with a remote span context as parent,
389
* allowing spans created within it to be children of the remote span.
390
*/
391
private _createRemoteContext(tc: TraceContext): Context {
392
const api = this._otelApi!;
393
const remoteSpanContext: SpanContext = {
394
traceId: tc.traceId,
395
spanId: tc.spanId,
396
traceFlags: 1, // SAMPLED
397
isRemote: true,
398
};
399
const remoteCtx = api.trace.setSpanContext(api.context.active(), remoteSpanContext);
400
return remoteCtx;
401
}
402
403
async runWithTraceContext<T>(traceContext: TraceContext, fn: () => Promise<T>): Promise<T> {
404
if (!this._otelApi) {
405
return fn();
406
}
407
const parentCtx = this._createRemoteContext(traceContext);
408
return this._otelApi.context.with(parentCtx, fn);
409
}
410
411
private _createSpan(name: string, options?: SpanOptions): ISpanHandle {
412
const spanOpts = { kind: toOTelSpanKind(options?.kind), attributes: options?.attributes as Attributes };
413
414
// If an explicit parent trace context is provided, create the span as its child.
415
// This ensures correct parent-child hierarchy even when async context propagation
416
// doesn't carry the active span (common in the VS Code extension host).
417
if (options?.parentTraceContext && this._otelApi) {
418
const parentCtx = this._createRemoteContext(options.parentTraceContext);
419
const span = this._tracer!.startSpan(name, spanOpts, parentCtx);
420
return new RealSpanHandle(span, this._onDidCompleteSpan, this._onDidEmitSpanEvent, options?.attributes, options.parentTraceContext.spanId);
421
}
422
423
const parentSpanId = this._getActiveParentSpanId();
424
const span = this._tracer!.startSpan(name, spanOpts);
425
return new RealSpanHandle(span, this._onDidCompleteSpan, this._onDidEmitSpanEvent, options?.attributes, parentSpanId);
426
}
427
428
private _getActiveParentSpanId(): string | undefined {
429
if (!this._otelApi) { return undefined; }
430
const activeSpan = this._otelApi.trace.getSpan(this._otelApi.context.active());
431
if (!activeSpan) { return undefined; }
432
const ctx = activeSpan.spanContext();
433
return ctx.spanId || undefined;
434
}
435
436
// ── Metric API ──
437
438
/**
439
* Explicit bucket boundaries per the OTel GenAI semantic conventions.
440
* @see https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-metrics/
441
*/
442
private static readonly _histogramOptions: ReadonlyMap<string, MetricOptions> = new Map([
443
['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] } }],
444
['gen_ai.client.token.usage', { advice: { explicitBucketBoundaries: [1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864] } }],
445
]);
446
447
private readonly _histograms = new Map<string, ReturnType<Meter['createHistogram']>>();
448
private readonly _counters = new Map<string, ReturnType<Meter['createCounter']>>();
449
450
recordMetric(name: string, value: number, attributes?: Record<string, string | number | boolean>): void {
451
if (!this._meter) {
452
if (!this._initFailed && this._buffer.length < NodeOTelService._MAX_BUFFER_SIZE) {
453
this._buffer.push(() => this.recordMetric(name, value, attributes));
454
}
455
return;
456
}
457
let histogram = this._histograms.get(name);
458
if (!histogram) {
459
histogram = this._meter.createHistogram(name, NodeOTelService._histogramOptions.get(name));
460
this._histograms.set(name, histogram);
461
}
462
histogram.record(value, attributes);
463
}
464
465
incrementCounter(name: string, value = 1, attributes?: Record<string, string | number | boolean>): void {
466
if (!this._meter) {
467
if (!this._initFailed && this._buffer.length < NodeOTelService._MAX_BUFFER_SIZE) {
468
this._buffer.push(() => this.incrementCounter(name, value, attributes));
469
}
470
return;
471
}
472
let counter = this._counters.get(name);
473
if (!counter) {
474
counter = this._meter.createCounter(name);
475
this._counters.set(name, counter);
476
}
477
counter.add(value, attributes);
478
}
479
480
// ── Log API ──
481
482
private _logEmitCount = 0;
483
484
emitLogRecord(body: string, attributes?: Record<string, unknown>): void {
485
if (!this._logger) {
486
if (!this._initFailed && this._buffer.length < NodeOTelService._MAX_BUFFER_SIZE) {
487
this._buffer.push(() => this.emitLogRecord(body, attributes));
488
}
489
return;
490
}
491
// Pass the active context so the log record inherits the trace ID from
492
// the current span (if any). Without this, logs emitted inside a span
493
// created via startSpan() (rather than startActiveSpan()) lack trace context.
494
const ctx = this._otelApi?.context.active();
495
this._logger.emit({ body, attributes: attributes as AnyValueMap, ...(ctx ? { context: ctx } : {}) });
496
this._logEmitCount++;
497
if (this._logEmitCount === 1) {
498
this._log('info', `[OTel] First log record emitted: ${body}`);
499
}
500
}
501
502
// ── Lifecycle ──
503
504
async flush(): Promise<void> {
505
await Promise.all([
506
...this._spanProcessors.map(p => p.forceFlush()),
507
this._logProcessor?.forceFlush(),
508
this._metricReader?.forceFlush(),
509
]);
510
}
511
512
async shutdown(): Promise<void> {
513
try {
514
// Clear all trace context timers
515
for (const timer of this._traceContextTimers.values()) {
516
clearTimeout(timer);
517
}
518
this._traceContextTimers.clear();
519
this._traceContextStore.clear();
520
521
await this.flush();
522
await Promise.all([
523
...this._spanProcessors.map(p => p.shutdown()),
524
this._logProcessor?.shutdown(),
525
this._metricReader?.shutdown(),
526
]);
527
const api = await import('@opentelemetry/api');
528
const apiLogs = await import('@opentelemetry/api-logs');
529
api.trace.disable();
530
api.metrics.disable();
531
apiLogs.logs.disable();
532
} catch {
533
// Swallow shutdown errors
534
} finally {
535
this._onDidCompleteSpan.dispose();
536
this._onDidEmitSpanEvent.dispose();
537
}
538
}
539
}
540
541
// ── Span Handle Implementations ──
542
543
class RealSpanHandle implements ISpanHandle {
544
private readonly _attributes: Record<string, string | number | boolean | string[]> = {};
545
private readonly _events: ISpanEventRecord[] = [];
546
private _statusCode = SpanStatusCode.UNSET;
547
private _statusMessage?: string;
548
private readonly _startTime = Date.now();
549
private _ended = false;
550
private readonly _parentSpanId: string | undefined;
551
552
constructor(
553
private readonly _span: Span,
554
private readonly _onDidCompleteSpan: Emitter<ICompletedSpanData>,
555
private readonly _onDidEmitSpanEvent: Emitter<ISpanEventData>,
556
initialAttributes?: Record<string, string | number | boolean | string[]>,
557
parentSpanId?: string,
558
) {
559
this._parentSpanId = parentSpanId;
560
if (initialAttributes) {
561
for (const k in initialAttributes) {
562
if (Object.prototype.hasOwnProperty.call(initialAttributes, k)) {
563
this._attributes[k] = initialAttributes[k];
564
}
565
}
566
}
567
}
568
569
setAttribute(key: string, value: string | number | boolean | string[]): void {
570
this._attributes[key] = value;
571
this._span.setAttribute(key, value);
572
}
573
574
setAttributes(attrs: Record<string, string | number | boolean | string[] | undefined>): void {
575
for (const k in attrs) {
576
if (Object.prototype.hasOwnProperty.call(attrs, k)) {
577
const v = attrs[k];
578
if (v !== undefined) {
579
this._attributes[k] = v;
580
this._span.setAttribute(k, v);
581
}
582
}
583
}
584
}
585
586
setStatus(code: SpanStatusCode, message?: string): void {
587
this._statusCode = code;
588
this._statusMessage = message;
589
const otelCode = code === SpanStatusCode.OK ? 1 : code === SpanStatusCode.ERROR ? 2 : 0;
590
this._span.setStatus({ code: otelCode, message });
591
}
592
593
recordException(error: unknown): void {
594
if (error instanceof Error) {
595
this._span.recordException(error);
596
} else {
597
this._span.recordException(new Error(String(error)));
598
}
599
}
600
601
addEvent(name: string, attributes?: Record<string, string | number | boolean | string[]>): void {
602
const timestamp = Date.now();
603
const record: ISpanEventRecord = { name, timestamp, attributes };
604
this._events.push(record);
605
this._span.addEvent(name, attributes);
606
607
// Fire real-time span event notification (guard against disposed emitter)
608
try {
609
const ctx = this._span.spanContext();
610
this._onDidEmitSpanEvent.fire({
611
spanId: ctx.spanId,
612
traceId: ctx.traceId,
613
parentSpanId: this._parentSpanId,
614
eventName: name,
615
attributes: attributes ?? {},
616
timestamp,
617
});
618
} catch { /* emitter may be disposed after shutdown */ }
619
}
620
621
getSpanContext(): TraceContext | undefined {
622
const ctx = this._span.spanContext();
623
return ctx.traceId && ctx.spanId
624
? { traceId: ctx.traceId, spanId: ctx.spanId, traceFlags: ctx.traceFlags, traceState: ctx.traceState?.serialize() }
625
: undefined;
626
}
627
628
end(): void {
629
if (this._ended) {
630
return;
631
}
632
this._ended = true;
633
this._span.end();
634
635
try {
636
const ctx = this._span.spanContext();
637
this._onDidCompleteSpan.fire({
638
name: (this._span as unknown as { name?: string }).name ?? '',
639
spanId: ctx.spanId,
640
traceId: ctx.traceId,
641
parentSpanId: this._parentSpanId,
642
startTime: this._startTime,
643
endTime: Date.now(),
644
status: { code: this._statusCode, message: this._statusMessage },
645
attributes: { ...this._attributes },
646
events: [...this._events],
647
});
648
} catch { /* emitter may be disposed after shutdown */ }
649
}
650
}
651
652
/**
653
* Buffers span operations until the SDK is initialized, then replays them.
654
*/
655
class BufferedSpanHandle implements ISpanHandle {
656
private static readonly _MAX_OPS = 200;
657
private readonly _ops: Array<(span: ISpanHandle) => void> = [];
658
private _real: ISpanHandle | undefined;
659
660
setAttribute(key: string, value: string | number | boolean | string[]): void {
661
if (this._real) { this._real.setAttribute(key, value); return; }
662
if (this._ops.length < BufferedSpanHandle._MAX_OPS) {
663
this._ops.push(s => s.setAttribute(key, value));
664
}
665
}
666
667
setAttributes(attrs: Record<string, string | number | boolean | string[] | undefined>): void {
668
if (this._real) { this._real.setAttributes(attrs); return; }
669
if (this._ops.length < BufferedSpanHandle._MAX_OPS) {
670
this._ops.push(s => s.setAttributes(attrs));
671
}
672
}
673
674
setStatus(code: SpanStatusCode, message?: string): void {
675
if (this._real) { this._real.setStatus(code, message); return; }
676
if (this._ops.length < BufferedSpanHandle._MAX_OPS) {
677
this._ops.push(s => s.setStatus(code, message));
678
}
679
}
680
681
recordException(error: unknown): void {
682
if (this._real) { this._real.recordException(error); return; }
683
if (this._ops.length < BufferedSpanHandle._MAX_OPS) {
684
this._ops.push(s => s.recordException(error));
685
}
686
}
687
688
addEvent(name: string, attributes?: Record<string, string | number | boolean | string[]>): void {
689
if (this._real) { this._real.addEvent(name, attributes); return; }
690
if (this._ops.length < BufferedSpanHandle._MAX_OPS) {
691
this._ops.push(s => s.addEvent(name, attributes));
692
}
693
}
694
695
getSpanContext(): TraceContext | undefined {
696
return this._real?.getSpanContext();
697
}
698
699
end(): void {
700
if (this._real) { this._real.end(); return; }
701
// Always buffer end() regardless of cap — it's critical for span lifecycle
702
this._ops.push(s => s.end());
703
}
704
705
replay(real: ISpanHandle): void {
706
this._real = real;
707
for (const op of this._ops) {
708
op(real);
709
}
710
this._ops.length = 0;
711
}
712
}
713
714
function toOTelSpanKind(kind: SpanKind | undefined): number {
715
switch (kind) {
716
case SpanKind.CLIENT: return 2; // OTel SpanKind.CLIENT
717
case SpanKind.INTERNAL: return 0; // OTel SpanKind.INTERNAL
718
default: return 0; // INTERNAL
719
}
720
}
721
722
/**
723
* Operation names that follow the GenAI semantic conventions and should be
724
* exported to the user's OTLP endpoint. Debug-panel-only spans (e.g.,
725
* `content_event`, `user_message`) are excluded from external export but
726
* still visible in the in-memory span store for the Agent Debug Log panel.
727
*/
728
const EXPORTABLE_OPERATION_NAMES: ReadonlySet<string> = new Set([
729
GenAiOperationName.CHAT,
730
GenAiOperationName.INVOKE_AGENT,
731
GenAiOperationName.EXECUTE_TOOL,
732
GenAiOperationName.EMBEDDINGS,
733
GenAiOperationName.EXECUTE_HOOK,
734
]);
735
736
/**
737
* Wraps a SpanExporter to log diagnostic info about export results.
738
* Logs once on first successful export (info), and on every failure (warn).
739
*
740
* Also filters out debug-panel-only spans (those with non-standard
741
* `gen_ai.operation.name` values) so they don't appear in the user's
742
* configured OTel collector.
743
*/
744
class DiagnosticSpanExporter implements SpanExporter {
745
private _firstSuccessLogged = false;
746
private _lastFailureLogTime = 0;
747
private static readonly _FAILURE_LOG_INTERVAL_MS = 60_000;
748
private readonly _inner: SpanExporter;
749
private readonly _exporterType: string;
750
private readonly _log: OTelLogFn;
751
752
constructor(inner: SpanExporter, exporterType: string, logFn: OTelLogFn) {
753
this._inner = inner;
754
this._exporterType = exporterType;
755
this._log = logFn;
756
}
757
758
export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void {
759
// Filter out debug-panel-only spans — only export standard GenAI operations
760
const exportable = spans.filter(span => {
761
const opName = span.attributes[GenAiAttr.OPERATION_NAME];
762
// If no operation name set, export it (safety: don't drop unknown spans)
763
if (opName === undefined) {
764
return true;
765
}
766
return EXPORTABLE_OPERATION_NAMES.has(String(opName));
767
});
768
if (exportable.length === 0) {
769
resultCallback({ code: 0 }); // ExportResultCode.SUCCESS
770
return;
771
}
772
this._inner.export(exportable, result => {
773
// ExportResultCode.SUCCESS === 0
774
if (result.code === 0) {
775
if (!this._firstSuccessLogged) {
776
this._firstSuccessLogged = true;
777
this._log('info', `[OTel] First span batch exported successfully via ${this._exporterType} (${exportable.length} spans)`);
778
}
779
} else {
780
// Rate-limit failure logging to avoid flooding stdout
781
const now = Date.now();
782
if (now - this._lastFailureLogTime >= DiagnosticSpanExporter._FAILURE_LOG_INTERVAL_MS) {
783
this._lastFailureLogTime = now;
784
this._log('warn', `[OTel] Span export failed via ${this._exporterType}: ${result.error ?? 'unknown error'}`);
785
}
786
}
787
resultCallback(result);
788
});
789
}
790
791
shutdown(): Promise<void> {
792
return this._inner.shutdown?.() ?? Promise.resolve();
793
}
794
795
forceFlush(): Promise<void> {
796
return this._inner.forceFlush?.() ?? Promise.resolve();
797
}
798
}
799
800
/**
801
* Wraps a SpanExporter to filter out debug-panel-only spans,
802
* passing only standard GenAI operations through to the inner exporter.
803
*/
804
class FilteredSpanExporter implements SpanExporter {
805
constructor(private readonly _inner: SpanExporter) { }
806
807
export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void {
808
const filtered = spans.filter(span => {
809
const opName = span.attributes[GenAiAttr.OPERATION_NAME];
810
return opName === undefined || EXPORTABLE_OPERATION_NAMES.has(String(opName));
811
});
812
if (filtered.length === 0) {
813
resultCallback({ code: 0 });
814
return;
815
}
816
this._inner.export(filtered, resultCallback);
817
}
818
819
shutdown(): Promise<void> { return this._inner.shutdown?.() ?? Promise.resolve(); }
820
forceFlush(): Promise<void> { return this._inner.forceFlush?.() ?? Promise.resolve(); }
821
}
822
823
/**
824
* No-op span exporter for db-only mode — the SQLite exporter handles persistence,
825
* this satisfies the OTel pipeline requirement for a primary exporter.
826
*/
827
class NoopSpanExporter implements SpanExporter {
828
export(_spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void {
829
resultCallback({ code: 0 }); // ExportResultCode.SUCCESS
830
}
831
shutdown(): Promise<void> { return Promise.resolve(); }
832
forceFlush(): Promise<void> { return Promise.resolve(); }
833
}
834
835