Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

telemetry(amazonq): sending metric data in onCodeGeneration #6226

Merged
merged 9 commits into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from 6 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
@@ -0,0 +1,4 @@
{
"type": "Feature",
"description": "send metric data in onCodeGeneration"
}
12 changes: 11 additions & 1 deletion packages/core/src/amazonqFeatureDev/client/featureDev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ import { createCodeWhispererChatStreamingClient } from '../../shared/clients/cod
import { getClientId, getOptOutPreference, getOperatingSystem } from '../../shared/telemetry/util'
import { extensionVersion } from '../../shared/vscode/env'
import apiConfig = require('./codewhispererruntime-2022-11-11.json')
import { FeatureDevCodeAcceptanceEvent, FeatureDevCodeGenerationEvent, TelemetryEvent } from './featuredevproxyclient'
import {
FeatureDevCodeAcceptanceEvent,
FeatureDevCodeGenerationEvent,
MetricData,
TelemetryEvent,
} from './featuredevproxyclient'

// Re-enable once BE is able to handle retries.
const writeAPIRetryOptions = {
Expand Down Expand Up @@ -299,6 +304,11 @@ export class FeatureDevClient {
await this.sendFeatureDevEvent('featureDevCodeAcceptanceEvent', event)
}

public async sendMetricData(event: MetricData) {
getLogger().debug(`featureDevCodeGenerationMetricData: dimensions: ${event.dimensions}`)
await this.sendFeatureDevEvent('metricData', event)
}

public async sendFeatureDevEvent<T extends keyof TelemetryEvent>(
eventName: T,
event: NonNullable<TelemetryEvent[T]>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
import { codeGenRetryLimit, defaultRetryLimit } from '../../limits'
import { Session } from '../../session/session'
import { featureDevScheme, featureName } from '../../constants'
import { DeletedFileInfo, DevPhase, type NewFileInfo } from '../../types'
import { DeletedFileInfo, DevPhase, MetricDataOperationName, MetricDataResult, type NewFileInfo } from '../../types'
import { AuthUtil } from '../../../codewhisperer/util/authUtil'
import { AuthController } from '../../../amazonq/auth/controller'
import { getLogger } from '../../../shared/logger'
Expand Down Expand Up @@ -413,6 +413,10 @@ export class FeatureDevController {
canBeVoted: true,
})
this.messenger.sendUpdatePlaceholder(tabID, i18n('AWS.amazonq.featureDev.pillText.generatingCode'))
await session.sendMetricDataTelemetry(
MetricDataOperationName.START_CODE_GENERATION,
MetricDataResult.SUCCESS
)
await session.send(message)
const filePaths = session.state.filePaths ?? []
const deletedFiles = session.state.deletedFiles ?? []
Expand Down Expand Up @@ -486,6 +490,44 @@ export class FeatureDevController {
await session.sendLinesOfCodeGeneratedTelemetry()
}
this.messenger.sendUpdatePlaceholder(tabID, i18n('AWS.amazonq.featureDev.pillText.selectOption'))
} catch (err: any) {
getLogger().error(`${featureName}: Error during code generation: ${err}`)

switch (err.constructor.name) {
case FeatureDevServiceError.name:
if (err.code === 'EmptyPatchException') {
await session.sendMetricDataTelemetry(
MetricDataOperationName.END_CODE_GENERATION,
MetricDataResult.LLMFAILURE
)
} else if (err.code === 'GuardrailsException' || err.code === 'ThrottlingException') {
await session.sendMetricDataTelemetry(
MetricDataOperationName.END_CODE_GENERATION,
MetricDataResult.ERROR
)
} else {
await session.sendMetricDataTelemetry(
MetricDataOperationName.END_CODE_GENERATION,
MetricDataResult.FAULT
)
}
break
case PromptRefusalException.name:
case NoChangeRequiredException.name:
await session.sendMetricDataTelemetry(
MetricDataOperationName.END_CODE_GENERATION,
MetricDataResult.ERROR
)
break
default:
await session.sendMetricDataTelemetry(
MetricDataOperationName.END_CODE_GENERATION,
MetricDataResult.FAULT
)

break
}
throw err
} finally {
// Finish processing the event

Expand Down Expand Up @@ -517,6 +559,7 @@ export class FeatureDevController {
}
}
}
await session.sendMetricDataTelemetry(MetricDataOperationName.END_CODE_GENERATION, MetricDataResult.SUCCESS)
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure if these designs are final. But does it makes to just send a single metric instead of start/end? This would save some work on the server side

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The purpose of this design was to get the success rate by calculating end versus start, so we do need two separate metrics. Also, start can potentially fail too.

}

private sendUpdateCodeMessage(tabID: string) {
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/amazonqFeatureDev/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,25 @@ export class Session {
return { leftPath, rightPath, ...diff }
}

public async sendMetricDataTelemetry(operationName: string, result: string) {
await this.proxyClient.sendMetricData({
metricName: 'Operation',
metricValue: 1,
timestamp: new Date(Date.now()),
product: 'FeatureDev',
dimensions: [
{
name: 'operationName',
value: operationName,
},
{
name: 'result',
value: result,
},
],
})
}

public async sendLinesOfCodeGeneratedTelemetry() {
let charactersOfCodeGenerated = 0
let linesOfCodeGenerated = 0
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/amazonqFeatureDev/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,15 @@ export interface UpdateFilesPathsParams {
messageId: string
disableFileActions?: boolean
}

export enum MetricDataOperationName {
START_CODE_GENERATION = 'StartCodeGeneration',
END_CODE_GENERATION = 'EndCodeGeneration',
}

export enum MetricDataResult {
SUCCESS = 'Success',
FAULT = 'Fault',
ERROR = 'Error',
LLMFAILURE = 'LLMFailure',
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import * as path from 'path'
import sinon from 'sinon'
import { waitUntil } from '../../../../shared/utilities/timeoutUtils'
import { ControllerSetup, createController, createSession, generateVirtualMemoryUri } from '../../utils'
import { CurrentWsFolders, DeletedFileInfo, NewFileInfo } from '../../../../amazonqFeatureDev/types'
import {
CurrentWsFolders,
DeletedFileInfo,
MetricDataOperationName,
MetricDataResult,
NewFileInfo,
} from '../../../../amazonqFeatureDev/types'
import { Session } from '../../../../amazonqFeatureDev/session/session'
import { Prompter } from '../../../../shared/ui/prompter'
import { assertTelemetry, toFile } from '../../../testUtil'
Expand All @@ -36,6 +42,7 @@ import { AuthUtil } from '../../../../codewhisperer'
import { featureDevScheme, featureName, messageWithConversationId } from '../../../../amazonqFeatureDev'
import { i18n } from '../../../../shared/i18n-helper'
import { FollowUpTypes } from '../../../../amazonq/commons/types'
import { ToolkitError } from '../../../../shared'

let mockGetCodeGeneration: sinon.SinonStub
describe('Controller', () => {
Expand Down Expand Up @@ -395,7 +402,47 @@ describe('Controller', () => {
})

describe('processUserChatMessage', function () {
async function fireChatMessage() {
// TODO: fix disablePreviousFileList error
const runs = [
{ name: 'ContentLengthError', error: new ContentLengthError() },
{
name: 'MonthlyConversationLimitError',
error: new MonthlyConversationLimitError('Service Quota Exceeded'),
},
{
name: 'FeatureDevServiceErrorGuardrailsException',
error: new FeatureDevServiceError(
i18n('AWS.amazonq.featureDev.error.codeGen.default'),
'GuardrailsException'
),
},
{
name: 'FeatureDevServiceErrorEmptyPatchException',
error: new FeatureDevServiceError(
i18n('AWS.amazonq.featureDev.error.throttling'),
'EmptyPatchException'
),
},
{
name: 'FeatureDevServiceErrorThrottlingException',
error: new FeatureDevServiceError(
i18n('AWS.amazonq.featureDev.error.codeGen.default'),
'ThrottlingException'
),
},
{ name: 'UploadCodeError', error: new UploadCodeError('403: Forbiden') },
{ name: 'UserMessageNotFoundError', error: new UserMessageNotFoundError() },
{ name: 'TabIdNotFoundError', error: new TabIdNotFoundError() },
{ name: 'PrepareRepoFailedError', error: new PrepareRepoFailedError() },
{ name: 'PromptRefusalException', error: new PromptRefusalException() },
{ name: 'ZipFileError', error: new ZipFileError() },
{ name: 'CodeIterationLimitError', error: new CodeIterationLimitError() },
{ name: 'UploadURLExpired', error: new UploadURLExpired() },
{ name: 'NoChangeRequiredException', error: new NoChangeRequiredException() },
{ name: 'default', error: new ToolkitError('Default', { code: 'Default' }) },
]

async function fireChatMessage(session: Session) {
const getSessionStub = sinon.stub(controllerSetup.sessionStorage, 'getSession').resolves(session)

controllerSetup.emitters.processHumanChatMessage.fire({
Expand All @@ -410,44 +457,121 @@ describe('Controller', () => {
}, {})
}

describe('processErrorChatMessage', function () {
// TODO: fix disablePreviousFileList error
const runs = [
{ name: 'ContentLengthError', error: new ContentLengthError() },
{
name: 'MonthlyConversationLimitError',
error: new MonthlyConversationLimitError('Service Quota Exceeded'),
},
{
name: 'FeatureDevServiceError',
error: new FeatureDevServiceError(
i18n('AWS.amazonq.featureDev.error.codeGen.default'),
'GuardrailsException'
),
},
{ name: 'UploadCodeError', error: new UploadCodeError('403: Forbiden') },
{ name: 'UserMessageNotFoundError', error: new UserMessageNotFoundError() },
{ name: 'TabIdNotFoundError', error: new TabIdNotFoundError() },
{ name: 'PrepareRepoFailedError', error: new PrepareRepoFailedError() },
{ name: 'PromptRefusalException', error: new PromptRefusalException() },
{ name: 'ZipFileError', error: new ZipFileError() },
{ name: 'CodeIterationLimitError', error: new CodeIterationLimitError() },
{ name: 'UploadURLExpired', error: new UploadURLExpired() },
{ name: 'NoChangeRequiredException', error: new NoChangeRequiredException() },
{ name: 'default', error: new Error() },
]
describe('onCodeGeneration', function () {
let session: any
let sendMetricDataTelemetrySpy: sinon.SinonStub

const errorResultMapping = new Map([
['EmptyPatchException', MetricDataResult.LLMFAILURE],
[PromptRefusalException.name, MetricDataResult.ERROR],
[NoChangeRequiredException.name, MetricDataResult.ERROR],
])

function getMetricResult(error: ToolkitError): MetricDataResult {
if (error instanceof FeatureDevServiceError && error.code) {
return errorResultMapping.get(error.code) ?? MetricDataResult.ERROR
}
return errorResultMapping.get(error.constructor.name) ?? MetricDataResult.FAULT
}

async function createCodeGenState() {
mockGetCodeGeneration = sinon.stub().resolves({ codeGenerationStatus: { status: 'Complete' } })

const workspaceFolders = [controllerSetup.workspaceFolder] as CurrentWsFolders
const testConfig = {
conversationId: conversationID,
proxyClient: {
createConversation: () => sinon.stub(),
createUploadUrl: () => sinon.stub(),
generatePlan: () => sinon.stub(),
startCodeGeneration: () => sinon.stub(),
getCodeGeneration: () => mockGetCodeGeneration(),
exportResultArchive: () => sinon.stub(),
} as unknown as FeatureDevClient,
workspaceRoots: [''],
uploadId: uploadID,
workspaceFolders,
}

const codeGenState = new CodeGenState(testConfig, getFilePaths(controllerSetup), [], [], tabID, 0, {})
const newSession = await createSession({
messenger: controllerSetup.messenger,
sessionState: codeGenState,
conversationID,
tabID,
uploadID,
scheme: featureDevScheme,
})
return newSession
}

async function verifyException(error: ToolkitError) {
sinon.stub(session, 'send').throws(error)

await fireChatMessage(session)
await verifyMetricsCalled()
assert.ok(
sendMetricDataTelemetrySpy.calledWith(
MetricDataOperationName.START_CODE_GENERATION,
MetricDataResult.SUCCESS
)
)
const metricResult = getMetricResult(error)
assert.ok(
sendMetricDataTelemetrySpy.calledWith(MetricDataOperationName.END_CODE_GENERATION, metricResult)
)
}

async function verifyMetricsCalled() {
await waitUntil(() => Promise.resolve(sendMetricDataTelemetrySpy.callCount >= 2), {})
}

beforeEach(async () => {
session = await createCodeGenState()
sinon.stub(session, 'preloader').resolves()
sendMetricDataTelemetrySpy = sinon.stub(session, 'sendMetricDataTelemetry')
})

it('sends success operation telemetry', async () => {
sinon.stub(session, 'send').resolves()
sinon.stub(session, 'sendLinesOfCodeGeneratedTelemetry').resolves() // Avoid sending extra telemetry

await fireChatMessage(session)
await verifyMetricsCalled()

assert.ok(
sendMetricDataTelemetrySpy.calledWith(
MetricDataOperationName.START_CODE_GENERATION,
MetricDataResult.SUCCESS
)
)
assert.ok(
sendMetricDataTelemetrySpy.calledWith(
MetricDataOperationName.END_CODE_GENERATION,
MetricDataResult.SUCCESS
)
)
})

runs.forEach(({ name, error }) => {
it(`sends failure operation telemetry on ${name}`, async () => {
await verifyException(error)
})
})
})

describe('processErrorChatMessage', function () {
function createTestErrorMessage(message: string) {
return createUserFacingErrorMessage(`${featureName} request failed: ${message}`)
}

async function verifyException(error: Error) {
async function verifyException(error: ToolkitError) {
sinon.stub(session, 'preloader').throws(error)
const sendAnswerSpy = sinon.stub(controllerSetup.messenger, 'sendAnswer')
const sendErrorMessageSpy = sinon.stub(controllerSetup.messenger, 'sendErrorMessage')
const sendMonthlyLimitErrorSpy = sinon.stub(controllerSetup.messenger, 'sendMonthlyLimitError')

await fireChatMessage()
await fireChatMessage(session)

switch (error.constructor.name) {
case ContentLengthError.name:
Expand Down
Loading