UNPKG

@microsoft/windows-admin-center-sdk

Version:

Microsoft - Windows Admin Center Shell

627 lines (625 loc) 30.5 kB
// 1DS documentation found at // https://1dsdocs.azurewebsites.net/getting-started/javascript-getting_started.html import { Meta } from '@angular/platform-browser'; import { EMPTY, Observable, of } from 'rxjs'; import { catchError, delay, retryWhen, shareReplay, take, tap } from 'rxjs/operators'; import { NotificationState } from '../notification/notification-state'; import { GatewayInstallationType, GatewayMode, GatewayOperationalMode } from '../shared/gateway-inventory/gateway-inventory'; import { GatewayInventoryCache } from '../shared/gateway-inventory/gateway-inventory-cache'; import { Logging } from './logging'; import { SmeMetaLabels, TelemetryActionTypes, TelemetryEvents, TelemetryEventStates, TelemetryEventTypes } from './sme-web-telemetry-models'; export class SmeWebTelemetry { /** * The source name to use when logging about this service. */ static get logSourceName() { return 'WebTelemetry'; } /** * Gets the name of current shell or module. */ static get nameOfModule() { return MsftSme.self().Init.moduleName ? MsftSme.self().Init.moduleName : SmeWebTelemetry.testMode; } static get backlogHasSpace() { return this.eventBacklog.length < 50; } static eventBacklog = []; static telemetryHandler = null; static testMode = 'test'; static metaService; static rpcInitAlready = false; static metaTags = {}; static powershellIdMap = {}; static isProd = true; static get isProduction() { return this.isProd; } // AppId/Tenant Token provided by MarTech support // AppId is not used, but it will be mapped to later in the pipeline // Full keys are JS:WindowsAdminCenter and o:f78e2b7c9ae4461399c360160d82dcfc for // AppId/Tenant token respectively. In Cosmos, only the initial portion of the tenant token is used. // private static windowsAdminCenterAppId = 'WindowsAdminCenter'; static windowsAdminCenterTenantToken = 'f78e2b7c9ae4461399c360160d82dcfc-9371288c-a1b9-4fdc-a196-0340fc5f9880-7071'; /** * Map of module versions used in this instance of web telemetry - memoize values here. */ static moduleVersions = {}; /** * Get the list of module versions for use in telemetry where the event is sent via RPC to shell * from the actual module the event is called in. * @returns list of module mappings to versions */ static getModuleVersion(moduleName) { if (this.moduleVersions[moduleName]) { return this.moduleVersions[moduleName]; } const environment = MsftSme.self().Environment; const moduleObject = environment.modules.find((module) => { return module.name === moduleName; }); const version = moduleObject ? moduleObject.version : 'N/A'; this.moduleVersions[moduleName] = version; return version; } /** * Send any manual events that were triggered prior to telemetry initializing. */ static sendBacklogEvents() { this.eventBacklog.forEach(([type, body]) => { switch (type) { case TelemetryEvents.Performance: { const overrideVals = body.overrideValues; this.tracePerformanceData(body.performance, overrideVals ? overrideVals.content : null); break; } case TelemetryEvents.Lighthouse: { this.traceLighthouseData(body.lighthouse, body.overrideValues); break; } case TelemetryEvents.ModuleOpenPerformance: this.traceModuleOpenPerformance(body.performance); break; case TelemetryEvents.Action: { const overrideVals = body.overrideValues; this.traceAction(body.element, overrideVals, body.customProperties); break; } case TelemetryEvents.ContentUpdate: { const overrideVals = body.overrideValues; this.traceContentUpdate(overrideVals, body.customProperties); break; } case TelemetryEvents.PageView: { const overrideVals = body.overrideValues; this.tracePageView(overrideVals, body.customProperties); break; } case TelemetryEvents.Notification: this.traceClientNotification(body.notification); break; default: return; } }); this.eventBacklog = []; } /** * Helper to combine setting metaTags, initialize handler, and * send any telemetry events that occurred prior to initialization */ static configureAndInitTelemetry() { this.setMetaInDom(); this.initTelemetryHandler().subscribe({ next: () => { this.sendBacklogEvents(); }, error: (error) => { Logging.logError(this.logSourceName, '{0}: {1}'.format(error.name, error.message)); } }); } /** * Helper function to set metaTags in DOM */ static setMetaInDom() { for (const [key, value] of Object.entries(this.metaTags)) { this.metaService.updateTag({ name: `awa-${key}`, content: value }); } } /** * Update Metatags and/or initialize telemetry, depending on whether rpc message came before or after app-context init. * @param newMetaTags Primarily contains WAC-Session-Id & extension-version, received from RPC message */ static updateFromRpcInit(newMetaTags) { // All the fields will be updated at the same time here, checking one (wac-session-id is the main one) is enough for the check const shouldInitHere = SmeMetaLabels.SessionId in this.metaTags && this.metaTags[SmeMetaLabels.SessionId] === 'N/A'; this.rpcInitAlready = true; MsftSme.deepAssign(this.metaTags, newMetaTags); // If metaTags contains session ID but it's N/A (ie initial init was called already), call the init. If not, // just wait for normal init to be called in app-context. Depending on timing RPC update can happen before or after. if (shouldInitHere) { this.configureAndInitTelemetry(); } } /** * Load 1DS if does not already exist. * @param appContext App context currently being used */ static init(appContext) { if (this.telemetryHandler) { return EMPTY; } this.metaService = new Meta(document); // Set fields we don't need gateway for, if connection to gateway fails, initialize telemetry with limited fields this.metaTags[SmeMetaLabels.Language] = MsftSme.self().Resources.localeId; this.metaTags[SmeMetaLabels.Market] = MsftSme.self().Resources.localeId; this.metaTags[SmeMetaLabels.IsProduction] = MsftSme.self().Init.isProduction.toString(); this.metaTags[SmeMetaLabels.Environment] = MsftSme.self().Init.isProduction.toString(); this.metaTags[SmeMetaLabels.ExtensionVersion] = MsftSme.self().Environment.version; this.metaTags[SmeMetaLabels.SessionId] = MsftSme.sessionId(); // Instantiate these initially so they aren't empty values this.metaTags[SmeMetaLabels.InstallationType] = 'N/A'; this.metaTags[SmeMetaLabels.Build] = 'N/A'; this.metaTags[SmeMetaLabels.GatewayMode] = GatewayMode[MsftSme.self().Init.mode]; this.metaTags[SmeMetaLabels.GatewayOperationalMode] = 'N/A'; this.isProd = MsftSme.self().Init.isProduction; const gatewayInventoryCache = new GatewayInventoryCache(appContext); return gatewayInventoryCache.query({}) .pipe(retryWhen(errors => { let gatewayRetries = 2; return errors.pipe(delay(1000), tap((error) => { if (gatewayRetries === 0) { throw error; } Logging.logDebug(this.logSourceName, 'Attempting to query gateway again...'); gatewayRetries--; })); }), take(1), tap((status) => { const inventory = status.instance; this.isProd = this.isProd && inventory.gatewayOperationalMode === GatewayOperationalMode.Production; this.metaTags[SmeMetaLabels.InstallationType] = inventory.installationType || GatewayInstallationType.Standard; this.metaTags[SmeMetaLabels.Build] = inventory.gatewayVersion; this.metaTags[SmeMetaLabels.GatewayMode] = GatewayMode[inventory.mode]; this.metaTags[SmeMetaLabels.GatewayOperationalMode] = GatewayOperationalMode[inventory.gatewayOperationalMode]; this.metaTags[SmeMetaLabels.IsProduction] = this.isProd.toString(); this.metaTags[SmeMetaLabels.Environment] = this.isProd.toString(); // If not shell, handle in RPC handler, because some fields will init after 1DS inits // If rpc handler has happened already, go ahead with this if (MsftSme.isShell() || this.rpcInitAlready) { this.configureAndInitTelemetry(); } }), catchError((error) => { Logging.logWarning(this.logSourceName, 'Telemetry failed to initialized with error {}'.format(error.message)); return of(false); })); } /** * Set config and initialize telemetry library handler */ static initTelemetryHandler() { const observable = new Observable(observer => { const script = document.createElement('script'); script.type = 'text/javascript'; script.src = MsftSme.self().Environment.configuration.telemetry.sourceLibraryCdnLink; script.integrity = MsftSme.self().Environment.configuration.telemetry.sourceLibraryCdnIntegrityHash; script.crossOrigin = 'anonymous'; script.async = true; script.onload = () => { this.telemetryHandler = new oneDS.ApplicationInsights(); const entryType = MsftSme.self().Init.entryPointType; const entryName = MsftSme.self().Init.entryPointName; const config = { instrumentationKey: this.windowsAdminCenterTenantToken, // post channel configuration channelConfiguration: { eventsLimitInMem: 50 }, // Properties Plugin configuration propertyConfiguration: { userAgent: 'Windows Admin Center', sessionAsGuid: true }, // Web Analytics Plugin configuration webAnalyticsConfiguration: { autoCapture: { scroll: false, pageView: true, onLoad: false, onUnload: true, click: true, resize: true, jsError: true, lineage: true }, coreData: { pageName: this.nameOfModule, // to prevent personal data from being sent in URI referrerUri: 'windows.admin.center', requestUri: 'windows.admin.center', pageTags: { entryPointName: entryName || '', entryPointType: entryType || '' } }, useShortNameForContentBlob: false } }; this.telemetryHandler.initialize(config, []); this.telemetryHandler.addTelemetryInitializer((event) => { // If we send an event that originates from a different module (eg notifications in shell) // change the extension version to match module if (event.baseData.name !== this.nameOfModule) { event.baseData.properties.pageTags.metaTags[SmeMetaLabels.ExtensionVersion] = this.getModuleVersion(event.baseData.name); } event.baseData.properties.pageTags.screenResolution = { availableHeight: window.screen.availHeight, availableWidth: window.screen.availWidth }; event.baseData.properties.pageTags.frameDetails = { height: window.innerHeight, width: window.innerWidth }; }); observer.next(); observer.complete(); }; script.onerror = () => { script.remove(); observer.error(new Error('1DS Script could not be loaded')); }; document.body.appendChild(script); }).pipe(shareReplay(1)); return observable; } /** * Send a Page-Action event through Web Telemetry. * @param element Element action is being executed on * @param overrideValues Various values to override within default Web Telemetry page action fields, see Web Telemetry documentation. * @param properties Extra properties in an index signature. These are placed under the data field in partC data. */ static traceAction(element, overrideValues, properties) { if (!this.telemetryHandler) { if (this.backlogHasSpace) { this.eventBacklog.push([TelemetryEvents.Action, { element: element, overrideValues: overrideValues, customProperties: properties }]); } return; } if (!overrideValues.actionType) { overrideValues.actionType = TelemetryActionTypes.Automatic; } this.telemetryHandler.capturePageAction(element, overrideValues, properties); } /** * Send a Page-View event through Web Telemetry. * @param overrideValues Various values to override within default Web Telemetry page view fields, see Web Telemetry documentation. * @param properties Extra properties in an index signature. These are placed under the data field in partC data. */ static tracePageView(overrideValues, properties) { if (!this.telemetryHandler) { if (this.backlogHasSpace) { this.eventBacklog.push([TelemetryEvents.PageView, { overrideValues: overrideValues }]); } return; } this.telemetryHandler.capturePageView(overrideValues, properties); } /** * Send a Content-Update event through Web Telemetry. * @param overrideValues Various values to override within default Web Telemetry content update fields, see Web Telemetry documentation * @param properties Extra properties in an index signature. These are placed under the data field in partC data. */ static traceContentUpdate(overrideValues, properties) { if (!this.telemetryHandler) { if (this.backlogHasSpace) { this.eventBacklog.push([TelemetryEvents.ContentUpdate, { overrideValues: overrideValues, customProperties: properties }]); } return; } if (!overrideValues.actionType) { overrideValues.actionType = TelemetryActionTypes.Automatic; } this.telemetryHandler.captureContentUpdate(overrideValues, properties); } /** * Add standard fields onto performance data * @param data SmePerformanceData to be sent */ static fillStandardPerformanceData(data) { const fullDataPayload = { extension: MsftSme.self().Environment.name, entryPointName: MsftSme.self().Init.entryPointName || '', url: window.location.pathname, moduleOpened: false, totalLoadTime: '', label: data.label, sme: {}, resources: {}, navigation: {} }; // Truncate all decimals to 5 places to avoid cosmos stream from redacting any // Convert PerformanceNavigationTiming to JSON so we can manipulate it via Object.() methods. fullDataPayload.totalLoadTime = data.totalLoadTime.toFixed(5); const timingData = { 'sme': data.sme || {}, 'resources': data.resources || {}, 'navigation': data.navigation ? data.navigation.toJSON() : {} }; for (const [sourceKey, sourceVal] of Object.entries(timingData)) { for (const [resourceKey, resourceVal] of Object.entries(sourceVal)) { // navigation has some string-string maps, ignore the string values. // Convert all numbers back into numbers to preserve types in cosmos stream if (!MsftSme.isEmpty(sourceVal) && typeof resourceVal === 'number') { fullDataPayload[sourceKey][resourceKey] = parseFloat(resourceVal.toFixed(7)); } } } // Set this here if need - often the appLoad times happen before Init is populated, // so check again to make sure if its actually empty. if (fullDataPayload.navigation) { fullDataPayload.entryPointName = MsftSme.self().Init.entryPointName || ''; } return fullDataPayload; } /** * Send a content update event. This content update contains an updated sme-specific timings structure with relevant * performance data under the data field. The original timings data is also contained under the navigation field. * The structure of the event is such: * data : { ..., * "timings": { * "extension": [extension-name], * "entryPointName": [entryPointName], * "url": [url-endpoint], * "moduleOpened" : [isModuleOpened], * "sme": { * [sme-mark] : [mark-timestamps], * ... * }, * "resources": { * [resource-endpoint] : [resource-load-complete-timestamps], * ... * }, * "navigation": { * [performance-navigation-event]: [navigation-event-timestamps] * } * }, ... * } * Certain fields are set within this class instead of outside modules since they will always be the same. * @param dataPayload performance data to be sent through telemetry. * @param contentOverrides any content overriding behavior wanted in the performance event */ static tracePerformanceData(dataPayload, contentOverrides) { if (!this.telemetryHandler) { if (this.backlogHasSpace) { this.eventBacklog.push([TelemetryEvents.Performance, { performance: dataPayload, overrideValues: { content: contentOverrides } }]); } return; } const data = SmeWebTelemetry.fillStandardPerformanceData(dataPayload); // A for automatic, performance tracking is done automatically. const overrideValues = { actionType: TelemetryActionTypes.Automatic, content: contentOverrides || dataPayload }; const customProps = { timings: data, type: TelemetryEventTypes.Performance }; this.telemetryHandler.captureContentUpdate(overrideValues, customProps); } /** * Send a content update event - this event will contain timings for lighthouse calculation in the backend * This can potentially be sent a couple times with overlapping data for one page load event, depending * on when the TTI is calculated. In this scenario, it will be handled on the backend. * @param dataPayload Lighthouse data * @param contentOverrides any content overriding behavior wanted in the performance event */ static traceLighthouseData(dataPayload, contentOverrides, properties) { if (!this.telemetryHandler) { if (this.backlogHasSpace) { this.eventBacklog.push([TelemetryEvents.Lighthouse, { lighthouse: dataPayload, overrideValues: { content: contentOverrides } }]); } return; } const overrideValues = { actionType: TelemetryActionTypes.Automatic, content: contentOverrides || dataPayload }; const customProps = { timings: dataPayload, type: TelemetryEventTypes.Lighthouse }; MsftSme.deepAssign(customProps, properties); this.telemetryHandler.captureContentUpdate(overrideValues, customProps); } /** * See tracePerformanceData comments, only difference is moduleOpened is true and the contentOverrides * @param dataPayload performance data to be sent through telemetry. */ static traceModuleOpenPerformance(dataPayload) { if (!this.telemetryHandler) { if (this.backlogHasSpace) { this.eventBacklog.push([TelemetryEvents.ModuleOpenPerformance, { performance: dataPayload }]); } return; } const data = SmeWebTelemetry.fillStandardPerformanceData(dataPayload); data.moduleOpened = true; const overrideValues = { customTiming: JSON.stringify(data) }; const customProps = { timings: data, type: TelemetryEventTypes.Performance }; this.telemetryHandler.capturePageViewPerformance(overrideValues, customProps); } /** * Helper to create event boilerplate for notification * @param clientNotification Client notification to send event for */ static traceClientNotification(clientNotification) { if (!this.telemetryHandler) { if (this.backlogHasSpace) { this.eventBacklog.push([TelemetryEvents.Notification, { notification: clientNotification }]); } return; } const overrideValues = { content: { message: clientNotification.message, title: clientNotification.title, state: NotificationState[clientNotification.state], contentType: TelemetryEventTypes.Notification }, // If source is not shell, replace pagename with proper source. pageName: clientNotification.sourceName ? clientNotification.sourceName : this.nameOfModule }; this.traceContentUpdate(overrideValues, { type: TelemetryEventTypes.Notification }); } /** * Helper function to assign a command to an id in a multi-step workitem process * @param id The ID of the powershell session to assign * @param command The command to assign to the session ID */ static addPowershellId(id, command) { this.powershellIdMap[id] = command; } /** * Helper function to assign a command to an id in a multi-step workitem process * @param id The ID of the powershell session to assign * @param command The command to assign to the session ID */ static removePowershellId(id) { delete this.powershellIdMap[id]; } /** * Helper to create event boilerplate for powershell event. In the case where command is not available, * use an ID (usually ps session ID, which may correlate with work item id) * @param command Powershell Command * @param state State of powershell event (start/end/etc) * @param details Optional details object - see interface for more detail. */ static tracePowershellEvent(command, state, details) { const id = details ? details.id : null; const response = details ? details.response : null; const psCommand = command ? command : this.powershellIdMap[id]; // Ignore any events that are either null or un-named PS commands. if ((psCommand && !psCommand.command) || !psCommand) { return; } // If state is not started and id is provided, remove the ID from the map to save space. if (state !== TelemetryEventStates.Started && id) { this.removePowershellId(id); } const psContent = { psCommand: psCommand.command, state: state, psModule: psCommand.module, otherData: details && details.otherData ? details.otherData : {} }; const sourceName = this.nameOfModule; if (response && response.error) { psContent['errorCodes'] = [response.error.code]; } else if (response && response.errors) { psContent['errorCodes'] = response.errors.map((e) => e.errorType); psContent.otherData['hResult'] = response.errors.map((e) => e.detailRecord && e.detailRecord.hResult); } SmeWebTelemetry.traceAction(null, { content: psContent, pageName: sourceName }, { type: TelemetryEventTypes.Powershell }); } /** * Helper to create event boilerplate for powershell batch event. Batch doesn't deal with work items, so * we can ignore the ID and sourceName handling that the above function handles. * @param commands Powershell Commands List in properties stringified form. PS Batch events places PS command into a * '{properties: PSCommand }' string structure. * @param state State of powershell event (start/end/etc) * @param details Optional details object contains various optional fields in powershell event. * @returns */ static tracePowershellBatchEvent(commands, state, details) { // check that at least one command is valid if (!commands || commands.length === 0) { return; } let parsedCommands = []; parsedCommands = commands.reduce((commandList, currentCommand) => { if (currentCommand) { const parsedCommand = JSON.parse(currentCommand).properties.command; if (parsedCommand) { commandList.push(parsedCommand); } return commandList; } }, parsedCommands); const response = details ? details.response : null; const psContent = { psCommands: parsedCommands, state: state, otherData: details && details.otherData ? details.otherData : {} }; if (response && response.error) { psContent['errorCodes'] = [response.error.code]; } else if (response && response.errors) { psContent['errorCodes'] = response.errors.map((e) => e.errorType); psContent.otherData['hResult'] = response.errors.map((e) => e.detailRecord && e.detailRecord.hResult); } SmeWebTelemetry.traceAction(null, { content: psContent }, { type: TelemetryEventTypes.Powershell }); } /** * Helper to redactGenericModel function * Determines whether field should be redacted according to fields provided for redacting and exceptions * @param key The field in question * @param keywordsToRedact List of string inclusions to redact - if the field contains any part of this, it will be redacted * @param exceptions Set of exceptions to the above - if the field matches an exception, it will not be redacted * @returns True if field should be redacted, false otherwise */ static fieldShouldBeRedacted(key, keywordsToRedact, exceptions) { if (exceptions.has(key)) { return false; } for (const field of keywordsToRedact) { if (key.toLowerCase().includes(field.toLowerCase())) { return true; } } return false; } /** * Telemetry utility function to redact a model object. This function will traverse the model in BFS fashion and redact any fields * that contain any string in the keywordsToRedact array, unless the field is an exception (in the exceptions array). * This function will not affect keys in StringMaps or Maps - if names exist there, they should be handled separately. * * @param model Model to redact * @param keywordsToRedact List of string inclusions to redact * @param fieldExceptions List of exceptions to the above * @returns Redacted model */ static redactGenericModel(model, keywordsToRedact, fieldExceptions) { const redacted_model = JSON.parse(JSON.stringify(model)); const remainingObjects = [redacted_model]; const exceptionSet = new Set(fieldExceptions); while (remainingObjects.length > 0) { const currentTree = remainingObjects[0]; Object.keys(currentTree).forEach(key => { if (MsftSme.isPlainObject(currentTree[key])) { remainingObjects.push(currentTree[key]); } else if (Array.isArray(currentTree[key]) && currentTree[key].some((field) => MsftSme.isPlainObject(field))) { // If array intermixes objects and non-objects, we will push the objects into the queue and ignore the non-objects. // The non-objects will avoid redaction, but this should never be a concern in practice. remainingObjects.push(currentTree[key].filter((field) => MsftSme.isPlainObject(field))); } else if (this.fieldShouldBeRedacted(key, keywordsToRedact, exceptionSet)) { if (Array.isArray(currentTree[key])) { for (let i = 0; i < currentTree[key].length; ++i) { currentTree[key][i] = '***'; } } else { currentTree[key] = '***'; } } }); remainingObjects.shift(); } return redacted_model; } } //# sourceMappingURL=sme-web-telemetry.js.map