Skip to content

Commit

Permalink
Add AzureActionHandler to UI package (#53)
Browse files Browse the repository at this point in the history
The AzureActionHandler provides consistent error handling and telemetry across extensions.
  • Loading branch information
ejizba authored Jan 4, 2018
1 parent 2b31173 commit de8fd9c
Show file tree
Hide file tree
Showing 18 changed files with 335 additions and 22 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -290,4 +290,5 @@ __pycache__/
node_modules
lib
package-lock.json
*.tgz
*.tgz
out
1 change: 1 addition & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ script:
- npm install
- npm run build
- npm run lint
- npm run test

notifications:
email:
Expand Down
7 changes: 4 additions & 3 deletions appservice/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "vscode-azureappservice",
"author": "Microsoft Corporation",
"version": "0.8.1",
"version": "0.8.2",
"description": "Common tools for developing Azure App Service extensions for VS Code",
"tags": [
"azure",
Expand All @@ -27,7 +27,8 @@
"prepack": "tsc -p ./",
"compile": "tsc -watch -p ./",
"lint": "tslint --project tsconfig.json -e src/*.d.ts -t verbose",
"prepare": "node ./node_modules/vscode/bin/install"
"prepare": "node ./node_modules/vscode/bin/install",
"test": "echo 'Test is not enabled for this package'"
},
"dependencies": {
"archiver": "^2.0.3",
Expand All @@ -38,7 +39,7 @@
"ms-rest-azure": "^2.4.4",
"opn": "^5.1.0",
"simple-git": "^1.80.1",
"vscode-azureextensionui": "~0.3.0",
"vscode-azureextensionui": "~0.4.1",
"vscode-azurekudu": "^0.1.2",
"vscode-nls": "^2.0.2"
},
Expand Down
3 changes: 2 additions & 1 deletion kudu/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"homepage": "https://github.com/Microsoft/vscode-azuretools/blob/master/kudu/README.md",
"scripts": {
"build": "autorest --version=2.0.4147 --input-file=swagger.json --package-name=vscode-azurekudu --title=KuduClient --output-folder=lib --nodejs --add-credentials --license-header=MICROSOFT_MIT_NO_VERSION;tsc -p ./",
"lint": "echo 'Lint is not enabled for this package'"
"lint": "echo 'Lint is not enabled for this package'",
"test": "echo 'Test is not enabled for this package'"
},
"dependencies": {
"ms-rest": "^2.2.2",
Expand Down
5 changes: 4 additions & 1 deletion ui/.npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,7 @@ node_modules/
src/
tsconfig.json
tslint.json
*.tgz
*.tgz
test/
out/test/
**/*.js.map
2 changes: 1 addition & 1 deletion ui/.vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"files.insertFinalNewline": true,
"files.trimTrailingWhitespace": true,
"search.exclude": {
"lib": true,
"out": true,
"**/node_modules": true
},
"tslint.autoFixOnSave": true,
Expand Down
44 changes: 42 additions & 2 deletions ui/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,44 @@
# VSCode Azure SDK for Node.js - UI Tools (Preview)

This package provides common Azure UI elements for VS Code extensions.
This package provides common Azure UI elements for VS Code extensions:
* [AzureActionHandler](#azure-action-handler): Displays error messages and optionally adds telemetry to commands/events.
* [AzureTreeDataProvider](#azure-tree-data-provider): Displays an Azure Explorer with Azure Subscriptions and child nodes of your implementation.
* [AzureBaseEditor](#azure-base-editor): Displays a text editor with upload support to Azure.

> NOTE: This package throws a `UserCancelledError` if the user cancels an operation. This error should be handled appropriately by your extension.
> NOTE: This package throws a `UserCancelledError` if the user cancels an operation. If you do not use the AzureActionHandler, you must handle this exception in your extension.
## Azure Action Handler

Use the Azure Action Handler to consistently display error messages and track commands with telemetry. You should construct the handler and register commands/events in your extension's `activate()` method. The simplest example is to register a command (in this case, refreshing a node):
```typescript
const actionHandler: AzureActionHandler = new AzureActionHandler(context, outputChannel, reporter);
actionHandler.registerCommand('yourExtension.Refresh', (node: IAzureNode) => { node.refresh(); });
```
Here are a few of the benefits this provides:
* Parses Azure errors of the form `{ "Code": "Conflict", "Message": "This is the actual message" }` and only displays the 'Message' property
* Displays single line errors normally and multi-line errors in the output window
* If you pass a TelemetryReporter, tracks multiple properties in addition to the [common extension properties](https://github.com/Microsoft/vscode-extension-telemetry#common-properties):
* result (Succeeded, Failed, or Canceled)
* duration
* error

If you want to add custom telemetry proprties, use `registerCommandWithCustomTelemetry` and add your own properties or measurements:
```typescript
actionHandler.registerCommandWithCustomTelemetry('yourExtension.Refresh', async (properties: TelemetryProperties, measurements: TelemetryMeasurements) => {
properties.customProp = "example prop";
measurements.customMeas = 49;
});
```

Finally, you can also register events. The main difference is that your callback must take in the `trackTelemetry` parameter. Events are not tracked by default (since they can happen very frequently). You must call `trackTelemetry()` to signal that this event is indeed handled by your extension. For example, if your extension only handles `json` files in the `onDidSaveTextDocument`, it might look like this:
```typescript
actionHandler.registerEvent('yourExtension.onDidSaveTextDocument', vscode.workspace.onDidSaveTextDocument, (trackTelemetry: () => void, doc: vscode.TextDocument) => {
if (doc.fileExtension === 'json') {
trackTelemetry();
// custom logic here
}
});
```

## Azure Tree Data Provider
![ExampleTree](resources/ExampleTree.png)
Expand Down Expand Up @@ -83,5 +119,9 @@ public async createChild(node: IAzureNode, showCreatingNode: (label: string) =>
}
```

## Azure Base Editor

Documentation coming soon...

## License
[MIT](LICENSE.md)
32 changes: 30 additions & 2 deletions ui/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
import { Subscription } from 'azure-arm-resource/lib/subscription/models';
import { ServiceClientCredentials } from 'ms-rest';
import { AzureEnvironment } from 'ms-rest-azure';
import { Uri, TreeDataProvider, Disposable, TreeItem, Event, OutputChannel, Memento, TextDocument } from 'vscode';
import { Uri, TreeDataProvider, Disposable, TreeItem, Event, OutputChannel, Memento, TextDocument, ExtensionContext } from 'vscode';
import TelemetryReporter from 'vscode-extension-telemetry';

export declare class AzureTreeDataProvider implements TreeDataProvider<IAzureNode>, Disposable {
public static readonly subscriptionContextValue: string;
Expand Down Expand Up @@ -142,8 +143,35 @@ export declare abstract class BaseEditor<ContextT> implements Disposable {
*/
abstract getSaveConfirmationText(context: ContextT): Promise<string>;

onDidSaveTextDocument(globalState: Memento, doc: TextDocument): Promise<void>;
onDidSaveTextDocument(trackTelemetry: () => void, globalState: Memento, doc: TextDocument): Promise<void>;
showEditor(context: ContextT, sizeLimit?: number): Promise<void>;
dispose(): Promise<void>;
}

/**
* Used to register VSCode commands and events. It wraps your callback with consistent error and telemetry handling
*/
export declare class AzureActionHandler {
constructor(extensionContext: ExtensionContext, outputChannel: OutputChannel, telemetryReporter?: TelemetryReporter);

registerCommand(commandId: string, callback: (...args: any[]) => any): void
registerCommandWithCustomTelemetry(commandId: string, callback: (properties: TelemetryProperties, measurements: TelemetryMeasurements, ...args: any[]) => any): void;

/**
* NOTE: An event callback must call trackTelemetry() in order for it to be "opted-in" for telemetry. This is meant to be called _after_ the event has determined that it apples to that extension
* For example, we might not want to track _every_ onDidSaveEvent, just the save events for our files
*/
registerEvent<T>(eventId: string, event: Event<T>, callback: (trackTelemetry: () => void, ...args: any[]) => any): void;
registerEventWithCustomTelemetry<T>(eventId: string, event: Event<T>, callback: (trackTelemetry: () => void, properties: TelemetryProperties, measurements: TelemetryMeasurements, ...args: any[]) => any): void;
}

export type TelemetryProperties = { [key: string]: string; };
export type TelemetryMeasurements = { [key: string]: number };

export declare function parseError(error: any): IParsedError;

export interface IParsedError {
errorType: string;
message: string;
isUserCancelledError: boolean;
}
8 changes: 6 additions & 2 deletions ui/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "vscode-azureextensionui",
"author": "Microsoft Corporation",
"version": "0.4.1",
"version": "0.5.0",
"description": "Common UI tools for developing Azure extensions for VS Code",
"tags": [
"azure",
Expand All @@ -11,7 +11,7 @@
"azure",
"vscode"
],
"main": "lib/index.js",
"main": "out/src/index.js",
"types": "index.d.ts",
"license": "MIT",
"repository": {
Expand All @@ -27,6 +27,7 @@
"prepack": "tsc -p ./",
"compile": "tsc -watch -p ./",
"lint": "tslint --project tsconfig.json -e src/*.d.ts -t verbose",
"test": "mocha 'out/test/**/*.js' --ui tdd",
"prepare": "node ./node_modules/vscode/bin/install"
},
"dependencies": {
Expand All @@ -35,10 +36,13 @@
"ms-rest": "^2.2.2",
"ms-rest-azure": "^2.4.4",
"opn": "^5.1.0",
"vscode-extension-telemetry": "^0.0.10",
"vscode-nls": "^2.0.2"
},
"devDependencies": {
"@types/fs-extra": "^4.0.6",
"@types/mocha": "^2.2.32",
"mocha": "^2.3.3",
"typescript": "^2.5.3",
"tslint": "^5.7.0",
"tslint-microsoft-contrib": "5.0.1",
Expand Down
80 changes: 80 additions & 0 deletions ui/src/AzureActionHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { commands, Event, ExtensionContext, OutputChannel, window } from 'vscode';
import TelemetryReporter from 'vscode-extension-telemetry';
import { TelemetryMeasurements, TelemetryProperties } from '../index';
import { IParsedError } from '../index';
import { localize } from './localize';
import { parseError } from './parseError';

// tslint:disable:no-any no-unsafe-any

export class AzureActionHandler {
private _extensionContext: ExtensionContext;
private _outputChannel: OutputChannel;
private _telemetryReporter: TelemetryReporter | undefined;
public constructor(extensionContext: ExtensionContext, outputChannel: OutputChannel, telemetryReporter?: TelemetryReporter) {
this._extensionContext = extensionContext;
this._outputChannel = outputChannel;
this._telemetryReporter = telemetryReporter;
}

public registerCommand(commandId: string, callback: (...args: any[]) => any): void {
this.registerCommandWithCustomTelemetry(commandId, (_properties: TelemetryProperties, _measurements: TelemetryMeasurements, ...args: any[]) => callback(...args));
}

public registerCommandWithCustomTelemetry(commandId: string, callback: (properties: TelemetryProperties, measurements: TelemetryMeasurements, ...args: any[]) => any): void {
this._extensionContext.subscriptions.push(commands.registerCommand(commandId, this.wrapCallback(commandId, (trackTelemetry: () => void, properties: TelemetryProperties, measurements: TelemetryMeasurements, ...args: any[]) => {
trackTelemetry(); // Always track telemetry for commands
return callback(properties, measurements, ...args);
})));
}

public registerEvent<T>(eventId: string, event: Event<T>, callback: (trackTelemetry: () => void, ...args: any[]) => any): void {
this.registerEventWithCustomTelemetry<T>(eventId, event, (trackTelemetry: () => void, _properties: TelemetryProperties, _measurements: TelemetryMeasurements, ...args: any[]) => callback(trackTelemetry, ...args));
}

public registerEventWithCustomTelemetry<T>(eventId: string, event: Event<T>, callback: (trackTelemetry: () => void, properties: TelemetryProperties, measurements: TelemetryMeasurements, ...args: any[]) => any): void {
this._extensionContext.subscriptions.push(event(this.wrapCallback(eventId, callback)));
}

private wrapCallback(callbackId: string, callback: (trackTelemetry: () => void, properties: TelemetryProperties, measurements: TelemetryMeasurements, ...args: any[]) => any): (...args: any[]) => Promise<any> {
return async (...args: any[]): Promise<any> => {
const start: number = Date.now();
const properties: TelemetryProperties = {};
const measurements: TelemetryMeasurements = {};
properties.result = 'Succeeded';
let sendTelemetry: boolean = false;

try {
await Promise.resolve(callback(() => { sendTelemetry = true; }, properties, measurements, ...args));
} catch (error) {
const errorData: IParsedError = parseError(error);
if (errorData.isUserCancelledError) {
properties.result = 'Canceled';
} else {
properties.result = 'Failed';
properties.error = errorData.errorType;
properties.errorMessage = errorData.message;
// Always append the error to the output channel, but only 'show' the output channel for multiline errors
this._outputChannel.appendLine(localize('outputError', 'Error: {0}', errorData.message));
if (errorData.message.includes('\n')) {
this._outputChannel.show();
window.showErrorMessage(localize('multilineError', 'An error has occured. Check output window for more details.'));
} else {
window.showErrorMessage(errorData.message);
}
}
} finally {
if (this._telemetryReporter && sendTelemetry) {
const end: number = Date.now();
measurements.duration = (end - start) / 1000;
this._telemetryReporter.sendTelemetryEvent(callbackId, properties, measurements);
}
}
};
}
}
5 changes: 3 additions & 2 deletions ui/src/BaseEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import * as fse from 'fs-extra';
import * as path from 'path';
import * as vscode from 'vscode';
import { DialogResponses } from './DialogResponses' ;
import { DialogResponses } from './DialogResponses';
import { UserCancelledError } from './errors';
import { localize } from "./localize";
import { createTemporaryFile } from './utils/createTemporaryFile';
Expand Down Expand Up @@ -57,9 +57,10 @@ export abstract class BaseEditor<ContextT> implements vscode.Disposable {
Object.keys(this.fileMap).forEach(async (key: string) => await fse.remove(path.dirname(key)));
}

public async onDidSaveTextDocument(globalState: vscode.Memento, doc: vscode.TextDocument): Promise<void> {
public async onDidSaveTextDocument(trackTelemetry: () => void, globalState: vscode.Memento, doc: vscode.TextDocument): Promise<void> {
const filePath: string | undefined = Object.keys(this.fileMap).find((fsPath: string) => path.relative(doc.uri.fsPath, fsPath) === '');
if (!this.ignoreSave && filePath) {
trackTelemetry();
const context: ContextT = this.fileMap[filePath][1];
const showSaveWarning: boolean | undefined = vscode.workspace.getConfiguration().get(this.showSavePromptKey);

Expand Down
2 changes: 2 additions & 0 deletions ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@
export { AzureTreeDataProvider } from './treeDataProvider/AzureTreeDataProvider';
export { UserCancelledError } from './errors';
export { BaseEditor } from './BaseEditor';
export { AzureActionHandler } from './AzureActionHandler';
export { parseError } from './parseError';
44 changes: 44 additions & 0 deletions ui/src/parseError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { IParsedError } from '../index';
import { localize } from './localize';

// tslint:disable-next-line:no-any
export function parseError(error: any): IParsedError {
let errorType: string;
let message: string;
if (error instanceof Error) {
try {
// Azure errors have a JSON object in the message
// tslint:disable-next-line:no-unsafe-any
errorType = JSON.parse(error.message).Code;
// tslint:disable-next-line:no-unsafe-any
message = JSON.parse(error.message).Message;
} catch (err) {
errorType = error.constructor.name;
message = error.message;
}
} else if (typeof (error) === 'object' && error !== null) {
errorType = (<object>error).constructor.name;
message = JSON.stringify(error);
// tslint:disable-next-line:no-unsafe-any
} else if (error !== undefined && error !== null && error.toString && error.toString().trim() !== '') {
errorType = typeof (error);
// tslint:disable-next-line:no-unsafe-any
message = error.toString();
} else {
errorType = typeof (error);
message = localize('unknownError', 'Unknown Error');
}

return {
errorType: errorType,
message: message,
// NOTE: Intentionally not using 'error instanceof UserCancelledError' because that doesn't work if multiple versions of the UI package are used in one extension
// See https://github.com/Microsoft/vscode-azuretools/issues/51 for more info
isUserCancelledError: errorType === 'UserCancelledError'
};
}
4 changes: 2 additions & 2 deletions ui/src/treeDataProvider/AzureTreeDataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ export class AzureTreeDataProvider implements TreeDataProvider<IAzureNode>, Disp
contextValue: 'azureCommandNode',
id: loginCommandId,
iconPath: {
light: path.join(__filename, '..', '..', '..', 'resources', 'light', 'Loading.svg'),
dark: path.join(__filename, '..', '..', '..', 'resources', 'dark', 'Loading.svg')
light: path.join(__filename, '..', '..', '..', '..', 'resources', 'light', 'Loading.svg'),
dark: path.join(__filename, '..', '..', '..', '..', 'resources', 'dark', 'Loading.svg')
}
})];
} else if (this._azureAccount.status === 'LoggedOut') {
Expand Down
4 changes: 2 additions & 2 deletions ui/src/treeDataProvider/CreatingTreeItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ export class CreatingTreeItem implements IAzureTreeItem {

public get iconPath(): { light: string, dark: string } {
return {
light: path.join(__filename, '..', '..', '..', 'resources', 'light', 'Loading.svg'),
dark: path.join(__filename, '..', '..', '..', 'resources', 'dark', 'Loading.svg')
light: path.join(__filename, '..', '..', '..', '..', 'resources', 'light', 'Loading.svg'),
dark: path.join(__filename, '..', '..', '..', '..', 'resources', 'dark', 'Loading.svg')
};
}
}
Loading

0 comments on commit de8fd9c

Please sign in to comment.