Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,36 @@ export function translateSpan(
}
}

if (operationName === GenAiOperationName.CHAT) {
const inputTokens = span.attributes[GenAiAttr.USAGE_INPUT_TOKENS] as number | undefined;
const outputTokens = span.attributes[GenAiAttr.USAGE_OUTPUT_TOKENS] as number | undefined;
const model = (span.attributes[GenAiAttr.RESPONSE_MODEL] as string | undefined)
?? (span.attributes[GenAiAttr.REQUEST_MODEL] as string | undefined);

if (typeof inputTokens === 'number' && typeof outputTokens === 'number') {
const data: Record<string, unknown> = {
model: model ?? 'unknown',
inputTokens,
outputTokens,
};

// Optional fields — include when available
const cacheReadTokens = span.attributes[GenAiAttr.USAGE_CACHE_READ_INPUT_TOKENS] as number | undefined;
if (typeof cacheReadTokens === 'number') {
data.cacheReadTokens = cacheReadTokens;
}
const timeToFirstToken = span.attributes[CopilotChatAttr.TIME_TO_FIRST_TOKEN] as number | undefined;
if (typeof timeToFirstToken === 'number') {
data.timeToFirstTokenMs = timeToFirstToken;
}
if (span.endTime > span.startTime) {
data.duration = span.endTime - span.startTime;
}

pushEvent(events, state, 'assistant.usage', data, /*ephemeral*/ true, subagentId);
}
}

return events;
}

Expand Down Expand Up @@ -342,6 +372,31 @@ export function translateDebugLogEntry(
}
break;
}

case 'llm_request': {
const inputTokens = entry.attrs.inputTokens;
const outputTokens = entry.attrs.outputTokens;
if (typeof inputTokens === 'number' && typeof outputTokens === 'number') {
const data: Record<string, unknown> = {
model: typeof entry.attrs.model === 'string' ? entry.attrs.model : 'unknown',
inputTokens,
outputTokens,
};
const cacheReadTokens = entry.attrs.cacheReadTokens ?? entry.attrs.cachedTokens;
if (typeof cacheReadTokens === 'number') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for reindex, we need to handle cachedTokens and ttf which is written by the chat debug logger.

data.cacheReadTokens = cacheReadTokens;
}
const ttft = entry.attrs.ttft;
if (typeof ttft === 'number') {
data.timeToFirstTokenMs = ttft;
}
if (typeof entry.dur === 'number' && entry.dur > 0) {
data.duration = entry.dur;
}
pushEventAt(events, state, ts, 'assistant.usage', data, /*ephemeral*/ true);
}
break;
}
}

return events;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ describe('translateSpan', () => {
expect(events[0].data.error).toBeDefined();
});

it('ignores non-relevant operation names', () => {
it('ignores chat spans without usage token attributes', () => {
const state = createSessionTranslationState();
const span = makeSpan({
attributes: {
Expand All @@ -137,6 +137,78 @@ describe('translateSpan', () => {
expect(events).toHaveLength(0);
});

it('emits assistant.usage for chat spans with usage tokens', () => {
const state = createSessionTranslationState();
state.started = true;

const span = makeSpan({
startTime: 1000,
endTime: 1500,
attributes: {
'gen_ai.operation.name': 'chat',
'gen_ai.response.model': 'claude-sonnet-4-20250514',
'gen_ai.usage.input_tokens': 12500,
'gen_ai.usage.output_tokens': 800,
'gen_ai.usage.cache_read.input_tokens': 5000,
'copilot_chat.time_to_first_token': 230,
},
});

const events = translateSpan(span, state);

expect(events).toHaveLength(1);
expect(events[0].type).toBe('assistant.usage');
expect(events[0].data.model).toBe('claude-sonnet-4-20250514');
expect(events[0].data.inputTokens).toBe(12500);
expect(events[0].data.outputTokens).toBe(800);
expect(events[0].data.cacheReadTokens).toBe(5000);
expect(events[0].data.timeToFirstTokenMs).toBe(230);
expect(events[0].data.duration).toBe(500);
expect(events[0].ephemeral).toBe(true);
});

it('emits assistant.usage with request model as fallback', () => {
const state = createSessionTranslationState();
state.started = true;

const span = makeSpan({
attributes: {
'gen_ai.operation.name': 'chat',
'gen_ai.request.model': 'gpt-5.4',
'gen_ai.usage.input_tokens': 100,
'gen_ai.usage.output_tokens': 50,
},
});

const events = translateSpan(span, state);

expect(events).toHaveLength(1);
expect(events[0].data.model).toBe('gpt-5.4');
// Optional fields should be absent when not in span
expect(events[0].data.cacheReadTokens).toBeUndefined();
expect(events[0].data.timeToFirstTokenMs).toBeUndefined();
});

it('emits assistant.usage with subagentId for sub-agent chat spans', () => {
const state = createSessionTranslationState();
state.started = true;

const span = makeSpan({
attributes: {
'gen_ai.operation.name': 'chat',
'gen_ai.response.model': 'claude-sonnet-4-20250514',
'gen_ai.usage.input_tokens': 200,
'gen_ai.usage.output_tokens': 100,
},
});

const events = translateSpan(span, state, undefined, 'sub-agent-123');

expect(events).toHaveLength(1);
expect(events[0].type).toBe('assistant.usage');
expect(events[0].agentId).toBe('sub-agent-123');
});

it('chains parentId across events', () => {
const state = createSessionTranslationState();
const span1 = makeSpan({
Expand Down Expand Up @@ -493,6 +565,69 @@ describe('translateDebugLogEntry', () => {
// Critical: assistant.message must chain to session.start, not the dropped user.message.
expect(replyEvents[0].parentId).toBe(startEvents[0].id);
});

it('emits assistant.usage for llm_request entry with token data', () => {
const state = createSessionTranslationState();
state.started = true;
const entry = makeDebugEntry({
type: 'llm_request',
name: 'llm_request',
dur: 450,
attrs: {
model: 'claude-sonnet-4-20250514',
inputTokens: 8000,
outputTokens: 500,
cachedTokens: 3000,
ttft: 120,
},
});
Comment thread
digitarald marked this conversation as resolved.

const events = translateDebugLogEntry(entry, 'sess-1', state);

expect(events).toHaveLength(1);
expect(events[0].type).toBe('assistant.usage');
expect(events[0].data.model).toBe('claude-sonnet-4-20250514');
expect(events[0].data.inputTokens).toBe(8000);
expect(events[0].data.outputTokens).toBe(500);
expect(events[0].data.cacheReadTokens).toBe(3000);
expect(events[0].data.timeToFirstTokenMs).toBe(120);
expect(events[0].data.duration).toBe(450);
});

it('ignores llm_request entry without token data', () => {
const state = createSessionTranslationState();
state.started = true;
const entry = makeDebugEntry({
type: 'llm_request',
name: 'llm_request',
attrs: { model: 'gpt-5.4' },
});

const events = translateDebugLogEntry(entry, 'sess-1', state);
expect(events).toHaveLength(0);
});

it('accepts cacheReadTokens as fallback field name', () => {
const state = createSessionTranslationState();
state.started = true;
const entry = makeDebugEntry({
type: 'llm_request',
name: 'llm_request',
dur: 200,
attrs: {
model: 'gpt-5.4',
inputTokens: 1000,
outputTokens: 200,
cacheReadTokens: 500,
},
});

const events = translateDebugLogEntry(entry, 'sess-1', state);

expect(events).toHaveLength(1);
expect(events[0].data.cacheReadTokens).toBe(500);
expect(events[0].data.timeToFirstTokenMs).toBeUndefined();
});
});
describe('deriveTitleFromUserMessage', () => {
it('returns the content unchanged when shorter than the limit', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr
/** Sessions currently initializing (prevent concurrent init). */
private readonly _initializingSessions = new Set<string>();

/** CHAT spans received before session was initialized, keyed by session ID. */
private readonly _pendingChatSpans = new Map<string, { span: ICompletedSpanData; subagentId: string | undefined }[]>();

// ── Shared state ─────────────────────────────────────────────────────────────

/** Buffered events tagged with their chat session ID for correct routing. */
Expand Down Expand Up @@ -310,6 +313,7 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr
this._translationStates.clear();
this._disabledSessions.clear();
this._initializingSessions.clear();
this._pendingChatSpans.clear();

super.dispose();
}
Expand Down Expand Up @@ -710,6 +714,17 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr
// init trigger would seed sessionSource/firstCloudWriteSessionSource and
// telemetry from the sub-agent's AGENT_NAME instead of the parent's.
if (!this._cloudSessions.has(sessionId) && !this._initializingSessions.has(sessionId)) {
if (operationName === GenAiOperationName.CHAT) {
// CHAT spans (LLM calls) complete before their parent invoke_agent.
// Buffer them so usage events aren't lost for the first turn.
let pending = this._pendingChatSpans.get(sessionId);
if (!pending) {
pending = [];
this._pendingChatSpans.set(sessionId, pending);
}
pending.push({ span, subagentId });
return;
}
if (operationName !== GenAiOperationName.INVOKE_AGENT || subagentId) {
return;
}
Expand All @@ -726,6 +741,20 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr
this._bufferEvents(sessionId, events);
this._ensureFlushTimer();
}

// Replay any CHAT spans that arrived before session initialization
if (operationName === GenAiOperationName.INVOKE_AGENT && !subagentId) {
const pendingChats = this._pendingChatSpans.get(sessionId);
if (pendingChats) {
this._pendingChatSpans.delete(sessionId);
for (const { span: chatSpan, subagentId: chatSubagentId } of pendingChats) {
const chatEvents = translateSpan(chatSpan, state, context, chatSubagentId);
if (chatEvents.length > 0) {
this._bufferEvents(sessionId, chatEvents);
}
}
Comment thread
digitarald marked this conversation as resolved.
}
}
} catch {
// Non-fatal — individual span processing failure
}
Expand Down Expand Up @@ -978,6 +1007,7 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr
this._translationStates.delete(sessionId);
this._disabledSessions.delete(sessionId);
this._initializingSessions.delete(sessionId);
this._pendingChatSpans.delete(sessionId);
// Keep _cloudSessions entry — the cloud session ID mapping is needed
// for future delete operations (e.g. sidebar delete fires after dispose).
}
Expand Down