From 61fdbe94c52d5fab153c79e677366b159cca79b9 Mon Sep 17 00:00:00 2001 From: Maxim Hayes Date: Thu, 7 Nov 2024 16:24:40 -0500 Subject: [PATCH] telemetry: add notifications telemetry I explored sending telemetry if the user hides or unhides the notification view panel. However, there is no VSC hook to detect this and other ways (e.g. checking visible property) are not reliable. So, we cannot track this. - toolkit_showNotification - for displaying notifications on receive or when clicked on - toolkit_invokeAction - invoking an action from a notification, e.g. clicking on a button in the notification or opening a text document - ui_click for clicking on the notifications node or dismiss button --- packages/core/src/notifications/activation.ts | 3 + packages/core/src/notifications/controller.ts | 14 +- packages/core/src/notifications/panelNode.ts | 122 ++++++++++-------- packages/core/src/notifications/types.ts | 4 + .../src/test/notifications/controller.test.ts | 10 +- 5 files changed, 92 insertions(+), 61 deletions(-) diff --git a/packages/core/src/notifications/activation.ts b/packages/core/src/notifications/activation.ts index fd1db8b1b72..a563880d46f 100644 --- a/packages/core/src/notifications/activation.ts +++ b/packages/core/src/notifications/activation.ts @@ -10,6 +10,7 @@ import { NotificationsNode } from './panelNode' import { RuleEngine, getRuleContext } from './rules' import globals from '../shared/extensionGlobals' import { AuthState } from './types' +import { getLogger } from '../shared/logger/logger' /** Time in MS to poll for emergency notifications */ const emergencyPollTime = 1000 * 10 * 60 @@ -44,4 +45,6 @@ export async function activate( const ruleContext = await getRuleContext(context, await authStateFn()) await controller.pollForEmergencies(new RuleEngine(ruleContext)) }, emergencyPollTime) + + getLogger('notifications').debug('Activated in-IDE notifications polling module') } diff --git a/packages/core/src/notifications/controller.ts b/packages/core/src/notifications/controller.ts index 04bb05cb8ab..9d1636c7a24 100644 --- a/packages/core/src/notifications/controller.ts +++ b/packages/core/src/notifications/controller.ts @@ -7,7 +7,13 @@ import * as vscode from 'vscode' import { ToolkitError } from '../shared/errors' import globals from '../shared/extensionGlobals' import { globalKey } from '../shared/globalState' -import { NotificationsState, NotificationsStateConstructor, NotificationType, ToolkitNotification } from './types' +import { + getNotificationTelemetryId, + NotificationsState, + NotificationsStateConstructor, + NotificationType, + ToolkitNotification, +} from './types' import { HttpResourceFetcher } from '../shared/resourcefetcher/httpResourceFetcher' import { getLogger } from '../shared/logger/logger' import { NotificationsNode } from './panelNode' @@ -17,6 +23,7 @@ import { TreeNode } from '../shared/treeview/resourceTreeDataProvider' import { withRetries } from '../shared/utilities/functionUtils' import { FileResourceFetcher } from '../shared/resourcefetcher/fileResourceFetcher' import { isAmazonQ } from '../shared/extensionUtilities' +import { telemetry } from '../shared/telemetry/telemetry' /** * Handles fetching and maintaining the state of in-IDE notifications. @@ -203,7 +210,10 @@ function registerDismissCommand() { /** See {@link NotificationsNode} for more info. */ const notification = item.command?.arguments[0] as ToolkitNotification - await NotificationsController.instance.dismissNotification(notification.id) + await telemetry.ui_click.run(async (span) => { + span.record({ elementId: `${getNotificationTelemetryId(notification)}:DISMISS` }) + await NotificationsController.instance.dismissNotification(notification.id) + }) } else { getLogger('notifications').error(`${name}: Cannot dismiss notification: item is not a vscode.TreeItem`) } diff --git a/packages/core/src/notifications/panelNode.ts b/packages/core/src/notifications/panelNode.ts index 118f7da1706..66a29930bcb 100644 --- a/packages/core/src/notifications/panelNode.ts +++ b/packages/core/src/notifications/panelNode.ts @@ -8,13 +8,14 @@ import { ResourceTreeDataProvider, TreeNode } from '../shared/treeview/resourceT import { Command, Commands } from '../shared/vscode/commands2' import { getIcon } from '../shared/icons' import { contextKey, setContext } from '../shared/vscode/setContext' -import { NotificationType, ToolkitNotification } from './types' +import { NotificationType, ToolkitNotification, getNotificationTelemetryId } from './types' import { ToolkitError } from '../shared/errors' import { isAmazonQ } from '../shared/extensionUtilities' import { getLogger } from '../shared/logger/logger' import { registerToolView } from '../awsexplorer/activationShared' import { readonlyDocument } from '../shared/utilities/textDocumentUtilities' import { openUrl } from '../shared/utilities/vsCodeUtils' +import { telemetry } from '../shared/telemetry/telemetry' /** * Controls the "Notifications" side panel/tree in each extension. It takes purely UX actions @@ -27,25 +28,24 @@ export class NotificationsNode implements TreeNode { public startUpNotifications: ToolkitNotification[] = [] public emergencyNotifications: ToolkitNotification[] = [] + /** Command executed when a notification item is clicked on in the panel. */ private readonly openNotificationCmd: Command private readonly focusCmdStr: string private readonly showContextStr: contextKey private readonly startUpNodeContext: string private readonly emergencyNodeContext: string - private readonly onDidChangeTreeItemEmitter = new vscode.EventEmitter() - private readonly onDidChangeChildrenEmitter = new vscode.EventEmitter() - private readonly onDidChangeVisibilityEmitter = new vscode.EventEmitter() - public readonly onDidChangeTreeItem = this.onDidChangeTreeItemEmitter.event - public readonly onDidChangeChildren = this.onDidChangeChildrenEmitter.event - public readonly onDidChangeVisibility = this.onDidChangeVisibilityEmitter.event - static #instance: NotificationsNode private constructor() { this.openNotificationCmd = Commands.register( isAmazonQ() ? '_aws.amazonq.notifications.open' : '_aws.toolkit.notifications.open', - async (n: ToolkitNotification) => this.openNotification(n) + (n: ToolkitNotification) => { + return telemetry.ui_click.run((span) => { + span.record({ elementId: getNotificationTelemetryId(n) }) + return this.openNotification(n) + }) + } ) if (isAmazonQ()) { @@ -73,12 +73,6 @@ export class NotificationsNode implements TreeNode { const hasNotifications = this.startUpNotifications.length > 0 || this.emergencyNotifications.length > 0 void setContext(this.showContextStr, hasNotifications) - this.onDidChangeChildrenEmitter.fire() - this.provider?.refresh() - } - - public refreshRootNode() { - this.onDidChangeTreeItemEmitter.fire() this.provider?.refresh() } @@ -129,28 +123,34 @@ export class NotificationsNode implements TreeNode { * Fired when a notification is clicked on in the panel. It will run any rendering * instructions included in the notification. See {@link ToolkitNotification.uiRenderInstructions}. */ - public async openNotification(notification: ToolkitNotification) { + private async openNotification(notification: ToolkitNotification) { switch (notification.uiRenderInstructions.onClick.type) { case 'modal': // Render blocking modal getLogger('notifications').verbose(`rendering modal for notificaiton: ${notification.id} ...`) - await this.showInformationWindow(notification, 'modal') + await this.showInformationWindow(notification, 'modal', false) break case 'openUrl': + // Show open url option if (!notification.uiRenderInstructions.onClick.url) { throw new ToolkitError('No url provided for onclick open url') } - // Show open url option getLogger('notifications').verbose(`opening url for notification: ${notification.id} ...`) - await openUrl(vscode.Uri.parse(notification.uiRenderInstructions.onClick.url)) + await openUrl( + vscode.Uri.parse(notification.uiRenderInstructions.onClick.url), + getNotificationTelemetryId(notification) + ) break case 'openTextDocument': // Display read-only txt document getLogger('notifications').verbose(`showing txt document for notification: ${notification.id} ...`) - await readonlyDocument.show( - notification.uiRenderInstructions.content['en-US'].description, - `Notification: ${notification.id}` - ) + await telemetry.toolkit_invokeAction.run(async () => { + telemetry.record({ source: getNotificationTelemetryId(notification), action: 'openTxt' }) + await readonlyDocument.show( + notification.uiRenderInstructions.content['en-US'].description, + `Notification: ${notification.id}` + ) + }) break } } @@ -160,57 +160,65 @@ export class NotificationsNode implements TreeNode { * Can be either a blocking modal or a bottom-right corner toast * Handles the button click actions based on the button type. */ - public async showInformationWindow(notification: ToolkitNotification, type: string = 'toast') { + private showInformationWindow(notification: ToolkitNotification, type: string = 'toast', passive: boolean = false) { const isModal = type === 'modal' - // modal has to have defined actions(buttons) + // modal has to have defined actions (buttons) const buttons = notification.uiRenderInstructions.actions ?? [] const buttonLabels = buttons.map((actions) => actions.displayText['en-US']) const detail = notification.uiRenderInstructions.content['en-US'].description - // we use toastPreview to display as titlefor toast, since detail won't be shown + // we use toastPreview to display as title for toast, since detail won't be shown const title = isModal ? notification.uiRenderInstructions.content['en-US'].title : (notification.uiRenderInstructions.content['en-US'].toastPreview ?? notification.uiRenderInstructions.content['en-US'].title) - const selectedText = await vscode.window.showInformationMessage( - title, - { modal: isModal, detail }, - ...buttonLabels - ) + telemetry.toolkit_showNotification.emit({ + id: getNotificationTelemetryId(notification), + passive, + component: 'editor', + result: 'Succeeded', + }) - if (selectedText) { - const selectedButton = buttons.find((actions) => actions.displayText['en-US'] === selectedText) - // Different button options - if (selectedButton) { - switch (selectedButton.type) { - case 'openTxt': - await readonlyDocument.show( - notification.uiRenderInstructions.content['en-US'].description, - `Notification: ${notification.id}` - ) - break - case 'updateAndReload': - await this.updateAndReload(notification.displayIf.extensionId) - break - case 'openUrl': - if (selectedButton.url) { - await openUrl(vscode.Uri.parse(selectedButton.url)) - } else { - throw new ToolkitError('url not provided') + return vscode.window + .showInformationMessage(title, { modal: isModal, detail }, ...buttonLabels) + .then((response) => { + return telemetry.toolkit_invokeAction.run(async (span) => { + span.record({ source: getNotificationTelemetryId(notification), action: response ?? 'OK' }) + if (response) { + const selectedButton = buttons.find((actions) => actions.displayText['en-US'] === response) + // Different button options + if (selectedButton) { + switch (selectedButton.type) { + case 'openTxt': + await readonlyDocument.show( + notification.uiRenderInstructions.content['en-US'].description, + `Notification: ${notification.id}` + ) + break + case 'updateAndReload': + await this.updateAndReload(notification.displayIf.extensionId) + break + case 'openUrl': + if (selectedButton.url) { + await openUrl(vscode.Uri.parse(selectedButton.url)) + } else { + throw new ToolkitError('url not provided') + } + break + default: + throw new ToolkitError('button action not defined') + } } - break - default: - throw new ToolkitError('button action not defined') - } - } - } + } + }) + }) } public async onReceiveNotifications(notifications: ToolkitNotification[]) { for (const notification of notifications) { - void this.showInformationWindow(notification, notification.uiRenderInstructions.onRecieve) + void this.showInformationWindow(notification, notification.uiRenderInstructions.onRecieve, true) } } diff --git a/packages/core/src/notifications/types.ts b/packages/core/src/notifications/types.ts index 7c3ac798d2f..12e6876e43d 100644 --- a/packages/core/src/notifications/types.ts +++ b/packages/core/src/notifications/types.ts @@ -138,3 +138,7 @@ export interface RuleContext { /** Type expected by things that build (or help build) {@link RuleContext} */ export type AuthState = Omit + +export function getNotificationTelemetryId(n: ToolkitNotification): string { + return `TARGETED_NOTIFICATION:${n.id}` +} diff --git a/packages/core/src/test/notifications/controller.test.ts b/packages/core/src/test/notifications/controller.test.ts index 3a78c94a746..e1ac14094b8 100644 --- a/packages/core/src/test/notifications/controller.test.ts +++ b/packages/core/src/test/notifications/controller.test.ts @@ -9,14 +9,19 @@ import assert from 'assert' import sinon from 'sinon' import globals from '../../shared/extensionGlobals' import { randomUUID } from '../../shared' -import { installFakeClock } from '../testUtil' +import { assertTelemetry, installFakeClock } from '../testUtil' import { NotificationFetcher, NotificationsController, RemoteFetcher, ResourceResponse, } from '../../notifications/controller' -import { NotificationData, NotificationType, ToolkitNotification } from '../../notifications/types' +import { + NotificationData, + NotificationType, + ToolkitNotification, + getNotificationTelemetryId, +} from '../../notifications/types' import { HttpResourceFetcher } from '../../shared/resourcefetcher/httpResourceFetcher' import { NotificationsNode } from '../../notifications/panelNode' import { RuleEngine } from '../../notifications/rules' @@ -482,6 +487,7 @@ describe('Notifications Controller', function () { assert.equal(onReceiveSpy.callCount, 1) assert.deepStrictEqual(onReceiveSpy.args[0][0], [content.notifications[0]]) + assertTelemetry('toolkit_showNotification', { id: getNotificationTelemetryId(content.notifications[0]) }) onReceiveSpy.restore() })