Skip to content

Commit fdaaae1

Browse files
authored
telemetry(amazonq): Add changed IDE diagnostics after user acceptance (aws#7130)
## Problem When user accepts suggestion from inline completion or chat, there can be a change in the current open editor's IDE diagnostics, this can be used as a measure for code suggestion quality. Ref: aws/aws-toolkit-jetbrains#5613 This change is part of the server side workspace context. To reduce the risk of large blast radius, this change is only applied for users who are in the experiment of server side project context (both treatment and control), which is the Amzn idc users. E2E verifying request id: `97a3c4d4-0c78-4329-93ae-379d2ec66646`. ## Solution 1. Add changed IDE diagnostics after user acceptance --- - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). - License: I confirm that my contribution is made under the terms of the Apache 2.0 license.
1 parent e39eff7 commit fdaaae1

File tree

8 files changed

+332
-60
lines changed

8 files changed

+332
-60
lines changed
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import * as assert from 'assert'
7+
import * as vscode from 'vscode'
8+
import { getDiagnosticsType, getDiagnosticsDifferences } from 'aws-core-vscode/codewhisperer'
9+
describe('diagnosticsUtil', function () {
10+
describe('getDiagnosticsType', function () {
11+
it('should identify SYNTAX_ERROR correctly', function () {
12+
assert.strictEqual(getDiagnosticsType('Expected semicolon'), 'SYNTAX_ERROR')
13+
assert.strictEqual(getDiagnosticsType('Incorrect indent level'), 'SYNTAX_ERROR')
14+
assert.strictEqual(getDiagnosticsType('Syntax error in line 5'), 'SYNTAX_ERROR')
15+
})
16+
17+
it('should identify TYPE_ERROR correctly', function () {
18+
assert.strictEqual(getDiagnosticsType('Type mismatch'), 'TYPE_ERROR')
19+
assert.strictEqual(getDiagnosticsType('Invalid type cast'), 'TYPE_ERROR')
20+
})
21+
22+
it('should identify REFERENCE_ERROR correctly', function () {
23+
assert.strictEqual(getDiagnosticsType('Variable is undefined'), 'REFERENCE_ERROR')
24+
assert.strictEqual(getDiagnosticsType('Variable not defined'), 'REFERENCE_ERROR')
25+
assert.strictEqual(getDiagnosticsType('Reference error occurred'), 'REFERENCE_ERROR')
26+
})
27+
28+
it('should identify BEST_PRACTICE correctly', function () {
29+
assert.strictEqual(getDiagnosticsType('Using deprecated method'), 'BEST_PRACTICE')
30+
assert.strictEqual(getDiagnosticsType('Variable is unused'), 'BEST_PRACTICE')
31+
assert.strictEqual(getDiagnosticsType('Variable not initialized'), 'BEST_PRACTICE')
32+
})
33+
34+
it('should identify SECURITY correctly', function () {
35+
assert.strictEqual(getDiagnosticsType('Potential security vulnerability'), 'SECURITY')
36+
assert.strictEqual(getDiagnosticsType('Security risk detected'), 'SECURITY')
37+
})
38+
39+
it('should return OTHER for unrecognized messages', function () {
40+
assert.strictEqual(getDiagnosticsType('Random message'), 'OTHER')
41+
assert.strictEqual(getDiagnosticsType(''), 'OTHER')
42+
})
43+
})
44+
45+
describe('getDiagnosticsDifferences', function () {
46+
const createDiagnostic = (message: string): vscode.Diagnostic => {
47+
return {
48+
message,
49+
severity: vscode.DiagnosticSeverity.Error,
50+
range: new vscode.Range(0, 0, 0, 1),
51+
source: 'test',
52+
}
53+
}
54+
55+
it('should return empty arrays when both inputs are undefined', function () {
56+
const result = getDiagnosticsDifferences(undefined, undefined)
57+
assert.deepStrictEqual(result, { added: [], removed: [] })
58+
})
59+
60+
it('should return empty arrays when filepaths are different', function () {
61+
const oldDiagnostics = {
62+
filepath: '/path/to/file1',
63+
diagnostics: [createDiagnostic('error1')],
64+
}
65+
const newDiagnostics = {
66+
filepath: '/path/to/file2',
67+
diagnostics: [createDiagnostic('error1')],
68+
}
69+
const result = getDiagnosticsDifferences(oldDiagnostics, newDiagnostics)
70+
assert.deepStrictEqual(result, { added: [], removed: [] })
71+
})
72+
73+
it('should correctly identify added and removed diagnostics', function () {
74+
const diagnostic1 = createDiagnostic('error1')
75+
const diagnostic2 = createDiagnostic('error2')
76+
const diagnostic3 = createDiagnostic('error3')
77+
78+
const oldDiagnostics = {
79+
filepath: '/path/to/file',
80+
diagnostics: [diagnostic1, diagnostic2],
81+
}
82+
const newDiagnostics = {
83+
filepath: '/path/to/file',
84+
diagnostics: [diagnostic2, diagnostic3],
85+
}
86+
87+
const result = getDiagnosticsDifferences(oldDiagnostics, newDiagnostics)
88+
assert.deepStrictEqual(result.added, [diagnostic3])
89+
assert.deepStrictEqual(result.removed, [diagnostic1])
90+
})
91+
})
92+
})

packages/core/src/amazonq/commons/controllers/contentController.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { ToolkitError, getErrorMsg } from '../../../shared/errors'
1919
import fs from '../../../shared/fs/fs'
2020
import { extractFileAndCodeSelectionFromMessage } from '../../../shared/utilities/textUtilities'
2121
import { UserWrittenCodeTracker } from '../../../codewhisperer/tracker/userWrittenCodeTracker'
22+
import { CWCTelemetryHelper } from '../../../codewhispererChat/controllers/chat/telemetryHelper'
2223
import type { ViewDiff } from '../../../codewhispererChat/controllers/chat/model'
2324
import type { TriggerEvent } from '../../../codewhispererChat/storages/triggerEvents'
2425
import { DiffContentProvider } from './diffContentProvider'
@@ -49,6 +50,7 @@ export class EditorContentController {
4950
) {
5051
const editor = window.activeTextEditor
5152
if (editor) {
53+
CWCTelemetryHelper.instance.setDocumentDiagnostics()
5254
UserWrittenCodeTracker.instance.onQStartsMakingEdits()
5355
const cursorStart = editor.selection.active
5456
const indentRange = new vscode.Range(new vscode.Position(cursorStart.line, 0), cursorStart)

packages/core/src/codewhisperer/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export * from './util/securityScanLanguageContext'
8989
export * from './util/importAdderUtil'
9090
export * from './util/globalStateUtil'
9191
export * from './util/zipUtil'
92+
export * from './util/diagnosticsUtil'
9293
export * from './util/commonUtil'
9394
export * from './util/supplementalContext/codeParsingUtil'
9495
export * from './util/supplementalContext/supplementalContextUtil'

packages/core/src/codewhisperer/util/codeWhispererSession.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
33
* SPDX-License-Identifier: Apache-2.0
44
*/
5-
65
import {
76
CodewhispererCompletionType,
87
CodewhispererLanguage,
@@ -13,6 +12,7 @@ import {
1312
import { GenerateRecommendationsRequest, ListRecommendationsRequest, Recommendation } from '../client/codewhisperer'
1413
import { Position } from 'vscode'
1514
import { CodeWhispererSupplementalContext, vsCodeState } from '../models/model'
15+
import { FileDiagnostic, getDiagnosticsOfCurrentFile } from './diagnosticsUtil'
1616

1717
class CodeWhispererSession {
1818
static #instance: CodeWhispererSession
@@ -45,6 +45,7 @@ class CodeWhispererSession {
4545
timeToFirstRecommendation = 0
4646
firstSuggestionShowTime = 0
4747
perceivedLatency = 0
48+
diagnosticsBeforeAccept: FileDiagnostic | undefined = undefined
4849

4950
public static get instance() {
5051
return (this.#instance ??= new CodeWhispererSession())
@@ -66,6 +67,7 @@ class CodeWhispererSession {
6667
if (this.invokeSuggestionStartTime) {
6768
this.timeToFirstRecommendation = timeToFirstRecommendation - this.invokeSuggestionStartTime
6869
}
70+
this.diagnosticsBeforeAccept = getDiagnosticsOfCurrentFile()
6971
}
7072

7173
setSuggestionState(index: number, value: string) {
@@ -116,6 +118,7 @@ class CodeWhispererSession {
116118
this.recommendations = []
117119
this.suggestionStates.clear()
118120
this.completionTypes.clear()
121+
this.diagnosticsBeforeAccept = undefined
119122
}
120123
}
121124

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
import * as vscode from 'vscode'
6+
import * as crypto from 'crypto'
7+
import { IdeDiagnostic } from '../client/codewhispereruserclient'
8+
9+
export function getDiagnosticsOfCurrentFile(): FileDiagnostic | undefined {
10+
if (vscode.window.activeTextEditor) {
11+
return {
12+
diagnostics: vscode.languages.getDiagnostics(vscode.window.activeTextEditor.document.uri),
13+
filepath: vscode.window.activeTextEditor.document.uri.fsPath,
14+
}
15+
}
16+
return undefined
17+
}
18+
19+
export type FileDiagnostic = {
20+
filepath: string
21+
diagnostics: vscode.Diagnostic[]
22+
}
23+
24+
export function getDiagnosticsDifferences(
25+
oldDiagnostics: FileDiagnostic | undefined,
26+
newDiagnostics: FileDiagnostic | undefined
27+
): { added: vscode.Diagnostic[]; removed: vscode.Diagnostic[] } {
28+
const result: { added: vscode.Diagnostic[]; removed: vscode.Diagnostic[] } = { added: [], removed: [] }
29+
if (
30+
oldDiagnostics === undefined ||
31+
newDiagnostics === undefined ||
32+
newDiagnostics.filepath !== oldDiagnostics.filepath
33+
) {
34+
return result
35+
}
36+
37+
// Create maps using diagnostic key for uniqueness
38+
const oldMap = new Map(oldDiagnostics.diagnostics.map((d) => [getDiagnosticKey(d), d]))
39+
const newMap = new Map(newDiagnostics.diagnostics.map((d) => [getDiagnosticKey(d), d]))
40+
41+
// Get added diagnostics (in new but not in old)
42+
result.added = [...newMap.values()].filter((d) => !oldMap.has(getDiagnosticKey(d)))
43+
44+
// Get removed diagnostics (in old but not in new)
45+
result.removed = [...oldMap.values()].filter((d) => !newMap.has(getDiagnosticKey(d)))
46+
47+
return result
48+
}
49+
50+
export function toIdeDiagnostics(diagnostic: vscode.Diagnostic): IdeDiagnostic {
51+
const severity =
52+
diagnostic.severity === vscode.DiagnosticSeverity.Error
53+
? 'ERROR'
54+
: diagnostic.severity === vscode.DiagnosticSeverity.Warning
55+
? 'WARNING'
56+
: diagnostic.severity === vscode.DiagnosticSeverity.Hint
57+
? 'HINT'
58+
: 'INFORMATION'
59+
60+
return {
61+
ideDiagnosticType: getDiagnosticsType(diagnostic.message),
62+
severity: severity,
63+
source: diagnostic.source,
64+
range: {
65+
start: {
66+
line: diagnostic.range.start.line,
67+
character: diagnostic.range.start.character,
68+
},
69+
end: {
70+
line: diagnostic.range.end.line,
71+
character: diagnostic.range.end.character,
72+
},
73+
},
74+
}
75+
}
76+
77+
export function getDiagnosticsType(message: string): string {
78+
const errorTypes = new Map([
79+
['SYNTAX_ERROR', ['expected', 'indent', 'syntax']],
80+
['TYPE_ERROR', ['type', 'cast']],
81+
['REFERENCE_ERROR', ['undefined', 'not defined', 'undeclared', 'reference']],
82+
['BEST_PRACTICE', ['deprecated', 'unused', 'uninitialized', 'not initialized']],
83+
['SECURITY', ['security', 'vulnerability']],
84+
])
85+
86+
const lowercaseMessage = message.toLowerCase()
87+
88+
for (const [errorType, keywords] of errorTypes) {
89+
if (keywords.some((keyword) => lowercaseMessage.includes(keyword))) {
90+
return errorType
91+
}
92+
}
93+
94+
return 'OTHER'
95+
}
96+
97+
/**
98+
* Generates a unique MD5 hash key for a VS Code diagnostic object.
99+
*
100+
* @param diagnostic - A VS Code Diagnostic object containing information about a code diagnostic
101+
* @returns A 32-character hexadecimal MD5 hash string that uniquely identifies the diagnostic
102+
*
103+
* @description
104+
* Creates a deterministic hash by combining the diagnostic's message, severity, code, and source.
105+
* This hash can be used as a unique identifier for deduplication or tracking purposes.
106+
* Note: range is not in the hashed string because a diagnostic can move and its range can change within the editor
107+
*/
108+
function getDiagnosticKey(diagnostic: vscode.Diagnostic): string {
109+
const jsonStr = JSON.stringify({
110+
message: diagnostic.message,
111+
severity: diagnostic.severity,
112+
code: diagnostic.code,
113+
source: diagnostic.source,
114+
})
115+
116+
return crypto.createHash('md5').update(jsonStr).digest('hex')
117+
}

packages/core/src/codewhisperer/util/telemetryHelper.ts

Lines changed: 51 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,12 @@ import { getLogger } from '../../shared/logger/logger'
2626
import { session } from './codeWhispererSession'
2727
import { CodeWhispererSupplementalContext } from '../models/model'
2828
import { FeatureConfigProvider } from '../../shared/featureConfig'
29-
import { CodeScanRemediationsEventType } from '../client/codewhispereruserclient'
29+
import CodeWhispererUserClient, { CodeScanRemediationsEventType } from '../client/codewhispereruserclient'
3030
import { CodeAnalysisScope as CodeAnalysisScopeClientSide } from '../models/constants'
3131
import { Session } from '../../amazonqTest/chat/session/session'
32+
import { sleep } from '../../shared/utilities/timeoutUtils'
33+
import { getDiagnosticsDifferences, getDiagnosticsOfCurrentFile, toIdeDiagnostics } from './diagnosticsUtil'
34+
import { Auth } from '../../auth/auth'
3235

3336
export class TelemetryHelper {
3437
// Some variables for client component latency
@@ -422,46 +425,56 @@ export class TelemetryHelper {
422425
e2eLatency = 0.0
423426
}
424427

425-
client
426-
.sendTelemetryEvent({
427-
telemetryEvent: {
428-
userTriggerDecisionEvent: {
429-
sessionId: sessionId,
430-
requestId: this.sessionDecisions[0].codewhispererFirstRequestId,
431-
customizationArn: selectedCustomization.arn === '' ? undefined : selectedCustomization.arn,
432-
programmingLanguage: {
433-
languageName: runtimeLanguageContext.toRuntimeLanguage(
434-
this.sessionDecisions[0].codewhispererLanguage
435-
),
436-
},
437-
completionType: this.getSendTelemetryCompletionType(aggregatedCompletionType),
438-
suggestionState: this.getSendTelemetrySuggestionState(aggregatedSuggestionState),
439-
recommendationLatencyMilliseconds: e2eLatency,
440-
triggerToResponseLatencyMilliseconds: session.timeToFirstRecommendation,
441-
perceivedLatencyMilliseconds: session.perceivedLatency,
442-
timestamp: new Date(Date.now()),
443-
suggestionReferenceCount: referenceCount,
444-
generatedLine: generatedLines,
445-
numberOfRecommendations: suggestionCount,
446-
acceptedCharacterCount: acceptedRecommendationContent.length,
447-
},
448-
},
449-
profileArn: profile?.arn,
450-
})
451-
.then()
452-
.catch((error) => {
453-
let requestId: string | undefined
454-
if (isAwsError(error)) {
455-
requestId = error.requestId
456-
}
428+
const userTriggerDecisionEvent: CodeWhispererUserClient.UserTriggerDecisionEvent = {
429+
sessionId: sessionId,
430+
requestId: this.sessionDecisions[0].codewhispererFirstRequestId,
431+
customizationArn: selectedCustomization.arn === '' ? undefined : selectedCustomization.arn,
432+
programmingLanguage: {
433+
languageName: runtimeLanguageContext.toRuntimeLanguage(this.sessionDecisions[0].codewhispererLanguage),
434+
},
435+
completionType: this.getSendTelemetryCompletionType(aggregatedCompletionType),
436+
suggestionState: this.getSendTelemetrySuggestionState(aggregatedSuggestionState),
437+
recommendationLatencyMilliseconds: e2eLatency,
438+
triggerToResponseLatencyMilliseconds: session.timeToFirstRecommendation,
439+
perceivedLatencyMilliseconds: session.perceivedLatency,
440+
timestamp: new Date(Date.now()),
441+
suggestionReferenceCount: referenceCount,
442+
generatedLine: generatedLines,
443+
numberOfRecommendations: suggestionCount,
444+
acceptedCharacterCount: acceptedRecommendationContent.length,
445+
}
446+
this.resetUserTriggerDecisionTelemetry()
457447

458-
getLogger().debug(
459-
`Failed to sendTelemetryEvent to CodeWhisperer, requestId: ${requestId ?? ''}, message: ${
460-
error.message
461-
}`
448+
const sendEvent = () =>
449+
client
450+
.sendTelemetryEvent({
451+
telemetryEvent: { userTriggerDecisionEvent: userTriggerDecisionEvent },
452+
profileArn: profile?.arn,
453+
})
454+
.catch((error) => {
455+
const requestId = isAwsError(error) ? error.requestId : undefined
456+
getLogger().debug(
457+
`Failed to sendTelemetryEvent to CodeWhisperer, requestId: ${requestId ?? ''}, message: ${error.message}`
458+
)
459+
})
460+
461+
if (userTriggerDecisionEvent.suggestionState === 'ACCEPT' && Auth.instance.isInternalAmazonUser()) {
462+
// wait 1 seconds for the user installed 3rd party LSP
463+
// to update its diagnostics.
464+
void sleep(1000).then(() => {
465+
const diagnosticDiff = getDiagnosticsDifferences(
466+
session.diagnosticsBeforeAccept,
467+
getDiagnosticsOfCurrentFile()
468+
)
469+
userTriggerDecisionEvent.addedIdeDiagnostics = diagnosticDiff.added.map((it) => toIdeDiagnostics(it))
470+
userTriggerDecisionEvent.removedIdeDiagnostics = diagnosticDiff.removed.map((it) =>
471+
toIdeDiagnostics(it)
462472
)
473+
void sendEvent()
463474
})
464-
this.resetUserTriggerDecisionTelemetry()
475+
} else {
476+
void sendEvent()
477+
}
465478
}
466479

467480
public getLastTriggerDecisionForClassifier() {

0 commit comments

Comments
 (0)