Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/monitoring/client-logger.js b/static_src/monitoring/client-logger.js
new file mode 100644
index 0000000..37959c0
--- /dev/null
+++ b/static_src/monitoring/client-logger.js
@@ -0,0 +1,272 @@
+/* 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;
diff --git a/static_src/monitoring/client-logger.test.js b/static_src/monitoring/client-logger.test.js
new file mode 100644
index 0000000..5c88355
--- /dev/null
+++ b/static_src/monitoring/client-logger.test.js
@@ -0,0 +1,627 @@
+import {assert} from 'chai';
+import sinon from 'sinon';
+
+import ClientLogger from './client-logger.js';
+import MonorailTSMon from './monorail-ts-mon.js';
+
+describe('ClientLogger', () => {
+ const startedKey = 'ClientLogger.rutabaga.started';
+ let c;
+
+ beforeEach(() => {
+ window.CS_env = {
+ token: 'rutabaga-token',
+ tokenExpiresSec: 1234,
+ app_version: 'rutabaga-version',
+ };
+ window.chops = {rpc: {PrpcClient: sinon.spy()}};
+ window.ga = sinon.spy();
+ MonorailTSMon.prototype.disableAfterNextFlush = sinon.spy();
+ c = new ClientLogger('rutabaga');
+ });
+
+ afterEach(() => {
+ sessionStorage.clear();
+ });
+
+ describe('constructor', () => {
+ it('assigns this.category', () => {
+ assert.equal(c.category, 'rutabaga');
+ });
+
+ it('gets started events from sessionStorage', () => {
+ const startedEvents = {
+ event1: {
+ time: 12345678,
+ labels: ['label1', 'label2'],
+ },
+ event2: {
+ time: 87654321,
+ labels: ['label2'],
+ },
+ };
+ sessionStorage[startedKey] = JSON.stringify(startedEvents);
+
+ c = new ClientLogger('rutabaga');
+ assert.deepEqual(startedEvents, c.startedEvents);
+ });
+ });
+
+ describe('records ts_mon metrics', () => {
+ let issueCreateMetric;
+ let issueUpdateMetric;
+ let autocompleteMetric;
+ let c;
+
+ beforeEach(() => {
+ window.ga = sinon.spy();
+ c = new ClientLogger('issues');
+ issueCreateMetric = c.tsMon._userTimingMetrics[0].metric;
+ issueCreateMetric.add = sinon.spy();
+
+ issueUpdateMetric = c.tsMon._userTimingMetrics[1].metric;
+ issueUpdateMetric.add = sinon.spy();
+
+ autocompleteMetric = c.tsMon._userTimingMetrics[2].metric;
+ autocompleteMetric.add = sinon.spy();
+ });
+
+ it('bogus', () => {
+ c.logStart('rutabaga');
+ c.logEnd('rutabaga');
+ sinon.assert.notCalled(issueCreateMetric.add);
+ sinon.assert.notCalled(issueUpdateMetric.add);
+ sinon.assert.notCalled(autocompleteMetric.add);
+ });
+
+ it('new-issue', () => {
+ c.logStart('new-issue', 'server-time');
+ c.logEnd('new-issue', 'server-time');
+ sinon.assert.notCalled(issueUpdateMetric.add);
+ sinon.assert.notCalled(autocompleteMetric.add);
+
+ sinon.assert.calledOnce(issueCreateMetric.add);
+ assert.isNumber(issueCreateMetric.add.getCall(0).args[0]);
+ assert.isString(issueCreateMetric.add.getCall(0).args[1].get('client_id'));
+ assert.equal(issueCreateMetric.add.getCall(0).args[1].get('host_name'),
+ 'rutabaga-version');
+ });
+
+ it('issue-update', () => {
+ c.logStart('issue-update', 'computer-time');
+ c.logEnd('issue-update', 'computer-time');
+ sinon.assert.notCalled(issueCreateMetric.add);
+ sinon.assert.notCalled(autocompleteMetric.add);
+
+ sinon.assert.calledOnce(issueUpdateMetric.add);
+ assert.isNumber(issueUpdateMetric.add.getCall(0).args[0]);
+ assert.isString(issueUpdateMetric.add.getCall(0).args[1].get('client_id'));
+ assert.equal(issueUpdateMetric.add.getCall(0).args[1].get('host_name'),
+ 'rutabaga-version');
+ });
+
+ it('populate-options', () => {
+ c.logStart('populate-options');
+ c.logEnd('populate-options');
+ sinon.assert.notCalled(issueCreateMetric.add);
+ sinon.assert.notCalled(issueUpdateMetric.add);
+ // Autocomplete is not called in issues category.
+ sinon.assert.notCalled(autocompleteMetric.add);
+
+ c = new ClientLogger('autocomplete');
+ autocompleteMetric = c.tsMon._userTimingMetrics[2].metric;
+ autocompleteMetric.add = sinon.spy();
+
+ c.logStart('populate-options', 'user-time');
+ c.logEnd('populate-options', 'user-time');
+ sinon.assert.notCalled(issueCreateMetric.add);
+ sinon.assert.notCalled(issueUpdateMetric.add);
+
+ sinon.assert.calledOnce(autocompleteMetric.add);
+ assert.isNumber(autocompleteMetric.add.getCall(0).args[0]);
+ assert.isString(autocompleteMetric.add.getCall(0).args[1].get('client_id'));
+ assert.equal(autocompleteMetric.add.getCall(0).args[1].get('host_name'),
+ 'rutabaga-version');
+ });
+ });
+
+ describe('logStart', () => {
+ let c;
+ let clock;
+ const currentTime = 5000;
+
+ beforeEach(() => {
+ c = new ClientLogger('rutabaga');
+ clock = sinon.useFakeTimers(currentTime);
+ });
+
+ afterEach(() => {
+ clock.restore();
+ sessionStorage.clear();
+ });
+
+ it('creates a new startedEvent if none', () => {
+ c.logStart('event-name', 'event-label');
+
+ sinon.assert.calledOnce(ga);
+ sinon.assert.calledWith(ga, 'send', 'event', 'rutabaga',
+ 'event-name-start', 'event-label');
+
+ const expectedStartedEvents = {
+ 'event-name': {
+ time: currentTime,
+ labels: {
+ 'event-label': currentTime,
+ },
+ },
+ };
+ assert.deepEqual(c.startedEvents, expectedStartedEvents);
+ assert.deepEqual(JSON.parse(sessionStorage[startedKey]),
+ expectedStartedEvents);
+ });
+
+ it('uses an existing startedEvent', () => {
+ c.startedEvents['event-name'] = {
+ time: 1234,
+ labels: {
+ 'event-label': 1000,
+ },
+ };
+ c.logStart('event-name', 'event-label');
+
+ sinon.assert.calledOnce(ga);
+ sinon.assert.calledWith(ga, 'send', 'event', 'rutabaga',
+ 'event-name-start', 'event-label');
+
+ // TODO(jeffcarp): Audit is this wanted behavior? Replacing event time
+ // but not label time?
+ const expectedStartedEvents = {
+ 'event-name': {
+ time: 1234,
+ labels: {
+ 'event-label': currentTime,
+ },
+ },
+ };
+ assert.deepEqual(c.startedEvents, expectedStartedEvents);
+ assert.deepEqual(JSON.parse(sessionStorage[startedKey]),
+ expectedStartedEvents);
+ });
+ });
+
+ describe('logPause', () => {
+ const startTime = 1234;
+ const currentTime = 5000;
+ let c;
+ let clock;
+
+ beforeEach(() => {
+ clock = sinon.useFakeTimers(currentTime);
+ c = new ClientLogger('rutabaga');
+ c.startedEvents['event-name'] = {
+ time: startTime,
+ labels: {
+ 'event-label': startTime,
+ },
+ };
+ });
+
+ afterEach(() => {
+ clock.restore();
+ sessionStorage.clear();
+ });
+
+ it('throws if no label given', () => {
+ assert.throws(() => {
+ c.logPause('bogus');
+ }, 'event with no label');
+ });
+
+ it('exits early if no start event exists', () => {
+ const originalStartedEvents = Object.assign(c.startedEvents, {});
+ c.logPause('bogus', 'fogus');
+ assert.deepEqual(c.startedEvents, originalStartedEvents);
+ });
+
+ it('exits early if no label exists', () => {
+ const originalStartedEvents = Object.assign(c.startedEvents, {});
+ c.logPause('event-name', 'fogus');
+ assert.deepEqual(c.startedEvents, originalStartedEvents);
+ });
+
+ it('adds elapsed time to start event', () => {
+ c.logPause('event-name', 'event-label');
+
+ const expectedStartedEvents = {
+ 'event-name': {
+ time: startTime,
+ labels: {
+ 'event-label': startTime,
+ },
+ elapsed: {
+ 'event-label': currentTime - startTime,
+ },
+ },
+ };
+ assert.deepEqual(c.startedEvents, expectedStartedEvents);
+ assert.deepEqual(
+ JSON.parse(sessionStorage['ClientLogger.rutabaga.started']),
+ expectedStartedEvents);
+ });
+ });
+
+ describe('logResume', () => {
+ let c;
+ let clock;
+ const startTimeEvent = 1234;
+ const startTimeLabel = 2345;
+ const labelElapsed = 4321;
+ const currentTime = 6000;
+
+ beforeEach(() => {
+ clock = sinon.useFakeTimers(currentTime);
+ c = new ClientLogger('rutabaga');
+ c.startedEvents['event-name'] = {
+ time: startTimeEvent,
+ labels: {
+ 'event-label': startTimeLabel,
+ },
+ elapsed: {
+ 'event-label': labelElapsed,
+ },
+ };
+ });
+
+ afterEach(() => {
+ clock.restore();
+ sessionStorage.clear();
+ });
+
+ it('throws if no label given', () => {
+ assert.throws(() => {
+ c.logResume('bogus');
+ }, 'no label');
+ });
+
+ it('exits early if no start event exists', () => {
+ const originalStartedEvents = Object.assign(c.startedEvents, {});
+ c.logResume('bogus', 'fogus');
+ assert.deepEqual(c.startedEvents, originalStartedEvents);
+ });
+
+ it('exits early if the label was never paused', () => {
+ c.startedEvents['event-name'] = {
+ time: startTimeEvent,
+ labels: {
+ 'event-label': startTimeLabel,
+ },
+ elapsed: {},
+ };
+
+ const originalStartedEvents = Object.assign(c.startedEvents, {});
+ c.logResume('event-name', 'event-label');
+ assert.deepEqual(c.startedEvents, originalStartedEvents);
+ });
+
+ it('sets start event time to current time', () => {
+ c.logResume('event-name', 'event-label');
+
+ const expectedStartedEvents = {
+ 'event-name': {
+ time: startTimeEvent,
+ labels: {
+ 'event-label': currentTime,
+ },
+ elapsed: {
+ 'event-label': labelElapsed,
+ },
+ },
+ };
+ assert.deepEqual(c.startedEvents, expectedStartedEvents);
+ assert.deepEqual(
+ JSON.parse(sessionStorage['ClientLogger.rutabaga.started']),
+ expectedStartedEvents);
+ });
+ });
+
+ describe('logEnd', () => {
+ let c;
+ let clock;
+ const startTimeEvent = 1234;
+ const startTimeLabel1 = 2345;
+ const startTimeLabel2 = 3456;
+ const currentTime = 10000;
+
+ beforeEach(() => {
+ c = new ClientLogger('rutabaga');
+ clock = sinon.useFakeTimers(currentTime);
+ c.tsMon.recordUserTiming = sinon.spy();
+ c.startedEvents = {
+ someEvent: {
+ time: startTimeEvent,
+ labels: {
+ label1: startTimeLabel1,
+ label2: startTimeLabel2,
+ },
+ },
+ };
+ });
+
+ afterEach(() => {
+ clock.restore();
+ });
+
+ it('returns early if no event was started', () => {
+ c.startedEvents = {someEvent: {}};
+ const originalStartedEvents = Object.assign(c.startedEvents, {});
+ c.logEnd('bogus');
+ sinon.assert.notCalled(window.ga);
+ assert.isNull(sessionStorage.getItem(startedKey));
+ assert.deepEqual(c.startedEvents, originalStartedEvents);
+ });
+
+ it('returns early if label was not started', () => {
+ c.startedEvents = {someEvent: {labels: {}}};
+ const originalStartedEvents = Object.assign(c.startedEvents, {});
+ c.logEnd('someEvent', 'bogus');
+ sinon.assert.notCalled(window.ga);
+ assert.isNull(sessionStorage.getItem(startedKey));
+ assert.deepEqual(c.startedEvents, originalStartedEvents);
+ });
+
+ it('does not log non-labeled events over threshold', () => {
+ c.startedEvents = {someEvent: {time: currentTime - 1000}};
+ c.logEnd('someEvent', null, 999);
+
+ sinon.assert.calledOnce(window.ga);
+ sinon.assert.calledWith(window.ga, 'send', 'event', 'rutabaga',
+ 'someEvent-end', null, undefined);
+ sinon.assert.notCalled(c.tsMon.recordUserTiming);
+ assert.equal(sessionStorage.getItem(startedKey), '{}');
+ });
+
+ it('does not log labeled events over threshold', () => {
+ const elapsedLabel2 = 2000;
+ c.startedEvents.someEvent.elapsed = {
+ label1: currentTime - 1000,
+ label2: elapsedLabel2,
+ };
+ c.logEnd('someEvent', 'label1', 999);
+
+ sinon.assert.calledOnce(window.ga);
+ sinon.assert.calledWith(window.ga, 'send', 'event', 'rutabaga',
+ 'someEvent-end', 'label1', undefined);
+ // TODO(jeffcarp): Feature: add GA event if over threshold.
+ sinon.assert.notCalled(c.tsMon.recordUserTiming);
+
+ const expectedStartedEvents = {
+ someEvent: {
+ time: startTimeEvent,
+ labels: {
+ label2: startTimeLabel2,
+ },
+ elapsed: {
+ label2: elapsedLabel2,
+ },
+ },
+ };
+ assert.deepEqual(c.startedEvents, expectedStartedEvents);
+ assert.deepEqual(JSON.parse(sessionStorage[startedKey]),
+ expectedStartedEvents);
+ });
+
+ it('calls ga() with timing and event info for all labels', () => {
+ const label1Elapsed = 1000;
+ const label2Elapsed = 2500;
+ c.startedEvents.someEvent.elapsed = {
+ label1: label1Elapsed,
+ label2: label2Elapsed,
+ };
+ c.logEnd('someEvent');
+
+ assert.deepEqual(ga.getCall(0).args, [
+ 'send', 'timing', {
+ timingCategory: 'rutabaga',
+ timingValue: currentTime - startTimeEvent,
+ timingVar: 'someEvent',
+ }]);
+
+ assert.deepEqual(ga.getCall(1).args, [
+ 'send', 'timing', {
+ timingCategory: 'rutabaga',
+ timingValue: (currentTime - startTimeLabel1) + label1Elapsed,
+ timingVar: 'someEvent',
+ timingLabel: 'label1',
+ }]);
+ assert.deepEqual(ga.getCall(2).args, [
+ 'send', 'timing', {
+ timingCategory: 'rutabaga',
+ timingValue: (currentTime - startTimeLabel2) + label2Elapsed,
+ timingVar: 'someEvent',
+ timingLabel: 'label2',
+ }]);
+ assert.deepEqual(ga.getCall(3).args, [
+ 'send', 'event', 'rutabaga', 'someEvent-end', undefined, undefined,
+ ]);
+ assert.deepEqual(c.tsMon.recordUserTiming.getCall(0).args, [
+ 'rutabaga', 'someEvent', null, currentTime - startTimeEvent,
+ ]);
+ assert.deepEqual(c.tsMon.recordUserTiming.getCall(1).args, [
+ 'rutabaga', 'someEvent', 'label1',
+ (currentTime - startTimeLabel1) + label1Elapsed,
+ ]);
+ assert.deepEqual(c.tsMon.recordUserTiming.getCall(2).args, [
+ 'rutabaga', 'someEvent', 'label2',
+ (currentTime - startTimeLabel2) + label2Elapsed,
+ ]);
+
+ assert.deepEqual(c.startedEvents, {});
+ assert.equal(sessionStorage.getItem(startedKey), '{}');
+ });
+
+ it('calling with a label calls ga() only for that label', () => {
+ const label1Elapsed = 1000;
+ const label2Elapsed = 2500;
+ c.startedEvents.someEvent.elapsed = {
+ label1: label1Elapsed,
+ label2: label2Elapsed,
+ };
+ c.logEnd('someEvent', 'label2');
+
+ assert.deepEqual(ga.getCall(0).args, [
+ 'send', 'timing', {
+ timingCategory: 'rutabaga',
+ timingValue: (currentTime - startTimeLabel2) + label2Elapsed,
+ timingVar: 'someEvent',
+ timingLabel: 'label2',
+ }]);
+ assert.deepEqual(window.ga.getCall(1).args, [
+ 'send', 'event', 'rutabaga', 'someEvent-end', 'label2', undefined,
+ ]);
+ sinon.assert.calledOnce(c.tsMon.recordUserTiming);
+ sinon.assert.calledWith(c.tsMon.recordUserTiming, 'rutabaga',
+ 'someEvent', 'label2', (currentTime - startTimeLabel2) + label2Elapsed);
+
+ const expectedStartedEvents = {
+ someEvent: {
+ time: startTimeEvent,
+ labels: {
+ label1: startTimeLabel1,
+ },
+ elapsed: {
+ label1: label1Elapsed,
+ },
+ },
+ };
+ assert.deepEqual(c.startedEvents, expectedStartedEvents);
+ assert.deepEqual(JSON.parse(sessionStorage[startedKey]),
+ expectedStartedEvents);
+ });
+
+ it('calling logStart, logPause, logResume, and logEnd works for labels',
+ () => {
+ let countedElapsedTime = 0;
+ c.logStart('someEvent', 'label1');
+ clock.tick(1000);
+ countedElapsedTime += 1000;
+ c.logPause('someEvent', 'label1');
+ clock.tick(1000);
+ c.logResume('someEvent', 'label1');
+ clock.tick(1000);
+ countedElapsedTime += 1000;
+ c.logEnd('someEvent', 'label1');
+
+ assert.deepEqual(ga.getCall(0).args, [
+ 'send', 'event', 'rutabaga', 'someEvent-start', 'label1', undefined,
+ ]);
+ assert.deepEqual(ga.getCall(1).args, [
+ 'send', 'timing', {
+ timingCategory: 'rutabaga',
+ timingValue: countedElapsedTime,
+ timingVar: 'someEvent',
+ timingLabel: 'label1',
+ }]);
+ assert.deepEqual(window.ga.getCall(2).args, [
+ 'send', 'event', 'rutabaga', 'someEvent-end', 'label1', undefined,
+ ]);
+ sinon.assert.calledOnce(c.tsMon.recordUserTiming);
+ sinon.assert.calledWith(c.tsMon.recordUserTiming, 'rutabaga',
+ 'someEvent', 'label1', countedElapsedTime);
+
+ const expectedStartedEvents = {
+ someEvent: {
+ time: startTimeEvent,
+ labels: {
+ label2: startTimeLabel2,
+ },
+ elapsed: {},
+ },
+ };
+ assert.deepEqual(c.startedEvents, expectedStartedEvents);
+ assert.deepEqual(JSON.parse(sessionStorage[startedKey]),
+ expectedStartedEvents);
+ });
+
+ it('logs some events when others are above threshold', () => {
+ c.startedEvents = {
+ someEvent: {
+ time: 9500,
+ labels: {
+ overThresholdWithoutElapsed: 8000,
+ overThresholdWithElapsed: 9500,
+ underThresholdWithoutElapsed: 9750,
+ underThresholdWithElapsed: 9650,
+ exactlyOnThresholdWithoutElapsed: 9001,
+ exactlyOnThresholdWithElapsed: 9002,
+ },
+ elapsed: {
+ overThresholdWithElapsed: 1000,
+ underThresholdWithElapsed: 100,
+ exactlyOnThresholdWithElapsed: 1,
+ },
+ },
+ };
+ c.logEnd('someEvent', null, 999);
+
+ // Verify ga() calls.
+ assert.equal(window.ga.getCalls().length, 6);
+ assert.deepEqual(ga.getCall(0).args, [
+ 'send', 'timing', {
+ timingCategory: 'rutabaga',
+ timingValue: 500,
+ timingVar: 'someEvent',
+ }]);
+ assert.deepEqual(ga.getCall(1).args, [
+ 'send', 'timing', {
+ timingCategory: 'rutabaga',
+ timingValue: 250,
+ timingVar: 'someEvent',
+ timingLabel: 'underThresholdWithoutElapsed',
+ }]);
+ assert.deepEqual(ga.getCall(2).args, [
+ 'send', 'timing', {
+ timingCategory: 'rutabaga',
+ timingValue: 450,
+ timingVar: 'someEvent',
+ timingLabel: 'underThresholdWithElapsed',
+ }]);
+ assert.deepEqual(ga.getCall(3).args, [
+ 'send', 'timing', {
+ timingCategory: 'rutabaga',
+ timingValue: 999,
+ timingVar: 'someEvent',
+ timingLabel: 'exactlyOnThresholdWithoutElapsed',
+ }]);
+ assert.deepEqual(ga.getCall(4).args, [
+ 'send', 'timing', {
+ timingCategory: 'rutabaga',
+ timingValue: 999,
+ timingVar: 'someEvent',
+ timingLabel: 'exactlyOnThresholdWithElapsed',
+ }]);
+ assert.deepEqual(ga.getCall(5).args, [
+ 'send', 'event', 'rutabaga', 'someEvent-end', null, undefined,
+ ]);
+
+ // Verify ts_mon.recordUserTiming() calls.
+ assert.equal(c.tsMon.recordUserTiming.getCalls().length, 5);
+ assert.deepEqual(c.tsMon.recordUserTiming.getCall(0).args, [
+ 'rutabaga', 'someEvent', null, 500,
+ ]);
+ assert.deepEqual(c.tsMon.recordUserTiming.getCall(1).args, [
+ 'rutabaga', 'someEvent', 'underThresholdWithoutElapsed', 250,
+ ]);
+ assert.deepEqual(c.tsMon.recordUserTiming.getCall(2).args, [
+ 'rutabaga', 'someEvent', 'underThresholdWithElapsed', 450,
+ ]);
+ assert.deepEqual(c.tsMon.recordUserTiming.getCall(3).args, [
+ 'rutabaga', 'someEvent', 'exactlyOnThresholdWithoutElapsed', 999,
+ ]);
+ assert.deepEqual(c.tsMon.recordUserTiming.getCall(4).args, [
+ 'rutabaga', 'someEvent', 'exactlyOnThresholdWithElapsed', 999,
+ ]);
+ assert.deepEqual(c.startedEvents, {});
+ assert.deepEqual(JSON.parse(sessionStorage[startedKey]), {});
+ });
+ });
+});
diff --git a/static_src/monitoring/monorail-ts-mon.js b/static_src/monitoring/monorail-ts-mon.js
new file mode 100644
index 0000000..2d90e3e
--- /dev/null
+++ b/static_src/monitoring/monorail-ts-mon.js
@@ -0,0 +1,266 @@
+/* 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 {TSMonClient} from '@chopsui/tsmon-client';
+
+export const tsMonClient = new TSMonClient();
+import AutoRefreshPrpcClient from 'prpc.js';
+
+const TS_MON_JS_PATH = '/_/jstsmon.do';
+const TS_MON_CLIENT_GLOBAL_NAME = '__tsMonClient';
+const PAGE_LOAD_MAX_THRESHOLD = 60000;
+export const PAGE_TYPES = Object.freeze({
+ ISSUE_DETAIL_SPA: 'issue_detail_spa',
+ ISSUE_ENTRY: 'issue_entry',
+ ISSUE_LIST_SPA: 'issue_list_spa',
+});
+
+export default class MonorailTSMon extends TSMonClient {
+ /** @override */
+ constructor() {
+ super(TS_MON_JS_PATH);
+ this.clientId = MonorailTSMon.generateClientId();
+ this.disableAfterNextFlush();
+ // Create an instance of pRPC client for refreshing XSRF tokens.
+ this.prpcClient = new AutoRefreshPrpcClient(
+ window.CS_env.token, window.CS_env.tokenExpiresSec);
+
+ // TODO(jeffcarp, 4415): Deduplicate metric defs.
+ const standardFields = new Map([
+ ['client_id', TSMonClient.stringField('client_id')],
+ ['host_name', TSMonClient.stringField('host_name')],
+ ['document_visible', TSMonClient.boolField('document_visible')],
+ ]);
+ this._userTimingMetrics = [
+ {
+ category: 'issues',
+ eventName: 'new-issue',
+ eventLabel: 'server-time',
+ metric: this.cumulativeDistribution(
+ 'monorail/frontend/issue_create_latency',
+ 'Latency between issue entry form submit and issue detail page load.',
+ null, standardFields,
+ ),
+ },
+ {
+ category: 'issues',
+ eventName: 'issue-update',
+ eventLabel: 'computer-time',
+ metric: this.cumulativeDistribution(
+ 'monorail/frontend/issue_update_latency',
+ 'Latency between issue update form submit and issue detail page load.',
+ null, standardFields,
+ ),
+ },
+ {
+ category: 'autocomplete',
+ eventName: 'populate-options',
+ eventLabel: 'user-time',
+ metric: this.cumulativeDistribution(
+ 'monorail/frontend/autocomplete_populate_latency',
+ 'Latency between page load and autocomplete options loading.',
+ null, standardFields,
+ ),
+ },
+ ];
+
+ this.dateRangeMetric = this.counter(
+ 'monorail/frontend/charts/switch_date_range',
+ 'Number of times user changes date range.',
+ null, (new Map([
+ ['client_id', TSMonClient.stringField('client_id')],
+ ['host_name', TSMonClient.stringField('host_name')],
+ ['document_visible', TSMonClient.boolField('document_visible')],
+ ['date_range', TSMonClient.intField('date_range')],
+ ])),
+ );
+
+ this.issueCommentsLoadMetric = this.cumulativeDistribution(
+ 'monorail/frontend/issue_comments_load_latency',
+ 'Time from navigation or click to issue comments loaded.',
+ null, (new Map([
+ ['client_id', TSMonClient.stringField('client_id')],
+ ['host_name', TSMonClient.stringField('host_name')],
+ ['template_name', TSMonClient.stringField('template_name')],
+ ['document_visible', TSMonClient.boolField('document_visible')],
+ ['full_app_load', TSMonClient.boolField('full_app_load')],
+ ])),
+ );
+
+ this.issueListLoadMetric = this.cumulativeDistribution(
+ 'monorail/frontend/issue_list_load_latency',
+ 'Time from navigation or click to search issues list loaded.',
+ null, (new Map([
+ ['client_id', TSMonClient.stringField('client_id')],
+ ['host_name', TSMonClient.stringField('host_name')],
+ ['template_name', TSMonClient.stringField('template_name')],
+ ['document_visible', TSMonClient.boolField('document_visible')],
+ ['full_app_load', TSMonClient.boolField('full_app_load')],
+ ])),
+ );
+
+
+ this.pageLoadMetric = this.cumulativeDistribution(
+ 'frontend/dom_content_loaded',
+ 'domContentLoaded performance timing.',
+ null, (new Map([
+ ['client_id', TSMonClient.stringField('client_id')],
+ ['host_name', TSMonClient.stringField('host_name')],
+ ['template_name', TSMonClient.stringField('template_name')],
+ ['document_visible', TSMonClient.boolField('document_visible')],
+ ])),
+ );
+ }
+
+ fetchImpl(rawMetricValues) {
+ return this.prpcClient.ensureTokenIsValid().then(() => {
+ return fetch(this._reportPath, {
+ method: 'POST',
+ credentials: 'same-origin',
+ body: JSON.stringify({
+ metrics: rawMetricValues,
+ token: this.prpcClient.token,
+ }),
+ });
+ });
+ }
+
+ recordUserTiming(category, eventName, eventLabel, elapsed) {
+ const metricFields = new Map([
+ ['client_id', this.clientId],
+ ['host_name', window.CS_env.app_version],
+ ['document_visible', MonorailTSMon.isPageVisible()],
+ ]);
+ for (const metric of this._userTimingMetrics) {
+ if (category === metric.category &&
+ eventName === metric.eventName &&
+ eventLabel === metric.eventLabel) {
+ metric.metric.add(elapsed, metricFields);
+ }
+ }
+ }
+
+ recordDateRangeChange(dateRange) {
+ const metricFields = new Map([
+ ['client_id', this.clientId],
+ ['host_name', window.CS_env.app_version],
+ ['document_visible', MonorailTSMon.isPageVisible()],
+ ['date_range', dateRange],
+ ]);
+ this.dateRangeMetric.add(1, metricFields);
+ }
+
+ // Make sure this function runs after the page is loaded.
+ recordPageLoadTiming(pageType, maxThresholdMs=null) {
+ if (!pageType) return;
+ // See timing definitions here:
+ // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigationTiming
+ const t = window.performance.timing;
+ const domContentLoadedMs = t.domContentLoadedEventEnd - t.navigationStart;
+
+ const measurePageTypes = new Set([
+ PAGE_TYPES.ISSUE_DETAIL_SPA,
+ PAGE_TYPES.ISSUE_ENTRY,
+ ]);
+
+ if (measurePageTypes.has(pageType)) {
+ if (maxThresholdMs !== null && domContentLoadedMs > maxThresholdMs) {
+ return;
+ }
+ const metricFields = new Map([
+ ['client_id', this.clientId],
+ ['host_name', window.CS_env.app_version],
+ ['template_name', pageType],
+ ['document_visible', MonorailTSMon.isPageVisible()],
+ ]);
+ this.pageLoadMetric.add(domContentLoadedMs, metricFields);
+ }
+ }
+
+ recordIssueCommentsLoadTiming(value, fullAppLoad) {
+ const metricFields = new Map([
+ ['client_id', this.clientId],
+ ['host_name', window.CS_env.app_version],
+ ['template_name', PAGE_TYPES.ISSUE_DETAIL_SPA],
+ ['document_visible', MonorailTSMon.isPageVisible()],
+ ['full_app_load', fullAppLoad],
+ ]);
+ this.issueCommentsLoadMetric.add(value, metricFields);
+ }
+
+ recordIssueEntryTiming(maxThresholdMs=PAGE_LOAD_MAX_THRESHOLD) {
+ this.recordPageLoadTiming(PAGE_TYPES.ISSUE_ENTRY, maxThresholdMs);
+ }
+
+ recordIssueDetailSpaTiming(maxThresholdMs=PAGE_LOAD_MAX_THRESHOLD) {
+ this.recordPageLoadTiming(PAGE_TYPES.ISSUE_DETAIL_SPA, maxThresholdMs);
+ }
+
+
+ /**
+ * Adds a value to the 'issue_list_load_latency' metric.
+ * @param {timestamp} value duration of the load time.
+ * @param {Boolean} fullAppLoad true if this metric was collected from
+ * a full app load (cold) rather than from navigation within the
+ * app (hot).
+ */
+ recordIssueListLoadTiming(value, fullAppLoad) {
+ const metricFields = new Map([
+ ['client_id', this.clientId],
+ ['host_name', window.CS_env.app_version],
+ ['template_name', PAGE_TYPES.ISSUE_LIST_SPA],
+ ['document_visible', MonorailTSMon.isPageVisible()],
+ ['full_app_load', fullAppLoad],
+ ]);
+ this.issueListLoadMetric.add(value, metricFields);
+ }
+
+ // Uses the window object to ensure that only one ts_mon JS client
+ // exists on the page at any given time. Returns the object on window,
+ // instantiating it if it doesn't exist yet.
+ static getGlobalClient() {
+ const key = TS_MON_CLIENT_GLOBAL_NAME;
+ if (!window.hasOwnProperty(key)) {
+ window[key] = new MonorailTSMon();
+ }
+ return window[key];
+ }
+
+ static generateClientId() {
+ /**
+ * Returns a random string used as the client_id field in ts_mon metrics.
+ *
+ * Rationale:
+ * If we assume Monorail has sustained 40 QPS, assume every request
+ * generates a new ClientLogger (likely an overestimation), and we want
+ * the likelihood of a client ID collision to be 0.01% for all IDs
+ * generated in any given year (in other words, 1 collision every 10K
+ * years), we need to generate a random string with at least 2^30 different
+ * possible values (i.e. 30 bits of entropy, see log2(d) in Wolfram link
+ * below). Using an unsigned integer gives us 32 bits of entropy, more than
+ * enough.
+ *
+ * Returns:
+ * A string (the base-32 representation of a random 32-bit integer).
+
+ * References:
+ * - https://en.wikipedia.org/wiki/Birthday_problem
+ * - https://www.wolframalpha.com/input/?i=d%3D40+*+60+*+60+*+24+*+365,+p%3D0.0001,+n+%3D+sqrt(2d+*+ln(1%2F(1-p))),+d,+log2(d),+n
+ * - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toString
+ */
+ const randomvalues = new Uint32Array(1);
+ window.crypto.getRandomValues(randomvalues);
+ return randomvalues[0].toString(32);
+ }
+
+ // Returns a Boolean, true if document is visible.
+ static isPageVisible(path) {
+ return document.visibilityState === 'visible';
+ }
+}
+
+// For integration with EZT pages, which don't use ES modules.
+window.getTSMonClient = MonorailTSMon.getGlobalClient;
diff --git a/static_src/monitoring/monorail-ts-mon.test.js b/static_src/monitoring/monorail-ts-mon.test.js
new file mode 100644
index 0000000..fdf3e81
--- /dev/null
+++ b/static_src/monitoring/monorail-ts-mon.test.js
@@ -0,0 +1,152 @@
+import {assert} from 'chai';
+import sinon from 'sinon';
+
+import MonorailTSMon, {PAGE_TYPES} from './monorail-ts-mon.js';
+
+describe('MonorailTSMon', () => {
+ let mts;
+
+ beforeEach(() => {
+ window.CS_env = {
+ token: 'rutabaga-token',
+ tokenExpiresSec: 1234,
+ app_version: 'rutabaga-version',
+ };
+ window.chops = {rpc: {PrpcClient: sinon.spy()}};
+ MonorailTSMon.prototype.disableAfterNextFlush = sinon.spy();
+ mts = new MonorailTSMon();
+ });
+
+ afterEach(() => {
+ delete window.CS_env;
+ });
+
+ describe('constructor', () => {
+ it('initializes a prpcClient', () => {
+ assert.equal(mts.prpcClient.constructor.name, 'AutoRefreshPrpcClient');
+ });
+
+ it('sets a client ID', () => {
+ assert.isNotNull(mts.clientId);
+ });
+
+ it('disables sending after next flush', () => {
+ sinon.assert.calledOnce(mts.disableAfterNextFlush);
+ });
+ });
+
+ it('generateClientId', () => {
+ const clientID = MonorailTSMon.generateClientId();
+ assert.isNotNumber(clientID);
+ const clientIDNum = parseInt(clientID, 32);
+ assert.isNumber(clientIDNum);
+ assert.isAtLeast(clientIDNum, 0);
+ assert.isAtMost(clientIDNum, Math.pow(2, 32));
+ });
+
+ describe('recordUserTiming', () => {
+ it('records a timing metric only if matches', () => {
+ const metric = {add: sinon.spy()};
+ mts._userTimingMetrics = [{
+ category: 'rutabaga',
+ eventName: 'rutabaga-name',
+ eventLabel: 'rutabaga-label',
+ metric: metric,
+ }];
+
+ mts.recordUserTiming('kohlrabi', 'rutabaga-name', 'rutabaga-label', 1);
+ sinon.assert.notCalled(metric.add);
+ metric.add.resetHistory();
+
+ mts.recordUserTiming('rutabaga', 'is-a-tuber', 'rutabaga-label', 1);
+ sinon.assert.notCalled(metric.add);
+ metric.add.resetHistory();
+
+ mts.recordUserTiming('rutabaga', 'rutabaga-name', 'went bad', 1);
+ sinon.assert.notCalled(metric.add);
+ metric.add.resetHistory();
+
+ mts.recordUserTiming('rutabaga', 'rutabaga-name', 'rutabaga-label', 1);
+ sinon.assert.calledOnce(metric.add);
+ assert.equal(metric.add.args[0][0], 1);
+ const argsKeys = Array.from(metric.add.args[0][1].keys());
+ assert.deepEqual(argsKeys, ['client_id', 'host_name', 'document_visible']);
+ });
+ });
+
+ describe('recordPageLoadTiming', () => {
+ beforeEach(() => {
+ mts.pageLoadMetric = {add: sinon.spy()};
+ sinon.stub(MonorailTSMon, 'isPageVisible').callsFake(() => (true));
+ });
+
+ afterEach(() => {
+ MonorailTSMon.isPageVisible.restore();
+ });
+
+ it('records page load on issue entry page', () => {
+ mts.recordIssueEntryTiming();
+ sinon.assert.calledOnce(mts.pageLoadMetric.add);
+ assert.isNumber(mts.pageLoadMetric.add.getCall(0).args[0]);
+ assert.isString(mts.pageLoadMetric.add.getCall(0).args[1].get(
+ 'client_id'));
+ assert.equal(mts.pageLoadMetric.add.getCall(0).args[1].get(
+ 'host_name'), 'rutabaga-version');
+ assert.equal(mts.pageLoadMetric.add.getCall(0).args[1].get(
+ 'template_name'), 'issue_entry');
+ assert.equal(mts.pageLoadMetric.add.getCall(0).args[1].get(
+ 'document_visible'), true);
+ });
+
+ it('does not record page load timing on other pages', () => {
+ mts.recordPageLoadTiming();
+ sinon.assert.notCalled(mts.pageLoadMetric.add);
+ });
+
+ it('does not record page load timing if over max threshold', () => {
+ window.performance = {
+ timing: {
+ navigationStart: 1000,
+ domContentLoadedEventEnd: 2001,
+ },
+ };
+ mts.recordIssueEntryTiming(1000);
+ sinon.assert.notCalled(mts.pageLoadMetric.add);
+ });
+
+ it('records page load on issue entry page if under threshold', () => {
+ MonorailTSMon.isPageVisible.restore();
+ sinon.stub(MonorailTSMon, 'isPageVisible').callsFake(() => (false));
+ window.performance = {
+ timing: {
+ navigationStart: 1000,
+ domContentLoadedEventEnd: 1999,
+ },
+ };
+ mts.recordIssueEntryTiming(1000);
+ sinon.assert.calledOnce(mts.pageLoadMetric.add);
+ assert.isNumber(mts.pageLoadMetric.add.getCall(0).args[0]);
+ assert.equal(mts.pageLoadMetric.add.getCall(0).args[0], 999);
+ assert.isString(mts.pageLoadMetric.add.getCall(0).args[1].get(
+ 'client_id'));
+ assert.equal(mts.pageLoadMetric.add.getCall(0).args[1].get(
+ 'host_name'), 'rutabaga-version');
+ assert.equal(mts.pageLoadMetric.add.getCall(0).args[1].get(
+ 'template_name'), 'issue_entry');
+ assert.equal(mts.pageLoadMetric.add.getCall(0).args[1].get(
+ 'document_visible'), false);
+ });
+ });
+
+ describe('getGlobalClient', () => {
+ it('only creates one global client', () => {
+ delete window.__tsMonClient;
+ const client1 = MonorailTSMon.getGlobalClient();
+ assert.equal(client1, window.__tsMonClient);
+
+ const client2 = MonorailTSMon.getGlobalClient();
+ assert.equal(client2, window.__tsMonClient);
+ assert.equal(client2, client1);
+ });
+ });
+});
diff --git a/static_src/monitoring/track-copy.js b/static_src/monitoring/track-copy.js
new file mode 100644
index 0000000..7123965
--- /dev/null
+++ b/static_src/monitoring/track-copy.js
@@ -0,0 +1,28 @@
+/* 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.
+ */
+
+// This counts copy and paste events.
+
+function labelForElement(el) {
+ let label = el.localName;
+ if (el.id) {
+ label = label + '#' + el.id;
+ }
+ return label;
+}
+
+window.addEventListener('copy', function(evt) {
+ const label = labelForElement(evt.srcElement);
+ const len = window.getSelection().toString().length;
+ ga('send', 'event', window.location.pathname, 'copy', label, len);
+});
+
+window.addEventListener('paste', function(evt) {
+ const label = labelForElement(evt.srcElement);
+ const text = evt.clipboardData.getData('text/plain');
+ const len = text ? text.length : 0;
+ ga('send', 'event', window.location.pathname, 'paste', label, len);
+});