Skip to content

Commit f4aa2a4

Browse files
authored
fix(codewhisperer): update code coverage tracker to keep tracker of removed characters (#2845)
## Problem Current code percentage written metric in CodeCoverageTracker does not count tokens removed by user, which makes this metric inaccurate. ## Solution Similar to what was implemented in AWS Toolkit for JetBrains by @Will-ShaoHua , we make best effort to: * Estimate total user token by counting user inputs and deletions * Estimate total accepted tokens from CodeWhisperer suggestion by counting unmodified suggestions Total user token is updated when there is a TextDocumentChange Event, * If the event is not from human input (i.e. from plugins, formatters), this event is ignored. * If the event is when user copy large chunk of code, this event is ignored. We only count the user manual keyboard input, intelliSense code acceptance and small chunk code copy as inserted total user token. For deletion events, we take the range from them and reduce the total tokens until total tokens reaches 0. Total accepted tokens is updated when user accept a code whisperer suggestion. At that time, the formatted suggestion is counted as accepted tokens. At the time of emitting telemetry data, a edit distance comparision will be done over the remaining code in accepted suggestion range and the accepted suggesetion code to estimate how many characters user has deleted in the accepted suggestion. This edit distance update will also happen when user changes active editor ( go to another file in VS Code).
1 parent 40f3237 commit f4aa2a4

File tree

5 files changed

+343
-86
lines changed

5 files changed

+343
-86
lines changed

src/codewhisperer/activation.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { SecurityPanelViewProvider } from './views/securityPanelViewProvider'
4444
import { disposeSecurityDiagnostic } from './service/diagnosticsProvider'
4545
import { RecommendationHandler } from './service/recommendationHandler'
4646
import { Commands } from '../shared/vscode/commands2'
47+
import { CodeWhispererCodeCoverageTracker } from './tracker/codewhispererCodeCoverageTracker'
4748

4849
const performance = globalThis.performance ?? require('perf_hooks').performance
4950

@@ -282,6 +283,10 @@ export async function activate(context: ExtContext): Promise<void> {
282283
}
283284
}
284285

286+
CodeWhispererCodeCoverageTracker.getTracker(
287+
e.document.languageId,
288+
context.extensionContext.globalState
289+
)?.countTotalTokens(e)
285290
/**
286291
* Handle this keystroke event only when
287292
* 1. It is in current non plaintext active editor
@@ -325,6 +330,12 @@ export async function activate(context: ExtContext): Promise<void> {
325330
}),
326331
vscode.window.onDidChangeActiveTextEditor(async e => {
327332
await InlineCompletion.instance.rejectRecommendation(vscode.window.activeTextEditor)
333+
if (vscode.window.activeTextEditor) {
334+
CodeWhispererCodeCoverageTracker.getTracker(
335+
vscode.window.activeTextEditor.document.languageId,
336+
context.extensionContext.globalState
337+
)?.updateAcceptedTokensCount(vscode.window.activeTextEditor)
338+
}
328339
}),
329340
vscode.window.onDidChangeTextEditorSelection(async e => {
330341
if (e.kind === TextEditorSelectionChangeKind.Mouse && vscode.window.activeTextEditor) {
@@ -403,6 +414,11 @@ export async function activate(context: ExtContext): Promise<void> {
403414
}
404415
}
405416

417+
CodeWhispererCodeCoverageTracker.getTracker(
418+
e.document.languageId,
419+
context.extensionContext.globalState
420+
)?.countTotalTokens(e)
421+
406422
if (
407423
e.document === vscode.window.activeTextEditor?.document &&
408424
runtimeLanguageContext.convertLanguage(e.document.languageId) !== 'plaintext' &&

src/codewhisperer/commands/onAcceptance.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,11 @@ export async function onAcceptance(acceptanceEntry: OnRecommendationAcceptanceEn
9494
completionType: acceptanceEntry.completionType,
9595
language: languageContext.language,
9696
})
97-
CodeWhispererCodeCoverageTracker.getTracker(languageContext.language, globalStorage).setAcceptedTokens(
98-
acceptanceEntry.recommendation
97+
const codeRangeAfterFormat = new vscode.Range(start, acceptanceEntry.editor.selection.active)
98+
CodeWhispererCodeCoverageTracker.getTracker(languageContext.language, globalStorage)?.countAcceptedTokens(
99+
codeRangeAfterFormat,
100+
acceptanceEntry.editor.document.getText(codeRangeAfterFormat),
101+
acceptanceEntry.editor.document.fileName
99102
)
100103
}
101104

src/codewhisperer/service/keyStrokeHandler.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,8 @@ import { DefaultCodeWhispererClient } from '../client/codewhisperer'
99
import * as EditorContext from '../util/editorContext'
1010
import { CodeWhispererConstants } from '../models/constants'
1111
import { vsCodeState, ConfigurationEntry } from '../models/model'
12-
import { runtimeLanguageContext } from '../util/runtimeLanguageContext'
1312
import { getLogger } from '../../shared/logger'
1413
import { InlineCompletion } from './inlineCompletion'
15-
import { CodeWhispererCodeCoverageTracker } from '../tracker/codewhispererCodeCoverageTracker'
16-
import globals from '../../shared/extensionGlobals'
1714
import { isCloud9 } from '../../shared/extensionUtilities'
1815
import { RecommendationHandler } from './recommendationHandler'
1916

@@ -50,12 +47,6 @@ export class KeyStrokeHandler {
5047
config: ConfigurationEntry
5148
): Promise<void> {
5249
try {
53-
const content = event.contentChanges[0].text
54-
const languageContext = runtimeLanguageContext.getLanguageContext(editor.document.languageId)
55-
CodeWhispererCodeCoverageTracker.getTracker(
56-
languageContext.language,
57-
globals.context.globalState
58-
).setTotalTokens(content)
5950
const changedText = this.getChangedText(event, config.isAutomatedTriggerEnabled, editor)
6051
if (changedText === '') {
6152
return

src/codewhisperer/tracker/codewhispererCodeCoverageTracker.ts

Lines changed: 127 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -8,88 +8,122 @@ import * as telemetry from '../../shared/telemetry/telemetry'
88
import { getLogger } from '../../shared/logger/logger'
99
import { CodeWhispererConstants } from '../models/constants'
1010
import globals from '../../shared/extensionGlobals'
11+
import { vsCodeState } from '../models/model'
12+
import { distance } from 'fastest-levenshtein'
13+
14+
interface CodeWhispererToken {
15+
range: vscode.Range
16+
text: string
17+
accepted: number
18+
}
19+
1120
/**
12-
* This singleton class is mainly used for calculating the percentage of user modification.
13-
* The current calculation method is (Levenshtein edit distance / acceptedSuggestion.length).
21+
* This singleton class is mainly used for calculating the code written by codeWhisperer
1422
*/
1523
export class CodeWhispererCodeCoverageTracker {
16-
private _acceptedTokens: string[]
17-
private _totalTokens: string[]
24+
private _acceptedTokens: { [key: string]: CodeWhispererToken[] }
25+
private _totalTokens: { [key: string]: number }
1826
private _timer?: NodeJS.Timer
1927
private _startTime: number
2028
private _language: telemetry.CodewhispererLanguage
2129

2230
private constructor(language: telemetry.CodewhispererLanguage, private readonly _globals: vscode.Memento) {
23-
this._acceptedTokens = []
24-
this._totalTokens = []
31+
this._acceptedTokens = {}
32+
this._totalTokens = {}
2533
this._startTime = 0
2634
this._language = language
2735
}
2836

29-
public setAcceptedTokens(recommendation: string) {
30-
const terms = this._globals.get<boolean>(CodeWhispererConstants.termsAcceptedKey) || false
31-
if (!terms) return
32-
33-
// generate accepted recoomendation token and stored in collection
34-
this._acceptedTokens.push(...recommendation)
35-
this._totalTokens.push(...recommendation)
37+
public get acceptedTokens(): { [key: string]: CodeWhispererToken[] } {
38+
return this._acceptedTokens
3639
}
37-
38-
public get AcceptedTokensLength(): number {
39-
return this._acceptedTokens.length
40+
public get totalTokens(): { [key: string]: number } {
41+
return this._totalTokens
4042
}
4143

42-
public setTotalTokens(content: string) {
43-
if (this._totalTokens.length === 0 && this._timer == undefined) {
44-
const currentDate = new globals.clock.Date()
45-
this._startTime = currentDate.getTime()
46-
this.startTimer()
47-
}
48-
49-
if (content.length <= 2) {
50-
this._totalTokens.push(content)
51-
} else if (content.length > 2) {
52-
this._totalTokens.push(...content)
53-
}
44+
public countAcceptedTokens(range: vscode.Range, text: string, filename: string) {
45+
const terms = this._globals.get<boolean>(CodeWhispererConstants.termsAcceptedKey) || false
46+
if (!terms) return
47+
// generate accepted recommendation token and stored in collection
48+
this.addAcceptedTokens(filename, { range: range, text: text, accepted: text.length })
49+
this.addTotalTokens(filename, text.length)
5450
}
5551

5652
public flush() {
5753
const terms = this._globals.get<boolean>(CodeWhispererConstants.termsAcceptedKey) || false
5854
if (!terms) {
59-
this._totalTokens = []
60-
this._acceptedTokens = []
55+
this._totalTokens = {}
56+
this._acceptedTokens = {}
6157
this.closeTimer()
6258
return
6359
}
64-
this.emitCodeWhispererCodeContribution()
60+
try {
61+
this.emitCodeWhispererCodeContribution()
62+
} catch (error) {
63+
getLogger().error(`Encountered ${error} when emitting code contribution metric`)
64+
}
65+
}
66+
67+
public updateAcceptedTokensCount(editor: vscode.TextEditor) {
68+
const filename = editor.document.fileName
69+
if (filename in this._acceptedTokens) {
70+
for (let i = 0; i < this._acceptedTokens[filename].length; i++) {
71+
const oldText = this._acceptedTokens[filename][i].text
72+
const newText = editor.document.getText(this._acceptedTokens[filename][i].range)
73+
this._acceptedTokens[filename][i].accepted = this.getUnmodifiedAcceptedTokens(oldText, newText)
74+
}
75+
}
76+
}
77+
// With edit distance, complicate usermodification can be considered as simple edit(add, delete, replace),
78+
// and thus the unmodified part of recommendation length can be deducted/approximated
79+
// ex. (modified > original): originalRecom: foo -> modifiedRecom: fobarbarbaro, distance = 9, delta = 12 - 9 = 3
80+
// ex. (modified == original): originalRecom: helloworld -> modifiedRecom: HelloWorld, distance = 2, delta = 10 - 2 = 8
81+
// ex. (modified < original): originalRecom: CodeWhisperer -> modifiedRecom: CODE, distance = 12, delta = 13 - 12 = 1
82+
public getUnmodifiedAcceptedTokens(origin: string, after: string) {
83+
return Math.max(origin.length, after.length) - distance(origin, after)
6584
}
6685

6786
public emitCodeWhispererCodeContribution() {
68-
const totalTokens = this._totalTokens
69-
const acceptedTokens = this._acceptedTokens
70-
const percentCount = ((acceptedTokens.length / totalTokens.length) * 100).toFixed(2)
87+
let totalTokens = 0
88+
for (const filename in this._totalTokens) {
89+
totalTokens += this._totalTokens[filename]
90+
}
91+
if (vscode.window.activeTextEditor) {
92+
this.updateAcceptedTokensCount(vscode.window.activeTextEditor)
93+
}
94+
let acceptedTokens = 0
95+
for (const filename in this._acceptedTokens) {
96+
this._acceptedTokens[filename].forEach(v => {
97+
if (filename in this._totalTokens && this._totalTokens[filename] >= v.accepted) {
98+
acceptedTokens += v.accepted
99+
}
100+
})
101+
}
102+
const percentCount = ((acceptedTokens / totalTokens) * 100).toFixed(2)
71103
const percentage = Math.round(parseInt(percentCount))
72104
telemetry.recordCodewhispererCodePercentage({
73-
codewhispererTotalTokens: totalTokens.length ? totalTokens.length : 0,
105+
codewhispererTotalTokens: totalTokens,
74106
codewhispererLanguage: this._language,
75-
codewhispererAcceptedTokens: acceptedTokens.length ? acceptedTokens.length : 0,
107+
codewhispererAcceptedTokens: acceptedTokens,
76108
codewhispererPercentage: percentage ? percentage : 0,
77109
})
78110
}
79111

80-
public startTimer() {
81-
if (this._timer !== undefined) {
82-
return
83-
}
112+
private tryStartTimer() {
113+
if (this._timer !== undefined) return
114+
const currentDate = new globals.clock.Date()
115+
this._startTime = currentDate.getTime()
84116
this._timer = setTimeout(() => {
85117
try {
86118
const currentTime = new globals.clock.Date().getTime()
87119
const delay: number = CodeWhispererConstants.defaultCheckPeriodMillis
88120
const diffTime: number = this._startTime + delay
89121
if (diffTime <= currentTime) {
90-
const totalTokens = this._totalTokens
91-
const acceptedTokens = this._acceptedTokens
92-
if (totalTokens.length > 0 && acceptedTokens.length > 0) {
122+
let totalTokens = 0
123+
for (const filename in this._totalTokens) {
124+
totalTokens += this._totalTokens[filename]
125+
}
126+
if (totalTokens > 0) {
93127
this.flush()
94128
} else {
95129
getLogger().debug(
@@ -100,28 +134,67 @@ export class CodeWhispererCodeCoverageTracker {
100134
} catch (e) {
101135
getLogger().verbose(`Exception Thrown from CodeWhispererCodeCoverageTracker: ${e}`)
102136
} finally {
103-
this._totalTokens = []
104-
this._acceptedTokens = []
137+
this._totalTokens = {}
138+
this._acceptedTokens = {}
105139
this._startTime = 0
106140
this.closeTimer()
107141
}
108142
}, CodeWhispererConstants.defaultCheckPeriodMillis)
109143
}
110144

111-
public closeTimer() {
145+
private closeTimer() {
112146
if (this._timer !== undefined) {
113147
clearTimeout(this._timer)
114148
this._timer = undefined
115149
}
116150
}
117151

118-
public static readonly instances = new Map<telemetry.CodewhispererLanguage, CodeWhispererCodeCoverageTracker>()
119-
public static getTracker(
120-
language: telemetry.CodewhispererLanguage = 'plaintext',
121-
memento: vscode.Memento
122-
): CodeWhispererCodeCoverageTracker {
123-
const instance = this.instances.get(language) ?? new this(language, memento)
124-
this.instances.set(language, instance)
125-
return instance
152+
public addAcceptedTokens(filename: string, token: CodeWhispererToken) {
153+
if (!(filename in this._acceptedTokens)) {
154+
this._acceptedTokens[filename] = []
155+
}
156+
this._acceptedTokens[filename].push(token)
157+
}
158+
159+
public addTotalTokens(filename: string, count: number) {
160+
if (!(filename in this._totalTokens)) {
161+
this._totalTokens[filename] = 0
162+
}
163+
this._totalTokens[filename] += count
164+
if (this._totalTokens[filename] < 0) {
165+
this._totalTokens[filename] = 0
166+
}
167+
}
168+
169+
public countTotalTokens(e: vscode.TextDocumentChangeEvent) {
170+
// ignore no contentChanges. ignore contentChanges from other plugins (formatters)
171+
// only include contentChanges from user action
172+
if (
173+
!CodeWhispererConstants.supportedLanguages.includes(e.document.languageId) ||
174+
vsCodeState.isCodeWhispererEditing ||
175+
e.contentChanges.length !== 1
176+
)
177+
return
178+
const content = e.contentChanges[0]
179+
// do not count user tokens if user copies large chunk of code
180+
if (content.text.length > 20) return
181+
this.tryStartTimer()
182+
// deletion events has no text.
183+
if (content.text.length === 0) {
184+
this.addTotalTokens(e.document.fileName, -content.rangeLength)
185+
} else {
186+
this.addTotalTokens(e.document.fileName, content.text.length)
187+
}
188+
}
189+
190+
public static readonly instances = new Map<string, CodeWhispererCodeCoverageTracker>()
191+
public static getTracker(language: string, memento: vscode.Memento): CodeWhispererCodeCoverageTracker | undefined {
192+
if (CodeWhispererConstants.supportedLanguages.includes(language)) {
193+
const instance =
194+
this.instances.get(language) ?? new this(language as telemetry.CodewhispererLanguage, memento)
195+
this.instances.set(language, instance)
196+
return instance
197+
}
198+
return undefined
126199
}
127200
}

0 commit comments

Comments
 (0)