Skip to content

Commit a420d8b

Browse files
telemetry(webview): Emit toolkit_X module telemetry on auth webview
Problem: With our telemetry, we do not know when the frontend webview UI has actually loaded Solution: Emit certain metrics during the webview loading process to get a better idea of if the webview UI successfully completed its initial load. - toolkit_willOpenModule, indicates intent to render a webview - toolkit_didLoadModule, indicates the final result of loading the webview - On Success it it just a success result. We know a success when the frontend send a successful message to the backend. It knows this by ensuring there were no errors and that a certain HTML element can be found, then once the page finishes its initial load it will send a success message to the backend. - On Failure, what happens is a timer times out after 10 seconds. Signed-off-by: nkomonen-amazon <[email protected]>
1 parent 2c0b4a5 commit a420d8b

File tree

9 files changed

+147
-31
lines changed

9 files changed

+147
-31
lines changed

packages/core/src/amazonq/webview/messages/messageDispatcher.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import globals from '../../../shared/extensionGlobals'
1616
import { openUrl } from '../../../shared/utilities/vsCodeUtils'
1717
import { DefaultAmazonQAppInitContext } from '../../apps/initContext'
1818

19+
const qChatModuleName = 'amazonqChat'
20+
1921
export function dispatchWebViewMessagesToApps(
2022
webview: Webview,
2123
webViewToAppsMessagePublishers: Map<TabType, MessagePublisher<any>>
@@ -29,8 +31,8 @@ export function dispatchWebViewMessagesToApps(
2931
* This would be equivalent of the duration between "user clicked open q" and "ui has become available"
3032
* NOTE: Amazon Q UI is only loaded ONCE. The state is saved between each hide/show of the webview.
3133
*/
32-
telemetry.webview_load.emit({
33-
webviewName: 'amazonq',
34+
telemetry.toolkit_didLoadModule.emit({
35+
module: qChatModuleName,
3436
duration: performance.measure(amazonqMark.uiReady, amazonqMark.open).duration,
3537
result: 'Succeeded',
3638
})
@@ -86,12 +88,19 @@ export function dispatchWebViewMessagesToApps(
8688
}
8789

8890
if (msg.type === 'error') {
89-
const event = msg.event === 'webview_load' ? telemetry.webview_load : telemetry.webview_error
90-
event.emit({
91-
webviewName: 'amazonqChat',
92-
result: 'Failed',
93-
reasonDesc: msg.errorMessage,
94-
})
91+
if (msg.event === 'toolkit_didLoadModule') {
92+
telemetry.toolkit_didLoadModule.emit({
93+
module: qChatModuleName,
94+
result: 'Failed',
95+
reasonDesc: msg.errorMessage,
96+
})
97+
} else {
98+
telemetry.webview_error.emit({
99+
webviewName: qChatModuleName,
100+
result: 'Failed',
101+
reasonDesc: msg.errorMessage,
102+
})
103+
}
95104
return
96105
}
97106

packages/core/src/amazonq/webview/ui/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export const createMynahUI = (
6363
const { error, message } = e
6464
ideApi.postMessage({
6565
type: 'error',
66-
event: connector.isUIReady ? 'webview_error' : 'webview_load',
66+
event: connector.isUIReady ? 'webview_error' : 'toolkit_didLoadModule',
6767
errorMessage: error ? error.toString() : message,
6868
})
6969
})

packages/core/src/login/webview/commonAuthViewProvider.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,11 @@ export class CommonAuthViewProvider implements WebviewViewProvider {
137137
}
138138
webviewView.webview.html = this._getHtmlForWebview(this.extensionContext.extensionUri, webviewView.webview)
139139
// register the webview server
140+
//
141+
// TODO: Fix potential race condition, since setup() sets up the message handlers, but it is possible that
142+
// when setting the .html above it will execute the HTML (and send messages) before the handlers are configured.
143+
// To repro, add a sleep right after setting .html and watch how nothing loads. I'm assuming all the messages are
144+
// dropped.
140145
await this.webView?.setup(webviewView.webview)
141146
}
142147

packages/core/src/login/webview/vue/backend.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,17 +63,19 @@ export abstract class CommonAuthWebview extends VueWebview {
6363

6464
private didCall: { login: boolean; reauth: boolean } = { login: false, reauth: false }
6565
public setUiReady(state: 'login' | 'reauth') {
66-
// Prevent telemetry spam, since showing/hiding chat triggers this each time.
67-
// So only emit once.
66+
this.loadTimeout?.dispose()
67+
68+
// Only emit once to prevent telemetry spam, since showing/hiding chat triggers this each time.
6869
if (this.didCall[state]) {
6970
return
7071
}
71-
72-
telemetry.webview_load.emit({
72+
telemetry.toolkit_didLoadModule.emit({
7373
passive: true,
74-
webviewName: state,
74+
module: state,
7575
result: 'Succeeded',
76+
traceId: this.traceId,
7677
})
78+
this.traceId = undefined
7779
this.didCall[state] = true
7880
}
7981

packages/core/src/login/webview/vue/login.vue

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@
145145
></SelectableItem>
146146
<button
147147
class="continue-button"
148+
id="connection-selection-continue-button"
148149
:disabled="selectedLoginOption === 0"
149150
v-on:click="handleContinueClick()"
150151
>
@@ -368,8 +369,6 @@ export default defineComponent({
368369
// Pre-select the first available login option
369370
await this.preselectLoginOption()
370371
await this.handleUrlInput() // validate the default startUrl
371-
372-
await client.setUiReady('login')
373372
},
374373
methods: {
375374
toggleItemSelection(itemId: number) {
@@ -590,6 +589,18 @@ export default defineComponent({
590589
},
591590
},
592591
})
592+
593+
/**
594+
* The ID of the element we will use to determine that the UI has completed its initial load.
595+
*
596+
* This makes assumptions that we will be in a certain state of the UI (eg showing a form vs. a loading bar).
597+
* So if the UI flow changes, this may need to be updated.
598+
*/
599+
export function getReadyElementId() {
600+
// On every initial load, we ASSUME that the user will always be in the connection selection state,
601+
// which is why we specifically look for this button.
602+
return 'connection-selection-continue-button'
603+
}
593604
</script>
594605

595606
<style>

packages/core/src/login/webview/vue/reauthenticate.vue

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,9 +127,6 @@ export default defineComponent({
127127
128128
this.doShow = true
129129
},
130-
async mounted() {
131-
await client.setUiReady('reauth')
132-
},
133130
methods: {
134131
async reauthenticate() {
135132
client.emitUiClick('auth_reauthenticate')
@@ -148,6 +145,16 @@ export default defineComponent({
148145
},
149146
},
150147
})
148+
149+
/**
150+
* The ID of the element we will use to determine that the UI has completed its initial load.
151+
*
152+
* This makes assumptions that we will be in a certain state of the UI (eg showing a form vs. a loading bar).
153+
* So if the UI flow changes, this may need to be updated.
154+
*/
155+
export function getReadyElementId() {
156+
return 'button#reauthenticate'
157+
}
151158
</script>
152159
<style>
153160
@import './base.css';

packages/core/src/login/webview/vue/root.vue

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ configure app to AMAZONQ if for Amazon Q login
1616
</template>
1717
<script lang="ts">
1818
import { PropType, defineComponent } from 'vue'
19-
import Login from './login.vue'
20-
import Reauthenticate from './reauthenticate.vue'
19+
import Login, { getReadyElementId as getLoginReadyElementId } from './login.vue'
20+
import Reauthenticate, { getReadyElementId as getReauthReadyElementId } from './reauthenticate.vue'
2121
import { AuthFlowState, FeatureId } from './types'
2222
import { WebviewClientFactory } from '../../../webviews/client'
2323
import { CommonAuthWebview } from './backend'
@@ -51,15 +51,67 @@ export default defineComponent({
5151
})
5252
5353
await this.refreshAuthState()
54+
55+
// We were recieving the 'load' event before refreshAuthState() resolved (I'm assuming behavior w/ Vue + browser loading not blocking),
56+
// so post refreshAuhState() if we detect we already loaded, then execute immediately since the event already happened.
57+
if (didLoad) {
58+
handleLoaded()
59+
} else {
60+
window.addEventListener('load', () => {
61+
handleLoaded()
62+
})
63+
}
5464
},
5565
methods: {
5666
async refreshAuthState() {
5767
await client.refreshAuthState()
5868
this.authFlowState = await client.getAuthState()
69+
70+
// Used for telemetry purposes
71+
if (this.authFlowState === 'LOGIN') {
72+
;(window as any).uiState = 'login'
73+
;(window as any).uiReadyElementId = getLoginReadyElementId()
74+
} else if (this.authFlowState && this.authFlowState !== undefined) {
75+
;(window as any).uiState = 'reauth'
76+
;(window as any).uiReadyElementId = getReauthReadyElementId()
77+
}
78+
5979
this.refreshKey += 1
6080
},
6181
},
6282
})
83+
84+
// THe following handles the process of indicating the UI has loaded successfully.
85+
let emittedReady = false
86+
let errorMessage: string | undefined = undefined
87+
// Catch JS errors
88+
window.onerror = function (message) {
89+
errorMessage = message.toString()
90+
}
91+
// Listen for DOM errors
92+
document.addEventListener(
93+
'error',
94+
function (e) {
95+
errorMessage = e.message
96+
},
97+
true
98+
)
99+
let didLoad = false
100+
window.addEventListener('load', () => {
101+
didLoad = true
102+
})
103+
const handleLoaded = () => {
104+
// TODO: See if this ever gets triggered, and if not, delete emittedReady
105+
if (emittedReady) {
106+
console.log(`NIKOLAS: load event triggered a subsequent time`)
107+
}
108+
109+
if (!emittedReady && errorMessage === undefined && !!document.getElementById((window as any).uiReadyElementId)) {
110+
emittedReady = true // ensure we only emit once per load
111+
client.setUiReady((window as any).uiState)
112+
window.postMessage({ command: `ui-is-ready`, state: (window as any).uiState })
113+
}
114+
}
63115
</script>
64116
<style>
65117
body {

packages/core/src/shared/telemetry/vscodeTelemetry.json

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -985,16 +985,6 @@
985985
],
986986
"passive": true
987987
},
988-
{
989-
"name": "webview_load",
990-
"description": "Represents a webview load event",
991-
"metadata": [
992-
{
993-
"type": "webviewName",
994-
"required": true
995-
}
996-
]
997-
},
998988
{
999989
"name": "webview_error",
1000990
"description": "Represents an error that occurs in a webview",

packages/core/src/webviews/main.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import * as vscode from 'vscode'
77
import { Protocol, registerWebviewServer } from './server'
88
import { getIdeProperties } from '../shared/extensionUtilities'
99
import { getFunctions } from '../shared/utilities/classUtils'
10+
import { telemetry } from '../shared/telemetry/telemetry'
11+
import { randomUUID } from '../shared/crypto'
12+
import { Timeout } from '../shared/utilities/timeoutUtils'
1013

1114
interface WebviewParams {
1215
/**
@@ -151,6 +154,16 @@ export abstract class VueWebview {
151154
*/
152155
public abstract readonly id: string
153156

157+
/**
158+
* A unique identifier used to connect the opening/loaded metrics of a webview
159+
*/
160+
public traceId: string | undefined = undefined
161+
/**
162+
* When the webview is doing its initial render/load, this times out if it takes too long
163+
* to get a "success" message (we assume something went wrong)
164+
*/
165+
public loadTimeout: Timeout | undefined
166+
154167
/**
155168
* The relative location, from the repository root, to the frontend entrypoint.
156169
*/
@@ -189,6 +202,7 @@ export abstract class VueWebview {
189202

190203
protected dispose(): void {
191204
this.disposed = true
205+
this.loadTimeout?.dispose()
192206
this.onDidDisposeEmitter.fire()
193207
}
194208

@@ -225,12 +239,38 @@ export abstract class VueWebview {
225239
}
226240

227241
public async setup(webview: vscode.Webview) {
242+
this.setupTelemetry()
243+
228244
const server = registerWebviewServer(webview, this.instance.protocol, this.instance.id)
229245
this.instance.onDidDispose(() => {
230246
server.dispose()
231247
})
232248
}
233249

250+
/**
251+
* Setup telemetry events that report on the initial loading of a webview
252+
*/
253+
private setupTelemetry() {
254+
this.instance.traceId = randomUUID()
255+
// Notify intent to open a module, this does not mean it successfully opened
256+
telemetry.toolkit_willOpenModule.emit({
257+
module: this.instance.id,
258+
result: 'Succeeded',
259+
traceId: this.instance.traceId,
260+
})
261+
this.instance.loadTimeout = new Timeout(10_000)
262+
this.instance.loadTimeout.token.onCancellationRequested(() => {
263+
// Timeout expired, so loading failed
264+
telemetry.toolkit_didLoadModule.emit({
265+
module: this.instance.id,
266+
result: 'Failed',
267+
traceId: this.instance.traceId,
268+
reason: 'LoadTimedOut',
269+
reasonDesc: 'Likely due to an error in the frontend',
270+
})
271+
})
272+
}
273+
234274
public async show(params: Omit<WebviewPanelParams, 'id' | 'webviewJs'>): Promise<vscode.WebviewPanel> {
235275
if (this.panel) {
236276
this.panel.reveal(params.viewColumn, false)

0 commit comments

Comments
 (0)