Skip to content

Commit 8f00e30

Browse files
authored
telemetry(webview): toolkit_didLoadModule (#2009)
1 parent 6ba8618 commit 8f00e30

File tree

13 files changed

+321
-6
lines changed

13 files changed

+321
-6
lines changed

plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/QLoginWebview.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,10 @@ class QWebviewBrowser(val project: Project, private val parentDisposable: Dispos
216216
intent = QProfileSwitchIntent.Auth
217217
)
218218
}
219+
220+
is BrowserMessage.PublishWebviewTelemetry -> {
221+
publishTelemetry(message)
222+
}
219223
}
220224
}
221225

plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/webview/BrowserMessage.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo
2727
JsonSubTypes.Type(value = BrowserMessage.Reauth::class, name = "reauth"),
2828
JsonSubTypes.Type(value = BrowserMessage.SendUiClickTelemetry::class, name = "sendUiClickTelemetry"),
2929
JsonSubTypes.Type(value = BrowserMessage.SwitchProfile::class, name = "switchProfile"),
30-
30+
JsonSubTypes.Type(value = BrowserMessage.PublishWebviewTelemetry::class, name = "webviewTelemetry")
3131
)
3232
sealed interface BrowserMessage {
3333

@@ -67,4 +67,6 @@ sealed interface BrowserMessage {
6767
) : BrowserMessage
6868

6969
data class SendUiClickTelemetry(val signInOptionClicked: String?) : BrowserMessage
70+
71+
data class PublishWebviewTelemetry(val event: String) : BrowserMessage
7072
}

plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/webview/LoginBrowser.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,26 @@ abstract class LoginBrowser(
406406
return false
407407
}
408408

409+
// TODO: should test via handleMessage, however because we can't initiate Q/ToolkitLoginBrowser in test due to jcef not supported in test env
410+
// plus handleMessage is abstract so as a interim, exposing it for testing purpose
411+
@VisibleForTesting
412+
fun publishTelemetry(message: BrowserMessage.PublishWebviewTelemetry) {
413+
val jsonNode = this.objectMapper.readTree(message.event) ?: return
414+
if (jsonNode["metricName"].asText() == "toolkit_didLoadModule") {
415+
val moduleNode = jsonNode["module"] ?: return
416+
val resultNode = jsonNode["result"] ?: return
417+
val result = MetricResult.from(resultNode.asText())
418+
val reasonNode = jsonNode["reason"]
419+
val durationNode = jsonNode["duration"]
420+
Telemetry.toolkit.didLoadModule.use { span ->
421+
span.module(moduleNode.asText())
422+
span.result(result)
423+
span.reason(reasonNode?.asText())
424+
span.duration(durationNode?.asDouble())
425+
}
426+
}
427+
}
428+
409429
companion object {
410430
private val LOG = getLogger<LoginBrowser>()
411431
fun getWebviewHTML(webScriptUri: String, query: JBCefJSQuery): String {

plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/BrowserMessageTest.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,19 @@ class BrowserMessageTest {
161161
signInOptionClicked = null
162162
)
163163
)
164+
165+
assertDeserializedInstanceOf<BrowserMessage.PublishWebviewTelemetry>(
166+
"""
167+
{
168+
"command": "webviewTelemetry",
169+
"event": "{ \"metricName\": \"foo\" }"
170+
}
171+
""".trimIndent()
172+
).isEqualTo(
173+
BrowserMessage.PublishWebviewTelemetry(
174+
event = "{ \"metricName\": \"foo\" }"
175+
)
176+
)
164177
}
165178

166179
@Test
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package software.aws.toolkits.jetbrains.core
5+
6+
import com.intellij.openapi.project.Project
7+
import com.intellij.testFramework.ProjectExtension
8+
import com.intellij.ui.jcef.JBCefBrowserBase
9+
import com.intellij.ui.jcef.JBCefJSQuery
10+
import org.assertj.core.api.Assertions.assertThat
11+
import org.junit.jupiter.api.BeforeEach
12+
import org.junit.jupiter.api.Test
13+
import org.junit.jupiter.api.extension.RegisterExtension
14+
import org.mockito.kotlin.argumentCaptor
15+
import org.mockito.kotlin.mock
16+
import org.mockito.kotlin.times
17+
import org.mockito.kotlin.verify
18+
import software.aws.toolkits.core.telemetry.MetricEvent
19+
import software.aws.toolkits.jetbrains.core.webview.BrowserMessage
20+
import software.aws.toolkits.jetbrains.core.webview.BrowserState
21+
import software.aws.toolkits.jetbrains.core.webview.LoginBrowser
22+
import software.aws.toolkits.jetbrains.services.telemetry.MockTelemetryServiceExtension
23+
import kotlin.test.assertNotNull
24+
25+
class TestLoginBrowser(project: Project) : LoginBrowser(project, "", "") {
26+
// test env can't initiate a real jcef and will throw error
27+
override val jcefBrowser: JBCefBrowserBase
28+
get() = mock()
29+
30+
override fun handleBrowserMessage(message: BrowserMessage?) {}
31+
32+
override fun prepareBrowser(state: BrowserState) {}
33+
34+
override fun loadWebView(query: JBCefJSQuery) {}
35+
}
36+
37+
class LoginBrowserTest {
38+
private lateinit var sut: TestLoginBrowser
39+
private val project: Project
40+
get() = projectExtension.project
41+
42+
@JvmField
43+
@RegisterExtension
44+
val mockTelemetryService = MockTelemetryServiceExtension()
45+
46+
companion object {
47+
@JvmField
48+
@RegisterExtension
49+
val projectExtension = ProjectExtension()
50+
}
51+
52+
@BeforeEach
53+
fun setup() {
54+
sut = TestLoginBrowser(project)
55+
}
56+
57+
@Test
58+
fun `publish telemetry happy path`() {
59+
val load = """
60+
{
61+
"metricName": "toolkit_didLoadModule",
62+
"module": "login",
63+
"result": "Succeeded",
64+
"duration": "0"
65+
}
66+
""".trimIndent()
67+
val message = BrowserMessage.PublishWebviewTelemetry(load)
68+
sut.publishTelemetry(message)
69+
70+
mockTelemetryService.batcher()
71+
argumentCaptor<MetricEvent> {
72+
verify(mockTelemetryService.batcher()).enqueue(capture())
73+
val event = firstValue.data.find { it.name == "toolkit_didLoadModule" }
74+
assertNotNull(event)
75+
assertThat(event)
76+
.matches { it.metadata["module"] == "login" }
77+
.matches { it.metadata["result"] == "Succeeded" }
78+
.matches { it.metadata["duration"] == "0.0" }
79+
}
80+
}
81+
82+
@Test
83+
fun `publish telemetry error path`() {
84+
val load = """
85+
{
86+
"metricName": "toolkit_didLoadModule",
87+
"module": "login",
88+
"result": "Failed",
89+
"reason": "unexpected error"
90+
}
91+
""".trimIndent()
92+
val message = BrowserMessage.PublishWebviewTelemetry(load)
93+
sut.publishTelemetry(message)
94+
95+
mockTelemetryService.batcher()
96+
argumentCaptor<MetricEvent> {
97+
verify(mockTelemetryService.batcher()).enqueue(capture())
98+
val event = firstValue.data.find { it.name == "toolkit_didLoadModule" }
99+
assertNotNull(event)
100+
assertThat(event)
101+
.matches { it.metadata["module"] == "login" }
102+
.matches { it.metadata["result"] == "Failed" }
103+
.matches { it.metadata["reason"] == "unexpected error" }
104+
}
105+
}
106+
107+
@Test
108+
fun `missing required field will do nothing`() {
109+
val load = """
110+
{
111+
"metricName": "toolkit_didLoadModule"
112+
}
113+
""".trimIndent()
114+
val message = BrowserMessage.PublishWebviewTelemetry(load)
115+
sut.publishTelemetry(message)
116+
117+
val load1 = """
118+
{
119+
"metricName": "toolkit_didLoadModule",
120+
"module": "login"
121+
}
122+
""".trimIndent()
123+
val message1 = BrowserMessage.PublishWebviewTelemetry(load1)
124+
sut.publishTelemetry(message1)
125+
126+
val load2 = """
127+
{
128+
"metricName": "toolkit_didLoadModule",
129+
"result": "Failed"
130+
}
131+
""".trimIndent()
132+
val message2 = BrowserMessage.PublishWebviewTelemetry(load2)
133+
sut.publishTelemetry(message2)
134+
135+
mockTelemetryService.batcher()
136+
argumentCaptor<MetricEvent> {
137+
verify(mockTelemetryService.batcher(), times(0)).enqueue(capture())
138+
}
139+
}
140+
141+
@Test
142+
fun `metricName doesn't match will do nothing`() {
143+
val load = """
144+
{
145+
"metricName": "foo",
146+
"module": "login",
147+
"result": "Failed",
148+
"reason": "unexpected error"
149+
}
150+
""".trimIndent()
151+
val message = BrowserMessage.PublishWebviewTelemetry(load)
152+
sut.publishTelemetry(message)
153+
154+
mockTelemetryService.batcher()
155+
argumentCaptor<MetricEvent> {
156+
verify(mockTelemetryService.batcher(), times(0)).enqueue(capture())
157+
}
158+
}
159+
}

plugins/core/webview/src/ideClient.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
33

4-
import { profile } from "console";
54
import {Store} from "vuex";
65
import {IdcInfo, Region, Stage, State, BrowserSetupData, AwsBearerTokenConnection, Profile} from "./model";
6+
import {WebviewTelemetry} from './webviewTelemetry'
77

88
export class IdeClient {
99
constructor(private readonly store: Store<State>) {}
1010

1111
// TODO: design and improve the API here
1212

1313
prepareUi(state: BrowserSetupData) {
14+
WebviewTelemetry.instance.reset()
1415
console.log('browser is preparing UI with state ', state)
1516
this.store.commit('setStage', state.stage)
17+
// hack as window.onerror don't have access to vuex store
18+
void ((window as any).uiState = state.stage)
19+
WebviewTelemetry.instance.willShowPage(state.stage)
1620
this.store.commit('setSsoRegions', state.regions)
1721
this.updateLastLoginIdcInfo(state.idcInfo)
1822
this.store.commit("setCancellable", state.cancellable)

plugins/core/webview/src/q-ui/components/profileSelection.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<div @keydown.enter="handleContinueClick">
55
<div class="font-amazon">
66
<!-- Title & Subtitle -->
7-
<div class="profile-header">
7+
<div id="profile-page" class="profile-header">
88
<h2 class="title bottom-small-gap">Select profile</h2>
99
<p class="profile-subtitle">
1010
Profiles have different configs defined by your administrators.

plugins/core/webview/src/q-ui/components/qOptions.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
class="font-amazon bottom-small-gap"
4242
></SelectableItem>
4343
<button
44+
id="login-page"
4445
class="login-flow-button continue-button font-amazon"
4546
:disabled="selectedLoginOption === LoginIdentifier.NONE"
4647
v-on:click="handleContinueClick()"

plugins/core/webview/src/q-ui/components/reauth.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
<div class="font-amazon bottom-small-gap title centered">Please re-authenticate to continue</div>
1919

2020
<button
21+
id="reauth-page"
2122
class="login-flow-button continue-button font-amazon"
2223
v-on:click="reauth()"
2324
>

plugins/core/webview/src/q-ui/components/root.vue

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,15 @@
1919
<script lang="ts">
2020
import { defineComponent } from 'vue'
2121
import Login from './login.vue'
22-
import Reauth from "@/q-ui/components/reauth.vue";
23-
import {Stage} from "../..//model";
24-
import Logo from "@/q-ui/components/logo.vue";
22+
import Reauth from "@/q-ui/components/reauth.vue"
23+
import {Stage} from '../..//model'
24+
import {WebviewTelemetry} from '../../webviewTelemetry'
25+
import Logo from '@/q-ui/components/logo.vue'
26+
27+
window.onerror = function (message) {
28+
WebviewTelemetry.instance.didShowPage((window as any).uiState, message.toString())
29+
}
30+
2531
export default defineComponent({
2632
name: 'auth',
2733
components: {
@@ -43,6 +49,12 @@ export default defineComponent({
4349
mounted() {
4450
window.changeTheme = this.changeTheme.bind(this)
4551
window.ideApi.postMessage({command: 'prepareUi'})
52+
// update() alone can't cover the very first rendering of the page as webview default page is 'start'
53+
WebviewTelemetry.instance.willShowPage(this.stage)
54+
handleUpdated(this.stage)
55+
},
56+
updated() {
57+
handleUpdated(this.stage)
4658
},
4759
methods: {
4860
changeTheme(darkMode: boolean) {
@@ -53,6 +65,32 @@ export default defineComponent({
5365
},
5466
},
5567
})
68+
69+
function handleUpdated(page: Stage) {
70+
let elementIdToFound: string | undefined = undefined
71+
switch (page) {
72+
case 'START':
73+
elementIdToFound = 'login-page'
74+
break
75+
case 'PROFILE_SELECT':
76+
elementIdToFound = 'profile-page'
77+
break
78+
79+
case 'REAUTH':
80+
elementIdToFound = 'reauth-page'
81+
break
82+
default:
83+
return
84+
}
85+
86+
if (!elementIdToFound) return
87+
const domElement = document.getElementById(elementIdToFound)
88+
if (!domElement) {
89+
WebviewTelemetry.instance.didShowPage(page, `NOT found domElement ${elementIdToFound}`)
90+
} else {
91+
WebviewTelemetry.instance.didShowPage(page)
92+
}
93+
}
5694
</script>
5795
<style>
5896
.body {

0 commit comments

Comments
 (0)