From 0ae6a046794379e53da58af8c315fcc04e846ab5 Mon Sep 17 00:00:00 2001 From: Eric Chen Date: Tue, 3 Dec 2019 17:16:11 +0800 Subject: [PATCH] feat(telemetry): add telemetry to collect submitted model number (#9) --- azure-pipelines.yml | 2 +- package.json | 5 +- {.vscode/scripts => scripts}/genAiKey.js | 2 +- src/common/constants.ts | 3 + src/common/nsat.ts | 2 +- src/common/telemetryClient.ts | 64 ++------------- src/common/telemetryContext.ts | 68 ++++++++++++++++ src/common/utility.ts | 11 +++ src/extension.ts | 16 ++-- src/modelRepository/modelRepositoryClient.ts | 20 ++--- .../modelRepositoryConnection.ts | 75 +++++++++-------- src/modelRepository/modelRepositoryManager.ts | 81 ++++++++++++++++--- src/view/ui.ts | 4 +- 13 files changed, 225 insertions(+), 128 deletions(-) rename {.vscode/scripts => scripts}/genAiKey.js (79%) create mode 100644 src/common/telemetryContext.ts diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 505a175..c6a9f65 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -39,7 +39,7 @@ steps: displayName: Compile Sources - script: | - # node scripts/genAiKey.js + node scripts/genAiKey.js npm install -g vsce vsce package displayName: Build VSIX Package diff --git a/package.json b/package.json index 5e384c1..35ccc96 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "Author IoT Plug and Play models, publish and manage with Model Repository", "version": "0.0.1", "publisher": "vsciot-vscode", - "aiKey": "5b869bc6-ca93-4f24-aa87-92871a3a616e", + "preview": true, + "aiKey": "25cf12e8-2dc7-4556-b54e-40fb9d1dfba1", "icon": "logo.png", "license": "SEE LICENSE IN LICENSE.txt", "engines": { @@ -61,7 +62,7 @@ ], "configuration": [ { - "title": "IoT Plug and Play Configuration", + "title": "IoT Plug and Play", "properties": { "azure-digital-twins.publicRepositoryUrl": { "type": "string", diff --git a/.vscode/scripts/genAiKey.js b/scripts/genAiKey.js similarity index 79% rename from .vscode/scripts/genAiKey.js rename to scripts/genAiKey.js index ea628e2..6ce83d4 100644 --- a/.vscode/scripts/genAiKey.js +++ b/scripts/genAiKey.js @@ -3,7 +3,7 @@ const fs = require("fs"); const PACKAGE_JSON_FILENAME = "package.json"; if (process.env.BUILD_SOURCEBRANCH) { - const IS_PROD = new RegExp(proces.env.PROD_TAG).test(process.env.BUILD_SOURCEBRANCH); + const IS_PROD = new RegExp(process.env.PROD_TAG).test(process.env.BUILD_SOURCEBRANCH); if (IS_PROD) { const packageJson = JSON.parse(fs.readFileSync(PACKAGE_JSON_FILENAME)); packageJson.aiKey = process.env.PROD_AIKEY; diff --git a/src/common/constants.ts b/src/common/constants.ts index d186c4a..c8b7d87 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -8,6 +8,9 @@ export class Constants { public static readonly EXTENSION_NAME = "azure-digital-twins"; public static readonly CHANNEL_NAME = "IoT Plug and Play"; public static readonly UTF8 = "utf8"; + public static readonly BASE64 = "base64"; + public static readonly SHA256 = "sha256"; + public static readonly HEX = "hex"; public static readonly EMPTY_STRING = ""; public static readonly DEFAULT_SEPARATOR = ","; public static readonly COMPLETION_TRIGGER = '"'; diff --git a/src/common/nsat.ts b/src/common/nsat.ts index 8ac3189..7b372f5 100644 --- a/src/common/nsat.ts +++ b/src/common/nsat.ts @@ -83,7 +83,7 @@ export class NSAT { }; this.telemetryClient.sendEvent("nsat.survey/userAsked"); const button = await window.showInformationMessage( - "Do you mind taking a quick feedback survey about the Azure IoT Edge Extension for VS Code?", + "Do you mind taking a quick feedback survey about IoT Plug and Play Extension for VS Code?", take, remind, never, diff --git a/src/common/telemetryClient.ts b/src/common/telemetryClient.ts index 7210aa4..7a1320e 100644 --- a/src/common/telemetryClient.ts +++ b/src/common/telemetryClient.ts @@ -4,29 +4,14 @@ import * as fs from "fs"; import * as vscode from "vscode"; import TelemetryReporter from "vscode-extension-telemetry"; - -/** - * Operation result of telemetry - */ -export enum TelemetryResult { - Succeeded = "Succeeded", - Failed = "Failed", - Cancelled = "Cancelled", -} - -/** - * Context of telemetry - */ -export interface TelemetryContext { - start: number; - properties: { [key: string]: string }; - measurements: { [key: string]: number }; -} +import { TelemetryContext } from "./telemetryContext"; /** * Telemetry client */ export class TelemetryClient { + private static readonly IS_INTERNAL = "isInternal"; + /** * validate content of package json * @param packageJSON package json @@ -50,7 +35,7 @@ export class TelemetryClient { private isInternal: boolean = false; constructor(context: vscode.ExtensionContext) { const packageJSON = JSON.parse(fs.readFileSync(context.asAbsolutePath("./package.json"), "utf8")); - if (!packageJSON || TelemetryClient.validatePackageJSON(packageJSON)) { + if (!packageJSON || !TelemetryClient.validatePackageJSON(packageJSON)) { return; } this.extensionId = `${packageJSON.publisher}.${packageJSON.name}`; @@ -69,49 +54,14 @@ export class TelemetryClient { return; } if (telemetryContext) { + telemetryContext.setProperty(TelemetryClient.IS_INTERNAL, this.isInternal.toString()); this.client.sendTelemetryEvent(eventName, telemetryContext.properties, telemetryContext.measurements); } else { - this.client.sendTelemetryEvent(eventName); + const properties = { [TelemetryClient.IS_INTERNAL]: this.isInternal.toString() }; + this.client.sendTelemetryEvent(eventName, properties); } } - /** - * create telemetry context - */ - public createContext(): TelemetryContext { - const context: TelemetryContext = { start: Date.now(), properties: {}, measurements: {} }; - context.properties.isInternal = this.isInternal.toString(); - context.properties.result = TelemetryResult.Succeeded; - return context; - } - - /** - * set telemetry context as error - * @param context telemetry context - * @param error error - */ - public setErrorContext(context: TelemetryContext, error: Error): void { - context.properties.result = TelemetryResult.Failed; - context.properties.error = error.name; - context.properties.errorMessage = error.message; - } - - /** - * set telemetry context as cancel - * @param context telemetry context - */ - public setCancelContext(context: TelemetryContext): void { - context.properties.result = TelemetryResult.Cancelled; - } - - /** - * close telemetry context - * @param context telemetry context - */ - public closeContext(context: TelemetryContext) { - context.measurements.duration = (Date.now() - context.start) / 1000; - } - /** * dispose telemetry client */ diff --git a/src/common/telemetryContext.ts b/src/common/telemetryContext.ts new file mode 100644 index 0000000..086ec8e --- /dev/null +++ b/src/common/telemetryContext.ts @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import { UserCancelledError } from "./userCancelledError"; + +/** + * Operation result + */ +enum OperationResult { + Success = "Succeeded", + Fail = "Failed", + Cancel = "Cancelled", +} + +/** + * Telemetry context + */ +export class TelemetryContext { + /** + * start a new context + */ + public static startNew(): TelemetryContext { + return new TelemetryContext(); + } + + public properties: { [key: string]: string }; + public measurements: { [key: string]: number }; + + private start: number; + private constructor() { + this.start = Date.now(); + this.properties = {}; + this.measurements = {}; + } + + /** + * set property + * @param name property name + * @param value property value + */ + public setProperty(name: string, value: string): void { + this.properties[name] = value; + } + + /** + * set error + * @param error error + */ + public setError(error: Error): void { + if (error instanceof UserCancelledError) { + this.properties.result = OperationResult.Cancel; + } else { + this.properties.result = OperationResult.Fail; + this.properties.error = error.name; + this.properties.errorMessage = error.message; + } + } + + /** + * end the context + */ + public end(): void { + if (!this.properties.result) { + this.properties.result = OperationResult.Success; + } + this.measurements.duration = (Date.now() - this.start) / 1000; + } +} diff --git a/src/common/utility.ts b/src/common/utility.ts index 43e9edc..9491205 100644 --- a/src/common/utility.ts +++ b/src/common/utility.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. +import { createHash } from "crypto"; import * as fs from "fs-extra"; import * as path from "path"; import { DeviceModelManager, ModelType } from "../deviceModel/deviceModelManager"; @@ -143,5 +144,15 @@ export class Utility { return fs.readJson(filePath, { encoding: Constants.UTF8 }); } + /** + * get hash value of payload + * @param payload payload + */ + public static hash(payload: string): string { + return createHash(Constants.SHA256) + .update(payload) + .digest(Constants.HEX); + } + private constructor() {} } diff --git a/src/extension.ts b/src/extension.ts index c98e22b..020d1c0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,7 +8,8 @@ import { Command } from "./common/command"; import { Constants } from "./common/constants"; import { NSAT } from "./common/nsat"; import { ProcessError } from "./common/processError"; -import { TelemetryClient, TelemetryContext } from "./common/telemetryClient"; +import { TelemetryClient } from "./common/telemetryClient"; +import { TelemetryContext } from "./common/telemetryContext"; import { UserCancelledError } from "./common/userCancelledError"; import { DeviceModelManager, ModelType } from "./deviceModel/deviceModelManager"; import { DigitalTwinCompletionItemProvider } from "./intelliSense/digitalTwinCompletionItemProvider"; @@ -24,7 +25,12 @@ export function activate(context: vscode.ExtensionContext) { const telemetryClient = new TelemetryClient(context); const nsat = new NSAT(Constants.NSAT_SURVEY_URL, telemetryClient); const deviceModelManager = new DeviceModelManager(context, outputChannel); - const modelRepositoryManager = new ModelRepositoryManager(context, outputChannel, Constants.WEB_VIEW_PATH); + const modelRepositoryManager = new ModelRepositoryManager( + context, + Constants.WEB_VIEW_PATH, + outputChannel, + telemetryClient, + ); const apiProvider = new ApiProvider(modelRepositoryManager); telemetryClient.sendEvent(Constants.EXTENSION_ACTIVATED_MSG); @@ -171,15 +177,15 @@ function initCommand( ): void { context.subscriptions.push( vscode.commands.registerCommand(command, async (...args: any[]) => { - const telemetryContext: TelemetryContext = telemetryClient.createContext(); + const telemetryContext: TelemetryContext = TelemetryContext.startNew(); telemetryClient.sendEvent(`${command}.start`); try { return await callback(...args); } catch (error) { + telemetryContext.setError(error); if (error instanceof UserCancelledError) { outputChannel.warn(error.message); } else { - telemetryClient.setErrorContext(telemetryContext, error); UI.showNotification(MessageType.Error, error.message); if (error instanceof ProcessError) { const message = `${error.message}\n${error.stack}`; @@ -189,7 +195,7 @@ function initCommand( } } } finally { - telemetryClient.closeContext(telemetryContext); + telemetryContext.end(); telemetryClient.sendEvent(`${command}.end`, telemetryContext); outputChannel.show(); if (enableSurvey) { diff --git a/src/modelRepository/modelRepositoryClient.ts b/src/modelRepository/modelRepositoryClient.ts index ad1884f..4550638 100644 --- a/src/modelRepository/modelRepositoryClient.ts +++ b/src/modelRepository/modelRepositoryClient.ts @@ -7,12 +7,6 @@ import { ModelType } from "../deviceModel/deviceModelManager"; import { GetResult, MetaModelType, SearchOptions, SearchResult } from "./modelRepositoryInterface"; import { RepositoryInfo } from "./modelRepositoryManager"; -const ETAG_HEADER = "etag"; -const MODEL_ID_HEADER = "x-ms-model-id"; -const MODEL_PATH = "models"; -const SEARCH_PATH = "models/search"; -const CONTENT_TYPE = "application/json"; - /** * Http method type */ @@ -42,8 +36,8 @@ export class ModelRepositoryClient { request(options) .then((response) => { const result: GetResult = { - etag: response.headers[ETAG_HEADER], - modelId: response.headers[MODEL_ID_HEADER], + etag: response.headers[ModelRepositoryClient.ETAG_HEADER], + modelId: response.headers["x-ms-model-id"], content: response.body, }; return resolve(result); @@ -102,7 +96,7 @@ export class ModelRepositoryClient { return new Promise((resolve, reject) => { request(options) .then((response) => { - const result: string = response.headers[ETAG_HEADER]; + const result: string = response.headers[ModelRepositoryClient.ETAG_HEADER]; return resolve(result); }) .catch((err) => { @@ -129,6 +123,8 @@ export class ModelRepositoryClient { }); } + private static readonly ETAG_HEADER = "etag"; + /** * convert to meta model type * @param type model type @@ -152,8 +148,8 @@ export class ModelRepositoryClient { */ private static createOptions(method: HttpMethod, repoInfo: RepositoryInfo, modelId?: string): request.OptionsWithUri { const uri = modelId - ? `${repoInfo.hostname}/${MODEL_PATH}/${encodeURIComponent(modelId)}` - : `${repoInfo.hostname}/${SEARCH_PATH}`; + ? `${repoInfo.hostname}/models/${encodeURIComponent(modelId)}` + : `${repoInfo.hostname}/models/search`; const qs: any = { "api-version": repoInfo.apiVersion }; if (repoInfo.repositoryId) { qs.repositoryId = repoInfo.repositoryId; @@ -165,7 +161,7 @@ export class ModelRepositoryClient { qs, encoding: Constants.UTF8, json: true, - headers: { "Authorization": accessToken, "Content-Type": CONTENT_TYPE }, + headers: { "Authorization": accessToken, "Content-Type": "application/json" }, resolveWithFullResponse: true, }; return options; diff --git a/src/modelRepository/modelRepositoryConnection.ts b/src/modelRepository/modelRepositoryConnection.ts index 85f2bea..ea829f3 100644 --- a/src/modelRepository/modelRepositoryConnection.ts +++ b/src/modelRepository/modelRepositoryConnection.ts @@ -1,24 +1,9 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. -import { createHmac, Hmac } from "crypto"; +import { createHmac } from "crypto"; import { Constants } from "../common/constants"; -const PROPERTY_SEPARATOR = ";"; -const KEY_VALUE_SEPARATOR = "="; -const PROPERTY_COUNT = 4; -const HOSTNAME_PROPERTY = "HostName"; -const REPOSITORY_ID_PROPERTY = "RepositoryId"; -const SHARED_ACCESS_KEY_NAME_PROPERTY = "SharedAccessKeyName"; -const SHARED_ACCESS_KEY_PROPERTY = "SharedAccessKey"; -const HOSTNAME_REGEX = new RegExp("[a-zA-Z0-9_\\-\\.]+$"); -const SHARED_ACCESS_KEY_NAME_REGEX = new RegExp("^[a-zA-Z0-9_\\-@\\.]+$"); -const BASE64 = "base64"; -const SHA256 = "sha256"; -const MILLISECONDS = 1000; -const EXPIRY_IN_MINUTES = 30; -const SECONDS_PER_MINUTE = 60; - /** * Model repository connection */ @@ -32,12 +17,12 @@ export class ModelRepositoryConnection { throw new Error(`Connection string ${Constants.NOT_EMPTY_MSG}`); } const map: { [key: string]: string } = {}; - const properties: string[] = connectionString.split(PROPERTY_SEPARATOR); - if (properties.length !== PROPERTY_COUNT) { + const properties: string[] = connectionString.split(";"); + if (properties.length !== ModelRepositoryConnection.PROPERTY_COUNT) { throw new Error(Constants.CONNECTION_STRING_INVALID_FORMAT_MSG); } for (const property of properties) { - const index: number = property.indexOf(KEY_VALUE_SEPARATOR); + const index: number = property.indexOf("="); if (index <= 0) { throw new Error(Constants.CONNECTION_STRING_INVALID_FORMAT_MSG); } @@ -50,15 +35,24 @@ export class ModelRepositoryConnection { } // validate connection const connection = new ModelRepositoryConnection( - map[HOSTNAME_PROPERTY], - map[REPOSITORY_ID_PROPERTY], - map[SHARED_ACCESS_KEY_NAME_PROPERTY], - map[SHARED_ACCESS_KEY_PROPERTY], + map[ModelRepositoryConnection.HOSTNAME_PROPERTY], + map[ModelRepositoryConnection.REPOSITORY_ID_PROPERTY], + map[ModelRepositoryConnection.SHARED_ACCESS_KEY_NAME_PROPERTY], + map[ModelRepositoryConnection.SHARED_ACCESS_KEY_PROPERTY], ); connection.validate(); return connection; } + private static readonly PROPERTY_COUNT = 4; + private static readonly HOSTNAME_PROPERTY = "HostName"; + private static readonly REPOSITORY_ID_PROPERTY = "RepositoryId"; + private static readonly SHARED_ACCESS_KEY_NAME_PROPERTY = "SharedAccessKeyName"; + private static readonly SHARED_ACCESS_KEY_PROPERTY = "SharedAccessKey"; + private static readonly HOSTNAME_REGEX = new RegExp("[a-zA-Z0-9_\\-\\.]+$"); + private static readonly SHARED_ACCESS_KEY_NAME_REGEX = new RegExp("^[a-zA-Z0-9_\\-@\\.]+$"); + private static readonly EXPIRY_IN_MINUTES = 30; + private readonly expiry: string; private constructor( public readonly hostName: string, @@ -67,7 +61,7 @@ export class ModelRepositoryConnection { public readonly sharedAccessKey: string, ) { const now: number = new Date().getTime(); - this.expiry = (Math.round(now / MILLISECONDS) + EXPIRY_IN_MINUTES * SECONDS_PER_MINUTE).toString(); + this.expiry = (Math.round(now / 1000) + ModelRepositoryConnection.EXPIRY_IN_MINUTES * 60).toString(); } /** @@ -77,10 +71,12 @@ export class ModelRepositoryConnection { const endpoint: string = encodeURIComponent(this.hostName); const payload: string = [encodeURIComponent(this.repositoryId), endpoint, this.expiry].join("\n").toLowerCase(); const signature: Buffer = Buffer.from(payload, Constants.UTF8); - const secret: Buffer = Buffer.from(this.sharedAccessKey, BASE64); - const hmac: Hmac = createHmac(SHA256, secret); - hmac.update(signature); - const hash: string = encodeURIComponent(hmac.digest(BASE64)); + const secret: Buffer = Buffer.from(this.sharedAccessKey, Constants.BASE64); + const hash: string = encodeURIComponent( + createHmac(Constants.SHA256, secret) + .update(signature) + .digest(Constants.BASE64), + ); return ( "SharedAccessSignature " + `sr=${endpoint}&sig=${hash}&se=${this.expiry}&skn=${this.sharedAccessKeyName}&rid=${this.repositoryId}` @@ -91,19 +87,28 @@ export class ModelRepositoryConnection { * validate model repository connection */ private validate(): void { - if (!this.hostName || !HOSTNAME_REGEX.test(this.hostName)) { - throw new Error(`${Constants.CONNECTION_STRING_INVALID_FORMAT_MSG} on property ${HOSTNAME_PROPERTY}`); + if (!this.hostName || !ModelRepositoryConnection.HOSTNAME_REGEX.test(this.hostName)) { + throw new Error( + `${Constants.CONNECTION_STRING_INVALID_FORMAT_MSG} on property ${ModelRepositoryConnection.HOSTNAME_PROPERTY}`, + ); } if (!this.repositoryId) { - throw new Error(`${Constants.CONNECTION_STRING_INVALID_FORMAT_MSG} on property ${REPOSITORY_ID_PROPERTY}`); - } - if (!this.sharedAccessKeyName || !SHARED_ACCESS_KEY_NAME_REGEX.test(this.sharedAccessKeyName)) { throw new Error( - `${Constants.CONNECTION_STRING_INVALID_FORMAT_MSG} on property ${SHARED_ACCESS_KEY_NAME_PROPERTY}`, + `${Constants.CONNECTION_STRING_INVALID_FORMAT_MSG} on property ${ModelRepositoryConnection.REPOSITORY_ID_PROPERTY}`, + ); + } + if ( + !this.sharedAccessKeyName || + !ModelRepositoryConnection.SHARED_ACCESS_KEY_NAME_REGEX.test(this.sharedAccessKeyName) + ) { + throw new Error( + `${Constants.CONNECTION_STRING_INVALID_FORMAT_MSG} on property ${ModelRepositoryConnection.SHARED_ACCESS_KEY_NAME_PROPERTY}`, ); } if (!this.sharedAccessKey) { - throw new Error(`${Constants.CONNECTION_STRING_INVALID_FORMAT_MSG} on property ${SHARED_ACCESS_KEY_PROPERTY}`); + throw new Error( + `${Constants.CONNECTION_STRING_INVALID_FORMAT_MSG} on property ${ModelRepositoryConnection.SHARED_ACCESS_KEY_PROPERTY}`, + ); } } } diff --git a/src/modelRepository/modelRepositoryManager.ts b/src/modelRepository/modelRepositoryManager.ts index d39be1c..c59e070 100644 --- a/src/modelRepository/modelRepositoryManager.ts +++ b/src/modelRepository/modelRepositoryManager.ts @@ -5,13 +5,16 @@ import * as vscode from "vscode"; import { VSCExpress } from "vscode-express"; import { BadRequestError } from "../common/badRequestError"; import { ColorizedChannel } from "../common/colorizedChannel"; +import { Command } from "../common/command"; import { Configuration } from "../common/configuration"; import { Constants } from "../common/constants"; import { CredentialStore } from "../common/credentialStore"; import { ProcessError } from "../common/processError"; +import { TelemetryClient } from "../common/telemetryClient"; +import { TelemetryContext } from "../common/telemetryContext"; import { UserCancelledError } from "../common/userCancelledError"; import { Utility } from "../common/utility"; -import { ModelType } from "../deviceModel/deviceModelManager"; +import { DeviceModelManager, ModelType } from "../deviceModel/deviceModelManager"; import { DigitalTwinConstants } from "../intelliSense/digitalTwinConstants"; import { ChoiceType, MessageType, UI } from "../view/ui"; import { UIConstants } from "../view/uiConstants"; @@ -139,7 +142,12 @@ export class ModelRepositoryManager { private readonly express: VSCExpress; private readonly component: string; - constructor(context: vscode.ExtensionContext, private readonly outputChannel: ColorizedChannel, filePath: string) { + constructor( + context: vscode.ExtensionContext, + filePath: string, + private readonly outputChannel: ColorizedChannel, + private readonly telemetryClient: TelemetryClient, + ) { this.express = new VSCExpress(context, filePath); this.component = Constants.MODEL_REPOSITORY_COMPONENT; } @@ -197,8 +205,8 @@ export class ModelRepositoryManager { * submit files to model repository */ public async submitFiles(): Promise { - const files: string[] | undefined = await UI.selectModelFiles(UIConstants.SELECT_MODELS_LABEL); - if (!files || files.length === 0) { + const files: string[] = await UI.selectModelFiles(UIConstants.SELECT_MODELS_LABEL); + if (files.length === 0) { return; } // check unsaved files and save @@ -213,13 +221,16 @@ export class ModelRepositoryManager { } } + const usageData = new Map(); try { const repoInfo: RepositoryInfo = await ModelRepositoryManager.createRepositoryInfo(false); - await this.doSubmitLoopSilently(repoInfo, files); + await this.doSubmitLoopSilently(repoInfo, files, usageData); } catch (error) { const operation = `Submit models to ${RepositoryType.Company}`; throw new ProcessError(operation, error, this.component); } + // send usage data + this.sendUsageDataOfSubmit(usageData); } /** @@ -414,15 +425,20 @@ export class ModelRepositoryManager { * submit model files silently, fault tolerant and don't throw exception * @param repoInfo repository info * @param files model file list + * @param usageData usage data */ - private async doSubmitLoopSilently(repoInfo: RepositoryInfo, files: string[]): Promise { - const option: SubmitOptions = { overwrite: false }; + private async doSubmitLoopSilently( + repoInfo: RepositoryInfo, + files: string[], + usageData: Map, + ): Promise { + const options: SubmitOptions = { overwrite: false }; for (const file of files) { const operation = `Submit file ${file}`; this.outputChannel.start(operation, this.component); try { - await this.doSubmitModel(repoInfo, file, option); + await this.doSubmitModel(repoInfo, file, options, usageData); this.outputChannel.end(operation, this.component); } catch (error) { this.outputChannel.error(operation, this.component, error); @@ -433,12 +449,19 @@ export class ModelRepositoryManager { /** * submit model file to repository * @param repoInfo repository info - * @param file model file - * @param option submit options + * @param filePath model file path + * @param options submit options + * @param usageData usage data */ - private async doSubmitModel(repoInfo: RepositoryInfo, file: string, option: SubmitOptions): Promise { - const content = await Utility.getJsonContent(file); + private async doSubmitModel( + repoInfo: RepositoryInfo, + filePath: string, + option: SubmitOptions, + usageData: Map, + ): Promise { + const content = await Utility.getJsonContent(filePath); const modelId: string = content[DigitalTwinConstants.ID]; + const modelType: ModelType = DeviceModelManager.convertToModelType(content[DigitalTwinConstants.TYPE]); let result: GetResult | undefined; try { result = await ModelRepositoryClient.getModel(repoInfo, modelId, true); @@ -467,5 +490,39 @@ export class ModelRepositoryManager { } } await ModelRepositoryClient.updateModel(repoInfo, modelId, content); + + // record submitted model id + let modelIds: string[] | undefined = usageData.get(modelType); + if (!modelIds) { + modelIds = []; + usageData.set(modelType, modelIds); + } + modelIds.push(modelId); + } + + /** + * send usage data of SubmitFiles + * @param usageData usage data + */ + private sendUsageDataOfSubmit(usageData: Map): void { + const telemetryContext: TelemetryContext = TelemetryContext.startNew(); + let propertyName: string = Constants.EMPTY_STRING; + let propertyValue: string; + for (const [key, value] of usageData) { + switch (key) { + case ModelType.Interface: + propertyName = "interfaceId"; + break; + case ModelType.CapabilityModel: + propertyName = "capabilityModelId"; + break; + default: + } + if (propertyName) { + propertyValue = value.map((id) => Utility.hash(id)).join(Constants.DEFAULT_SEPARATOR); + telemetryContext.setProperty(propertyName, propertyValue); + } + } + this.telemetryClient.sendEvent(`${Command.SubmitFiles}.data`, telemetryContext); } } diff --git a/src/view/ui.ts b/src/view/ui.ts index 566722e..1412e85 100644 --- a/src/view/ui.ts +++ b/src/view/ui.ts @@ -192,11 +192,11 @@ export class UI { * @param label label * @param type model type */ - public static async selectModelFiles(label: string, type?: ModelType): Promise { + public static async selectModelFiles(label: string, type?: ModelType): Promise { const fileInfos: ModelFileInfo[] = await UI.findModelFiles(type); if (fileInfos.length === 0) { UI.showNotification(MessageType.Warn, UIConstants.MODELS_NOT_FOUND_MSG); - return undefined; + return []; } const items: Array> = fileInfos.map((f) => { return {