blob: 37959c02abd7cd0018cf5c3ac8d45602430c1e33 [file] [log] [blame]
/* Copyright 2018 The Chromium Authors. All Rights Reserved.
*
* Use of this source code is governed by a BSD-style
* license that can be found in the LICENSE file.
*/
import MonorailTSMon from './monorail-ts-mon.js';
/**
* ClientLogger is a JavaScript library for tracking events with Google
* Analytics and ts_mon.
*
* @example
* // Example usage (tracking time to create a new issue, including time spent
* // by the user editing stuff):
*
*
* // t0: on page load for /issues/new:
* let l = new Clientlogger('issues');
* l.logStart('new-issue', 'user-time');
*
* // t1: on submit for /issues/new:
*
* l.logStart('new-issue', 'server-time');
*
* // t2: on page load for /issues/detail:
*
* let l = new Clientlogger('issues');
*
* if (l.started('new-issue') {
* l.logEnd('new-issue');
* }
*
* // This would record the following metrics:
*
* issues.new-issue {
* time: t2-t0
* }
*
* issues.new-issue["server-time"] {
* time: t2-t1
* }
*
* issues.new-issue["user-time"] {
* time: t1-t0
* }
*/
export default class ClientLogger {
/**
* @param {string} category Arbitrary string for categorizing metrics in
* this client. Used by Google Analytics for event logging.
*/
constructor(category) {
this.category = category;
this.tsMon = MonorailTSMon.getGlobalClient();
const categoryKey = `ClientLogger.${category}.started`;
const startedEvtsStr = sessionStorage[categoryKey];
if (startedEvtsStr) {
this.startedEvents = JSON.parse(startedEvtsStr);
} else {
this.startedEvents = {};
}
}
/**
* @param {string} eventName Arbitrary string for the name of the event.
* ie: "issue-load"
* @return {Object} Event object for the string checked.
*/
started(eventName) {
return this.startedEvents[eventName];
}
/**
* Log events that bookend some activity whose duration we’re interested in.
* @param {string} eventName Name of the event to start.
* @param {string} eventLabel Arbitrary string label to tie to event.
*/
logStart(eventName, eventLabel) {
// Tricky situation: initial new issue POST gets rejected
// due to form validation issues. Start a new timer, or keep
// the original?
const startedEvent = this.startedEvents[eventName] || {
time: new Date().getTime(),
};
if (eventLabel) {
if (!startedEvent.labels) {
startedEvent.labels = {};
}
startedEvent.labels[eventLabel] = new Date().getTime();
}
this.startedEvents[eventName] = startedEvent;
sessionStorage[`ClientLogger.${this.category}.started`] =
JSON.stringify(this.startedEvents);
logEvent(this.category, `${eventName}-start`, eventLabel);
}
/**
* Pause the stopwatch for this event.
* @param {string} eventName Name of the event to pause.
* @param {string} eventLabel Arbitrary string label tied to the event.
*/
logPause(eventName, eventLabel) {
if (!eventLabel) {
throw `logPause called for event with no label: ${eventName}`;
}
const startEvent = this.startedEvents[eventName];
if (!startEvent) {
console.warn(`logPause called for event with no logStart: ${eventName}`);
return;
}
if (!startEvent.labels[eventLabel]) {
console.warn(`logPause called for event label with no logStart: ` +
`${eventName}.${eventLabel}`);
return;
}
const elapsed = new Date().getTime() - startEvent.labels[eventLabel];
if (!startEvent.elapsed) {
startEvent.elapsed = {};
startEvent.elapsed[eventLabel] = 0;
}
// Save accumulated time.
startEvent.elapsed[eventLabel] += elapsed;
sessionStorage[`ClientLogger.${this.category}.started`] =
JSON.stringify(this.startedEvents);
}
/**
* Resume the stopwatch for this event.
* @param {string} eventName Name of the event to resume.
* @param {string} eventLabel Arbitrary string label tied to the event.
*/
logResume(eventName, eventLabel) {
if (!eventLabel) {
throw `logResume called for event with no label: ${eventName}`;
}
const startEvent = this.startedEvents[eventName];
if (!startEvent) {
console.warn(`logResume called for event with no logStart: ${eventName}`);
return;
}
if (!startEvent.hasOwnProperty('elapsed') ||
!startEvent.elapsed.hasOwnProperty(eventLabel)) {
console.warn(`logResume called for event that was never paused:` +
`${eventName}.${eventLabel}`);
return;
}
// TODO(jeffcarp): Throw if an event is resumed twice.
startEvent.labels[eventLabel] = new Date().getTime();
sessionStorage[`ClientLogger.${this.category}.started`] =
JSON.stringify(this.startedEvents);
}
/**
* Stop ecording this event.
* @param {string} eventName Name of the event to stop recording.
* @param {string} eventLabel Arbitrary string label tied to the event.
* @param {number=} maxThresholdMs Avoid sending timing data if it took
* longer than this threshold.
*/
logEnd(eventName, eventLabel, maxThresholdMs=null) {
const startEvent = this.startedEvents[eventName];
if (!startEvent) {
console.warn(`logEnd called for event with no logStart: ${eventName}`);
return;
}
// If they've specified a label, report the elapsed since the start
// of that label.
if (eventLabel) {
if (!startEvent.labels.hasOwnProperty(eventLabel)) {
console.warn(`logEnd called for event + label with no logStart: ` +
`${eventName}/${eventLabel}`);
return;
}
this._sendTiming(startEvent, eventName, eventLabel, maxThresholdMs);
delete startEvent.labels[eventLabel];
if (startEvent.hasOwnProperty('elapsed')) {
delete startEvent.elapsed[eventLabel];
}
} else {
// If no label is specified, report timing for the whole event.
this._sendTiming(startEvent, eventName, null, maxThresholdMs);
// And also end and report any labels they had running.
for (const label in startEvent.labels) {
this._sendTiming(startEvent, eventName, label, maxThresholdMs);
}
delete this.startedEvents[eventName];
}
sessionStorage[`ClientLogger.${this.category}.started`] =
JSON.stringify(this.startedEvents);
logEvent(this.category, `${eventName}-end`, eventLabel);
}
/**
* Helper to send data on the event to TSMon.
* @param {Object} event Data for the event being sent.
* @param {string} eventName Name of the event being sent.
* @param {string} recordOnlyThisLabel Label to record.
* @param {number=} maxThresholdMs Optional threshold to drop events
* if they took too long.
* @private
*/
_sendTiming(event, eventName, recordOnlyThisLabel, maxThresholdMs=null) {
// Calculate elapsed.
let elapsed;
if (recordOnlyThisLabel) {
elapsed = new Date().getTime() - event.labels[recordOnlyThisLabel];
if (event.elapsed && event.elapsed[recordOnlyThisLabel]) {
elapsed += event.elapsed[recordOnlyThisLabel];
}
} else {
elapsed = new Date().getTime() - event.time;
}
// Return if elapsed exceeds maxThresholdMs.
if (maxThresholdMs !== null && elapsed > maxThresholdMs) {
return;
}
const options = {
'timingCategory': this.category,
'timingVar': eventName,
'timingValue': elapsed,
};
if (recordOnlyThisLabel) {
options['timingLabel'] = recordOnlyThisLabel;
}
ga('send', 'timing', options);
this.tsMon.recordUserTiming(
this.category, eventName, recordOnlyThisLabel, elapsed);
}
}
/**
* Log single usr events with Google Analytics.
* @param {string} category Category of the event.
* @param {string} eventAction Name of the event.
* @param {string=} eventLabel Optional custom string value tied to the event.
* @param {number=} eventValue Optional custom number value tied to the event.
*/
export function logEvent(category, eventAction, eventLabel, eventValue) {
ga('send', 'event', category, eventAction, eventLabel,
eventValue);
}
// Until the rest of the app is in modules, this must be exposed on window.
window.ClientLogger = ClientLogger;