| /** |
| * @license |
| * Copyright (C) 2016 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| (function() { |
| 'use strict'; |
| |
| // Latency reporting constants. |
| const TIMING = { |
| TYPE: 'timing-report', |
| CATEGORY_UI_LATENCY: 'UI Latency', |
| CATEGORY_RPC: 'RPC Timing', |
| // Reported events - alphabetize below. |
| APP_STARTED: 'App Started', |
| PAGE_LOADED: 'Page Loaded', |
| }; |
| |
| // Plugin-related reporting constants. |
| const PLUGINS = { |
| TYPE: 'lifecycle', |
| // Reported events - alphabetize below. |
| INSTALLED: 'Plugins installed', |
| }; |
| |
| // Chrome extension-related reporting constants. |
| const EXTENSION = { |
| TYPE: 'lifecycle', |
| // Reported events - alphabetize below. |
| DETECTED: 'Extension detected', |
| }; |
| |
| // Navigation reporting constants. |
| const NAVIGATION = { |
| TYPE: 'nav-report', |
| CATEGORY: 'Location Changed', |
| PAGE: 'Page', |
| }; |
| |
| const ERROR = { |
| TYPE: 'error', |
| CATEGORY: 'exception', |
| }; |
| |
| const ERROR_DIALOG = { |
| TYPE: 'error', |
| CATEGORY: 'Error Dialog', |
| }; |
| |
| const TIMER = { |
| CHANGE_DISPLAYED: 'ChangeDisplayed', |
| CHANGE_LOAD_FULL: 'ChangeFullyLoaded', |
| DASHBOARD_DISPLAYED: 'DashboardDisplayed', |
| DIFF_VIEW_CONTENT_DISPLAYED: 'DiffViewOnlyContent', |
| DIFF_VIEW_DISPLAYED: 'DiffViewDisplayed', |
| DIFF_VIEW_LOAD_FULL: 'DiffViewFullyLoaded', |
| FILE_LIST_DISPLAYED: 'FileListDisplayed', |
| PLUGINS_LOADED: 'PluginsLoaded', |
| STARTUP_CHANGE_DISPLAYED: 'StartupChangeDisplayed', |
| STARTUP_CHANGE_LOAD_FULL: 'StartupChangeFullyLoaded', |
| STARTUP_DASHBOARD_DISPLAYED: 'StartupDashboardDisplayed', |
| STARTUP_DIFF_VIEW_CONTENT_DISPLAYED: 'StartupDiffViewOnlyContent', |
| STARTUP_DIFF_VIEW_DISPLAYED: 'StartupDiffViewDisplayed', |
| STARTUP_DIFF_VIEW_LOAD_FULL: 'StartupDiffViewFullyLoaded', |
| STARTUP_FILE_LIST_DISPLAYED: 'StartupFileListDisplayed', |
| WEB_COMPONENTS_READY: 'WebComponentsReady', |
| METRICS_PLUGIN_LOADED: 'MetricsPluginLoaded', |
| }; |
| |
| const STARTUP_TIMERS = {}; |
| STARTUP_TIMERS[TIMER.PLUGINS_LOADED] = 0; |
| STARTUP_TIMERS[TIMER.METRICS_PLUGIN_LOADED] = 0; |
| STARTUP_TIMERS[TIMER.STARTUP_CHANGE_DISPLAYED] = 0; |
| STARTUP_TIMERS[TIMER.STARTUP_CHANGE_LOAD_FULL] = 0; |
| STARTUP_TIMERS[TIMER.STARTUP_DASHBOARD_DISPLAYED] = 0; |
| STARTUP_TIMERS[TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED] = 0; |
| STARTUP_TIMERS[TIMER.STARTUP_DIFF_VIEW_DISPLAYED] = 0; |
| STARTUP_TIMERS[TIMER.STARTUP_DIFF_VIEW_LOAD_FULL] = 0; |
| STARTUP_TIMERS[TIMER.STARTUP_FILE_LIST_DISPLAYED] = 0; |
| STARTUP_TIMERS[TIMING.APP_STARTED] = 0; |
| // WebComponentsReady timer is triggered from gr-router. |
| STARTUP_TIMERS[TIMER.WEB_COMPONENTS_READY] = 0; |
| |
| const INTERACTION_TYPE = 'interaction'; |
| |
| const DRAFT_ACTION_TIMER = 'TimeBetweenDraftActions'; |
| const DRAFT_ACTION_TIMER_MAX = 2 * 60 * 1000; // 2 minutes. |
| |
| const pending = []; |
| |
| // Variables that hold context info in global scope |
| const loadedPlugins = []; |
| const detectedExtensions = []; |
| let reportRepoName = undefined; |
| |
| const onError = function(oldOnError, msg, url, line, column, error) { |
| if (oldOnError) { |
| oldOnError(msg, url, line, column, error); |
| } |
| if (error) { |
| line = line || error.lineNumber; |
| column = column || error.columnNumber; |
| let shortenedErrorStack = msg; |
| if (error.stack) { |
| const errorStackLines = error.stack.split('\n'); |
| shortenedErrorStack = errorStackLines.slice(0, |
| Math.min(3, errorStackLines.length)).join('\n'); |
| } |
| msg = shortenedErrorStack || error.toString(); |
| } |
| const payload = { |
| url, |
| line, |
| column, |
| error, |
| }; |
| GrReporting.prototype.reporter(ERROR.TYPE, ERROR.CATEGORY, msg, payload); |
| return true; |
| }; |
| |
| const catchErrors = function(opt_context) { |
| const context = opt_context || window; |
| context.onerror = onError.bind(null, context.onerror); |
| context.addEventListener('unhandledrejection', e => { |
| const msg = e.reason.message; |
| const payload = { |
| error: e.reason, |
| }; |
| GrReporting.prototype.reporter(ERROR.TYPE, ERROR.CATEGORY, msg, payload); |
| }); |
| }; |
| catchErrors(); |
| |
| // PerformanceObserver interface is a browser API. |
| if (window.PerformanceObserver) { |
| const supportedEntryTypes = PerformanceObserver.supportedEntryTypes || []; |
| // Safari doesn't support longtask yet |
| if (supportedEntryTypes.includes('longtask')) { |
| const catchLongJsTasks = new PerformanceObserver(list => { |
| for (const task of list.getEntries()) { |
| // We are interested in longtask longer than 200 ms (default is 50 ms) |
| if (task.duration > 200) { |
| GrReporting.prototype.reporter(TIMING.TYPE, |
| TIMING.CATEGORY_UI_LATENCY, `Task ${task.name}`, |
| Math.round(task.duration), false); |
| } |
| } |
| }); |
| catchLongJsTasks.observe({entryTypes: ['longtask']}); |
| } |
| } |
| |
| // The Polymer pass of JSCompiler requires this to be reassignable |
| // eslint-disable-next-line prefer-const |
| let GrReporting = Polymer({ |
| is: 'gr-reporting', |
| |
| properties: { |
| category: String, |
| |
| _baselines: { |
| type: Object, |
| value: STARTUP_TIMERS, // Shared across all instances. |
| }, |
| |
| _timers: { |
| type: Object, |
| value: {timeBetweenDraftActions: null}, // Shared across all instances. |
| }, |
| }, |
| |
| get performanceTiming() { |
| return window.performance.timing; |
| }, |
| |
| now() { |
| return window.performance.now(); |
| }, |
| |
| _arePluginsLoaded() { |
| return this._baselines && |
| !this._baselines.hasOwnProperty(TIMER.PLUGINS_LOADED); |
| }, |
| |
| _isMetricsPluginLoaded() { |
| return this._arePluginsLoaded() || this._baselines && |
| !this._baselines.hasOwnProperty(TIMER.METRICS_PLUGIN_LOADED); |
| }, |
| |
| reporter(...args) { |
| const report = (this._isMetricsPluginLoaded() && !pending.length) ? |
| this.defaultReporter : this.cachingReporter; |
| const contextInfo = { |
| loadedPlugins, |
| detectedExtensions, |
| repoName: reportRepoName, |
| isInBackgroundTab: document.visibilityState === 'hidden', |
| }; |
| args.splice(4, 0, contextInfo); |
| report.apply(this, args); |
| }, |
| |
| /** |
| * The default reporter reports events immediately. |
| * @param {string} type |
| * @param {string} category |
| * @param {string} eventName |
| * @param {string|number} eventValue |
| * @param {Object} contextInfo |
| * @param {boolean|undefined} opt_noLog If true, the event will not be |
| * logged to the JS console. |
| */ |
| defaultReporter(type, category, eventName, eventValue, contextInfo, |
| opt_noLog) { |
| const detail = { |
| type, |
| category, |
| name: eventName, |
| value: eventValue, |
| }; |
| if (category === TIMING.CATEGORY_UI_LATENCY && contextInfo) { |
| detail.loadedPlugins = contextInfo.loadedPlugins; |
| detail.detectedExtensions = contextInfo.detectedExtensions; |
| } |
| if (contextInfo && contextInfo.repoName) { |
| detail.repoName = contextInfo.repoName; |
| } |
| if (contextInfo && contextInfo.isInBackgroundTab !== undefined) { |
| detail.inBackgroundTab = contextInfo.isInBackgroundTab; |
| } |
| document.dispatchEvent(new CustomEvent(type, {detail})); |
| if (opt_noLog) { return; } |
| if (type === ERROR.TYPE && category === ERROR.CATEGORY) { |
| console.error(eventValue && eventValue.error || eventName); |
| } else { |
| if (eventValue !== undefined) { |
| console.log(`Reporting: ${eventName}: ${eventValue}`); |
| } else { |
| console.log(`Reporting: ${eventName}`); |
| } |
| } |
| }, |
| |
| /** |
| * The caching reporter will queue reports until plugins have loaded, and |
| * log events immediately if they're reported after plugins have loaded. |
| * @param {string} type |
| * @param {string} category |
| * @param {string} eventName |
| * @param {string|number} eventValue |
| * @param {Object} contextInfo |
| * @param {boolean|undefined} opt_noLog If true, the event will not be |
| * logged to the JS console. |
| */ |
| cachingReporter(type, category, eventName, eventValue, contextInfo, |
| opt_noLog) { |
| if (type === ERROR.TYPE && category === ERROR.CATEGORY) { |
| console.error(eventValue && eventValue.error || eventName); |
| } |
| if (this._isMetricsPluginLoaded()) { |
| if (pending.length) { |
| for (const args of pending.splice(0)) { |
| this.defaultReporter(...args); |
| } |
| } |
| this.defaultReporter(type, category, eventName, eventValue, contextInfo, |
| opt_noLog); |
| } else { |
| pending.push([type, category, eventName, eventValue, contextInfo, |
| opt_noLog]); |
| } |
| }, |
| |
| /** |
| * User-perceived app start time, should be reported when the app is ready. |
| */ |
| appStarted() { |
| this.timeEnd(TIMING.APP_STARTED); |
| }, |
| |
| /** |
| * Page load time and other metrics, should be reported at any time |
| * after navigation. |
| */ |
| pageLoaded() { |
| if (this.performanceTiming.loadEventEnd === 0) { |
| console.error('pageLoaded should be called after window.onload'); |
| this.async(this.pageLoaded, 100); |
| } else { |
| const loadTime = this.performanceTiming.loadEventEnd - |
| this.performanceTiming.navigationStart; |
| this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY, |
| TIMING.PAGE_LOADED, loadTime, true); |
| |
| const requestStart = this.performanceTiming.requestStart - |
| this.performanceTiming.navigationStart; |
| this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY, |
| 'requestStart', requestStart, true); |
| |
| const responseEnd = this.performanceTiming.responseEnd - |
| this.performanceTiming.navigationStart; |
| this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY, |
| 'responseEnd', responseEnd, true); |
| |
| const domLoading = this.performanceTiming.domLoading - |
| this.performanceTiming.navigationStart; |
| this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY, |
| 'domLoading', domLoading, true); |
| |
| const domContentLoadedEventStart = |
| this.performanceTiming.domContentLoadedEventStart - |
| this.performanceTiming.navigationStart; |
| this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY, |
| 'domContentLoadedEventStart', domContentLoadedEventStart, true); |
| |
| if (this.performanceTiming.redirectEnd > 0) { |
| const redirectEnd = this.performanceTiming.redirectEnd - |
| this.performanceTiming.navigationStart; |
| this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY, |
| 'redirectEnd', redirectEnd, true); |
| } |
| } |
| }, |
| |
| beforeLocationChanged() { |
| for (const prop of Object.keys(this._baselines)) { |
| delete this._baselines[prop]; |
| } |
| this.time(TIMER.CHANGE_DISPLAYED); |
| this.time(TIMER.CHANGE_LOAD_FULL); |
| this.time(TIMER.DASHBOARD_DISPLAYED); |
| this.time(TIMER.DIFF_VIEW_CONTENT_DISPLAYED); |
| this.time(TIMER.DIFF_VIEW_DISPLAYED); |
| this.time(TIMER.DIFF_VIEW_LOAD_FULL); |
| this.time(TIMER.FILE_LIST_DISPLAYED); |
| reportRepoName = undefined; |
| }, |
| |
| locationChanged(page) { |
| this.reporter( |
| NAVIGATION.TYPE, NAVIGATION.CATEGORY, NAVIGATION.PAGE, page); |
| }, |
| |
| dashboardDisplayed() { |
| if (this._baselines.hasOwnProperty(TIMER.STARTUP_DASHBOARD_DISPLAYED)) { |
| this.timeEnd(TIMER.STARTUP_DASHBOARD_DISPLAYED); |
| } else { |
| this.timeEnd(TIMER.DASHBOARD_DISPLAYED); |
| } |
| }, |
| |
| changeDisplayed() { |
| if (this._baselines.hasOwnProperty(TIMER.STARTUP_CHANGE_DISPLAYED)) { |
| this.timeEnd(TIMER.STARTUP_CHANGE_DISPLAYED); |
| } else { |
| this.timeEnd(TIMER.CHANGE_DISPLAYED); |
| } |
| }, |
| |
| changeFullyLoaded() { |
| if (this._baselines.hasOwnProperty(TIMER.STARTUP_CHANGE_LOAD_FULL)) { |
| this.timeEnd(TIMER.STARTUP_CHANGE_LOAD_FULL); |
| } else { |
| this.timeEnd(TIMER.CHANGE_LOAD_FULL); |
| } |
| }, |
| |
| diffViewDisplayed() { |
| if (this._baselines.hasOwnProperty(TIMER.STARTUP_DIFF_VIEW_DISPLAYED)) { |
| this.timeEnd(TIMER.STARTUP_DIFF_VIEW_DISPLAYED); |
| } else { |
| this.timeEnd(TIMER.DIFF_VIEW_DISPLAYED); |
| } |
| }, |
| |
| diffViewFullyLoaded() { |
| if (this._baselines.hasOwnProperty(TIMER.STARTUP_DIFF_VIEW_LOAD_FULL)) { |
| this.timeEnd(TIMER.STARTUP_DIFF_VIEW_LOAD_FULL); |
| } else { |
| this.timeEnd(TIMER.DIFF_VIEW_LOAD_FULL); |
| } |
| }, |
| |
| diffViewContentDisplayed() { |
| if (this._baselines.hasOwnProperty( |
| TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED)) { |
| this.timeEnd(TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED); |
| } else { |
| this.timeEnd(TIMER.DIFF_VIEW_CONTENT_DISPLAYED); |
| } |
| }, |
| |
| fileListDisplayed() { |
| if (this._baselines.hasOwnProperty(TIMER.STARTUP_FILE_LIST_DISPLAYED)) { |
| this.timeEnd(TIMER.STARTUP_FILE_LIST_DISPLAYED); |
| } else { |
| this.timeEnd(TIMER.FILE_LIST_DISPLAYED); |
| } |
| }, |
| |
| reportExtension(name) { |
| this.reporter(EXTENSION.TYPE, EXTENSION.DETECTED, name); |
| if (!detectedExtensions.includes(name)) { |
| detectedExtensions.push(name); |
| } |
| }, |
| |
| pluginLoaded(name) { |
| if (name.startsWith('metrics-')) { |
| this.timeEnd(TIMER.METRICS_PLUGIN_LOADED); |
| } |
| loadedPlugins.push(name); |
| }, |
| |
| pluginsLoaded(pluginsList) { |
| this.timeEnd(TIMER.PLUGINS_LOADED); |
| this.reporter( |
| PLUGINS.TYPE, PLUGINS.INSTALLED, (pluginsList || []).join(',')); |
| }, |
| |
| /** |
| * Reset named timer. |
| */ |
| time(name) { |
| this._baselines[name] = this.now(); |
| window.performance.mark(`${name}-start`); |
| }, |
| |
| /** |
| * Finish named timer and report it to server. |
| */ |
| timeEnd(name) { |
| if (!this._baselines.hasOwnProperty(name)) { return; } |
| const baseTime = this._baselines[name]; |
| delete this._baselines[name]; |
| this._reportTiming(name, this.now() - baseTime); |
| |
| // Finalize the interval. Either from a registered start mark or |
| // the navigation start time (if baseTime is 0). |
| const startMark = baseTime === 0 ? undefined : `${name}-start`; |
| window.performance.measure(name, startMark); |
| }, |
| |
| /** |
| * Reports just line timeEnd, but additionally reports an average given a |
| * denominator and a separate reporiting name for the average. |
| * @param {string} name Timing name. |
| * @param {string} averageName Average timing name. |
| * @param {number} denominator Number by which to divide the total to |
| * compute the average. |
| */ |
| timeEndWithAverage(name, averageName, denominator) { |
| if (!this._baselines.hasOwnProperty(name)) { return; } |
| const baseTime = this._baselines[name]; |
| this.timeEnd(name); |
| |
| // Guard against division by zero. |
| if (!denominator) { return; } |
| const time = Math.round(this.now() - baseTime); |
| this._reportTiming(averageName, time / denominator); |
| }, |
| |
| /** |
| * Send a timing report with an arbitrary time value. |
| * @param {string} name Timing name. |
| * @param {number} time The time to report as an integer of milliseconds. |
| */ |
| _reportTiming(name, time) { |
| this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY, name, |
| Math.round(time)); |
| }, |
| |
| /** |
| * Get a timer object to for reporing a user timing. The start time will be |
| * the time that the object has been created, and the end time will be the |
| * time that the "end" method is called on the object. |
| * @param {string} name Timing name. |
| * @returns {!Object} The timer object. |
| */ |
| getTimer(name) { |
| let called = false; |
| let start; |
| let max = null; |
| |
| const timer = { |
| |
| // Clear the timer and reset the start time. |
| reset: () => { |
| called = false; |
| start = this.now(); |
| return timer; |
| }, |
| |
| // Stop the timer and report the intervening time. |
| end: () => { |
| if (called) { |
| throw new Error(`Timer for "${name}" already ended.`); |
| } |
| called = true; |
| const time = this.now() - start; |
| |
| // If a maximum is specified and the time exceeds it, do not report. |
| if (max && time > max) { return timer; } |
| |
| this._reportTiming(name, time); |
| return timer; |
| }, |
| |
| // Set a maximum reportable time. If a maximum is set and the timer is |
| // ended after the specified amount of time, the value is not reported. |
| withMaximum(maximum) { |
| max = maximum; |
| return timer; |
| }, |
| }; |
| |
| // The timer is initialized to its creation time. |
| return timer.reset(); |
| }, |
| |
| /** |
| * Log timing information for an RPC. |
| * @param {string} anonymizedUrl The URL of the RPC with tokens obfuscated. |
| * @param {number} elapsed The time elapsed of the RPC. |
| */ |
| reportRpcTiming(anonymizedUrl, elapsed) { |
| this.reporter(TIMING.TYPE, TIMING.CATEGORY_RPC, 'RPC-' + anonymizedUrl, |
| elapsed, true); |
| }, |
| |
| reportInteraction(eventName, opt_msg) { |
| this.reporter(INTERACTION_TYPE, this.category, eventName, opt_msg); |
| }, |
| |
| /** |
| * A draft interaction was started. Update the time-betweeen-draft-actions |
| * timer. |
| */ |
| recordDraftInteraction() { |
| // If there is no timer defined, then this is the first interaction. |
| // Set up the timer so that it's ready to record the intervening time when |
| // called again. |
| const timer = this._timers.timeBetweenDraftActions; |
| if (!timer) { |
| // Create a timer with a maximum length. |
| this._timers.timeBetweenDraftActions = this.getTimer(DRAFT_ACTION_TIMER) |
| .withMaximum(DRAFT_ACTION_TIMER_MAX); |
| return; |
| } |
| |
| // Mark the time and reinitialize the timer. |
| timer.end().reset(); |
| }, |
| |
| reportErrorDialog(message) { |
| this.reporter(ERROR_DIALOG.TYPE, ERROR_DIALOG.CATEGORY, |
| 'ErrorDialog: ' + message, {error: new Error(message)}); |
| }, |
| |
| setRepoName(repoName) { |
| reportRepoName = repoName; |
| }, |
| }); |
| |
| window.GrReporting = GrReporting; |
| // Expose onerror installation so it would be accessible from tests. |
| window.GrReporting._catchErrors = catchErrors; |
| window.GrReporting.STARTUP_TIMERS = Object.assign({}, STARTUP_TIMERS); |
| })(); |