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