From e9819e262c30bcef0a2bcfbe1c4abcf4308ff1e8 Mon Sep 17 00:00:00 2001 From: Patrick McGannon Date: Thu, 16 Apr 2026 11:04:51 -0600 Subject: [PATCH 1/2] Aligning telemetry with Copilot CLI with cached tokens. --- .../byok/vscode-node/anthropicProvider.ts | 16 ++++--- .../byok/vscode-node/geminiNativeProvider.ts | 13 ++++-- src/extension/otel/vscode-node/otelContrib.ts | 6 ++- src/extension/prompt/node/chatMLFetcher.ts | 17 +++++-- src/extension/test/vscode-node/services.ts | 2 + src/platform/endpoint/node/messagesApi.ts | 3 +- src/platform/networking/common/openai.ts | 1 + src/platform/otel/common/genAiAttributes.ts | 2 + src/platform/otel/common/genAiMetrics.ts | 2 +- .../common/test/agentTraceHierarchy.spec.ts | 18 +++++++- .../otel/common/test/genAiMetrics.spec.ts | 34 ++++++++++++++ src/platform/otel/node/fileExporters.ts | 33 ++++++++++++- .../otel/node/test/fileExporters.spec.ts | 46 +++++++++++++++++++ 13 files changed, 173 insertions(+), 20 deletions(-) diff --git a/src/extension/byok/vscode-node/anthropicProvider.ts b/src/extension/byok/vscode-node/anthropicProvider.ts index 4427956e33..158ebd6cb5 100644 --- a/src/extension/byok/vscode-node/anthropicProvider.ts +++ b/src/extension/byok/vscode-node/anthropicProvider.ts @@ -329,9 +329,12 @@ export class AnthropicLMProvider extends AbstractLanguageModelChatProvider { otelSpan.setAttributes({ [GenAiAttr.USAGE_INPUT_TOKENS]: result.usage.prompt_tokens ?? 0, [GenAiAttr.USAGE_OUTPUT_TOKENS]: result.usage.completion_tokens ?? 0, - ...(result.usage.prompt_tokens_details?.cached_tokens + ...(result.usage.prompt_tokens_details?.cached_tokens !== undefined ? { [GenAiAttr.USAGE_CACHE_READ_INPUT_TOKENS]: result.usage.prompt_tokens_details.cached_tokens } : {}), + ...(result.usage.prompt_tokens_details?.cache_creation_input_tokens !== undefined + ? { [GenAiAttr.USAGE_CACHE_CREATION_INPUT_TOKENS]: result.usage.prompt_tokens_details.cache_creation_input_tokens } + : {}), [GenAiAttr.RESPONSE_MODEL]: model.id, [GenAiAttr.RESPONSE_ID]: requestId, [GenAiAttr.RESPONSE_FINISH_REASONS]: ['stop'], @@ -361,8 +364,10 @@ export class AnthropicLMProvider extends AbstractLanguageModelChatProvider { const durationSec = (Date.now() - issuedTime) / 1000; const metricAttrs = { operationName: GenAiOperationName.CHAT, providerName: 'anthropic', requestModel: model.id, responseModel: model.id }; GenAiMetrics.recordOperationDuration(this._otelService, durationSec, metricAttrs); - if (result.usage.prompt_tokens) { GenAiMetrics.recordTokenUsage(this._otelService, result.usage.prompt_tokens, 'input', metricAttrs); } - if (result.usage.completion_tokens) { GenAiMetrics.recordTokenUsage(this._otelService, result.usage.completion_tokens, 'output', metricAttrs); } + if (result.usage.prompt_tokens !== undefined) { GenAiMetrics.recordTokenUsage(this._otelService, result.usage.prompt_tokens, 'input', metricAttrs); } + if (result.usage.completion_tokens !== undefined) { GenAiMetrics.recordTokenUsage(this._otelService, result.usage.completion_tokens, 'output', metricAttrs); } + if (result.usage.prompt_tokens_details?.cached_tokens !== undefined) { GenAiMetrics.recordTokenUsage(this._otelService, result.usage.prompt_tokens_details.cached_tokens, 'cache_read_input', metricAttrs); } + if (result.usage.prompt_tokens_details?.cache_creation_input_tokens !== undefined) { GenAiMetrics.recordTokenUsage(this._otelService, result.usage.prompt_tokens_details.cache_creation_input_tokens, 'cache_creation_input', metricAttrs); } if (result.ttft) { GenAiMetrics.recordTimeToFirstToken(this._otelService, model.id, result.ttft / 1000); } } @@ -768,11 +773,10 @@ export class AnthropicLMProvider extends AbstractLanguageModelChatProvider { completion_tokens: -1, prompt_tokens: chunk.message.usage.input_tokens + (chunk.message.usage.cache_creation_input_tokens ?? 0) + (chunk.message.usage.cache_read_input_tokens ?? 0), total_tokens: -1, - // Cast needed: Anthropic returns cache_creation_input_tokens which APIUsage.prompt_tokens_details doesn't define prompt_tokens_details: { cached_tokens: chunk.message.usage.cache_read_input_tokens ?? 0, - cache_creation_input_tokens: chunk.message.usage.cache_creation_input_tokens - } as any + cache_creation_input_tokens: chunk.message.usage.cache_creation_input_tokens ?? undefined + } }; } else if (usage && chunk.type === 'message_delta') { if (chunk.usage.output_tokens) { diff --git a/src/extension/byok/vscode-node/geminiNativeProvider.ts b/src/extension/byok/vscode-node/geminiNativeProvider.ts index 6afba73d80..960f356256 100644 --- a/src/extension/byok/vscode-node/geminiNativeProvider.ts +++ b/src/extension/byok/vscode-node/geminiNativeProvider.ts @@ -194,9 +194,12 @@ export class GeminiNativeBYOKLMProvider extends AbstractLanguageModelChatProvide otelSpan.setAttributes({ [GenAiAttr.USAGE_INPUT_TOKENS]: result.usage.prompt_tokens ?? 0, [GenAiAttr.USAGE_OUTPUT_TOKENS]: result.usage.completion_tokens ?? 0, - ...(result.usage.prompt_tokens_details?.cached_tokens + ...(result.usage.prompt_tokens_details?.cached_tokens !== undefined ? { [GenAiAttr.USAGE_CACHE_READ_INPUT_TOKENS]: result.usage.prompt_tokens_details.cached_tokens } : {}), + ...(result.usage.prompt_tokens_details?.cache_creation_input_tokens !== undefined + ? { [GenAiAttr.USAGE_CACHE_CREATION_INPUT_TOKENS]: result.usage.prompt_tokens_details.cache_creation_input_tokens } + : {}), [GenAiAttr.RESPONSE_MODEL]: model.id, [GenAiAttr.RESPONSE_ID]: requestId, [GenAiAttr.RESPONSE_FINISH_REASONS]: ['stop'], @@ -226,8 +229,10 @@ export class GeminiNativeBYOKLMProvider extends AbstractLanguageModelChatProvide const durationSec = (Date.now() - issuedTime) / 1000; const metricAttrs = { operationName: GenAiOperationName.CHAT, providerName: 'gemini', requestModel: model.id, responseModel: model.id }; GenAiMetrics.recordOperationDuration(this._otelService, durationSec, metricAttrs); - if (result.usage.prompt_tokens) { GenAiMetrics.recordTokenUsage(this._otelService, result.usage.prompt_tokens, 'input', metricAttrs); } - if (result.usage.completion_tokens) { GenAiMetrics.recordTokenUsage(this._otelService, result.usage.completion_tokens, 'output', metricAttrs); } + if (result.usage.prompt_tokens !== undefined) { GenAiMetrics.recordTokenUsage(this._otelService, result.usage.prompt_tokens, 'input', metricAttrs); } + if (result.usage.completion_tokens !== undefined) { GenAiMetrics.recordTokenUsage(this._otelService, result.usage.completion_tokens, 'output', metricAttrs); } + if (result.usage.prompt_tokens_details?.cached_tokens !== undefined) { GenAiMetrics.recordTokenUsage(this._otelService, result.usage.prompt_tokens_details.cached_tokens, 'cache_read_input', metricAttrs); } + if (result.usage.prompt_tokens_details?.cache_creation_input_tokens !== undefined) { GenAiMetrics.recordTokenUsage(this._otelService, result.usage.prompt_tokens_details.cache_creation_input_tokens, 'cache_creation_input', metricAttrs); } if (result.ttft) { GenAiMetrics.recordTimeToFirstToken(this._otelService, model.id, result.ttft / 1000); } } @@ -508,4 +513,4 @@ export class GeminiNativeBYOKLMProvider extends AbstractLanguageModelChatProvide throw error; } } -} \ No newline at end of file +} diff --git a/src/extension/otel/vscode-node/otelContrib.ts b/src/extension/otel/vscode-node/otelContrib.ts index 070c3c8fca..6ef3ebd5f5 100644 --- a/src/extension/otel/vscode-node/otelContrib.ts +++ b/src/extension/otel/vscode-node/otelContrib.ts @@ -27,7 +27,11 @@ export class OTelContrib extends Disposable implements IExtensionContribution { ) { super(); if (this._otelService.config.enabled) { - this._logService.info(`[OTel] Instrumentation enabled — exporter=${this._otelService.config.exporterType} endpoint=${this._otelService.config.otlpEndpoint} captureContent=${this._otelService.config.captureContent}`); + const config = this._otelService.config; + const destination = config.exporterType === 'file' + ? `outfile=${config.fileExporterPath ?? ''}` + : `endpoint=${config.otlpEndpoint}`; + this._logService.info(`[OTel] Instrumentation enabled — enabledVia=${config.enabledVia} exporter=${config.exporterType} ${destination} captureContent=${config.captureContent} dbSpanExporter=${config.dbSpanExporter}`); } else { this._logService.trace('[OTel] Instrumentation disabled'); } diff --git a/src/extension/prompt/node/chatMLFetcher.ts b/src/extension/prompt/node/chatMLFetcher.ts index fc2e21895c..889c7fded6 100644 --- a/src/extension/prompt/node/chatMLFetcher.ts +++ b/src/extension/prompt/node/chatMLFetcher.ts @@ -368,12 +368,18 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { requestModel: chatEndpoint.model, responseModel: result.resolvedModel, }; - if (result.usage.prompt_tokens) { + if (result.usage.prompt_tokens !== undefined) { GenAiMetrics.recordTokenUsage(this._otelService, result.usage.prompt_tokens, 'input', metricAttrs); } - if (result.usage.completion_tokens) { + if (result.usage.completion_tokens !== undefined) { GenAiMetrics.recordTokenUsage(this._otelService, result.usage.completion_tokens, 'output', metricAttrs); } + if (result.usage.prompt_tokens_details?.cached_tokens !== undefined) { + GenAiMetrics.recordTokenUsage(this._otelService, result.usage.prompt_tokens_details.cached_tokens, 'cache_read_input', metricAttrs); + } + if (result.usage.prompt_tokens_details?.cache_creation_input_tokens !== undefined) { + GenAiMetrics.recordTokenUsage(this._otelService, result.usage.prompt_tokens_details.cache_creation_input_tokens, 'cache_creation_input', metricAttrs); + } // Set token usage and response details on the chat span before ending it otelInferenceSpan?.setAttributes({ @@ -382,12 +388,15 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { [GenAiAttr.RESPONSE_MODEL]: result.resolvedModel ?? chatEndpoint.model, [GenAiAttr.RESPONSE_ID]: result.requestId, [GenAiAttr.RESPONSE_FINISH_REASONS]: ['stop'], - ...(result.usage.prompt_tokens_details?.cached_tokens + ...(result.usage.prompt_tokens_details?.cached_tokens !== undefined ? { [GenAiAttr.USAGE_CACHE_READ_INPUT_TOKENS]: result.usage.prompt_tokens_details.cached_tokens } : {}), + ...(result.usage.prompt_tokens_details?.cache_creation_input_tokens !== undefined + ? { [GenAiAttr.USAGE_CACHE_CREATION_INPUT_TOKENS]: result.usage.prompt_tokens_details.cache_creation_input_tokens } + : {}), [CopilotChatAttr.TIME_TO_FIRST_TOKEN]: timeToFirstToken, ...(result.serverRequestId ? { [CopilotChatAttr.SERVER_REQUEST_ID]: result.serverRequestId } : {}), - ...(result.usage.completion_tokens_details?.reasoning_tokens + ...(result.usage.completion_tokens_details?.reasoning_tokens !== undefined ? { [GenAiAttr.USAGE_REASONING_TOKENS]: result.usage.completion_tokens_details.reasoning_tokens } : {}), }); diff --git a/src/extension/test/vscode-node/services.ts b/src/extension/test/vscode-node/services.ts index 41d1b981d9..6ba52e7a26 100644 --- a/src/extension/test/vscode-node/services.ts +++ b/src/extension/test/vscode-node/services.ts @@ -5,6 +5,7 @@ import { CopilotTokenStore, ICopilotTokenStore } from '../../../platform/authentication/common/copilotTokenStore'; import { BlockedExtensionService, IBlockedExtensionService } from '../../../platform/chat/common/blockedExtensionService'; +import { IChatDebugFileLoggerService, NullChatDebugFileLoggerService } from '../../../platform/chat/common/chatDebugFileLoggerService'; import { IChatMLFetcher } from '../../../platform/chat/common/chatMLFetcher'; import { IChatSessionService } from '../../../platform/chat/common/chatSessionService'; import { TestChatSessionService } from '../../../platform/chat/test/common/testChatSessionService'; @@ -158,6 +159,7 @@ export function createExtensionTestingServices(): TestingServiceCollection { testingServiceCollection.define(IExtensionsService, new SyncDescriptor(VSCodeExtensionsService)); testingServiceCollection.define(IPowerService, new SyncDescriptor(NullPowerService)); testingServiceCollection.define(IChatMLFetcher, new SyncDescriptor(ChatMLFetcherImpl)); + testingServiceCollection.define(IChatDebugFileLoggerService, new SyncDescriptor(NullChatDebugFileLoggerService)); testingServiceCollection.define(IChatWebSocketManager, new SyncDescriptor(NullChatWebSocketManager)); testingServiceCollection.define(IImageService, nullImageService); testingServiceCollection.define(ITabsAndEditorsService, new SyncDescriptor(TabsAndEditorsServiceImpl)); diff --git a/src/platform/endpoint/node/messagesApi.ts b/src/platform/endpoint/node/messagesApi.ts index 427850dccf..e633c8ef57 100644 --- a/src/platform/endpoint/node/messagesApi.ts +++ b/src/platform/endpoint/node/messagesApi.ts @@ -1042,6 +1042,7 @@ export class AnthropicMessagesProcessor { total_tokens: computedPromptTokens + this.outputTokens, prompt_tokens_details: { cached_tokens: this.cacheReadTokens, + cache_creation_input_tokens: this.cacheCreationTokens, }, completion_tokens_details: { reasoning_tokens: 0, @@ -1085,5 +1086,3 @@ export class AnthropicMessagesProcessor { } } } - - diff --git a/src/platform/networking/common/openai.ts b/src/platform/networking/common/openai.ts index 22f404c028..66abbd60f6 100644 --- a/src/platform/networking/common/openai.ts +++ b/src/platform/networking/common/openai.ts @@ -42,6 +42,7 @@ export interface APIUsage { */ prompt_tokens_details?: { cached_tokens: number; + cache_creation_input_tokens?: number; }; /** * Breakdown of tokens used in a completion. diff --git a/src/platform/otel/common/genAiAttributes.ts b/src/platform/otel/common/genAiAttributes.ts index a92c7da898..e5a1cd98af 100644 --- a/src/platform/otel/common/genAiAttributes.ts +++ b/src/platform/otel/common/genAiAttributes.ts @@ -27,6 +27,8 @@ export const GenAiProviderName = { export const GenAiTokenType = { INPUT: 'input', OUTPUT: 'output', + CACHE_READ_INPUT: 'cache_read_input', + CACHE_CREATION_INPUT: 'cache_creation_input', } as const; // gen_ai.tool.type values diff --git a/src/platform/otel/common/genAiMetrics.ts b/src/platform/otel/common/genAiMetrics.ts index 2b589eb6c2..6eb3618c00 100644 --- a/src/platform/otel/common/genAiMetrics.ts +++ b/src/platform/otel/common/genAiMetrics.ts @@ -41,7 +41,7 @@ export class GenAiMetrics { static recordTokenUsage( otel: IOTelService, tokenCount: number, - tokenType: 'input' | 'output', + tokenType: 'input' | 'output' | 'cache_read_input' | 'cache_creation_input', attrs: { operationName: string; providerName: string; diff --git a/src/platform/otel/common/test/agentTraceHierarchy.spec.ts b/src/platform/otel/common/test/agentTraceHierarchy.spec.ts index c8decd6052..f46a72f2e8 100644 --- a/src/platform/otel/common/test/agentTraceHierarchy.spec.ts +++ b/src/platform/otel/common/test/agentTraceHierarchy.spec.ts @@ -214,14 +214,30 @@ describe('Agent Trace Hierarchy', () => { providerName: GenAiProviderName.GITHUB, requestModel: 'gpt-4o', }); + GenAiMetrics.recordTokenUsage(otel, 300, 'cache_read_input', { + operationName: GenAiOperationName.CHAT, + providerName: GenAiProviderName.GITHUB, + requestModel: 'gpt-4o', + }); + GenAiMetrics.recordTokenUsage(otel, 125, 'cache_creation_input', { + operationName: GenAiOperationName.CHAT, + providerName: GenAiProviderName.GITHUB, + requestModel: 'gpt-4o', + }); - expect(otel.metrics).toHaveLength(3); + expect(otel.metrics).toHaveLength(5); expect(otel.metrics[0].name).toBe('gen_ai.client.operation.duration'); expect(otel.metrics[0].value).toBe(3.5); expect(otel.metrics[1].name).toBe('gen_ai.client.token.usage'); expect(otel.metrics[1].value).toBe(1500); expect(otel.metrics[2].name).toBe('gen_ai.client.token.usage'); expect(otel.metrics[2].value).toBe(250); + expect(otel.metrics[3].name).toBe('gen_ai.client.token.usage'); + expect(otel.metrics[3].value).toBe(300); + expect(otel.metrics[3].attributes?.[GenAiAttr.TOKEN_TYPE]).toBe('cache_read_input'); + expect(otel.metrics[4].name).toBe('gen_ai.client.token.usage'); + expect(otel.metrics[4].value).toBe(125); + expect(otel.metrics[4].attributes?.[GenAiAttr.TOKEN_TYPE]).toBe('cache_creation_input'); }); it('records edit acceptance and survival metrics', () => { diff --git a/src/platform/otel/common/test/genAiMetrics.spec.ts b/src/platform/otel/common/test/genAiMetrics.spec.ts index 1f1a9d254e..ee243c5c7d 100644 --- a/src/platform/otel/common/test/genAiMetrics.spec.ts +++ b/src/platform/otel/common/test/genAiMetrics.spec.ts @@ -72,6 +72,40 @@ describe('GenAiMetrics', () => { }); }); + it('recordTokenUsage supports cached input tokens', () => { + const otel = createMockOTelService(); + + GenAiMetrics.recordTokenUsage(otel, 250, 'cache_read_input', { + operationName: GenAiOperationName.CHAT, + providerName: GenAiProviderName.OPENAI, + requestModel: 'gpt-4o', + }); + + expect(otel.recordMetric).toHaveBeenCalledWith('gen_ai.client.token.usage', 250, { + [GenAiAttr.OPERATION_NAME]: 'chat', + [GenAiAttr.PROVIDER_NAME]: 'openai', + [GenAiAttr.TOKEN_TYPE]: GenAiTokenType.CACHE_READ_INPUT, + [GenAiAttr.REQUEST_MODEL]: 'gpt-4o', + }); + }); + + it('recordTokenUsage supports cache creation input tokens', () => { + const otel = createMockOTelService(); + + GenAiMetrics.recordTokenUsage(otel, 125, 'cache_creation_input', { + operationName: GenAiOperationName.CHAT, + providerName: GenAiProviderName.OPENAI, + requestModel: 'gpt-4o', + }); + + expect(otel.recordMetric).toHaveBeenCalledWith('gen_ai.client.token.usage', 125, { + [GenAiAttr.OPERATION_NAME]: 'chat', + [GenAiAttr.PROVIDER_NAME]: 'openai', + [GenAiAttr.TOKEN_TYPE]: GenAiTokenType.CACHE_CREATION_INPUT, + [GenAiAttr.REQUEST_MODEL]: 'gpt-4o', + }); + }); + it('recordToolCallCount increments counter', () => { const otel = createMockOTelService(); diff --git a/src/platform/otel/node/fileExporters.ts b/src/platform/otel/node/fileExporters.ts index d49d61c2a3..8b5a6da029 100644 --- a/src/platform/otel/node/fileExporters.ts +++ b/src/platform/otel/node/fileExporters.ts @@ -17,6 +17,37 @@ function safeStringify(data: unknown): string { } } +function serializeSpan(span: ReadableSpan): Record { + if (typeof span.spanContext !== 'function') { + return span as unknown as Record; + } + const spanContext = span.spanContext(); + return { + resource: { + attributes: span.resource.attributes, + }, + instrumentationScope: span.instrumentationLibrary, + traceId: spanContext.traceId, + spanId: spanContext.spanId, + traceFlags: spanContext.traceFlags, + traceState: spanContext.traceState?.serialize(), + parentSpanId: span.parentSpanId, + name: span.name, + kind: span.kind, + startTime: span.startTime, + endTime: span.endTime, + duration: span.duration, + status: span.status, + attributes: span.attributes, + links: span.links, + events: span.events, + ended: span.ended, + droppedAttributesCount: span.droppedAttributesCount, + droppedEventsCount: span.droppedEventsCount, + droppedLinksCount: span.droppedLinksCount, + }; +} + abstract class BaseFileExporter { protected readonly writeStream: fs.WriteStream; @@ -35,7 +66,7 @@ abstract class BaseFileExporter { export class FileSpanExporter extends BaseFileExporter implements SpanExporter { export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void { - const data = spans.map(s => safeStringify(s) + '\n').join(''); + const data = spans.map(s => safeStringify(serializeSpan(s)) + '\n').join(''); this.writeStream.write(data, err => { resultCallback({ code: err ? ExportResultCode.FAILED : ExportResultCode.SUCCESS, error: err ?? undefined }); }); diff --git a/src/platform/otel/node/test/fileExporters.spec.ts b/src/platform/otel/node/test/fileExporters.spec.ts index c92aee3dd0..7c1aab0580 100644 --- a/src/platform/otel/node/test/fileExporters.spec.ts +++ b/src/platform/otel/node/test/fileExporters.spec.ts @@ -53,6 +53,52 @@ describe('FileSpanExporter', () => { expect(JSON.parse(lines[0]).name).toBe('span-0'); expect(JSON.parse(lines[2]).name).toBe('span-2'); }); + + it('serializes class-based spans without writing empty objects', async () => { + class FakeReadableSpan { + readonly #ctx = { traceId: 'trace-id', spanId: 'span-id', traceFlags: 1, traceState: undefined }; + readonly #resource = { attributes: { 'service.name': 'copilot-chat' } }; + readonly #instrumentationLibrary = { name: 'copilot-chat', version: '0.44.0' }; + readonly #status = { code: 1 }; + readonly #attributes = { a: 1 }; + readonly #links: unknown[] = []; + readonly #events: unknown[] = []; + readonly #time: [number, number] = [1, 2]; + + spanContext() { return this.#ctx; } + get resource() { return this.#resource; } + get instrumentationLibrary() { return this.#instrumentationLibrary; } + get parentSpanId() { return undefined; } + get name() { return 'hidden-span'; } + get kind() { return 0; } + get startTime() { return this.#time; } + get endTime() { return this.#time; } + get duration() { return this.#time; } + get status() { return this.#status; } + get attributes() { return this.#attributes; } + get links() { return this.#links; } + get events() { return this.#events; } + get ended() { return true; } + get droppedAttributesCount() { return 0; } + get droppedEventsCount() { return 0; } + get droppedLinksCount() { return 0; } + } + + expect(JSON.stringify(new FakeReadableSpan())).toBe('{}'); + + await new Promise((resolve, reject) => { + exporter.export([new FakeReadableSpan() as any], result => { + result.code === ExportResultCode.SUCCESS ? resolve() : reject(result.error); + }); + }); + await exporter.shutdown(); + const content = fs.readFileSync(tmpFile, 'utf-8'); + const parsed = JSON.parse(content.trim()); + expect(parsed.name).toBe('hidden-span'); + expect(parsed.traceId).toBe('trace-id'); + expect(parsed.resource.attributes).toEqual({ 'service.name': 'copilot-chat' }); + expect(parsed.attributes).toEqual({ a: 1 }); + }); }); describe('FileLogExporter', () => { From 27e0fe8fb7c1ed51821ec02acdaf9f851d75612b Mon Sep 17 00:00:00 2001 From: Patrick McGannon Date: Thu, 16 Apr 2026 11:52:59 -0600 Subject: [PATCH 2/2] build break and bad span export shape in src/platform/otel/node/fileExporters.ts:20-35. serializeSpan() is using old OTel v1 ReadableSpan fields: span.instrumentationLibrary and span.parentSpanId. This repo resolves ReadableSpan from OTel v2, where those are instrumentationScope and parentSpanContext (node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/sdk-trace-base/build/src/export/ReadableSpan.d.ts:5-23). --- src/platform/otel/node/fileExporters.ts | 4 ++-- src/platform/otel/node/test/fileExporters.spec.ts | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/platform/otel/node/fileExporters.ts b/src/platform/otel/node/fileExporters.ts index 8b5a6da029..175798a653 100644 --- a/src/platform/otel/node/fileExporters.ts +++ b/src/platform/otel/node/fileExporters.ts @@ -26,12 +26,12 @@ function serializeSpan(span: ReadableSpan): Record { resource: { attributes: span.resource.attributes, }, - instrumentationScope: span.instrumentationLibrary, + instrumentationScope: span.instrumentationScope, traceId: spanContext.traceId, spanId: spanContext.spanId, traceFlags: spanContext.traceFlags, traceState: spanContext.traceState?.serialize(), - parentSpanId: span.parentSpanId, + parentSpanId: span.parentSpanContext?.spanId, name: span.name, kind: span.kind, startTime: span.startTime, diff --git a/src/platform/otel/node/test/fileExporters.spec.ts b/src/platform/otel/node/test/fileExporters.spec.ts index 7c1aab0580..836dd6428b 100644 --- a/src/platform/otel/node/test/fileExporters.spec.ts +++ b/src/platform/otel/node/test/fileExporters.spec.ts @@ -58,17 +58,18 @@ describe('FileSpanExporter', () => { class FakeReadableSpan { readonly #ctx = { traceId: 'trace-id', spanId: 'span-id', traceFlags: 1, traceState: undefined }; readonly #resource = { attributes: { 'service.name': 'copilot-chat' } }; - readonly #instrumentationLibrary = { name: 'copilot-chat', version: '0.44.0' }; + readonly #instrumentationScope = { name: 'copilot-chat', version: '0.44.0' }; readonly #status = { code: 1 }; readonly #attributes = { a: 1 }; readonly #links: unknown[] = []; readonly #events: unknown[] = []; readonly #time: [number, number] = [1, 2]; + readonly #parentSpanContext = { traceId: 'trace-id', spanId: 'parent-span-id', traceFlags: 1, traceState: undefined }; spanContext() { return this.#ctx; } get resource() { return this.#resource; } - get instrumentationLibrary() { return this.#instrumentationLibrary; } - get parentSpanId() { return undefined; } + get instrumentationScope() { return this.#instrumentationScope; } + get parentSpanContext() { return this.#parentSpanContext; } get name() { return 'hidden-span'; } get kind() { return 0; } get startTime() { return this.#time; } @@ -96,6 +97,8 @@ describe('FileSpanExporter', () => { const parsed = JSON.parse(content.trim()); expect(parsed.name).toBe('hidden-span'); expect(parsed.traceId).toBe('trace-id'); + expect(parsed.parentSpanId).toBe('parent-span-id'); + expect(parsed.instrumentationScope).toEqual({ name: 'copilot-chat', version: '0.44.0' }); expect(parsed.resource.attributes).toEqual({ 'service.name': 'copilot-chat' }); expect(parsed.attributes).toEqual({ a: 1 }); });