blob: 37959c02abd7cd0018cf5c3ac8d45602430c1e33 [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001/* Copyright 2018 The Chromium Authors. All Rights Reserved.
2 *
3 * Use of this source code is governed by a BSD-style
4 * license that can be found in the LICENSE file.
5 */
6
7import MonorailTSMon from './monorail-ts-mon.js';
8
9/**
10 * ClientLogger is a JavaScript library for tracking events with Google
11 * Analytics and ts_mon.
12 *
13 * @example
14 * // Example usage (tracking time to create a new issue, including time spent
15 * // by the user editing stuff):
16 *
17 *
18 * // t0: on page load for /issues/new:
19 * let l = new Clientlogger('issues');
20 * l.logStart('new-issue', 'user-time');
21 *
22 * // t1: on submit for /issues/new:
23 *
24 * l.logStart('new-issue', 'server-time');
25 *
26 * // t2: on page load for /issues/detail:
27 *
28 * let l = new Clientlogger('issues');
29 *
30 * if (l.started('new-issue') {
31 * l.logEnd('new-issue');
32 * }
33 *
34 * // This would record the following metrics:
35 *
36 * issues.new-issue {
37 * time: t2-t0
38 * }
39 *
40 * issues.new-issue["server-time"] {
41 * time: t2-t1
42 * }
43 *
44 * issues.new-issue["user-time"] {
45 * time: t1-t0
46 * }
47 */
48export default class ClientLogger {
49 /**
50 * @param {string} category Arbitrary string for categorizing metrics in
51 * this client. Used by Google Analytics for event logging.
52 */
53 constructor(category) {
54 this.category = category;
55 this.tsMon = MonorailTSMon.getGlobalClient();
56
57 const categoryKey = `ClientLogger.${category}.started`;
58 const startedEvtsStr = sessionStorage[categoryKey];
59 if (startedEvtsStr) {
60 this.startedEvents = JSON.parse(startedEvtsStr);
61 } else {
62 this.startedEvents = {};
63 }
64 }
65
66 /**
67 * @param {string} eventName Arbitrary string for the name of the event.
68 * ie: "issue-load"
69 * @return {Object} Event object for the string checked.
70 */
71 started(eventName) {
72 return this.startedEvents[eventName];
73 }
74
75 /**
76 * Log events that bookend some activity whose duration we’re interested in.
77 * @param {string} eventName Name of the event to start.
78 * @param {string} eventLabel Arbitrary string label to tie to event.
79 */
80 logStart(eventName, eventLabel) {
81 // Tricky situation: initial new issue POST gets rejected
82 // due to form validation issues. Start a new timer, or keep
83 // the original?
84
85 const startedEvent = this.startedEvents[eventName] || {
86 time: new Date().getTime(),
87 };
88
89 if (eventLabel) {
90 if (!startedEvent.labels) {
91 startedEvent.labels = {};
92 }
93 startedEvent.labels[eventLabel] = new Date().getTime();
94 }
95
96 this.startedEvents[eventName] = startedEvent;
97
98 sessionStorage[`ClientLogger.${this.category}.started`] =
99 JSON.stringify(this.startedEvents);
100
101 logEvent(this.category, `${eventName}-start`, eventLabel);
102 }
103
104 /**
105 * Pause the stopwatch for this event.
106 * @param {string} eventName Name of the event to pause.
107 * @param {string} eventLabel Arbitrary string label tied to the event.
108 */
109 logPause(eventName, eventLabel) {
110 if (!eventLabel) {
111 throw `logPause called for event with no label: ${eventName}`;
112 }
113
114 const startEvent = this.startedEvents[eventName];
115
116 if (!startEvent) {
117 console.warn(`logPause called for event with no logStart: ${eventName}`);
118 return;
119 }
120
121 if (!startEvent.labels[eventLabel]) {
122 console.warn(`logPause called for event label with no logStart: ` +
123 `${eventName}.${eventLabel}`);
124 return;
125 }
126
127 const elapsed = new Date().getTime() - startEvent.labels[eventLabel];
128 if (!startEvent.elapsed) {
129 startEvent.elapsed = {};
130 startEvent.elapsed[eventLabel] = 0;
131 }
132
133 // Save accumulated time.
134 startEvent.elapsed[eventLabel] += elapsed;
135
136 sessionStorage[`ClientLogger.${this.category}.started`] =
137 JSON.stringify(this.startedEvents);
138 }
139
140 /**
141 * Resume the stopwatch for this event.
142 * @param {string} eventName Name of the event to resume.
143 * @param {string} eventLabel Arbitrary string label tied to the event.
144 */
145 logResume(eventName, eventLabel) {
146 if (!eventLabel) {
147 throw `logResume called for event with no label: ${eventName}`;
148 }
149
150 const startEvent = this.startedEvents[eventName];
151
152 if (!startEvent) {
153 console.warn(`logResume called for event with no logStart: ${eventName}`);
154 return;
155 }
156
157 if (!startEvent.hasOwnProperty('elapsed') ||
158 !startEvent.elapsed.hasOwnProperty(eventLabel)) {
159 console.warn(`logResume called for event that was never paused:` +
160 `${eventName}.${eventLabel}`);
161 return;
162 }
163
164 // TODO(jeffcarp): Throw if an event is resumed twice.
165
166 startEvent.labels[eventLabel] = new Date().getTime();
167
168 sessionStorage[`ClientLogger.${this.category}.started`] =
169 JSON.stringify(this.startedEvents);
170 }
171
172 /**
173 * Stop ecording this event.
174 * @param {string} eventName Name of the event to stop recording.
175 * @param {string} eventLabel Arbitrary string label tied to the event.
176 * @param {number=} maxThresholdMs Avoid sending timing data if it took
177 * longer than this threshold.
178 */
179 logEnd(eventName, eventLabel, maxThresholdMs=null) {
180 const startEvent = this.startedEvents[eventName];
181
182 if (!startEvent) {
183 console.warn(`logEnd called for event with no logStart: ${eventName}`);
184 return;
185 }
186
187 // If they've specified a label, report the elapsed since the start
188 // of that label.
189 if (eventLabel) {
190 if (!startEvent.labels.hasOwnProperty(eventLabel)) {
191 console.warn(`logEnd called for event + label with no logStart: ` +
192 `${eventName}/${eventLabel}`);
193 return;
194 }
195
196 this._sendTiming(startEvent, eventName, eventLabel, maxThresholdMs);
197
198 delete startEvent.labels[eventLabel];
199 if (startEvent.hasOwnProperty('elapsed')) {
200 delete startEvent.elapsed[eventLabel];
201 }
202 } else {
203 // If no label is specified, report timing for the whole event.
204 this._sendTiming(startEvent, eventName, null, maxThresholdMs);
205
206 // And also end and report any labels they had running.
207 for (const label in startEvent.labels) {
208 this._sendTiming(startEvent, eventName, label, maxThresholdMs);
209 }
210
211 delete this.startedEvents[eventName];
212 }
213
214 sessionStorage[`ClientLogger.${this.category}.started`] =
215 JSON.stringify(this.startedEvents);
216 logEvent(this.category, `${eventName}-end`, eventLabel);
217 }
218
219 /**
220 * Helper to send data on the event to TSMon.
221 * @param {Object} event Data for the event being sent.
222 * @param {string} eventName Name of the event being sent.
223 * @param {string} recordOnlyThisLabel Label to record.
224 * @param {number=} maxThresholdMs Optional threshold to drop events
225 * if they took too long.
226 * @private
227 */
228 _sendTiming(event, eventName, recordOnlyThisLabel, maxThresholdMs=null) {
229 // Calculate elapsed.
230 let elapsed;
231 if (recordOnlyThisLabel) {
232 elapsed = new Date().getTime() - event.labels[recordOnlyThisLabel];
233 if (event.elapsed && event.elapsed[recordOnlyThisLabel]) {
234 elapsed += event.elapsed[recordOnlyThisLabel];
235 }
236 } else {
237 elapsed = new Date().getTime() - event.time;
238 }
239
240 // Return if elapsed exceeds maxThresholdMs.
241 if (maxThresholdMs !== null && elapsed > maxThresholdMs) {
242 return;
243 }
244
245 const options = {
246 'timingCategory': this.category,
247 'timingVar': eventName,
248 'timingValue': elapsed,
249 };
250 if (recordOnlyThisLabel) {
251 options['timingLabel'] = recordOnlyThisLabel;
252 }
253 ga('send', 'timing', options);
254 this.tsMon.recordUserTiming(
255 this.category, eventName, recordOnlyThisLabel, elapsed);
256 }
257}
258
259/**
260 * Log single usr events with Google Analytics.
261 * @param {string} category Category of the event.
262 * @param {string} eventAction Name of the event.
263 * @param {string=} eventLabel Optional custom string value tied to the event.
264 * @param {number=} eventValue Optional custom number value tied to the event.
265 */
266export function logEvent(category, eventAction, eventLabel, eventValue) {
267 ga('send', 'event', category, eventAction, eventLabel,
268 eventValue);
269}
270
271// Until the rest of the app is in modules, this must be exposed on window.
272window.ClientLogger = ClientLogger;