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);
+});