blob: 42d112ec57ec846ccfa6ec362b591c3a516585ad [file] [log] [blame]
/**
* @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.
*/
// Latency reporting constants.
const TIMING = {
TYPE: 'timing-report',
CATEGORY: {
UI_LATENCY: 'UI Latency',
RPC: 'RPC Timing',
},
EVENT: {
APP_STARTED: 'App Started',
},
};
const LIFECYCLE = {
TYPE: 'lifecycle',
CATEGORY: {
DEFAULT: 'Default',
EXTENSION_DETECTED: 'Extension detected',
PLUGINS_INSTALLED: 'Plugins installed',
},
};
const INTERACTION = {
TYPE: 'interaction',
CATEGORY: {
DEFAULT: 'Default',
VISIBILITY: 'Visibility',
},
};
const NAVIGATION = {
TYPE: 'nav-report',
CATEGORY: {
LOCATION_CHANGED: 'Location Changed',
},
EVENT: {
PAGE: 'Page',
},
};
const ERROR = {
TYPE: 'error',
CATEGORY: {
EXCEPTION: 'exception',
ERROR_DIALOG: '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.EVENT.APP_STARTED] = 0;
// WebComponentsReady timer is triggered from gr-router.
STARTUP_TIMERS[TIMER.WEB_COMPONENTS_READY] = 0;
const DRAFT_ACTION_TIMER = 'TimeBetweenDraftActions';
const DRAFT_ACTION_TIMER_MAX = 2 * 60 * 1000; // 2 minutes.
const SLOW_RPC_THRESHOLD = 500;
export function initErrorReporter(appContext) {
const reportingService = appContext.reportingService;
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,
};
reportingService.reporter(ERROR.TYPE, ERROR.CATEGORY.EXCEPTION,
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,
};
reportingService.reporter(ERROR.TYPE,
ERROR.CATEGORY.EXCEPTION, msg, payload);
});
};
catchErrors();
// for testing
return {catchErrors};
}
export function initPerformanceReporter(appContext) {
const reportingService = appContext.reportingService;
// 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) {
reportingService.reporter(TIMING.TYPE,
TIMING.CATEGORY.UI_LATENCY, `Task ${task.name}`,
Math.round(task.duration), {}, false);
}
}
});
catchLongJsTasks.observe({entryTypes: ['longtask']});
}
}
}
export function initVisibilityReporter(appContext) {
const reportingService = appContext.reportingService;
document.addEventListener('visibilitychange', () => {
reportingService.onVisibilityChange();
});
}
// Calculates the time of Gerrit being in a background tab. When Gerrit reports
// a pageLoad metric it’s attached to its details for latency analysis.
// It resets on locationChange.
class HiddenDurationTimer {
constructor() {
this.reset();
}
reset() {
this.accHiddenDurationMs = 0;
this.lastVisibleTimestampMs = 0;
}
onVisibilityChange() {
if (document.visibilityState === 'hidden') {
this.lastVisibleTimestampMs = now();
} else if (document.visibilityState === 'visible') {
if (this.lastVisibleTimestampMs !== null) {
this.accHiddenDurationMs += now() - this.lastVisibleTimestampMs;
// Set to null for guarding against two 'visible' events in a row.
this.lastVisibleTimestampMs = null;
}
}
}
get hiddenDurationMs() {
if (document.visibilityState === 'hidden'
&& this.lastVisibleTimestampMs !== null) {
return this.accHiddenDurationMs + now() - this.lastVisibleTimestampMs;
}
return this.accHiddenDurationMs;
}
}
export function now() {
return Math.round(window.performance.now());
}
export class GrReporting {
constructor(flagsService) {
this._flagsService = flagsService;
this._baselines = STARTUP_TIMERS;
this._timers = {
timeBetweenDraftActions: null,
};
this._reportRepoName = undefined;
this._pending = [];
this._slowRpcList = [];
this.hiddenDurationTimer = new HiddenDurationTimer();
}
get performanceTiming() {
return window.performance.timing;
}
get slowRpcSnapshot() {
return (this._slowRpcList || []).slice();
}
_arePluginsLoaded() {
return this._baselines &&
!this._baselines.hasOwnProperty(TIMER.PLUGINS_LOADED);
}
_isMetricsPluginLoaded() {
return this._arePluginsLoaded() || this._baselines &&
!this._baselines.hasOwnProperty(TIMER.METRICS_PLUGIN_LOADED);
}
/**
* Reporter reports events. Events will be queued if metrics plugin is not
* yet installed.
*
* @param {string} type
* @param {string} category
* @param {string} eventName
* @param {string|number} eventValue
* @param {Object} eventDetails
* @param {boolean|undefined} opt_noLog If true, the event will not be
* logged to the JS console.
*/
reporter(type, category, eventName, eventValue, eventDetails, opt_noLog) {
const eventInfo = this._createEventInfo(type, category,
eventName, eventValue, eventDetails);
if (type === ERROR.TYPE && category === ERROR.CATEGORY.EXCEPTION) {
console.error(eventValue && eventValue.error || eventName);
}
// We report events immediately when metrics plugin is loaded
if (this._isMetricsPluginLoaded() && !this._pending.length) {
this._reportEvent(eventInfo, opt_noLog);
} else {
// We cache until metrics plugin is loaded
this._pending.push([eventInfo, opt_noLog]);
if (this._isMetricsPluginLoaded()) {
this._pending.forEach(([eventInfo, opt_noLog]) => {
this._reportEvent(eventInfo, opt_noLog);
});
this._pending = [];
}
}
}
_reportEvent(eventInfo, opt_noLog) {
const {type, value, name} = eventInfo;
document.dispatchEvent(new CustomEvent(type, {detail: eventInfo}));
if (opt_noLog) { return; }
if (type !== ERROR.TYPE) {
if (value !== undefined) {
console.log(`Reporting: ${name}: ${value}`);
} else {
console.log(`Reporting: ${name}`);
}
}
}
_createEventInfo(type, category, name, value, eventDetails) {
const eventInfo = {
type,
category,
name,
value,
eventStart: now(),
};
if (typeof(eventDetails) === 'object' &&
Object.entries(eventDetails).length !== 0) {
eventInfo.eventDetails = JSON.stringify(eventDetails);
}
if (this._reportRepoName) {
eventInfo.repoName = this._reportRepoName;
}
const isInBackgroundTab = document.visibilityState === 'hidden';
if (isInBackgroundTab !== undefined) {
eventInfo.inBackgroundTab = isInBackgroundTab;
}
if (this._flagsService.enabledExperiments.length) {
eventInfo.enabledExperiments =
JSON.stringify(this._flagsService.enabledExperiments);
}
return eventInfo;
}
/**
* User-perceived app start time, should be reported when the app is ready.
*/
appStarted() {
this.timeEnd(TIMING.EVENT.APP_STARTED);
this._reportNavResTimes();
}
onVisibilityChange() {
this.hiddenDurationTimer.onVisibilityChange();
const eventName = `Visibility changed to ${document.visibilityState}`;
this.reporter(LIFECYCLE.TYPE, LIFECYCLE.CATEGORY.VISIBILITY,
eventName, undefined, {
hiddenDurationMs: this.hiddenDurationTimer.hiddenDurationMs,
}, true);
}
/**
* Browser's navigation and resource timings
*/
_reportNavResTimes() {
const perfEvents = Object.keys(this.performanceTiming.toJSON());
perfEvents.forEach(
eventName => this._reportPerformanceTiming(eventName)
);
}
_reportPerformanceTiming(eventName, eventDetails) {
const eventTiming = this.performanceTiming[eventName];
if (eventTiming > 0) {
const elapsedTime = eventTiming -
this.performanceTiming.navigationStart;
// NavResTime - Navigation and resource timings.
this.reporter(TIMING.TYPE, TIMING.CATEGORY.UI_LATENCY,
`NavResTime - ${eventName}`, elapsedTime, eventDetails, 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);
this._reportRepoName = undefined;
// reset slow rpc list since here start page loads which report these rpcs
this._slowRpcList = [];
this.hiddenDurationTimer.reset();
}
locationChanged(page) {
this.reporter(NAVIGATION.TYPE, NAVIGATION.CATEGORY.LOCATION_CHANGED,
NAVIGATION.EVENT.PAGE, page);
}
dashboardDisplayed() {
if (this._baselines.hasOwnProperty(TIMER.STARTUP_DASHBOARD_DISPLAYED)) {
this.timeEnd(TIMER.STARTUP_DASHBOARD_DISPLAYED, this._pageLoadDetails());
} else {
this.timeEnd(TIMER.DASHBOARD_DISPLAYED, this._pageLoadDetails());
}
}
changeDisplayed() {
if (this._baselines.hasOwnProperty(TIMER.STARTUP_CHANGE_DISPLAYED)) {
this.timeEnd(TIMER.STARTUP_CHANGE_DISPLAYED, this._pageLoadDetails());
} else {
this.timeEnd(TIMER.CHANGE_DISPLAYED, this._pageLoadDetails());
}
}
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, this._pageLoadDetails());
} else {
this.timeEnd(TIMER.DIFF_VIEW_DISPLAYED, this._pageLoadDetails());
}
}
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);
}
}
_pageLoadDetails() {
const details = {
rpcList: this.slowRpcSnapshot,
};
if (window.screen) {
details.screenSize = {
width: window.screen.width,
height: window.screen.height,
};
}
if (document && document.documentElement) {
details.viewport = {
width: document.documentElement.clientWidth,
height: document.documentElement.clientHeight,
};
}
if (window.performance && window.performance.memory) {
const toMb = bytes => Math.round((bytes / (1024 * 1024)) * 100) / 100;
details.usedJSHeapSizeMb =
toMb(window.performance.memory.usedJSHeapSize);
}
details.hiddenDurationMs = this.hiddenDurationTimer.hiddenDurationMs;
return details;
}
reportExtension(name) {
this.reporter(LIFECYCLE.TYPE, LIFECYCLE.CATEGORY.EXTENSION_DETECTED, name);
}
pluginLoaded(name) {
if (name.startsWith('metrics-')) {
this.timeEnd(TIMER.METRICS_PLUGIN_LOADED);
}
}
pluginsLoaded(pluginsList) {
this.timeEnd(TIMER.PLUGINS_LOADED);
this.reporter(
LIFECYCLE.TYPE, LIFECYCLE.CATEGORY.PLUGINS_INSTALLED,
LIFECYCLE.CATEGORY.PLUGINS_INSTALLED, undefined,
{pluginsList: pluginsList || []}, true);
}
/**
* Reset named timer.
*/
time(name) {
this._baselines[name] = now();
window.performance.mark(`${name}-start`);
}
/**
* Finish named timer and report it to server.
*/
timeEnd(name, eventDetails) {
if (!this._baselines.hasOwnProperty(name)) { return; }
const baseTime = this._baselines[name];
delete this._baselines[name];
this._reportTiming(name, now() - baseTime, eventDetails);
// Finalize the interval. Either from a registered start mark or
// the navigation start time (if baseTime is 0).
if (baseTime !== 0) {
window.performance.measure(name, `${name}-start`);
} else {
// Microsft Edge does not handle the 2nd param correctly
// (if undefined).
window.performance.measure(name);
}
}
/**
* 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 = 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.
* @param {Object} eventDetails non sensitive details
*/
_reportTiming(name, time, eventDetails) {
this.reporter(TIMING.TYPE, TIMING.CATEGORY.UI_LATENCY, name, time,
eventDetails);
}
/**
* 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 = 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 = 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);
if (elapsed >= SLOW_RPC_THRESHOLD) {
this._slowRpcList.push({anonymizedUrl, elapsed});
}
}
reportLifeCycle(eventName, details) {
this.reporter(LIFECYCLE.TYPE, LIFECYCLE.CATEGORY.DEFAULT, eventName,
undefined, details, true);
}
reportInteraction(eventName, details) {
this.reporter(INTERACTION.TYPE, INTERACTION.CATEGORY.DEFAULT, eventName,
undefined, details, true);
}
/**
* 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.TYPE, ERROR.CATEGORY.ERROR_DIALOG,
'ErrorDialog: ' + message, {error: new Error(message)});
}
setRepoName(repoName) {
this._reportRepoName = repoName;
}
}
export const DEFAULT_STARTUP_TIMERS = Object.assign({}, STARTUP_TIMERS);