Skip to content

Commit 381687b

Browse files
authored
Send empty telemetry event for empty recommendations (#3245)
1. There are two types of empty recommendations server could return to the client. The first one is an empty list of recommendations. The second one is a list of recommendations but some recommendations are empty (strings). Previously we don't send user decision events for both cases. This is resulting in a discrepancy between serviceInvocation event and userDecision event per codewhispererSessionId. We decided to send userDecision events for both. Additionally, for empty list, we just send the event and stop early, since there isn't additonal things to render. For empty recommendations, they will be kept in the state but not shown to the users, and userDecision events for them will be sent later when we send events for all recommendations we received on session close.
1 parent 115a062 commit 381687b

File tree

5 files changed

+146
-56
lines changed

5 files changed

+146
-56
lines changed

jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/popup/CodeWhispererPopupManager.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,7 @@ class CodeWhispererPopupManager {
570570

571571
private fun isValidRecommendation(detailContext: DetailContext, userInput: String, typeahead: String): Boolean {
572572
if (detailContext.isDiscarded) return false
573+
if (detailContext.recommendation.content().isEmpty()) return false
573574
val indexOfFirstNonWhiteSpace = typeahead.indexOfFirst { !it.isWhitespace() }
574575
if (indexOfFirstNonWhiteSpace == -1) return true
575576

jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt

Lines changed: 40 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhisperer
5959
import software.aws.toolkits.jetbrains.services.codewhisperer.util.CodeWhispererUtil.checkEmptyRecommendations
6060
import software.aws.toolkits.resources.message
6161
import software.aws.toolkits.telemetry.CodewhispererCompletionType
62+
import software.aws.toolkits.telemetry.CodewhispererSuggestionState
6263
import software.aws.toolkits.telemetry.CodewhispererTriggerType
6364
import java.util.concurrent.TimeUnit
6465

@@ -151,42 +152,6 @@ class CodeWhispererService {
151152
null
152153
)
153154

154-
if (emptyRecommendations) {
155-
// In case the server returns a response with empty recommendations, client-side should not
156-
// show them. They will not be added to the worker queue or appear in any of the user decision.
157-
// So we should continue to the next one or stop if there's no next one.
158-
LOG.debug { "Received 0 recommendation from response, requestId: $requestId" }
159-
if (response.nextToken().isNotEmpty()) {
160-
// If job is cancelled before we do another request, don't bother making
161-
// another API call to save resources
162-
if (!isActive) {
163-
LOG.debug { "Skipping sending remaining requests on CodeWhisperer session exit" }
164-
break
165-
}
166-
continue
167-
}
168-
169-
// At this point, the current recommendation is empty and there's no next request,
170-
// We should choose to display the "no recommendations" hint when it's a manual trigger and
171-
// There's no active CodeWhisperer popup, and we should do these in EDT.
172-
runInEdt {
173-
CodeWhispererInvocationStatus.getInstance().finishInvocation()
174-
if (CodeWhispererInvocationStatus.getInstance().isPopupActive()) {
175-
states?.let {
176-
CodeWhispererPopupManager.getInstance().updatePopupPanel(
177-
it,
178-
CodeWhispererPopupManager.getInstance().sessionContext
179-
)
180-
}
181-
return@runInEdt
182-
}
183-
CodeWhispererPopupManager.getInstance().cancelPopup(popup)
184-
if (requestContext.triggerTypeInfo.triggerType != CodewhispererTriggerType.OnDemand) return@runInEdt
185-
showCodeWhispererInfoHint(requestContext.editor, message("codewhisperer.popup.no_recommendations"))
186-
}
187-
break
188-
}
189-
190155
val validatedResponse = validateResponse(response)
191156

192157
runInEdt {
@@ -294,13 +259,14 @@ class CodeWhispererService {
294259
val responseContext = workerContext.responseContext
295260
val response = workerContext.response
296261
val popup = workerContext.popup
262+
val requestId = response.responseMetadata().requestId()
297263

298264
// At this point when we are in EDT, the state of the popup will be thread-safe
299265
// across this thread execution, so if popup is disposed, we will stop here.
300266
// This extra check is needed because there's a time between when we get the response and
301267
// when we enter the EDT.
302268
if (popup.isDisposed) {
303-
LOG.debug { "Stop showing CodeWhisperer recommendations on CodeWhisperer session exit" }
269+
LOG.debug { "Stop showing CodeWhisperer recommendations on CodeWhisperer session exit. RequestId: $requestId" }
304270
return null
305271
}
306272

@@ -312,35 +278,50 @@ class CodeWhispererService {
312278
requestContext.editor,
313279
requestContext.caretPosition
314280
)
315-
val popupIsShowing: Boolean
281+
val isPopupShowing: Boolean
316282
val nextStates: InvocationContext?
317283
if (currStates == null) {
318284
// first response
319285
nextStates = initStates(requestContext, responseContext, response, caretMovement, popup)
320-
popupIsShowing = true
286+
isPopupShowing = false
321287

322288
// receiving a null state means caret has moved backward or there's a conflict with
323289
// Intellisense popup, so we are going to cancel the job
324290
if (nextStates == null) {
325-
LOG.debug { "Cancelling popup and exiting CodeWhisperer session" }
291+
LOG.debug { "Cancelling popup and exiting CodeWhisperer session. RequestId: $requestId" }
326292
CodeWhispererPopupManager.getInstance().cancelPopup(popup)
327293
return null
328294
}
329295
} else {
330296
// subsequent responses
331297
nextStates = updateStates(currStates, response)
332-
popupIsShowing = checkRecommendationsValidity(currStates, false)
298+
isPopupShowing = checkRecommendationsValidity(currStates, false)
333299
}
334300

335301
val hasAtLeastOneValid = checkRecommendationsValidity(nextStates, response.nextToken().isEmpty())
302+
303+
// If there are no recommendations in this response, we need to manually send the user decision event here
304+
// since it won't be sent automatically later
305+
if (response.recommendations().isEmpty()) {
306+
LOG.debug { "Received an empty list from response, requestId: $requestId" }
307+
CodeWhispererTelemetryService.getInstance().sendUserDecisionEvent(
308+
requestId,
309+
requestContext,
310+
responseContext,
311+
Recommendation.builder().build(),
312+
-1,
313+
CodewhispererSuggestionState.Empty,
314+
nextStates.recommendationContext.details.size
315+
)
316+
}
336317
if (!hasAtLeastOneValid) {
337318
if (response.nextToken().isEmpty()) {
338319
LOG.debug { "None of the recommendations are valid, exiting CodeWhisperer session" }
339320
CodeWhispererPopupManager.getInstance().cancelPopup(popup)
340321
return null
341322
}
342-
} else {
343-
updateCodeWhisperer(nextStates, !popupIsShowing)
323+
} else if (response.recommendations().isNotEmpty()) {
324+
updateCodeWhisperer(nextStates, isPopupShowing)
344325
}
345326
return nextStates
346327
}
@@ -426,16 +407,23 @@ class CodeWhispererService {
426407
!CodeWhispererSettings.getInstance().isIncludeCodeWithReference()
427408
}
428409

429-
// set to true when at least one is not discarded
430-
val hasAtLeastOneValid = details.any { !it.isDiscarded }
410+
// set to true when at least one is not discarded or empty
411+
val hasAtLeastOneValid = details.any { !it.isDiscarded && it.recommendation.content().isNotEmpty() }
431412

432-
// show popup hint when non is valid and at least one is discarded by reference filter
433-
if (!hasAtLeastOneValid && hasAtLeastOneDiscardedByReferenceFilter && showHint) {
434-
HintManager.getInstance().showInformationHint(
435-
states.requestContext.editor,
436-
message("codewhisperer.popup.reference.filter"),
437-
HintManager.UNDER
438-
)
413+
if (!hasAtLeastOneValid && showHint) {
414+
if (hasAtLeastOneDiscardedByReferenceFilter) {
415+
// show popup hint for filter when none is valid and at least one is discarded by reference filter
416+
showCodeWhispererInfoHint(
417+
states.requestContext.editor,
418+
message("codewhisperer.popup.reference.filter")
419+
)
420+
} else if (states.requestContext.triggerTypeInfo.triggerType == CodewhispererTriggerType.OnDemand) {
421+
// show popup hint for no recommendation when none is valid and no one is discarded by reference filter
422+
showCodeWhispererInfoHint(
423+
states.requestContext.editor,
424+
message("codewhisperer.popup.no_recommendations")
425+
)
426+
}
439427
}
440428
return hasAtLeastOneValid
441429
}

jetbrains-core/src/software/aws/toolkits/jetbrains/services/codewhisperer/telemetry/CodeWhispererTelemetryService.kt

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ class CodeWhispererTelemetryService {
7575
)
7676
}
7777

78-
private fun sendUserDecisionEvent(
78+
fun sendUserDecisionEvent(
7979
requestId: String,
8080
requestContext: RequestContext,
8181
responseContext: ResponseContext,
@@ -185,7 +185,8 @@ class CodeWhispererTelemetryService {
185185
sessionContext.seen.contains(index),
186186
hasUserAccepted,
187187
isDiscarded,
188-
detail.hasReferences()
188+
detail.hasReferences(),
189+
detail.content().isEmpty()
189190
)
190191
sendUserDecisionEvent(requestId, requestContext, responseContext, detail, index, suggestionState, detailContexts.size)
191192
}
@@ -197,9 +198,12 @@ class CodeWhispererTelemetryService {
197198
hasSeen: Boolean,
198199
hasUserAccepted: Boolean,
199200
isDiscarded: Boolean,
200-
hasReference: Boolean
201+
hasReference: Boolean,
202+
isEmpty: Boolean
201203
): CodewhispererSuggestionState =
202-
if (!CodeWhispererSettings.getInstance().isIncludeCodeWithReference() && hasReference) {
204+
if (isEmpty) {
205+
CodewhispererSuggestionState.Empty
206+
} else if (!CodeWhispererSettings.getInstance().isIncludeCodeWithReference() && hasReference) {
203207
CodewhispererSuggestionState.Filter
204208
} else if (isDiscarded) {
205209
CodewhispererSuggestionState.Discard

jetbrains-core/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTelemetryTest.kt

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.google.gson.Gson
77
import com.intellij.openapi.application.ApplicationManager
88
import com.intellij.openapi.command.WriteCommandAction
99
import com.intellij.openapi.editor.Editor
10+
import com.intellij.openapi.ui.popup.JBPopup
1011
import com.intellij.psi.PsiDocumentManager
1112
import com.intellij.testFramework.replaceService
1213
import com.intellij.testFramework.runInEdtAndWait
@@ -21,6 +22,7 @@ import org.mockito.kotlin.atLeast
2122
import org.mockito.kotlin.atLeastOnce
2223
import org.mockito.kotlin.doAnswer
2324
import org.mockito.kotlin.mock
25+
import org.mockito.kotlin.never
2426
import org.mockito.kotlin.spy
2527
import org.mockito.kotlin.stub
2628
import org.mockito.kotlin.timeout
@@ -33,6 +35,9 @@ import software.amazon.awssdk.services.codewhisperer.model.ListRecommendationsRe
3335
import software.aws.toolkits.core.telemetry.MetricEvent
3436
import software.aws.toolkits.core.telemetry.TelemetryBatcher
3537
import software.aws.toolkits.core.telemetry.TelemetryPublisher
38+
import software.aws.toolkits.jetbrains.services.codewhisperer.CodeWhispererTestUtil.emptyListResponse
39+
import software.aws.toolkits.jetbrains.services.codewhisperer.CodeWhispererTestUtil.listOfEmptyRecommendationResponse
40+
import software.aws.toolkits.jetbrains.services.codewhisperer.CodeWhispererTestUtil.listOfMixedEmptyAndNonEmptyRecommendationResponse
3641
import software.aws.toolkits.jetbrains.services.codewhisperer.CodeWhispererTestUtil.pythonResponse
3742
import software.aws.toolkits.jetbrains.services.codewhisperer.CodeWhispererTestUtil.pythonTestLeftContext
3843
import software.aws.toolkits.jetbrains.services.codewhisperer.CodeWhispererTestUtil.testCodeWhispererException
@@ -520,6 +525,70 @@ class CodeWhispererTelemetryTest : CodeWhispererTestBase() {
520525
metricCaptor.allValues.forEach { println(it) }
521526
}
522527

528+
@Test
529+
fun `test empty list of recommendations should sent 1 empty userDecision event and no popup shown`() {
530+
mockClient.stub {
531+
on {
532+
mockClient.listRecommendations(any<ListRecommendationsRequest>())
533+
} doAnswer {
534+
emptyListResponse
535+
}
536+
}
537+
invokeCodeWhispererService()
538+
539+
verify(popupManagerSpy, never()).showPopup(any(), any(), any(), any(), any(), any(), any(), any())
540+
runInEdtAndWait {
541+
val metricCaptor = argumentCaptor<MetricEvent>()
542+
verify(batcher, atLeastOnce()).enqueue(metricCaptor.capture())
543+
assertEventsContainsFieldsAndCount(
544+
metricCaptor.allValues, userDecision, 1,
545+
"codewhispererSuggestionState" to CodewhispererSuggestionState.Empty.toString()
546+
)
547+
}
548+
}
549+
550+
@Test
551+
fun `test empty recommendations should send empty user decision events`() {
552+
testSendEmptyUserDecisionEventForEmptyRecommendations(listOfEmptyRecommendationResponse)
553+
}
554+
555+
@Test
556+
fun `test a mix of empty and non-empty recommendations should send empty user decision events accordingly`() {
557+
testSendEmptyUserDecisionEventForEmptyRecommendations(listOfMixedEmptyAndNonEmptyRecommendationResponse)
558+
}
559+
560+
private fun testSendEmptyUserDecisionEventForEmptyRecommendations(response: ListRecommendationsResponse) {
561+
mockClient.stub {
562+
on {
563+
mockClient.listRecommendations(any<ListRecommendationsRequest>())
564+
} doAnswer {
565+
response
566+
}
567+
}
568+
569+
invokeCodeWhispererService()
570+
571+
val numOfEmptyRecommendations = response.recommendations().filter { it.content().isEmpty() }.size
572+
if (numOfEmptyRecommendations == response.recommendations().size) {
573+
verify(popupManagerSpy, never()).showPopup(any(), any(), any(), any(), any(), any(), any(), any())
574+
} else {
575+
val popupCaptor = argumentCaptor<JBPopup>()
576+
verify(popupManagerSpy, timeout(5000))
577+
.showPopup(any(), any(), any(), any(), any(), popupCaptor.capture(), any(), any())
578+
runInEdtAndWait {
579+
popupManagerSpy.closePopup(popupCaptor.lastValue)
580+
}
581+
}
582+
runInEdtAndWait {
583+
val metricCaptor = argumentCaptor<MetricEvent>()
584+
verify(batcher, atLeastOnce()).enqueue(metricCaptor.capture())
585+
assertEventsContainsFieldsAndCount(
586+
metricCaptor.allValues, userDecision, numOfEmptyRecommendations,
587+
"codewhispererSuggestionState" to CodewhispererSuggestionState.Empty.toString()
588+
)
589+
}
590+
}
591+
523592
private fun Editor.appendString(string: String) {
524593
val currentOffset = caretModel.primaryCaret.offset
525594
document.insertString(currentOffset, string)

jetbrains-core/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestUtil.kt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,34 @@ object CodeWhispererTestUtil {
6565
.responseMetadata(metadata)
6666
.sdkHttpResponse(sdkHttpResponse)
6767
.build() as ListRecommendationsResponse
68+
val emptyListResponse: ListRecommendationsResponse = ListRecommendationsResponse.builder()
69+
.recommendations(listOf())
70+
.nextToken("")
71+
.responseMetadata(metadata)
72+
.sdkHttpResponse(sdkHttpResponse)
73+
.build() as ListRecommendationsResponse
74+
val listOfEmptyRecommendationResponse: ListRecommendationsResponse = ListRecommendationsResponse.builder()
75+
.recommendations(
76+
generateMockRecommendationDetail(""),
77+
generateMockRecommendationDetail(""),
78+
generateMockRecommendationDetail(""),
79+
)
80+
.nextToken("")
81+
.responseMetadata(metadata)
82+
.sdkHttpResponse(sdkHttpResponse)
83+
.build() as ListRecommendationsResponse
84+
val listOfMixedEmptyAndNonEmptyRecommendationResponse: ListRecommendationsResponse = ListRecommendationsResponse.builder()
85+
.recommendations(
86+
generateMockRecommendationDetail(""),
87+
generateMockRecommendationDetail("test recommendation 3"),
88+
generateMockRecommendationDetail(""),
89+
generateMockRecommendationDetail("test recommendation 4"),
90+
generateMockRecommendationDetail("test recommendation 5")
91+
)
92+
.nextToken("")
93+
.responseMetadata(metadata)
94+
.sdkHttpResponse(sdkHttpResponse)
95+
.build() as ListRecommendationsResponse
6896

6997
const val pythonFileName = "test.py"
7098
const val javaFileName = "test.java"

0 commit comments

Comments
 (0)