Path: blob/main/extensions/copilot/src/platform/otel/common/test/genAiEvents.spec.ts
13406 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 { describe, expect, it, vi } from 'vitest';6import { Event } from '../../../../util/vs/base/common/event';7import { CopilotChatAttr, GenAiAttr, GenAiOperationName, StdAttr } from '../genAiAttributes';8import { emitAgentTurnEvent, emitEditFeedbackEvent, emitEditSurvivalEvent, emitInferenceDetailsEvent, emitSessionStartEvent, emitToolCallEvent } from '../genAiEvents';9import { resolveOTelConfig } from '../otelConfig';10import type { IOTelService } from '../otelService';1112function createMockOTel(captureContent = false): IOTelService & { emitLogRecord: ReturnType<typeof vi.fn> } {13const config = resolveOTelConfig({14env: captureContent ? { 'COPILOT_OTEL_ENABLED': 'true', 'COPILOT_OTEL_CAPTURE_CONTENT': 'true' } : { 'COPILOT_OTEL_ENABLED': 'true' },15extensionVersion: '1.0.0',16sessionId: 'test',17});18return {19_serviceBrand: undefined!,20config,21startSpan: vi.fn(),22startActiveSpan: vi.fn(),23getActiveTraceContext: vi.fn(),24storeTraceContext: vi.fn(),25getStoredTraceContext: vi.fn(),26runWithTraceContext: vi.fn((_ctx: any, fn: any) => fn()),27recordMetric: vi.fn(),28incrementCounter: vi.fn(),29emitLogRecord: vi.fn(),30flush: vi.fn(),31shutdown: vi.fn(),32injectCompletedSpan: vi.fn(),33onDidCompleteSpan: Event.None,34onDidEmitSpanEvent: Event.None,35};36}3738describe('emitInferenceDetailsEvent', () => {39it('emits event with standard attributes', () => {40const otel = createMockOTel();41emitInferenceDetailsEvent(otel,42{ model: 'gpt-4o', temperature: 0.7, maxTokens: 4096 },43{ id: 'resp-1', model: 'gpt-4o', finishReasons: ['stop'], inputTokens: 100, outputTokens: 50 },44);4546expect(otel.emitLogRecord).toHaveBeenCalledOnce();47const [body, attrs] = otel.emitLogRecord.mock.calls[0];48expect(body).toContain('gpt-4o');49expect(attrs['event.name']).toBe('gen_ai.client.inference.operation.details');50expect(attrs[GenAiAttr.OPERATION_NAME]).toBe(GenAiOperationName.CHAT);51expect(attrs[GenAiAttr.REQUEST_MODEL]).toBe('gpt-4o');52expect(attrs[GenAiAttr.RESPONSE_MODEL]).toBe('gpt-4o');53expect(attrs[GenAiAttr.RESPONSE_ID]).toBe('resp-1');54expect(attrs[GenAiAttr.USAGE_INPUT_TOKENS]).toBe(100);55expect(attrs[GenAiAttr.USAGE_OUTPUT_TOKENS]).toBe(50);56expect(attrs[GenAiAttr.REQUEST_TEMPERATURE]).toBe(0.7);57expect(attrs[GenAiAttr.REQUEST_MAX_TOKENS]).toBe(4096);58});5960it('does not include content attributes when captureContent is false', () => {61const otel = createMockOTel(false);62emitInferenceDetailsEvent(otel,63{ model: 'gpt-4o', messages: [{ role: 'user', text: 'secret' }] },64{ id: 'resp-1' },65);6667const attrs = otel.emitLogRecord.mock.calls[0][1];68expect(attrs).not.toHaveProperty(GenAiAttr.INPUT_MESSAGES);69expect(attrs).not.toHaveProperty(GenAiAttr.SYSTEM_INSTRUCTIONS);70expect(attrs).not.toHaveProperty(GenAiAttr.TOOL_DEFINITIONS);71});7273it('includes content attributes when captureContent is true', () => {74const otel = createMockOTel(true);75const messages = [{ role: 'user', content: 'hello' }];76const systemMsg = 'You are helpful';77const tools = [{ name: 'readFile' }];7879emitInferenceDetailsEvent(otel,80{ model: 'gpt-4o', messages, systemMessage: systemMsg, tools },81undefined,82);8384const attrs = otel.emitLogRecord.mock.calls[0][1];85// Messages should be normalized to OTel GenAI format86expect(attrs[GenAiAttr.INPUT_MESSAGES]).toBe(JSON.stringify([{ role: 'user', parts: [{ type: 'text', content: 'hello' }] }]));87// System instructions should be wrapped in OTel format88expect(attrs[GenAiAttr.SYSTEM_INSTRUCTIONS]).toBe(JSON.stringify([{ type: 'text', content: 'You are helpful' }]));89expect(attrs[GenAiAttr.TOOL_DEFINITIONS]).toBe(JSON.stringify(tools));90});9192it('includes error.type when error is provided', () => {93const otel = createMockOTel();94emitInferenceDetailsEvent(otel,95{ model: 'gpt-4o' },96undefined,97{ type: 'TimeoutError', message: 'request timed out' },98);99100const attrs = otel.emitLogRecord.mock.calls[0][1];101expect(attrs[StdAttr.ERROR_TYPE]).toBe('TimeoutError');102});103104it('handles undefined response', () => {105const otel = createMockOTel();106emitInferenceDetailsEvent(otel, { model: 'gpt-4o' }, undefined);107108const attrs = otel.emitLogRecord.mock.calls[0][1];109expect(attrs).not.toHaveProperty(GenAiAttr.RESPONSE_MODEL);110expect(attrs).not.toHaveProperty(GenAiAttr.RESPONSE_ID);111});112});113114describe('emitSessionStartEvent', () => {115it('emits session start with required attributes', () => {116const otel = createMockOTel();117emitSessionStartEvent(otel, 'sess-123', 'gpt-4o', 'copilot');118119expect(otel.emitLogRecord).toHaveBeenCalledWith('copilot_chat.session.start', {120'event.name': 'copilot_chat.session.start',121'session.id': 'sess-123',122[GenAiAttr.REQUEST_MODEL]: 'gpt-4o',123[GenAiAttr.AGENT_NAME]: 'copilot',124});125});126});127128describe('emitToolCallEvent', () => {129it('emits success tool call event', () => {130const otel = createMockOTel();131emitToolCallEvent(otel, 'readFile', 150, true);132133const [body, attrs] = otel.emitLogRecord.mock.calls[0];134expect(body).toContain('readFile');135expect(attrs['event.name']).toBe('copilot_chat.tool.call');136expect(attrs[GenAiAttr.TOOL_NAME]).toBe('readFile');137expect(attrs['duration_ms']).toBe(150);138expect(attrs['success']).toBe(true);139expect(attrs).not.toHaveProperty(StdAttr.ERROR_TYPE);140});141142it('includes error type on failure', () => {143const otel = createMockOTel();144emitToolCallEvent(otel, 'runCommand', 5000, false, 'TimeoutError');145146const attrs = otel.emitLogRecord.mock.calls[0][1];147expect(attrs['success']).toBe(false);148expect(attrs[StdAttr.ERROR_TYPE]).toBe('TimeoutError');149});150});151152describe('emitAgentTurnEvent', () => {153it('emits turn event with all attributes', () => {154const otel = createMockOTel();155emitAgentTurnEvent(otel, 3, 500, 200, 2);156157const [body, attrs] = otel.emitLogRecord.mock.calls[0];158expect(body).toContain('3');159expect(attrs['event.name']).toBe('copilot_chat.agent.turn');160expect(attrs['turn.index']).toBe(3);161expect(attrs[GenAiAttr.USAGE_INPUT_TOKENS]).toBe(500);162expect(attrs[GenAiAttr.USAGE_OUTPUT_TOKENS]).toBe(200);163expect(attrs['tool_call_count']).toBe(2);164});165});166167describe('emitEditFeedbackEvent', () => {168it('includes workspace metadata when provided', () => {169const otel = createMockOTel();170emitEditFeedbackEvent(otel, 'accepted', 'typescript', 'copilot', 'req-1', 'agent', false, false, {171headBranchName: 'main',172headCommitHash: 'abc123',173remoteUrl: 'github.com/org/repo',174fileRelativePath: 'src/app.ts',175});176177const attrs = otel.emitLogRecord.mock.calls[0][1];178expect(attrs[CopilotChatAttr.REPO_HEAD_BRANCH_NAME]).toBe('main');179expect(attrs[CopilotChatAttr.REPO_HEAD_COMMIT_HASH]).toBe('abc123');180expect(attrs[CopilotChatAttr.REPO_REMOTE_URL]).toBe('github.com/org/repo');181expect(attrs[CopilotChatAttr.FILE_RELATIVE_PATH]).toBe('src/app.ts');182});183});184185describe('emitEditSurvivalEvent', () => {186it('includes workspace metadata alongside survival data', () => {187const otel = createMockOTel();188emitEditSurvivalEvent(otel, 'apply_patch', 0.95, 0.88, 30000, false, 'req-1', {189headBranchName: 'feature/x',190headCommitHash: 'deadbeef',191});192193const attrs = otel.emitLogRecord.mock.calls[0][1];194expect(attrs['event.name']).toBe('copilot_chat.edit.survival');195expect(attrs['survival_rate_four_gram']).toBe(0.95);196expect(attrs[CopilotChatAttr.REPO_HEAD_BRANCH_NAME]).toBe('feature/x');197expect(attrs[CopilotChatAttr.REPO_HEAD_COMMIT_HASH]).toBe('deadbeef');198});199});200201202