Path: blob/main/extensions/copilot/src/extension/chatSessions/claude/node/claudeOTelTracker.ts
13405 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 type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';6import { CopilotChatAttr, emitSessionStartEvent, GenAiAttr, GenAiMetrics, GenAiOperationName, GenAiProviderName, IOTelService, type ISpanHandle, SpanKind, SpanStatusCode, type TraceContext, truncateForOTel } from '../../../../platform/otel/common/index';7import { IClaudeSessionStateService } from '../common/claudeSessionStateService';89/**10* Manages OTel span lifecycle for a Claude agent session.11*12* Extracted from ClaudeCodeSession to keep tracing concerns separate from13* session orchestration. Tracks the invoke_agent root span, accumulates14* parent-only token usage, and manages trace context for subagent nesting.15*/16export class ClaudeOTelTracker {17private _currentSpan: ISpanHandle | undefined;18private _currentTraceContext: TraceContext | undefined;19private _startTime: number | undefined;20private _isFirstRequest = true;21private _turnCount = 0;22private _parentInputTokens = 0;23private _parentOutputTokens = 0;24private _parentCacheReadTokens = 0;25private _parentCacheCreationTokens = 0;2627constructor(28private readonly _sessionId: string,29private readonly _otelService: IOTelService,30private readonly _sessionStateService: IClaudeSessionStateService,31) { }3233/** The trace context of the current invoke_agent span, used to parent child spans. */34get traceContext(): TraceContext | undefined {35return this._currentTraceContext;36}3738/**39* Starts a new invoke_agent span for a user request.40* Ends any previous span and resets accumulators.41*/42startRequest(modelId: string): void {43this.endRequest();4445this._currentSpan = this._otelService.startSpan('invoke_agent claude', {46kind: SpanKind.INTERNAL,47attributes: {48[GenAiAttr.OPERATION_NAME]: GenAiOperationName.INVOKE_AGENT,49[GenAiAttr.AGENT_NAME]: 'claude',50[GenAiAttr.PROVIDER_NAME]: GenAiProviderName.GITHUB,51[GenAiAttr.CONVERSATION_ID]: this._sessionId,52[CopilotChatAttr.SESSION_ID]: this._sessionId,53[CopilotChatAttr.CHAT_SESSION_ID]: this._sessionId,54[GenAiAttr.REQUEST_MODEL]: modelId,55},56});57this._currentTraceContext = this._currentSpan.getSpanContext();58this._startTime = Date.now();59this._turnCount = 0;60this._parentInputTokens = 0;61this._parentOutputTokens = 0;62this._parentCacheReadTokens = 0;63this._parentCacheCreationTokens = 0;6465// Store trace context so the language model server can parent chat spans66this._sessionStateService.setTraceContextForSession(this._sessionId, this._currentTraceContext);6768// Emit session start event and metric for the first request69if (this._isFirstRequest) {70this._isFirstRequest = false;71GenAiMetrics.incrementSessionCount(this._otelService);72emitSessionStartEvent(this._otelService, this._sessionId, modelId, 'claude');73}74}7576/**77* Emits a user_message span event for the debug panel.78*/79emitUserMessage(promptLabel: string): void {80const userMsgSpan = this._otelService.startSpan('user_message', {81kind: SpanKind.INTERNAL,82attributes: {83[GenAiAttr.OPERATION_NAME]: 'user_message',84[CopilotChatAttr.CHAT_SESSION_ID]: this._sessionId,85},86parentTraceContext: this._currentTraceContext,87});88const userContent = truncateForOTel(promptLabel);89userMsgSpan.setAttribute(CopilotChatAttr.USER_REQUEST, userContent);90userMsgSpan.addEvent('user_message', { content: userContent, [CopilotChatAttr.CHAT_SESSION_ID]: this._sessionId });91userMsgSpan.end();92}9394/**95* Processes an SDK message for OTel tracking.96* Call this for every message in the processing loop.97*/98onMessage(message: SDKMessage, subagentTraceContexts: Map<string, TraceContext>): void {99if (message.type === 'assistant') {100this._turnCount++;101this._accumulateParentTokenUsage(message);102}103104if (message.type === 'result' && this._currentSpan) {105this._setResultAttributes(message);106}107108this._updateTraceContextForMessage(message, subagentTraceContexts);109}110111/**112* Ends the current invoke_agent span with OK status and records metrics.113*/114endRequest(): void {115this._endSpan();116}117118/**119* Ends the current invoke_agent span with ERROR status.120*/121endRequestWithError(message: string): void {122this._endSpan(SpanStatusCode.ERROR, message);123}124125// ── Private ──────────────────────────────────────────────────────────────126127private _endSpan(statusCode?: SpanStatusCode, statusMessage?: string): void {128if (!this._currentSpan) {129return;130}131const span = this._currentSpan;132span.setAttribute(CopilotChatAttr.TURN_COUNT, this._turnCount);133134// Set parent-only token usage (comparable with foreground agent).135span.setAttributes({136[GenAiAttr.USAGE_INPUT_TOKENS]: this._parentInputTokens,137[GenAiAttr.USAGE_OUTPUT_TOKENS]: this._parentOutputTokens,138...(this._parentCacheReadTokens ? { [GenAiAttr.USAGE_CACHE_READ_INPUT_TOKENS]: this._parentCacheReadTokens } : {}),139...(this._parentCacheCreationTokens ? { [GenAiAttr.USAGE_CACHE_CREATION_INPUT_TOKENS]: this._parentCacheCreationTokens } : {}),140});141142if (statusCode !== undefined) {143span.setStatus(statusCode, statusMessage);144} else {145span.setStatus(SpanStatusCode.OK);146}147span.end();148149// Record agent-level metrics150if (this._startTime) {151const durationSec = (Date.now() - this._startTime) / 1000;152GenAiMetrics.recordAgentDuration(this._otelService, 'claude', durationSec);153}154GenAiMetrics.recordAgentTurnCount(this._otelService, 'claude', this._turnCount);155156this._currentSpan = undefined;157this._currentTraceContext = undefined;158this._startTime = undefined;159this._sessionStateService.setTraceContextForSession(this._sessionId, undefined);160}161162/**163* Accumulates parent-only token usage from an assistant message.164* Excludes subagent turns so gen_ai.usage.* on the root span is comparable165* with the foreground agent.166*/167private _accumulateParentTokenUsage(message: SDKMessage & { type: 'assistant' }): void {168if (message.parent_tool_use_id) {169return;170}171const msgUsage = message.message?.usage;172if (msgUsage) {173this._parentInputTokens += (msgUsage.input_tokens ?? 0)174+ (msgUsage.cache_creation_input_tokens ?? 0)175+ (msgUsage.cache_read_input_tokens ?? 0);176this._parentOutputTokens += (msgUsage.output_tokens ?? 0);177this._parentCacheReadTokens += (msgUsage.cache_read_input_tokens ?? 0);178this._parentCacheCreationTokens += (msgUsage.cache_creation_input_tokens ?? 0);179}180}181182/**183* Sets cost, turn count, and response model on the invoke_agent span from a result message.184*/185private _setResultAttributes(message: SDKMessage & { type: 'result' }): void {186if (!this._currentSpan) {187return;188}189if (message.num_turns !== undefined) {190this._currentSpan.setAttribute(CopilotChatAttr.TURN_COUNT, message.num_turns);191}192if (message.total_cost_usd !== undefined) {193this._currentSpan.setAttribute(CopilotChatAttr.TOTAL_COST_USD, message.total_cost_usd);194}195const responseModel = message.modelUsage ? Object.keys(message.modelUsage)[0] : undefined;196if (responseModel) {197this._currentSpan.setAttribute(GenAiAttr.RESPONSE_MODEL, responseModel);198}199}200201/**202* Updates the session trace context based on whether a message is from a subagent.203* Ensures chat spans created by chatMLFetcher are parented under the correct204* Agent tool span during subagent execution.205*/206private _updateTraceContextForMessage(message: SDKMessage, subagentTraceContexts: Map<string, TraceContext>): void {207if (!('parent_tool_use_id' in message)) {208return;209}210if (message.parent_tool_use_id) {211const subagentCtx = subagentTraceContexts.get(message.parent_tool_use_id);212if (subagentCtx) {213this._sessionStateService.setTraceContextForSession(this._sessionId, subagentCtx);214}215} else {216this._sessionStateService.setTraceContextForSession(this._sessionId, this._currentTraceContext);217}218}219}220221222