Skip to content
This repository was archived by the owner on May 20, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions src/extension/byok/vscode-node/anthropicProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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); }
}

Expand Down Expand Up @@ -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) {
Expand Down
13 changes: 9 additions & 4 deletions src/extension/byok/vscode-node/geminiNativeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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); }
}

Expand Down Expand Up @@ -508,4 +513,4 @@ export class GeminiNativeBYOKLMProvider extends AbstractLanguageModelChatProvide
throw error;
}
}
}
}
6 changes: 5 additions & 1 deletion src/extension/otel/vscode-node/otelContrib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? '<unset>'}`
: `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');
}
Expand Down
17 changes: 13 additions & 4 deletions src/extension/prompt/node/chatMLFetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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 }
: {}),
});
Expand Down
2 changes: 2 additions & 0 deletions src/extension/test/vscode-node/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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));
Expand Down
3 changes: 1 addition & 2 deletions src/platform/endpoint/node/messagesApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1085,5 +1086,3 @@ export class AnthropicMessagesProcessor {
}
}
}


1 change: 1 addition & 0 deletions src/platform/networking/common/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/platform/otel/common/genAiAttributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/platform/otel/common/genAiMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 17 additions & 1 deletion src/platform/otel/common/test/agentTraceHierarchy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
34 changes: 34 additions & 0 deletions src/platform/otel/common/test/genAiMetrics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
33 changes: 32 additions & 1 deletion src/platform/otel/node/fileExporters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,37 @@ function safeStringify(data: unknown): string {
}
}

function serializeSpan(span: ReadableSpan): Record<string, unknown> {
if (typeof span.spanContext !== 'function') {
return span as unknown as Record<string, unknown>;
}
const spanContext = span.spanContext();
return {
resource: {
attributes: span.resource.attributes,
},
instrumentationScope: span.instrumentationScope,
traceId: spanContext.traceId,
spanId: spanContext.spanId,
traceFlags: spanContext.traceFlags,
traceState: spanContext.traceState?.serialize(),
parentSpanId: span.parentSpanContext?.spanId,
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;

Expand All @@ -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 });
});
Expand Down
49 changes: 49 additions & 0 deletions src/platform/otel/node/test/fileExporters.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,55 @@ 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 #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 instrumentationScope() { return this.#instrumentationScope; }
get parentSpanContext() { return this.#parentSpanContext; }
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<void>((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.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 });
});
});

describe('FileLogExporter', () => {
Expand Down