diff --git a/extensions/dacpac/src/telemetry.ts b/extensions/dacpac/src/telemetry.ts index 9212f8a9b5..513b7a6fdd 100644 --- a/extensions/dacpac/src/telemetry.ts +++ b/extensions/dacpac/src/telemetry.ts @@ -14,5 +14,10 @@ let packageInfo = Utils.getPackageInfo(packageJson); export const TelemetryReporter = new AdsTelemetryReporter(packageInfo.name, packageInfo.version, packageInfo.aiKey); export enum TelemetryViews { - SelectOperationPage = 'SelectOperationPage' + DataTierApplicationWizard = 'DataTierApplicationWizard', + DeployDacpac = 'DeployDacpac', + DeployPlanPage = 'DeployPlanPage', + ExportBacpac = 'ExportBacpac', + ExtractDacpac = 'ExtractDacpac', + ImportBacpac = 'ImportBacpac' } diff --git a/extensions/dacpac/src/test/wizardServiceInteraction.test.ts b/extensions/dacpac/src/test/wizardServiceInteraction.test.ts index 8eb3900d00..314a00cd8e 100644 --- a/extensions/dacpac/src/test/wizardServiceInteraction.test.ts +++ b/extensions/dacpac/src/test/wizardServiceInteraction.test.ts @@ -10,6 +10,7 @@ import * as should from 'should'; import * as sinon from 'sinon'; import * as TypeMoq from 'typemoq'; import * as loc from '../localizedConstants'; +import * as utils from '../utils'; import { DataTierApplicationWizard, Operation } from '../wizard/dataTierApplicationWizard'; import { DacFxDataModel } from '../wizard/api/models'; import { DacFxTestService, deployOperationId, extractOperationId, importOperationId, exportOperationId, generateDeployPlan } from './testDacFxService'; @@ -56,6 +57,11 @@ describe('Dacfx wizard with connection', function (): void { it('Should call all service methods correctly', async () => { wizard.model.server = connectionProfileMock; + wizard.model.potentialDataLoss = true; + wizard.model.upgradeExisting = true; + + const fileSizeStub = sinon.stub(utils, 'tryGetFileSize'); + fileSizeStub.resolves(TypeMoq.It.isAnyNumber()); await validateServiceCalls(wizard, Operation.deploy, deployOperationId); await validateServiceCalls(wizard, Operation.extract, extractOperationId); @@ -65,7 +71,7 @@ describe('Dacfx wizard with connection', function (): void { it('executeOperation should show error message if deploy fails', async () => { let service = TypeMoq.Mock.ofInstance(new DacFxTestService()); - service.setup(x => x.deployDacpac(TypeMoq.It.isAny(),TypeMoq.It.isAny(),TypeMoq.It.isAny(),TypeMoq.It.isAny(),TypeMoq.It.isAny(),TypeMoq.It.isAny())).returns(x => Promise.resolve({ + service.setup(x => x.deployDacpac(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(x => Promise.resolve({ errorMessage: 'error1', success: false, operationId: '' @@ -74,6 +80,10 @@ describe('Dacfx wizard with connection', function (): void { let wizard = new DataTierApplicationWizard(service.object); wizard.model = {}; wizard.model.server = connectionProfileMock; + wizard.model.potentialDataLoss = true; + wizard.model.upgradeExisting = true; + const fileSizeStub = sinon.stub(utils, 'tryGetFileSize'); + fileSizeStub.resolves(TypeMoq.It.isAnyNumber()); let showErrorMessageStub = sinon.stub(vscode.window, 'showErrorMessage').resolves(); wizard.selectedOperation = Operation.deploy; await wizard.executeOperation(); @@ -83,7 +93,7 @@ describe('Dacfx wizard with connection', function (): void { it('executeOperation should show error message if export fails', async () => { let service = TypeMoq.Mock.ofInstance(new DacFxTestService()); - service.setup(x => x.exportBacpac(TypeMoq.It.isAny(),TypeMoq.It.isAny(),TypeMoq.It.isAny(),TypeMoq.It.isAny())).returns(x => Promise.resolve({ + service.setup(x => x.exportBacpac(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(x => Promise.resolve({ errorMessage: 'error1', success: false, operationId: '' @@ -92,6 +102,8 @@ describe('Dacfx wizard with connection', function (): void { let wizard = new DataTierApplicationWizard(service.object); wizard.model = {}; wizard.model.server = connectionProfileMock; + const fileSizeStub = sinon.stub(utils, 'tryGetFileSize'); + fileSizeStub.resolves(TypeMoq.It.isAnyNumber()); let showErrorMessageStub = sinon.stub(vscode.window, 'showErrorMessage').resolves(); wizard.selectedOperation = Operation.export; await wizard.executeOperation(); @@ -101,7 +113,7 @@ describe('Dacfx wizard with connection', function (): void { it('executeOperation should show error message if extract fails', async () => { let service = TypeMoq.Mock.ofInstance(new DacFxTestService()); - service.setup(x => x.extractDacpac(TypeMoq.It.isAny(),TypeMoq.It.isAny(),TypeMoq.It.isAny(),TypeMoq.It.isAny(),TypeMoq.It.isAny(),TypeMoq.It.isAny())).returns(x => Promise.resolve({ + service.setup(x => x.extractDacpac(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(x => Promise.resolve({ errorMessage: 'error1', success: false, operationId: '' @@ -110,6 +122,8 @@ describe('Dacfx wizard with connection', function (): void { let wizard = new DataTierApplicationWizard(service.object); wizard.model = {}; wizard.model.server = connectionProfileMock; + const fileSizeStub = sinon.stub(utils, 'tryGetFileSize'); + fileSizeStub.resolves(TypeMoq.It.isAnyNumber()); let showErrorMessageStub = sinon.stub(vscode.window, 'showErrorMessage').resolves(); wizard.selectedOperation = Operation.extract; await wizard.executeOperation(); @@ -119,7 +133,7 @@ describe('Dacfx wizard with connection', function (): void { it('Should show error message if generateDeployScript fails', async () => { let service = TypeMoq.Mock.ofInstance(new DacFxTestService()); - service.setup(x => x.generateDeployScript(TypeMoq.It.isAny(),TypeMoq.It.isAny(),TypeMoq.It.isAny(),TypeMoq.It.isAny())).returns(x => Promise.resolve({ + service.setup(x => x.generateDeployScript(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(x => Promise.resolve({ errorMessage: 'error1', success: false, operationId: '' @@ -128,6 +142,9 @@ describe('Dacfx wizard with connection', function (): void { let wizard = new DataTierApplicationWizard(service.object); wizard.model = {}; wizard.model.server = connectionProfileMock; + wizard.model.potentialDataLoss = true; + const fileSizeStub = sinon.stub(utils, 'tryGetFileSize'); + fileSizeStub.resolves(TypeMoq.It.isAnyNumber()); let showErrorMessageStub = sinon.stub(vscode.window, 'showErrorMessage').resolves(); await wizard.generateDeployScript(); should(showErrorMessageStub.calledOnce).be.true(); @@ -136,7 +153,7 @@ describe('Dacfx wizard with connection', function (): void { it('executeOperation should show error message if import fails', async () => { let service = TypeMoq.Mock.ofInstance(new DacFxTestService()); - service.setup(x => x.importBacpac(TypeMoq.It.isAny(),TypeMoq.It.isAny(),TypeMoq.It.isAny(),TypeMoq.It.isAny())).returns(x => Promise.resolve({ + service.setup(x => x.importBacpac(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(x => Promise.resolve({ errorMessage: 'error1', success: false, operationId: '' @@ -145,6 +162,8 @@ describe('Dacfx wizard with connection', function (): void { let wizard = new DataTierApplicationWizard(service.object); wizard.model = {}; wizard.model.server = connectionProfileMock; + const fileSizeStub = sinon.stub(utils, 'tryGetFileSize'); + fileSizeStub.resolves(TypeMoq.It.isAnyNumber()); let showErrorMessageStub = sinon.stub(vscode.window, 'showErrorMessage').resolves(); wizard.selectedOperation = Operation.import; await wizard.executeOperation(); diff --git a/extensions/dacpac/src/utils.ts b/extensions/dacpac/src/utils.ts index 49c967e272..e3d4b759de 100644 --- a/extensions/dacpac/src/utils.ts +++ b/extensions/dacpac/src/utils.ts @@ -3,6 +3,8 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as fs from 'fs'; + export interface IPackageInfo { name: string; version: string; @@ -22,14 +24,16 @@ export function getPackageInfo(packageJson: any): IPackageInfo | undefined { } /** - * Map an error message into a short name for the type of error. - * @param msg The error message to map + * Get file size from the file stats using the file path uri + * If the file does not exists, purposely returning undefined instead of throwing an error for telemetry purpose. + * @param uri The file path */ -export function getTelemetryErrorType(msg: string): string { - if (msg && msg.indexOf('Object reference not set to an instance of an object') !== -1) { - return 'ObjectReferenceNotSet'; +export async function tryGetFileSize(uri: string): Promise { + try { + const stats = await fs.promises.stat(uri); + return stats?.size; } - else { - return 'Other'; + catch (e) { + return undefined; } } diff --git a/extensions/dacpac/src/wizard/api/models.ts b/extensions/dacpac/src/wizard/api/models.ts index 0944b2345e..7624070c27 100644 --- a/extensions/dacpac/src/wizard/api/models.ts +++ b/extensions/dacpac/src/wizard/api/models.ts @@ -16,4 +16,5 @@ export interface DacFxDataModel { filePath: string; version: string; upgradeExisting: boolean; + potentialDataLoss: boolean; } diff --git a/extensions/dacpac/src/wizard/dataTierApplicationWizard.ts b/extensions/dacpac/src/wizard/dataTierApplicationWizard.ts index b36db9468c..9471c90e67 100644 --- a/extensions/dacpac/src/wizard/dataTierApplicationWizard.ts +++ b/extensions/dacpac/src/wizard/dataTierApplicationWizard.ts @@ -6,6 +6,8 @@ import * as vscode from 'vscode'; import * as azdata from 'azdata'; import * as loc from '../localizedConstants'; +import * as mssql from '../../../mssql'; +import * as utils from '../utils'; import { SelectOperationPage } from './pages/selectOperationpage'; import { DeployConfigPage } from './pages/deployConfigPage'; import { DeployPlanPage } from './pages/deployPlanPage'; @@ -15,7 +17,8 @@ import { ExtractConfigPage } from './pages/extractConfigPage'; import { ImportConfigPage } from './pages/importConfigPage'; import { DacFxDataModel } from './api/models'; import { BasePage } from './api/basePage'; -import * as mssql from '../../../mssql'; +import { TelemetryReporter, TelemetryViews } from '../telemetry'; +import { TelemetryEventMeasures, TelemetryEventProperties } from 'ads-extension-telemetry'; const msSqlProvider = 'MSSQL'; class Page { @@ -84,7 +87,7 @@ export class DataTierApplicationWizard { public selectedOperation: Operation; constructor(dacfxInputService?: mssql.IDacFxService) { - this.wizard = azdata.window.createWizard(loc.wizardTitle); + this.wizard = azdata.window.createWizard(loc.wizardTitle, 'Data Tier Application Wizard'); this.dacfxService = dacfxInputService; } @@ -110,6 +113,8 @@ export class DataTierApplicationWizard { } // don't open the wizard if connection dialog is cancelled if (!this.connection) { + //Reporting Dacpac wizard cancelled event to Telemetry + TelemetryReporter.sendActionEvent(TelemetryViews.DataTierApplicationWizard, 'ConnectionDialogCancelled'); return false; } } @@ -123,13 +128,13 @@ export class DataTierApplicationWizard { } public setPages(): void { - let selectOperationWizardPage = azdata.window.createWizardPage(loc.selectOperationPageName); - let deployConfigWizardPage = azdata.window.createWizardPage(loc.deployConfigPageName); - let deployPlanWizardPage = azdata.window.createWizardPage(loc.deployPlanPageName); - let summaryWizardPage = azdata.window.createWizardPage(loc.summaryPageName); - let extractConfigWizardPage = azdata.window.createWizardPage(loc.extractConfigPageName); - let importConfigWizardPage = azdata.window.createWizardPage(loc.importConfigPageName); - let exportConfigWizardPage = azdata.window.createWizardPage(loc.exportConfigPageName); + let selectOperationWizardPage = azdata.window.createWizardPage(loc.selectOperationPageName, 'Select an Operation Page'); + let deployConfigWizardPage = azdata.window.createWizardPage(loc.deployConfigPageName, 'Deploy Config Page'); + let deployPlanWizardPage = azdata.window.createWizardPage(loc.deployPlanPageName, 'Deploy Plan Page'); + let summaryWizardPage = azdata.window.createWizardPage(loc.summaryPageName, 'Summary Page'); + let extractConfigWizardPage = azdata.window.createWizardPage(loc.extractConfigPageName, 'Extract Config Page'); + let importConfigWizardPage = azdata.window.createWizardPage(loc.importConfigPageName, 'Import Config Page'); + let exportConfigWizardPage = azdata.window.createWizardPage(loc.exportConfigPageName, 'Export Config Page'); this.pages.set(PageName.selectOperation, new Page(selectOperationWizardPage)); this.pages.set(PageName.deployConfig, new Page(deployConfigWizardPage)); @@ -206,6 +211,7 @@ export class DataTierApplicationWizard { this.wizard.generateScriptButton.hidden = true; this.wizard.generateScriptButton.onClick(async () => await this.generateDeployScript()); this.wizard.doneButton.onClick(async () => await this.executeOperation()); + this.wizard.cancelButton.onClick(() => this.cancelDataTierApplicationWizard()); } public registerNavigationValidator(validator: (pageChangeInfo: azdata.window.WizardPageChangeInfo) => boolean) { @@ -287,48 +293,161 @@ export class DataTierApplicationWizard { } } - public async deploy(): Promise { - const service = await this.getService(msSqlProvider); - const ownerUri = await azdata.connection.getUriForConnection(this.model.server.connectionId); + // Cancel button on click event is using to send the data loss information to telemetry + private cancelDataTierApplicationWizard(): void { + TelemetryReporter.createActionEvent(TelemetryViews.DataTierApplicationWizard, 'WizardCanceled') + .withAdditionalProperties({ + isPotentialDataLoss: this.model.potentialDataLoss.toString() + }).send(); + } - return await service.deployDacpac(this.model.filePath, this.model.database, this.model.upgradeExisting, ownerUri, azdata.TaskExecutionMode.execute); + public async deploy(): Promise { + const deployStartTime = new Date().getTime(); + let service: mssql.IDacFxService; + let ownerUri: string; + let result: mssql.DacFxResult; + let additionalProps: TelemetryEventProperties = {}; + let additionalMeasurements: TelemetryEventMeasures = {}; + try { + service = await this.getService(msSqlProvider); + ownerUri = await azdata.connection.getUriForConnection(this.model.server.connectionId); + result = await service.deployDacpac(this.model.filePath, this.model.database, this.model.upgradeExisting, ownerUri, azdata.TaskExecutionMode.execute); + } catch (e) { + additionalProps.exceptionOccurred = 'true'; + } + + // If result is null which means exception occured, will be adding additional props to the Telemetry + if (!result) { + additionalProps = { ...additionalProps, ...this.getDacServiceArgsAsProps(service, this.model.database, this.model.filePath, ownerUri) }; + } + additionalProps.deploymentStatus = result?.success.toString(); + additionalProps.upgradeExistingDatabase = this.model.upgradeExisting.toString(); + additionalProps.potentialDataLoss = this.model.potentialDataLoss.toString(); + + additionalMeasurements.deployDacpacFileSizeBytes = await utils.tryGetFileSize(this.model.filePath); + additionalMeasurements.totalDurationMs = (new Date().getTime() - deployStartTime); + + // Deploy Dacpac: 'Deploy button' clicked in deploy summary page, Reporting the event selection to the telemetry + this.sendDacServiceTelemetryEvent(TelemetryViews.DeployDacpac, 'DeployDacpacOperation', additionalProps, additionalMeasurements); + return result; } private async extract(): Promise { - const service = await this.getService(msSqlProvider); - const ownerUri = await azdata.connection.getUriForConnection(this.model.server.connectionId); + const extractStartTime = new Date().getTime(); + let service: mssql.IDacFxService; + let ownerUri: string; + let result: mssql.DacFxResult; + let additionalProps: TelemetryEventProperties = {}; + let additionalMeasurements: TelemetryEventMeasures = {}; + try { + service = await this.getService(msSqlProvider); + ownerUri = await azdata.connection.getUriForConnection(this.model.server.connectionId); + result = await service.extractDacpac(this.model.database, this.model.filePath, this.model.database, this.model.version, ownerUri, azdata.TaskExecutionMode.execute); + } catch (e) { + additionalProps.exceptionOccurred = 'true'; + } - return await service.extractDacpac(this.model.database, this.model.filePath, this.model.database, this.model.version, ownerUri, azdata.TaskExecutionMode.execute); + // If result is null which means exception occured, will be adding additional props to the Telemetry + if (!result) { + additionalProps = { ...additionalProps, ...this.getDacServiceArgsAsProps(service, this.model.database, this.model.filePath, ownerUri) }; + } + additionalProps.extractStatus = result?.success.toString(); + additionalMeasurements.extractedDacpacFileSizeBytes = await utils.tryGetFileSize(this.model.filePath); + additionalMeasurements.totalDurationMs = (new Date().getTime() - extractStartTime); + // Extract Dacpac: 'Extract button' clicked in extract summary page, Reporting the event selection to the telemetry + this.sendDacServiceTelemetryEvent(TelemetryViews.ExtractDacpac, 'ExtractDacpacOperation', additionalProps, additionalMeasurements); + return result; } private async export(): Promise { - const service = await this.getService(msSqlProvider); - const ownerUri = await azdata.connection.getUriForConnection(this.model.server.connectionId); + const exportStartTime = new Date().getTime(); + let service: mssql.IDacFxService; + let ownerUri: string; + let result: mssql.DacFxResult; + let additionalProps: TelemetryEventProperties = {}; + let additionalMeasurements: TelemetryEventMeasures = {}; + try { + service = await this.getService(msSqlProvider); + ownerUri = await azdata.connection.getUriForConnection(this.model.server.connectionId); + result = await service.exportBacpac(this.model.database, this.model.filePath, ownerUri, azdata.TaskExecutionMode.execute); + } catch (e) { + additionalProps.exceptionOccurred = 'true'; + } - return await service.exportBacpac(this.model.database, this.model.filePath, ownerUri, azdata.TaskExecutionMode.execute); + // If result is null which means exception occured, will be adding additional props to the Telemetry + if (!result) { + additionalProps = { ...additionalProps, ...this.getDacServiceArgsAsProps(service, this.model.database, this.model.filePath, ownerUri) }; + } + additionalProps.exportStatus = result?.success.toString(); + additionalMeasurements.exportedBacpacFileSizeBytes = await utils.tryGetFileSize(this.model.filePath); + additionalMeasurements.totalDurationMs = (new Date().getTime() - exportStartTime); + // Export Bacpac: 'Export button' clicked in Export summary page, Reporting the event selection to the telemetry + this.sendDacServiceTelemetryEvent(TelemetryViews.ExportBacpac, 'ExportBacpacOperation', additionalProps, additionalMeasurements); + return result; } private async import(): Promise { - const service = await this.getService(msSqlProvider); - const ownerUri = await azdata.connection.getUriForConnection(this.model.server.connectionId); + const importStartTime = new Date().getTime(); + let service: mssql.IDacFxService; + let ownerUri: string; + let result: mssql.DacFxResult; + let additionalProps: TelemetryEventProperties = {}; + let additionalMeasurements: TelemetryEventMeasures = {}; + try { + service = await this.getService(msSqlProvider); + ownerUri = await azdata.connection.getUriForConnection(this.model.server.connectionId); + result = await service.importBacpac(this.model.filePath, this.model.database, ownerUri, azdata.TaskExecutionMode.execute); + } catch (e) { + additionalProps.exceptionOccurred = 'true'; + } - return await service.importBacpac(this.model.filePath, this.model.database, ownerUri, azdata.TaskExecutionMode.execute); + // If result is null which means exception occured, will be adding additional props to the Telemetry + if (!result) { + additionalProps = { ...additionalProps, ...this.getDacServiceArgsAsProps(service, this.model.database, this.model.filePath, ownerUri) }; + } + additionalProps.importStatus = result?.success.toString(); + additionalMeasurements.importedBacpacFileSizeBytes = await utils.tryGetFileSize(this.model.filePath); + additionalMeasurements.totalDurationMs = (new Date().getTime() - importStartTime); + // Import Bacpac: 'Import button' clicked in Import summary page, Reporting the event selection to the telemetry + this.sendDacServiceTelemetryEvent(TelemetryViews.ImportBacpac, 'ImportBacpacOperation', additionalProps, additionalMeasurements); + return result; } public async generateDeployScript(): Promise { - const service = await this.getService(msSqlProvider); - const ownerUri = await azdata.connection.getUriForConnection(this.model.server.connectionId); - this.wizard.message = { - text: loc.generatingScriptMessage, - level: azdata.window.MessageLevel.Information, - description: '' - }; + const genScriptStartTime = new Date().getTime(); + let service: mssql.IDacFxService; + let ownerUri: string; + let result: mssql.DacFxResult; + let additionalProps: TelemetryEventProperties = {}; + let additionalMeasurements: TelemetryEventMeasures = {}; + try { + this.wizard.message = { + text: loc.generatingScriptMessage, + level: azdata.window.MessageLevel.Information, + description: '' + }; - let result = await service.generateDeployScript(this.model.filePath, this.model.database, ownerUri, azdata.TaskExecutionMode.script); + service = await this.getService(msSqlProvider); + ownerUri = await azdata.connection.getUriForConnection(this.model.server.connectionId); + result = await service.generateDeployScript(this.model.filePath, this.model.database, ownerUri, azdata.TaskExecutionMode.script); + } catch (e) { + additionalProps.exceptionOccurred = 'true'; + } if (!result || !result.success) { vscode.window.showErrorMessage(loc.generateDeployErrorMessage(result?.errorMessage)); } + + // If result is null which means exception occured, will be adding additional props to the Telemetry + if (!result) { + additionalProps = { ...additionalProps, ...this.getDacServiceArgsAsProps(service, this.model.database, this.model.filePath, ownerUri) }; + } + additionalProps.isScriptGenerated = result?.success.toString(); + additionalProps.potentialDataLoss = this.model.potentialDataLoss.toString(); + additionalMeasurements.deployDacpacFileSizeBytes = await utils.tryGetFileSize(this.model.filePath); + additionalMeasurements.totalDurationMs = (new Date().getTime() - genScriptStartTime); + // Deploy Dacpac 'generate script' button clicked in DeployPlanPage, Reporting the event selection to the telemetry with fail/sucess status + this.sendDacServiceTelemetryEvent(TelemetryViews.DeployDacpac, 'GenerateDeployScriptOperation', additionalProps, additionalMeasurements); return result; } @@ -372,15 +491,32 @@ export class DataTierApplicationWizard { } public async generateDeployPlan(): Promise { - const service = await this.getService(msSqlProvider); - const ownerUri = await azdata.connection.getUriForConnection(this.model.server.connectionId); - - const result = await service.generateDeployPlan(this.model.filePath, this.model.database, ownerUri, azdata.TaskExecutionMode.execute); + const deployPlanStartTime = new Date().getTime(); + let service: mssql.IDacFxService; + let ownerUri: string; + let result: mssql.GenerateDeployPlanResult; + let additionalProps: TelemetryEventProperties = {}; + let additionalMeasurements: TelemetryEventMeasures = {}; + try { + service = await this.getService(msSqlProvider); + ownerUri = await azdata.connection.getUriForConnection(this.model.server.connectionId); + result = await service.generateDeployPlan(this.model.filePath, this.model.database, ownerUri, azdata.TaskExecutionMode.execute); + } catch (e) { + additionalProps.exceptionOccurred = 'true'; + } if (!result || !result.success) { vscode.window.showErrorMessage(loc.deployPlanErrorMessage(result?.errorMessage)); } + // If result is null which means exception occured, will be adding additional props to the Telemetry + if (!result) { + additionalProps = { ...additionalProps, ...this.getDacServiceArgsAsProps(service, this.model.database, this.model.filePath, ownerUri) }; + } + additionalProps.isPlanGenerated = result?.success.toString(); + additionalMeasurements.totalDurationMs = (new Date().getTime() - deployPlanStartTime); + // send Generate deploy plan error/succes telemetry event + this.sendDacServiceTelemetryEvent(TelemetryViews.DeployPlanPage, 'GenerateDeployPlanOperation', additionalProps, additionalMeasurements); return result.report; } @@ -390,4 +526,20 @@ export class DataTierApplicationWizard { } return this.dacfxService; } + + public getDacServiceArgsAsProps(service: mssql.IDacFxService, database: string, filePath: string, ownerUri: string): { [k: string]: string } { + return { + isServiceExist: (!!service).toString(), + isDatabaseExists: (!!database).toString(), + isFilePathExist: (!!filePath).toString(), + isOwnerUriExist: (!!ownerUri).toString() + }; + } + + private sendDacServiceTelemetryEvent(telemetryView: string, telemetryAction: string, additionalProps: TelemetryEventProperties, additionalMeasurements: TelemetryEventMeasures): void { + TelemetryReporter.createActionEvent(telemetryView, telemetryAction) + .withAdditionalProperties(additionalProps) + .withAdditionalMeasurements(additionalMeasurements) + .send(); + } } diff --git a/extensions/dacpac/src/wizard/pages/deployPlanPage.ts b/extensions/dacpac/src/wizard/pages/deployPlanPage.ts index 8ca68668aa..ade2860b13 100644 --- a/extensions/dacpac/src/wizard/pages/deployPlanPage.ts +++ b/extensions/dacpac/src/wizard/pages/deployPlanPage.ts @@ -77,12 +77,14 @@ export class DeployPlanPage extends DacFxConfigPage { this.formBuilder.addFormItem(this.dataLossComponentGroup, { horizontal: true, componentWidth: 400 }); this.dataLossCheckbox.checked = false; this.dataLossCheckbox.enabled = false; + this.model.potentialDataLoss = false; this.formBuilder.removeFormItem(this.noDataLossTextComponent); this.loader.loading = true; this.table.data = []; await this.populateTable(); this.loader.loading = false; + return true; } @@ -103,6 +105,7 @@ export class DeployPlanPage extends DacFxConfigPage { value: loc.dataLossTextWithCount(result.dataLossAlerts.size) }); this.dataLossCheckbox.enabled = true; + this.model.potentialDataLoss = true; } else { // check checkbox to enable Next button and remove checkbox because there won't be any possible data loss this.dataLossCheckbox.checked = true; diff --git a/src/sql/azdata.proposed.d.ts b/src/sql/azdata.proposed.d.ts index f42190e1ab..af698f05d2 100644 --- a/src/sql/azdata.proposed.d.ts +++ b/src/sql/azdata.proposed.d.ts @@ -644,6 +644,13 @@ declare module 'azdata' { width?: DialogWidth; } + export interface WizardPage extends ModelViewPanel { + /** + * An optional name for the page. If provided it will be used for telemetry + */ + pageName?: string; + } + export type DialogWidth = 'narrow' | 'medium' | 'wide' | number; /** @@ -661,6 +668,13 @@ declare module 'azdata' { * @param width The width of the wizard, default value is 'narrow' */ export function createWizard(title: string, name?: string, width?: DialogWidth): Wizard; + + /** + * Create a wizard page with the given title, for inclusion in a wizard + * @param title The title of the page + * @param pageName The optional page name parameter will be used for telemetry + */ + export function createWizardPage(title: string, pageName?: string): WizardPage; } export namespace workspace { diff --git a/src/sql/platform/telemetry/common/telemetryKeys.ts b/src/sql/platform/telemetry/common/telemetryKeys.ts index 3ac83c6529..f46f434155 100644 --- a/src/sql/platform/telemetry/common/telemetryKeys.ts +++ b/src/sql/platform/telemetry/common/telemetryKeys.ts @@ -27,6 +27,7 @@ export const FirewallRuleRequested = 'FirewallRuleCreated'; export const DashboardNavigated = 'DashboardNavigated'; export const GetDataGridItems = 'GetDataGridItems'; export const GetDataGridColumns = 'GetDataGridColumns'; +export const WizardPagesNavigation = 'WizardPagesNavigation'; // Telemetry Properties diff --git a/src/sql/workbench/api/browser/mainThreadModelViewDialog.ts b/src/sql/workbench/api/browser/mainThreadModelViewDialog.ts index 03d5217ea5..8f7a2b42ca 100644 --- a/src/sql/workbench/api/browser/mainThreadModelViewDialog.ts +++ b/src/sql/workbench/api/browser/mainThreadModelViewDialog.ts @@ -151,7 +151,7 @@ export class MainThreadModelViewDialog implements MainThreadModelViewDialogShape public $setWizardPageDetails(handle: number, details: IModelViewWizardPageDetails): Thenable { let page = this._wizardPages.get(handle); if (!page) { - page = new WizardPage(details.title, details.content); + page = new WizardPage(details.title, details.content, details.pageName); page.onValidityChanged(valid => this._proxy.$onPanelValidityChanged(handle, valid)); this._wizardPages.set(handle, page); this._wizardPageHandles.set(page, handle); @@ -161,6 +161,7 @@ export class MainThreadModelViewDialog implements MainThreadModelViewDialogShape page.content = details.content; page.enabled = details.enabled; page.description = details.description; + page.pageName = details.pageName; if (details.customButtons !== undefined) { page.customButtons = details.customButtons.map(buttonHandle => this.getButton(buttonHandle)); } diff --git a/src/sql/workbench/api/common/extHostModelViewDialog.ts b/src/sql/workbench/api/common/extHostModelViewDialog.ts index 6323273f4a..10497310d7 100644 --- a/src/sql/workbench/api/common/extHostModelViewDialog.ts +++ b/src/sql/workbench/api/common/extHostModelViewDialog.ts @@ -324,7 +324,8 @@ class WizardPageImpl extends ModelViewPanelImpl implements azdata.window.WizardP constructor(public title: string, extHostModelViewDialog: ExtHostModelViewDialog, extHostModelView: ExtHostModelViewShape, - extension: IExtensionDescription) { + extension: IExtensionDescription, + public pageName?: string) { super('modelViewWizardPage', extHostModelViewDialog, extHostModelView, extension); } @@ -758,8 +759,8 @@ export class ExtHostModelViewDialog implements ExtHostModelViewDialogShape { this._pageInfoChangedCallbacks.set(handle, callback); } - public createWizardPage(title: string, extension?: IExtensionDescription): azdata.window.WizardPage { - let page = new WizardPageImpl(title, this, this._extHostModelView, extension); + public createWizardPage(title: string, extension?: IExtensionDescription, pageName?: string): azdata.window.WizardPage { + let page = new WizardPageImpl(title, this, this._extHostModelView, extension, pageName); page.handle = this.getHandle(page); return page; } @@ -781,7 +782,8 @@ export class ExtHostModelViewDialog implements ExtHostModelViewDialogShape { customButtons: page.customButtons ? page.customButtons.map(button => this.getHandle(button)) : undefined, enabled: page.enabled, title: page.title, - description: page.description + description: page.description, + pageName: page.pageName }); } diff --git a/src/sql/workbench/api/common/sqlExtHost.api.impl.ts b/src/sql/workbench/api/common/sqlExtHost.api.impl.ts index 87477cb1ca..6c2d9ae76c 100644 --- a/src/sql/workbench/api/common/sqlExtHost.api.impl.ts +++ b/src/sql/workbench/api/common/sqlExtHost.api.impl.ts @@ -435,8 +435,8 @@ export function createAdsApiFactory(accessor: ServicesAccessor): IAdsExtensionAp closeDialog(dialog: azdata.window.Dialog) { return extHostModelViewDialog.closeDialog(dialog); }, - createWizardPage(title: string): azdata.window.WizardPage { - return extHostModelViewDialog.createWizardPage(title, extension); + createWizardPage(title: string, pageName?: string): azdata.window.WizardPage { + return extHostModelViewDialog.createWizardPage(title, extension, pageName); }, createWizard(title: string, name?: string, width?: azdata.window.DialogWidth): azdata.window.Wizard { return extHostModelViewDialog.createWizard(title, name, width); diff --git a/src/sql/workbench/api/common/sqlExtHostTypes.ts b/src/sql/workbench/api/common/sqlExtHostTypes.ts index 0e24f4db4c..68072447c0 100644 --- a/src/sql/workbench/api/common/sqlExtHostTypes.ts +++ b/src/sql/workbench/api/common/sqlExtHostTypes.ts @@ -278,6 +278,7 @@ export interface IModelViewWizardPageDetails { enabled: boolean; customButtons: number[]; description: string; + pageName?: string; } export interface IModelViewWizardDetails { diff --git a/src/sql/workbench/browser/modal/modal.ts b/src/sql/workbench/browser/modal/modal.ts index acf787530c..dcd6ac533c 100644 --- a/src/sql/workbench/browser/modal/modal.ts +++ b/src/sql/workbench/browser/modal/modal.ts @@ -391,14 +391,15 @@ export abstract class Modal extends Disposable implements IThemable { /** * Hides the modal and removes key listeners */ - protected hide(reason?: string) { + protected hide(reason?: string, currentPageName?: string): void { this._modalShowingContext.get()!.pop(); this._bodyContainer!.remove(); this.disposableStore.clear(); this._telemetryService.createActionEvent(TelemetryKeys.TelemetryView.Shell, TelemetryKeys.ModalDialogClosed) .withAdditionalProperties({ name: this._name, - reason: reason + reason: reason, + currentPageName: currentPageName }) .send(); this.restoreKeyboardFocus(); diff --git a/src/sql/workbench/services/dialog/browser/wizardModal.ts b/src/sql/workbench/services/dialog/browser/wizardModal.ts index d194c8576c..0c83d8d402 100644 --- a/src/sql/workbench/services/dialog/browser/wizardModal.ts +++ b/src/sql/workbench/services/dialog/browser/wizardModal.ts @@ -3,6 +3,7 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as TelemetryKeys from 'sql/platform/telemetry/common/telemetryKeys'; import 'vs/css!./media/dialogModal'; import { Modal, IModalOptions } from 'sql/workbench/browser/modal/modal'; import { Wizard, DialogButton, WizardPage } from 'sql/workbench/services/dialog/common/dialogTypes'; @@ -49,14 +50,14 @@ export class WizardModal extends Modal { options: IModalOptions, @ILayoutService layoutService: ILayoutService, @IThemeService themeService: IThemeService, - @IAdsTelemetryService telemetryService: IAdsTelemetryService, + @IAdsTelemetryService private _telemetryEventService: IAdsTelemetryService, @IContextKeyService contextKeyService: IContextKeyService, @IInstantiationService private _instantiationService: IInstantiationService, @IClipboardService clipboardService: IClipboardService, @ILogService logService: ILogService, @ITextResourcePropertiesService textResourcePropertiesService: ITextResourcePropertiesService ) { - super(_wizard.title, _wizard.name, telemetryService, layoutService, clipboardService, themeService, logService, textResourcePropertiesService, contextKeyService, options); + super(_wizard.title, _wizard.name, _telemetryEventService, layoutService, clipboardService, themeService, logService, textResourcePropertiesService, contextKeyService, options); this._useDefaultMessageBoxLocation = false; } @@ -175,6 +176,7 @@ export class WizardModal extends Modal { public async showPage(index: number, validate: boolean = true, focus: boolean = false, readHeader: boolean = true): Promise { let pageToShow = this._wizard.pages[index]; + const prevPageIndex = this._wizard.currentPage; if (!pageToShow) { this.done(validate).catch(err => onUnexpectedError(err)); return; @@ -209,6 +211,15 @@ export class WizardModal extends Modal { this._doneButton.enabled = this._wizard.doneButton.enabled && pageToShow.valid; } }); + + if (index !== prevPageIndex) { + this._telemetryEventService.createActionEvent(TelemetryKeys.TelemetryView.Shell, TelemetryKeys.WizardPagesNavigation) + .withAdditionalProperties({ + wizardName: this._wizard.name, + pageNavigationFrom: this._wizard.pages[prevPageIndex].pageName ?? prevPageIndex, + pageNavigationTo: this._wizard.pages[index].pageName ?? index + }).send(); + } } private setButtonsForPage(index: number) { @@ -268,9 +279,10 @@ export class WizardModal extends Modal { } public cancel(): void { + const currentPage = this._wizard.pages[this._wizard.currentPage]; this._onCancel.fire(); this.dispose(); - this.hide('cancel'); + this.hide('cancel', currentPage.pageName ?? this._wizard.currentPage.toString()); } private async validateNavigation(newPage: number): Promise { diff --git a/src/sql/workbench/services/dialog/common/dialogTypes.ts b/src/sql/workbench/services/dialog/common/dialogTypes.ts index 499e9fa578..d98445ca64 100644 --- a/src/sql/workbench/services/dialog/common/dialogTypes.ts +++ b/src/sql/workbench/services/dialog/common/dialogTypes.ts @@ -157,7 +157,7 @@ export class WizardPage extends DialogTab { private _onUpdate: Emitter = new Emitter(); public readonly onUpdate: Event = this._onUpdate.event; - constructor(public title: string, content?: string) { + constructor(public title: string, content?: string, public pageName?: string) { super(title, content); } diff --git a/src/sql/workbench/test/electron-browser/api/mainThreadModelViewDialog.test.ts b/src/sql/workbench/test/electron-browser/api/mainThreadModelViewDialog.test.ts index 01b3a6736b..e352022546 100644 --- a/src/sql/workbench/test/electron-browser/api/mainThreadModelViewDialog.test.ts +++ b/src/sql/workbench/test/electron-browser/api/mainThreadModelViewDialog.test.ts @@ -140,14 +140,16 @@ suite('MainThreadModelViewDialog Tests', () => { content: 'content1', enabled: true, customButtons: [], - description: 'description1' + description: 'description1', + pageName: 'pageName1' }; page2Details = { title: 'page2', content: 'content2', enabled: true, customButtons: [button1Handle, button2Handle], - description: 'description2' + description: 'description2', + pageName: undefined }; wizardDetails = { backButton: backButtonHandle, @@ -302,7 +304,8 @@ suite('MainThreadModelViewDialog Tests', () => { content: 'content_3', customButtons: [], enabled: true, - description: undefined + description: undefined, + pageName: undefined }; // If I open the wizard and then add a page