diff --git a/src/ariaTelemetryReporter.ts b/src/ariaTelemetryReporter.ts new file mode 100644 index 00000000..64b6ed2b --- /dev/null +++ b/src/ariaTelemetryReporter.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { TelemetryReporter } from '@vscode/extension-telemetry'; +import packageJson from '../package.json'; + +// packageJson is mocked away as null in some tests, so read it defensively. +const packageInfo = packageJson as { publisher?: string; name?: string } | null; +const extensionIdPrefix = packageInfo && packageInfo.publisher && packageInfo.name + ? `${packageInfo.publisher}.${packageInfo.name}/` + : ''; + +// The 1DS collector drops event names containing '/' and rewrites '-' and '.' +// to '_'. VS Code prefixes every gated event with './', so the +// prefix alone is enough to have an event discarded. +export function sanitizeEventName(eventName: string): string { + const withoutPrefix = eventName.startsWith(extensionIdPrefix) + ? eventName.slice(extensionIdPrefix.length) + : eventName; + + return withoutPrefix + .replace(/[^a-zA-Z0-9]/g, '_') + .replace(/_{2,}/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 98); +} + +function sanitizeSenderEventNames(reporter: unknown): void { + /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */ + const sender = (reporter as any)?.telemetrySender; + if (!sender || typeof sender.sendEventData !== 'function') { + return; + } + + const sendEventData = sender.sendEventData.bind(sender) as (eventName: string, data: unknown) => void; + sender.sendEventData = (eventName: string, data: unknown): void => { + sendEventData(sanitizeEventName(eventName), data); + }; + /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */ +} + +export class AriaTelemetryReporter extends TelemetryReporter { + constructor(key: string) { + super(key); + sanitizeSenderEventNames(this); + } +} diff --git a/src/cdpTargetsProvider.ts b/src/cdpTargetsProvider.ts index 46622bd6..c99badb8 100644 --- a/src/cdpTargetsProvider.ts +++ b/src/cdpTargetsProvider.ts @@ -10,6 +10,7 @@ import { fixRemoteWebSocket, getListOfTargets, getRemoteEndpointSettings, IRemot import { IncomingMessage } from 'http'; import * as https from 'https'; import { LaunchConfigManager } from './launchConfigManager'; +import { sendTaxonomyEvent } from './telemetryTaxonomy'; export class CDPTargetsProvider implements vscode.TreeDataProvider { readonly onDidChangeTreeData: vscode.Event; @@ -60,7 +61,7 @@ export class CDPTargetsProvider implements vscode.TreeDataProvider { } } } else { - this.telemetryReporter.sendTelemetryEvent('view/error/no_json_array'); + sendTaxonomyEvent(this.telemetryReporter, { area: 'view', feature: 'targets', action: 'list', outcome: 'error', detail: 'no_json_array' }); } // Sort the targets by type and then title, but keep 'page' types at the top diff --git a/src/devtoolsPanel.ts b/src/devtoolsPanel.ts index 89e84bc9..b6b4194c 100644 --- a/src/devtoolsPanel.ts +++ b/src/devtoolsPanel.ts @@ -37,6 +37,7 @@ import { ErrorReporter } from './errorReporter'; import { ErrorCodes } from './common/errorCodes'; import { ScreencastPanel } from './screencastPanel'; import { providedHeadlessDebugConfig } from './launchConfigManager'; +import { sendTaxonomyErrorEvent, sendTaxonomyEvent } from './telemetryTaxonomy'; export class DevToolsPanel { private readonly config: IRuntimeConfig; @@ -192,7 +193,7 @@ export class DevToolsPanel { if (this.timeStart !== null) { const timeEnd = performance.now(); const sessionTime = timeEnd - this.timeStart; - this.telemetryReporter.sendTelemetryEvent('websocket/dispose', undefined, {sessionTime}); + sendTaxonomyEvent(this.telemetryReporter, { area: 'websocket', feature: 'connection', action: 'dispose' }, undefined, {sessionTime}); this.timeStart = null; } @@ -215,7 +216,7 @@ export class DevToolsPanel { case 'open': case 'close': case 'error': - this.telemetryReporter.sendTelemetryEvent(`websocket/${e}`); + sendTaxonomyEvent(this.telemetryReporter, { area: 'websocket', feature: 'connection', action: e, outcome: e === 'error' ? 'error' : 'success' }); break; } if (this.collectConsoleMessages && message && message.includes('Runtime.consoleAPICalled')) { @@ -235,8 +236,9 @@ export class DevToolsPanel { private onSocketReady() { // Report success telemetry - this.telemetryReporter.sendTelemetryEvent( - this.panelSocket.isConnectedToTarget ? 'websocket/reconnect' : 'websocket/connect'); + sendTaxonomyEvent( + this.telemetryReporter, + { area: 'websocket', feature: 'connection', action: this.panelSocket.isConnectedToTarget ? 'reconnect' : 'connect' }); this.timeStart = performance.now(); } @@ -307,8 +309,9 @@ export class DevToolsPanel { case 'performance': { const measures: ITelemetryMeasures = {}; measures[`${telemetry.name}.duration`] = telemetry.data; - this.telemetryReporter.sendTelemetryEvent( - `devtools/${telemetry.name}`, + sendTaxonomyEvent( + this.telemetryReporter, + { area: 'devtools', feature: telemetry.name, action: 'measure' }, undefined, measures); break; @@ -317,8 +320,9 @@ export class DevToolsPanel { case 'enumerated': { const properties: ITelemetryProps = {}; properties[`${telemetry.name}.actionCode`] = telemetry.data.toString(); - this.telemetryReporter.sendTelemetryEvent( - `devtools/${telemetry.name}`, + sendTaxonomyEvent( + this.telemetryReporter, + { area: 'devtools', feature: telemetry.name, action: 'enumerate' }, properties); break; } @@ -326,8 +330,9 @@ export class DevToolsPanel { case 'error': { const properties: ITelemetryProps = {}; properties[`${telemetry.name}.info`] = JSON.stringify(telemetry.data); - this.telemetryReporter.sendTelemetryErrorEvent( - `devtools/${telemetry.name}`, + sendTaxonomyErrorEvent( + this.telemetryReporter, + { area: 'devtools', feature: telemetry.name, action: 'report' }, properties); break; } @@ -377,7 +382,7 @@ export class DevToolsPanel { private async onSocketOpenInEditor(message: string) { // Report usage telemetry - this.telemetryReporter.sendTelemetryEvent('extension/openInEditor', { + sendTaxonomyEvent(this.telemetryReporter, { area: 'extension', feature: 'editor', action: 'openInEditor' }, { sourceMaps: `${this.config.sourceMaps}`, }); @@ -461,7 +466,7 @@ export class DevToolsPanel { this.fallbackChain = this.determineVersionFallback(); } else { if (this.currentRevision) { - this.telemetryReporter.sendTelemetryEvent('websocket/failedConnection', {revision: this.currentRevision}); + sendTaxonomyEvent(this.telemetryReporter, { area: 'websocket', feature: 'connection', action: 'connect', outcome: 'error', detail: 'failedConnection' }, {revision: this.currentRevision}); } // We failed trying to retrieve the specified revision diff --git a/src/extension.ts b/src/extension.ts index 3999b298..ac1ba7ec 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -10,6 +10,7 @@ import { CDPTargetsProvider } from './cdpTargetsProvider'; import { DevToolsPanel } from './devtoolsPanel'; import { ScreencastPanel } from './screencastPanel'; import { LaunchDebugProvider } from './launchDebugProvider'; +import { sendTaxonomyErrorEvent, sendTaxonomyEvent } from './telemetryTaxonomy'; import { buttonCode, checkWithinHoverRange, @@ -70,7 +71,7 @@ export function activate(context: vscode.ExtensionContext): void { const documentDiagnostics = vscode.languages.getDiagnostics(document.uri); for (const diagnostic of documentDiagnostics) { if (diagnostic.source === languageServerName && checkWithinHoverRange(position, diagnostic.range) && diagnostic.code as DiagnosticCodeType) { - telemetryReporter.sendTelemetryEvent('user/webhint/hover', { 'hint': (diagnostic.code as DiagnosticCodeType).value }); + sendTaxonomyEvent(telemetryReporter, { area: 'user', feature: 'webhint', action: 'hover' }, { 'hint': (diagnostic.code as DiagnosticCodeType).value }); } } return null; @@ -104,9 +105,9 @@ export function activate(context: vscode.ExtensionContext): void { `${SETTINGS_VIEW_NAME}.launch`, async (fromEmptyTargetView?: boolean) => { if (fromEmptyTargetView) { - telemetryReporter.sendTelemetryEvent('user/buttonPress', { 'VSCode.buttonCode': buttonCode.emptyTargetListLaunchBrowserInstance }); + sendTaxonomyEvent(telemetryReporter, { area: 'user', feature: 'ui', action: 'buttonPress' }, { 'VSCode.buttonCode': buttonCode.emptyTargetListLaunchBrowserInstance }); } else { - telemetryReporter.sendTelemetryEvent('user/buttonPress', { 'VSCode.buttonCode': buttonCode.launchBrowserInstance }); + sendTaxonomyEvent(telemetryReporter, { area: 'user', feature: 'ui', action: 'buttonPress' }, { 'VSCode.buttonCode': buttonCode.launchBrowserInstance }); } await launch(context); cdpTargetsProvider.refresh(); @@ -114,18 +115,18 @@ export function activate(context: vscode.ExtensionContext): void { context.subscriptions.push(vscode.commands.registerCommand( `${SETTINGS_VIEW_NAME}.refresh`, () => { - telemetryReporter.sendTelemetryEvent('user/buttonPress', { 'VSCode.buttonCode': buttonCode.refreshTargetList }); + sendTaxonomyEvent(telemetryReporter, { area: 'user', feature: 'ui', action: 'buttonPress' }, { 'VSCode.buttonCode': buttonCode.refreshTargetList }); cdpTargetsProvider.refresh(); })); context.subscriptions.push(vscode.commands.registerCommand( `${SETTINGS_VIEW_NAME}.attach`, (target?: CDPTarget, isJsDebugProxiedCDPConnection = false) => { if (!target){ - telemetryReporter.sendTelemetryEvent('command/attach/noTarget'); + sendTaxonomyEvent(telemetryReporter, { area: 'command', feature: 'target', action: 'attach', outcome: 'noTarget' }); return; } - telemetryReporter.sendTelemetryEvent('user/buttonPress', { 'VSCode.buttonCode': buttonCode.attachToTarget }); - telemetryReporter.sendTelemetryEvent('view/devtools'); + sendTaxonomyEvent(telemetryReporter, { area: 'user', feature: 'ui', action: 'buttonPress' }, { 'VSCode.buttonCode': buttonCode.attachToTarget }); + sendTaxonomyEvent(telemetryReporter, { area: 'view', feature: 'devtools', action: 'open' }); const runtimeConfig = getRuntimeConfig(); if (isJsDebugProxiedCDPConnection) { runtimeConfig.isJsDebugProxiedCDPConnection = true; @@ -138,11 +139,11 @@ export function activate(context: vscode.ExtensionContext): void { (target?: CDPTarget, isJsDebugProxiedCDPConnection: boolean = false) => { if (!target){ const errorMessage = 'No target selected'; - telemetryReporter.sendTelemetryErrorEvent('command/screencast/target', {message: errorMessage}); + sendTaxonomyErrorEvent(telemetryReporter, { area: 'command', feature: 'screencast', action: 'toggle', outcome: 'noTarget' }, {message: errorMessage}); return; } - telemetryReporter.sendTelemetryEvent('user/buttonPress', { 'VSCode.buttonCode': buttonCode.toggleScreencast }); - telemetryReporter.sendTelemetryEvent('view/screencast'); + sendTaxonomyEvent(telemetryReporter, { area: 'user', feature: 'ui', action: 'buttonPress' }, { 'VSCode.buttonCode': buttonCode.toggleScreencast }); + sendTaxonomyEvent(telemetryReporter, { area: 'view', feature: 'screencast', action: 'open' }); ScreencastPanel.createOrShow(context, telemetryReporter, target.websocketUrl, isJsDebugProxiedCDPConnection); })); @@ -155,21 +156,21 @@ export function activate(context: vscode.ExtensionContext): void { })); context.subscriptions.push(vscode.commands.registerCommand(`${SETTINGS_VIEW_NAME}.openSettings`, () => { - telemetryReporter.sendTelemetryEvent('user/buttonPress', { 'VSCode.buttonCode': buttonCode.openSettings }); + sendTaxonomyEvent(telemetryReporter, { area: 'user', feature: 'ui', action: 'buttonPress' }, { 'VSCode.buttonCode': buttonCode.openSettings }); void vscode.commands.executeCommand('workbench.action.openSettings', `${SETTINGS_STORE_NAME}`); })); context.subscriptions.push(vscode.commands.registerCommand(`${SETTINGS_VIEW_NAME}.viewChangelog`, () => { - telemetryReporter.sendTelemetryEvent('user/buttonPress', { 'VSCode.buttonCode': buttonCode.viewChangelog }); + sendTaxonomyEvent(telemetryReporter, { area: 'user', feature: 'ui', action: 'buttonPress' }, { 'VSCode.buttonCode': buttonCode.viewChangelog }); void vscode.env.openExternal(vscode.Uri.parse('https://github.com/microsoft/vscode-edge-devtools/blob/main/CHANGELOG.md')); })); context.subscriptions.push(vscode.commands.registerCommand( `${SETTINGS_VIEW_NAME}.close-instance`, async (target?: CDPTarget) => { if (!target) { - telemetryReporter.sendTelemetryEvent('command/close/noTarget'); + sendTaxonomyEvent(telemetryReporter, { area: 'command', feature: 'target', action: 'close', outcome: 'noTarget' }); return; } - telemetryReporter.sendTelemetryEvent('user/buttonPress', { 'VSCode.buttonCode': buttonCode.closeTarget }); + sendTaxonomyEvent(telemetryReporter, { area: 'user', feature: 'ui', action: 'buttonPress' }, { 'VSCode.buttonCode': buttonCode.closeTarget }); // disable buttons for this target target.contextValue = 'cdpTargetClosing'; cdpTargetsProvider.changeDataEvent.fire(target); @@ -207,7 +208,7 @@ export function activate(context: vscode.ExtensionContext): void { context.subscriptions.push(vscode.commands.registerCommand( `${SETTINGS_VIEW_NAME}.configureLaunchJson`, () => { - telemetryReporter.sendTelemetryEvent('user/buttonPress', { + sendTaxonomyEvent(telemetryReporter, { area: 'user', feature: 'ui', action: 'buttonPress' }, { 'VSCode.buttonCode': LaunchConfigManager.instance.getLaunchConfig() === 'None' ? buttonCode.generateLaunchJson : buttonCode.configureLaunchJson, }); void LaunchConfigManager.instance.configureLaunchJson(); @@ -215,7 +216,7 @@ export function activate(context: vscode.ExtensionContext): void { context.subscriptions.push(vscode.commands.registerCommand( `${SETTINGS_VIEW_NAME}.launchProject`, () => { - telemetryReporter.sendTelemetryEvent('user/buttonPress', { 'VSCode.buttonCode': buttonCode.launchProject }); + sendTaxonomyEvent(telemetryReporter, { area: 'user', feature: 'ui', action: 'buttonPress' }, { 'VSCode.buttonCode': buttonCode.launchProject }); LaunchConfigManager.instance.updateLaunchConfig(); if (vscode.workspace.workspaceFolders) { const workspaceFolder = vscode.workspace.workspaceFolders[0]; @@ -240,7 +241,7 @@ export function activate(context: vscode.ExtensionContext): void { } })); context.subscriptions.push(vscode.commands.registerCommand(`${SETTINGS_VIEW_NAME}.viewDocumentation`, () => { - telemetryReporter.sendTelemetryEvent('user/buttonPress', { 'VSCode.buttonCode': buttonCode.viewDocumentation }); + sendTaxonomyEvent(telemetryReporter, { area: 'user', feature: 'ui', action: 'buttonPress' }, { 'VSCode.buttonCode': buttonCode.viewDocumentation }); void vscode.env.openExternal(vscode.Uri.parse('https://learn.microsoft.com/microsoft-edge/visual-studio-code/microsoft-edge-devtools-extension')); })); @@ -250,13 +251,13 @@ export function activate(context: vscode.ExtensionContext): void { })); context.subscriptions.push(vscode.commands.registerCommand(`${SETTINGS_VIEW_NAME}.launchHtml`, async (fileUri: vscode.Uri): Promise => { - telemetryReporter.sendTelemetryEvent('contextMenu/launchHtml'); + sendTaxonomyEvent(telemetryReporter, { area: 'contextMenu', feature: 'item', action: 'launchHtml' }); await launchHtml(fileUri); })); context.subscriptions.push(vscode.commands.registerCommand(`${SETTINGS_VIEW_NAME}.launchScreencast`, async (fileUri: vscode.Uri): Promise => { - telemetryReporter.sendTelemetryEvent('contextMenu/launchScreencast'); + sendTaxonomyEvent(telemetryReporter, { area: 'contextMenu', feature: 'item', action: 'launchScreencast' }); await launchScreencast(context, fileUri); })); @@ -348,27 +349,27 @@ async function startWebhint(context: vscode.ExtensionContext): Promise { switch (command) { case 'vscode-webhint/ignore-hint-project': { - telemetryReporter.sendTelemetryEvent('user/webhint/quickfix/disable-hint', { hint: hintName }); + sendTaxonomyEvent(telemetryReporter, { area: 'user', feature: 'webhint', action: 'quickfix', detail: 'disable-hint' }, { hint: hintName }); break; } case 'vscode-webhint/ignore-feature-project': { - telemetryReporter.sendTelemetryEvent('user/webhint/quickfix/disable-rule', { hint: hintName, value: featureName }); + sendTaxonomyEvent(telemetryReporter, { area: 'user', feature: 'webhint', action: 'quickfix', detail: 'disable-rule' }, { hint: hintName, value: featureName }); break; } case 'vscode-webhint/edit-hintrc-project': { - telemetryReporter.sendTelemetryEvent('user/webhint/quickfix/edit-hintrc'); + sendTaxonomyEvent(telemetryReporter, { area: 'user', feature: 'webhint', action: 'quickfix', detail: 'edit-hintrc' }); break; } case 'vscode-webhint/ignore-browsers-project': { if (args.length > 1) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const browserList = args[2]['browsers'] as any[]; // eslint-disable-line @typescript-eslint/no-unsafe-member-access - telemetryReporter.sendTelemetryEvent('user/webhint/quickfix/ignore-browsers', { hint: hintName, value: browserList.join(',') }); + sendTaxonomyEvent(telemetryReporter, { area: 'user', feature: 'webhint', action: 'quickfix', detail: 'ignore-browsers' }, { hint: hintName, value: browserList.join(',') }); } break; } case 'vscode-webhint/apply-code-fix': { - telemetryReporter.sendTelemetryEvent('user/webhint/quickfix/apply-code-fix', {value: featureName }); + sendTaxonomyEvent(telemetryReporter, { area: 'user', feature: 'webhint', action: 'quickfix', detail: 'apply-code-fix' }, {value: featureName }); break; } } @@ -387,7 +388,7 @@ async function startWebhint(context: vscode.ExtensionContext): Promise { if (!telemetryReporter) { telemetryReporter = createTelemetryReporter(context); } - telemetryReporter.sendTelemetryEvent('user/webhint/install-failed'); + sendTaxonomyEvent(telemetryReporter, { area: 'user', feature: 'webhint', action: 'install', outcome: 'error' }); if (!disableInstallFailedNotification) { const message = 'Ensure `node` and `npm` are installed to enable automatically reporting issues in source files pertaining to accessibility, compatibility, security, and more.'; void vscode.window.showInformationMessage(message, 'Remind me Later', 'Don\'t show again', 'Disable Extension').then(button => { @@ -519,7 +520,7 @@ export async function attach( message: exceptionStack as string || 'No available targets to attach.', }); - telemetryReporter.sendTelemetryEvent('command/attach/error/no_json_array', telemetryProps); + sendTaxonomyEvent(telemetryReporter, { area: 'command', feature: 'target', action: 'attach', outcome: 'error', detail: 'no_json_array' }, telemetryProps); } } @@ -528,12 +529,12 @@ export async function attachToCurrentDebugTarget(context: vscode.ExtensionContex telemetryReporter = createTelemetryReporter(context); } - telemetryReporter.sendTelemetryEvent('command/attachToCurrentDebugTarget'); + sendTaxonomyEvent(telemetryReporter, { area: 'command', feature: 'currentDebugTarget', action: 'attach' }); const sessionId = debugSessionId || getActiveDebugSessionId(); if (!sessionId) { const errorMessage = 'No active debug session'; - telemetryReporter.sendTelemetryErrorEvent('command/attachToCurrentDebugTarget/devtools', {message: errorMessage}); + sendTaxonomyErrorEvent(telemetryReporter, { area: 'command', feature: 'currentDebugTarget', action: 'attach', detail: 'no_active_session' }, {message: errorMessage}); void vscode.window.showErrorMessage(errorMessage); return; } @@ -541,17 +542,17 @@ export async function attachToCurrentDebugTarget(context: vscode.ExtensionContex const targetWebsocketUrl = await getJsDebugCDPProxyWebsocketUrl(sessionId); if (targetWebsocketUrl instanceof Error) { - telemetryReporter.sendTelemetryErrorEvent('command/attachToCurrentDebugTarget/devtools', {message: targetWebsocketUrl.message}); + sendTaxonomyErrorEvent(telemetryReporter, { area: 'command', feature: 'currentDebugTarget', action: 'attach', detail: 'proxy_url_failed' }, {message: targetWebsocketUrl.message}); void vscode.window.showErrorMessage(targetWebsocketUrl.message); } else if (targetWebsocketUrl) { // Auto connect to found target - telemetryReporter.sendTelemetryEvent('command/attachToCurrentDebugTarget/devtools'); + sendTaxonomyEvent(telemetryReporter, { area: 'command', feature: 'currentDebugTarget', action: 'attach', detail: 'devtools' }); const runtimeConfig = getRuntimeConfig(config); runtimeConfig.isJsDebugProxiedCDPConnection = true; DevToolsPanel.createOrShow(context, telemetryReporter, targetWebsocketUrl, runtimeConfig); } else { const errorMessage = 'Unable to attach DevTools to current debug session.'; - telemetryReporter.sendTelemetryErrorEvent('command/attachToCurrentDebugTarget/devtools', {message: errorMessage}); + sendTaxonomyErrorEvent(telemetryReporter, { area: 'command', feature: 'currentDebugTarget', action: 'attach', detail: 'attach_failed' }, {message: errorMessage}); void vscode.window.showErrorMessage(errorMessage); } } @@ -566,21 +567,21 @@ export async function launch(context: vscode.ExtensionContext, launchUrl?: strin const isHeadless: string = settings.get('headless') || 'false'; const telemetryProps = { viaConfig: `${!!config}`, browserType, isHeadless}; - telemetryReporter.sendTelemetryEvent('command/launch', telemetryProps); + sendTaxonomyEvent(telemetryReporter, { area: 'command', feature: 'browser', action: 'launch' }, telemetryProps); const { hostname, port, defaultUrl, userDataDir } = getRemoteEndpointSettings(config); const url = launchUrl || defaultUrl; const target = await openNewTab(hostname, port, url); if (target && target.webSocketDebuggerUrl) { // Show the devtools - telemetryReporter.sendTelemetryEvent('command/launch/devtools', telemetryProps); + sendTaxonomyEvent(telemetryReporter, { area: 'command', feature: 'browser', action: 'launch', detail: 'devtools' }, telemetryProps); const runtimeConfig = getRuntimeConfig(config); DevToolsPanel.createOrShow(context, telemetryReporter, target.webSocketDebuggerUrl, runtimeConfig); } else { // Launch a new instance const browserPath = await getBrowserPath(config); if (!browserPath) { - telemetryReporter.sendTelemetryEvent('command/launch/error/browser_not_found', telemetryProps); + sendTaxonomyEvent(telemetryReporter, { area: 'command', feature: 'browser', action: 'launch', outcome: 'error', detail: 'browser_not_found' }, telemetryProps); void vscode.window.showErrorMessage( 'Microsoft Edge could not be found. ' + 'Ensure you have installed Microsoft Edge ' + @@ -597,7 +598,7 @@ export async function launch(context: vscode.ExtensionContext, launchUrl?: strin const match = exeName.match(/(chrome|edge)/gi) || []; const knownBrowser = match.length > 0 ? match[0] : 'other'; const browserProps = { exe: `${knownBrowser?.toLowerCase()}` }; - telemetryReporter.sendTelemetryEvent('command/launch/browser', browserProps); + sendTaxonomyEvent(telemetryReporter, { area: 'command', feature: 'browser', action: 'launch', detail: 'newInstance' }, browserProps); browserInstance = await launchBrowser(browserPath, port, url, userDataDir); if (url !== SETTINGS_DEFAULT_URL) { diff --git a/src/launchDebugProvider.ts b/src/launchDebugProvider.ts index 22ec15ae..646b92a3 100644 --- a/src/launchDebugProvider.ts +++ b/src/launchDebugProvider.ts @@ -8,6 +8,7 @@ import { SETTINGS_STORE_NAME, } from './utils'; import { providedDebugConfig } from './launchConfigManager'; +import { sendTaxonomyEvent } from './telemetryTaxonomy'; type AttachCallback = ( context: vscode.ExtensionContext, @@ -56,14 +57,14 @@ export class LaunchDebugProvider implements vscode.DebugConfigurationProvider { if ((config && config.type === `${SETTINGS_STORE_NAME}.debug`) || debugWithoutConfig) { const targetUri: string = this.getUrlFromConfig(folder, config); if (config.request && config.request === 'attach') { - this.telemetryReporter.sendTelemetryEvent('debug/attach'); + sendTaxonomyEvent(this.telemetryReporter, { area: 'debug', feature: 'session', action: 'attach' }); void this.attach(this.context, targetUri, userConfig, true); } else if ((config.request && config.request === 'launch') || debugWithoutConfig) { - this.telemetryReporter.sendTelemetryEvent('debug/launch'); + sendTaxonomyEvent(this.telemetryReporter, { area: 'debug', feature: 'session', action: 'launch' }); void this.launch(this.context, targetUri, userConfig); } } else { - this.telemetryReporter.sendTelemetryEvent('debug/error/config_not_found'); + sendTaxonomyEvent(this.telemetryReporter, { area: 'debug', feature: 'config', action: 'resolve', outcome: 'error', detail: 'config_not_found' }); void vscode.window.showErrorMessage('No supported launch config was found.'); } diff --git a/src/screencastPanel.ts b/src/screencastPanel.ts index f046cea8..1a2d3469 100644 --- a/src/screencastPanel.ts +++ b/src/screencastPanel.ts @@ -20,6 +20,7 @@ import { import { TelemetryReporter } from '@vscode/extension-telemetry'; import { DevToolsPanel } from './devtoolsPanel'; import { providedHeadlessDebugConfig } from './launchConfigManager'; +import { sendTaxonomyEvent } from './telemetryTaxonomy'; export class ScreencastPanel { private readonly context: vscode.ExtensionContext; @@ -87,16 +88,18 @@ export class ScreencastPanel { private recordEnumeratedHistogram(actionName: string, actionCode: number) { const properties: ITelemetryProps = {}; properties[`${actionName}.actionCode`] = actionCode.toString(); - this.telemetryReporter.sendTelemetryEvent( - `devtools/${actionName}`, + sendTaxonomyEvent( + this.telemetryReporter, + { area: 'devtools', feature: actionName, action: 'enumerate' }, properties); } private recordPerformanceHistogram(actionName: string, duration: number) { const measures: ITelemetryMeasures = {}; measures[`${actionName}.duration`] = duration; - this.telemetryReporter.sendTelemetryEvent( - `devtools/${actionName}`, + sendTaxonomyEvent( + this.telemetryReporter, + { area: 'devtools', feature: actionName, action: 'measure' }, undefined, measures); } @@ -151,8 +154,9 @@ export class ScreencastPanel { return; } - this.telemetryReporter.sendTelemetryEvent( - `devtools/${telemetry.name}/${telemetry.data.event}`, { + sendTaxonomyEvent( + this.telemetryReporter, + { area: 'devtools', feature: telemetry.name, action: 'screencast', detail: telemetry.data.event as string }, { 'value': telemetry.data.value as string, }); } diff --git a/src/telemetryTaxonomy.ts b/src/telemetryTaxonomy.ts new file mode 100644 index 00000000..3b9a953d --- /dev/null +++ b/src/telemetryTaxonomy.ts @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { TelemetryReporter } from '@vscode/extension-telemetry'; + +// Used verbatim as the event name, so the set is closed and every value is at +// least 4 characters, which is the minimum the 1DS collector accepts. +export type TelemetryArea = + | 'command' + | 'contextMenu' + | 'debug' + | 'devtools' + | 'extension' + | 'user' + | 'view' + | 'websocket' + | 'workspace'; + +export type TelemetryOutcome = + | 'success' + | 'error' + | 'noTarget' + | 'notFound' + | 'cancelled'; + +// Fixed dimensions attached to every event, replacing the previous +// 'area/subarea/subarea' names that the collector dropped for containing '/'. +export interface TelemetryTaxonomy { + area: TelemetryArea; + feature: string; + action: string; + outcome?: TelemetryOutcome; + detail?: string; +} + +export type TelemetryProperties = { [key: string]: string }; +export type TelemetryMeasurements = { [key: string]: number }; + +export function taxonomyToProperties( + taxonomy: TelemetryTaxonomy, + properties?: TelemetryProperties): TelemetryProperties { + const dimensions: TelemetryProperties = { + area: taxonomy.area, + feature: taxonomy.feature, + action: taxonomy.action, + outcome: taxonomy.outcome || 'success', + }; + + if (taxonomy.detail !== undefined) { + dimensions.detail = taxonomy.detail; + } + + return { ...dimensions, ...properties }; +} + +export function sendTaxonomyEvent( + telemetryReporter: Readonly, + taxonomy: TelemetryTaxonomy, + properties?: TelemetryProperties, + measurements?: TelemetryMeasurements): void { + telemetryReporter.sendTelemetryEvent( + taxonomy.area, + taxonomyToProperties(taxonomy, properties), + measurements); +} + +export function sendTaxonomyErrorEvent( + telemetryReporter: Readonly, + taxonomy: TelemetryTaxonomy, + properties?: TelemetryProperties, + measurements?: TelemetryMeasurements): void { + telemetryReporter.sendTelemetryErrorEvent( + taxonomy.area, + taxonomyToProperties({ ...taxonomy, outcome: taxonomy.outcome || 'error' }, properties), + measurements); +} diff --git a/src/utils.ts b/src/utils.ts index a919dffb..8ebe9529 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -12,6 +12,8 @@ import * as debugCore from 'vscode-chrome-debug-core'; import { TelemetryReporter } from '@vscode/extension-telemetry'; import packageJson from '../package.json'; import { DebugTelemetryReporter } from './debugTelemetryReporter'; +import { AriaTelemetryReporter } from './ariaTelemetryReporter'; +import { sendTaxonomyEvent } from './telemetryTaxonomy'; import puppeteer, {Browser} from 'puppeteer-core'; import { ErrorReporter } from './errorReporter'; @@ -142,7 +144,7 @@ export const buttonCode: Record = { viewChangelog: '8', closeTarget: '9', emptyTargetListLaunchBrowserInstance: '10', - toggleScreencast: '10', + toggleScreencast: '11', }; /** @@ -338,7 +340,7 @@ export async function getJsDebugCDPProxyWebsocketUrl(debugSessionId: string): Pr export function createTelemetryReporter(_context: vscode.ExtensionContext): Readonly { if (packageJson && (_context.extensionMode === vscode.ExtensionMode.Production)) { // Use the real telemetry reporter - return new TelemetryReporter(packageJson.oneDSKey); + return new AriaTelemetryReporter(packageJson.oneDSKey); } // Fallback to a fake telemetry reporter return new DebugTelemetryReporter(); @@ -738,7 +740,7 @@ export function reportExtensionSettings(telemetryReporter: Readonly ({[k]: v}))); - telemetryReporter.sendTelemetryEvent('user/settingsChangedAtLaunch', changedSettingsObject); + sendTaxonomyEvent(telemetryReporter, { area: 'user', feature: 'settings', action: 'changedAtLaunch' }, changedSettingsObject); } export function reportChangedExtensionSetting(event: vscode.ConfigurationChangeEvent, telemetryReporter: Readonly): void { @@ -752,7 +754,7 @@ export function reportChangedExtensionSetting(event: vscode.ConfigurationChangeE const telemetryObject: {[key: string]: string} = {}; const objString = typeof settingValue !== 'object' ? settingValue.toString() : JSON.stringify(settingValue); telemetryObject[settingName] = objString; - telemetryReporter.sendTelemetryEvent('user/settingsChanged', telemetryObject); + sendTaxonomyEvent(telemetryReporter, { area: 'user', feature: 'settings', action: 'changed' }, telemetryObject); } } } @@ -771,7 +773,7 @@ export function reportUrlType(url: string, telemetryReporter: Readonly): Promise { @@ -808,7 +810,7 @@ export async function reportFileExtensionTypes(telemetryReporter: Readonly ({[k]: v}))); - telemetryReporter.sendTelemetryEvent('workspace/metadata', undefined, fileTypes); + sendTaxonomyEvent(telemetryReporter, { area: 'workspace', feature: 'metadata', action: 'scan' }, undefined, fileTypes); } export function checkWithinHoverRange(position: vscode.Position, range: vscode.Range): boolean { diff --git a/test/ariaTelemetryReporter.test.ts b/test/ariaTelemetryReporter.test.ts new file mode 100644 index 00000000..f7fe26fd --- /dev/null +++ b/test/ariaTelemetryReporter.test.ts @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { createFakeVSCode } from './helpers/helpers'; + +jest.mock('vscode', () => createFakeVSCode(), { virtual: true }); + +// Stands in for the real base class so the sender wrapping can be observed. +jest.mock('@vscode/extension-telemetry', () => { + class FakeTelemetryReporter { + telemetrySender: { sendEventData: jest.Mock; sendErrorData: jest.Mock }; + originalSendEventData: jest.Mock; + originalSendErrorData: jest.Mock; + constructor(_key: string) { + this.originalSendEventData = jest.fn(); + this.originalSendErrorData = jest.fn(); + this.telemetrySender = { + sendEventData: this.originalSendEventData, + sendErrorData: this.originalSendErrorData, + }; + } + } + return { TelemetryReporter: FakeTelemetryReporter, default: FakeTelemetryReporter }; +}); + +const prefix = 'ms-edgedevtools.vscode-edge-devtools/'; + +describe('sanitizeEventName', () => { + it('strips the publisher prefix VS Code adds to gated events', async () => { + const { sanitizeEventName } = await import('../src/ariaTelemetryReporter'); + expect(sanitizeEventName(`${prefix}workspace`)).toBe('workspace'); + expect(sanitizeEventName(`${prefix}user`)).toBe('user'); + }); + + it('leaves an already clean name untouched', async () => { + const { sanitizeEventName } = await import('../src/ariaTelemetryReporter'); + expect(sanitizeEventName('workspace')).toBe('workspace'); + expect(sanitizeEventName('unhandlederror')).toBe('unhandlederror'); + }); + + it('replaces characters the collector rejects', async () => { + const { sanitizeEventName } = await import('../src/ariaTelemetryReporter'); + expect(sanitizeEventName(`${prefix}user/buttonPress`)).toBe('user_buttonPress'); + expect(sanitizeEventName('command/attach/error/no_json_array')).toBe('command_attach_error_no_json_array'); + expect(sanitizeEventName('diag-probe.dotted')).toBe('diag_probe_dotted'); + }); + + it('collapses runs and trims leading and trailing separators', async () => { + const { sanitizeEventName } = await import('../src/ariaTelemetryReporter'); + expect(sanitizeEventName('//a//b//')).toBe('a_b'); + }); + + it('only ever emits characters the collector accepts', async () => { + const { sanitizeEventName } = await import('../src/ariaTelemetryReporter'); + const names = [ + `${prefix}user/buttonPress`, + `${prefix}devtools/Some.Name With Spaces`, + 'websocket/failedConnection', + ]; + for (const name of names) { + expect(sanitizeEventName(name)).toMatch(/^[a-zA-Z0-9_]+$/); + } + }); + + it('caps the name at the collector limit', async () => { + const { sanitizeEventName } = await import('../src/ariaTelemetryReporter'); + expect(sanitizeEventName('a'.repeat(200))).toHaveLength(98); + }); +}); + +describe('AriaTelemetryReporter', () => { + it('rewrites event names on their way to the sender', async () => { + const { AriaTelemetryReporter } = await import('../src/ariaTelemetryReporter'); + const reporter = new AriaTelemetryReporter('key') as unknown as { + telemetrySender: { sendEventData: (name: string, data: unknown) => void }; + originalSendEventData: jest.Mock; + }; + + reporter.telemetrySender.sendEventData(`${prefix}user/buttonPress`, { data: 1 }); + + expect(reporter.originalSendEventData).toHaveBeenCalledWith('user_buttonPress', { data: 1 }); + }); + + it('wraps the sender rather than leaving it untouched', async () => { + const { AriaTelemetryReporter } = await import('../src/ariaTelemetryReporter'); + const reporter = new AriaTelemetryReporter('key') as unknown as { + telemetrySender: { sendEventData: unknown }; + originalSendEventData: jest.Mock; + }; + + expect(reporter.telemetrySender.sendEventData).not.toBe(reporter.originalSendEventData); + }); + + // sendTelemetryErrorEvent passes a string event name, and VS Code's + // logError(string) overload routes to sendEventData, so error events are + // sanitized by the same wrapper. + it('sanitizes error event names, which also travel via sendEventData', async () => { + const { AriaTelemetryReporter } = await import('../src/ariaTelemetryReporter'); + const reporter = new AriaTelemetryReporter('key') as unknown as { + telemetrySender: { sendEventData: (name: string, data: unknown) => void }; + originalSendEventData: jest.Mock; + }; + + reporter.telemetrySender.sendEventData(`${prefix}command`, { properties: { outcome: 'error' } }); + + expect(reporter.originalSendEventData).toHaveBeenCalledWith( + 'command', + { properties: { outcome: 'error' } }); + }); + + // sendErrorData takes an Exception, not an event name; the 1DS fallback + // hardcodes the already valid name 'unhandlederror'. Nothing to sanitize. + it('leaves sendErrorData alone because it carries no event name', async () => { + const { AriaTelemetryReporter } = await import('../src/ariaTelemetryReporter'); + const reporter = new AriaTelemetryReporter('key') as unknown as { + telemetrySender: { sendErrorData: (error: Error, data?: unknown) => void }; + originalSendErrorData: jest.Mock; + }; + + const error = new Error('boom'); + reporter.telemetrySender.sendErrorData(error, { properties: {} }); + + expect(reporter.originalSendErrorData).toHaveBeenCalledWith(error, { properties: {} }); + }); + + it('keeps a name the collector already accepts unchanged', async () => { + const { sanitizeEventName } = await import('../src/ariaTelemetryReporter'); + expect(sanitizeEventName('unhandlederror')).toBe('unhandlederror'); + }); +}); diff --git a/test/devtoolsPanel.test.ts b/test/devtoolsPanel.test.ts index 27cf37b1..06f0596f 100644 --- a/test/devtoolsPanel.test.ts +++ b/test/devtoolsPanel.test.ts @@ -259,7 +259,9 @@ describe("devtoolsPanel", () => { // Ensure it posts telemetry callback.call(thisObj, "open"); expect(mockTelemetry.sendTelemetryEvent).toHaveBeenCalledWith( - "websocket/open", + "websocket", + { area: "websocket", feature: "connection", action: "open", outcome: "success" }, + undefined, ); }); @@ -281,13 +283,19 @@ describe("devtoolsPanel", () => { // Ensure it sends connect initially hookedEvents.get("ready")!(); - expect(mockTelemetry.sendTelemetryEvent).toHaveBeenCalledWith("websocket/connect"); + expect(mockTelemetry.sendTelemetryEvent).toHaveBeenCalledWith( + "websocket", + { area: "websocket", feature: "connection", action: "connect", outcome: "success" }, + undefined); // Ensure it sends reconnect when already connected const socket: Writable = mockPanelSocket; socket.isConnectedToTarget = true; hookedEvents.get("ready")!(); - expect(mockTelemetry.sendTelemetryEvent).toHaveBeenCalledWith("websocket/reconnect"); + expect(mockTelemetry.sendTelemetryEvent).toHaveBeenCalledWith( + "websocket", + { area: "websocket", feature: "connection", action: "reconnect", outcome: "success" }, + undefined); }); it("does nothing yet for websocket", async () => { @@ -309,8 +317,8 @@ describe("devtoolsPanel", () => { }; hookedEvents.get("telemetry")!(JSON.stringify(expectedPerf)); expect(mockTelemetry.sendTelemetryEvent).toHaveBeenCalledWith( - `devtools/${expectedPerf.name}`, - undefined, + "devtools", + { area: "devtools", feature: expectedPerf.name, action: "measure", outcome: "success" }, expect.objectContaining({ "myHistogram.duration": 100 }), ); @@ -321,8 +329,9 @@ describe("devtoolsPanel", () => { }; hookedEvents.get("telemetry")!(JSON.stringify(expectedEnum)); expect(mockTelemetry.sendTelemetryEvent).toHaveBeenCalledWith( - `devtools/${expectedEnum.name}`, - expect.objectContaining({ "myHistogram2.actionCode": "2" }), + "devtools", + expect.objectContaining({ area: "devtools", feature: expectedEnum.name, action: "enumerate", "myHistogram2.actionCode": "2" }), + undefined, ); const expectedError: TelemetryData = { @@ -339,8 +348,9 @@ describe("devtoolsPanel", () => { }; hookedEvents.get("telemetry")!(JSON.stringify(expectedError)); expect(mockTelemetry.sendTelemetryErrorEvent).toHaveBeenCalledWith( - `devtools/${expectedError.name}`, - expect.objectContaining({ "UnknownError.info": JSON.stringify(expectedError.data) }), + "devtools", + expect.objectContaining({ area: "devtools", feature: expectedError.name, action: "report", outcome: "error", "UnknownError.info": JSON.stringify(expectedError.data) }), + undefined, ); }); @@ -496,8 +506,9 @@ describe("devtoolsPanel", () => { await hookedEvents.get("openInEditor")!(JSON.stringify(expectedRequest)); expect(mockTelemetry.sendTelemetryEvent).toHaveBeenCalledWith( - `extension/openInEditor`, - expect.objectContaining({ sourceMaps: "true" }), + "extension", + expect.objectContaining({ area: "extension", feature: "editor", action: "openInEditor", sourceMaps: "true" }), + undefined, ); }); diff --git a/test/extension.test.ts b/test/extension.test.ts index 9fb1b514..2d421dce 100644 --- a/test/extension.test.ts +++ b/test/extension.test.ts @@ -648,8 +648,9 @@ describe("extension", () => { await newExtension.launch(createFakeExtensionContext()); expect(mockReporter.sendTelemetryEvent).toHaveBeenNthCalledWith( 2, - "command/launch/browser", - expect.objectContaining({ exe: t.exe }), + "command", + expect.objectContaining({ area: "command", feature: "browser", action: "launch", detail: "newInstance", exe: t.exe }), + undefined, ); } }); diff --git a/test/telemetryTaxonomy.test.ts b/test/telemetryTaxonomy.test.ts new file mode 100644 index 00000000..db10ff1b --- /dev/null +++ b/test/telemetryTaxonomy.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { createFakeVSCode } from './helpers/helpers'; +import { + sendTaxonomyErrorEvent, + sendTaxonomyEvent, + taxonomyToProperties, +} from '../src/telemetryTaxonomy'; + +jest.mock('vscode', () => createFakeVSCode(), { virtual: true }); +jest.mock('@vscode/extension-telemetry'); + +function createFakeReporter() { + return { + sendTelemetryEvent: jest.fn(), + sendTelemetryErrorEvent: jest.fn(), + }; +} + +describe('taxonomyToProperties', () => { + it('defaults outcome to success and omits an absent detail', () => { + expect(taxonomyToProperties({ area: 'workspace', feature: 'metadata', action: 'scan' })).toEqual({ + area: 'workspace', + feature: 'metadata', + action: 'scan', + outcome: 'success', + }); + }); + + it('includes detail when provided', () => { + const properties = taxonomyToProperties({ + area: 'command', feature: 'browser', action: 'launch', outcome: 'error', detail: 'browser_not_found', + }); + expect(properties).toEqual({ + area: 'command', + feature: 'browser', + action: 'launch', + outcome: 'error', + detail: 'browser_not_found', + }); + }); + + it('merges event specific properties alongside the dimensions', () => { + const properties = taxonomyToProperties( + { area: 'user', feature: 'ui', action: 'buttonPress' }, + { 'VSCode.buttonCode': '8' }); + expect(properties['VSCode.buttonCode']).toBe('8'); + expect(properties.area).toBe('user'); + }); +}); + +describe('sendTaxonomyEvent', () => { + it('uses the area as the event name', () => { + const reporter = createFakeReporter(); + sendTaxonomyEvent( + reporter as never, + { area: 'user', feature: 'ui', action: 'buttonPress' }, + { 'VSCode.buttonCode': '8' }); + + expect(reporter.sendTelemetryEvent).toHaveBeenCalledWith( + 'user', + { area: 'user', feature: 'ui', action: 'buttonPress', outcome: 'success', 'VSCode.buttonCode': '8' }, + undefined); + }); + + it('forwards measurements unchanged', () => { + const reporter = createFakeReporter(); + sendTaxonomyEvent( + reporter as never, + { area: 'workspace', feature: 'metadata', action: 'scan' }, + undefined, + { css: 4 }); + + expect(reporter.sendTelemetryEvent).toHaveBeenCalledWith( + 'workspace', + { area: 'workspace', feature: 'metadata', action: 'scan', outcome: 'success' }, + { css: 4 }); + }); + + it('never produces an event name the collector would drop', () => { + const reporter = createFakeReporter(); + sendTaxonomyEvent(reporter as never, { area: 'contextMenu', feature: 'item', action: 'launchHtml' }); + + const eventName = reporter.sendTelemetryEvent.mock.calls[0][0] as string; + expect(eventName).toMatch(/^[a-zA-Z0-9]([a-zA-Z0-9]|_){2,98}[a-zA-Z0-9]$/); + }); +}); + +describe('sendTaxonomyErrorEvent', () => { + it('defaults outcome to error', () => { + const reporter = createFakeReporter(); + sendTaxonomyErrorEvent( + reporter as never, + { area: 'command', feature: 'currentDebugTarget', action: 'attach', detail: 'no_active_session' }, + { message: 'No active debug session' }); + + expect(reporter.sendTelemetryErrorEvent).toHaveBeenCalledWith( + 'command', + { + area: 'command', + feature: 'currentDebugTarget', + action: 'attach', + outcome: 'error', + detail: 'no_active_session', + message: 'No active debug session', + }, + undefined); + }); + + it('keeps an explicit outcome', () => { + const reporter = createFakeReporter(); + sendTaxonomyErrorEvent( + reporter as never, + { area: 'command', feature: 'screencast', action: 'toggle', outcome: 'noTarget' }); + + const properties = reporter.sendTelemetryErrorEvent.mock.calls[0][1] as { outcome: string }; + expect(properties.outcome).toBe('noTarget'); + }); +}); diff --git a/test/utils.test.ts b/test/utils.test.ts index 72b0696b..de0a9da9 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -428,17 +428,36 @@ describe("utils", () => { }); it("returns a retail version when valid package in retail env", async () => { - const retailReporter = {}; - jest.doMock("@vscode/extension-telemetry", () => ({ TelemetryReporter: function retail() { return retailReporter; } })); + const sendEventData = jest.fn(); + jest.doMock("../package.json", () => ({ + publisher: "ms-edgedevtools", + name: "vscode-edge-devtools", + oneDSKey: "key", + }), { virtual: true }); + // Models the real base class: a constructor owning a telemetrySender. + jest.doMock("@vscode/extension-telemetry", () => ({ + TelemetryReporter: class { + telemetrySender = { sendEventData }; + }, + })); jest.resetModules(); jest.requireMock("vscode").env.machineId = "12345"; utils = await import("../src/utils"); + const { AriaTelemetryReporter } = await import("../src/ariaTelemetryReporter"); const mockContext = createFakeExtensionContext(); + (mockContext as { extensionMode: number }).extensionMode = 1; const reporter = utils.createTelemetryReporter(mockContext); expect(reporter).toBeDefined(); - expect(reporter).toEqual(retailReporter); + expect(reporter).toBeInstanceOf(AriaTelemetryReporter); + + // The sender is wrapped, so names reach the collector sanitized. + const sender = (reporter as unknown as { + telemetrySender: { sendEventData: (name: string, data: unknown) => void }; + }).telemetrySender; + sender.sendEventData("ms-edgedevtools.vscode-edge-devtools/user", {}); + expect(sendEventData).toHaveBeenCalledWith("user", {}); }); }); @@ -1013,7 +1032,7 @@ describe("utils", () => { for (let i = 0; i < input.length; i++) { utils.reportUrlType(input[i], reporter); - expect(reporter.sendTelemetryEvent).toHaveBeenCalledWith('user/browserNavigation', { 'urlType': expected[i] }); + expect(reporter.sendTelemetryEvent).toHaveBeenCalledWith('user', { area: 'user', feature: 'browser', action: 'navigate', outcome: 'success', 'urlType': expected[i] }, undefined); } }); }); @@ -1022,7 +1041,7 @@ describe("utils", () => { it('correctly lists extension types in the workspace', async () => { const reporter = createFakeTelemetryReporter(); await utils.reportFileExtensionTypes(reporter); - expect(reporter.sendTelemetryEvent).toHaveBeenCalledWith('workspace/metadata', undefined, {"css": 1, "html": 0, "js": 1, "json": 1, "jsx": 1, "mjs": 0, "other": 0, "scss": 0, "total": 4, "ts": 0}); + expect(reporter.sendTelemetryEvent).toHaveBeenCalledWith('workspace', { area: 'workspace', feature: 'metadata', action: 'scan', outcome: 'success' }, {"css": 1, "html": 0, "js": 1, "json": 1, "jsx": 1, "mjs": 0, "other": 0, "scss": 0, "total": 4, "ts": 0}); }); }); @@ -1044,7 +1063,7 @@ describe("utils", () => { it('correctly records all changed extension settings', async () => { const reporter = createFakeTelemetryReporter(); utils.reportExtensionSettings(reporter); - expect(reporter.sendTelemetryEvent).toHaveBeenCalledWith('user/settingsChangedAtLaunch', { isHeadless: 'false' }); + expect(reporter.sendTelemetryEvent).toHaveBeenCalledWith('user', { area: 'user', feature: 'settings', action: 'changedAtLaunch', outcome: 'success', isHeadless: 'false' }, undefined); }); it('correctly sends telemetry event for changed event', async () => { @@ -1057,7 +1076,7 @@ describe("utils", () => { } }}; utils.reportChangedExtensionSetting(configurationChangedEvent, reporter); - expect(reporter.sendTelemetryEvent).toHaveBeenCalledWith('user/settingsChanged', { isHeadless: 'false' }); + expect(reporter.sendTelemetryEvent).toHaveBeenCalledWith('user', { area: 'user', feature: 'settings', action: 'changed', outcome: 'success', isHeadless: 'false' }, undefined); }); }); });