chromium/components/ukm/debug/ukm_internals.ts

// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// <if expr="is_ios">
import 'chrome://resources/js/ios/web_ui.js';

// </if>

import {assert} from 'chrome://resources/js/assert.js';
import {sendWithPromise} from 'chrome://resources/js/cr.js';
import {getRequiredElement} from 'chrome://resources/js/util.js';

interface Metric {
  name: string;
  value: [number, number];
}

interface UkmEvent {
  name: string;
  metrics: Metric[];
}

interface UkmSource {
  id: [number, number];
  type: string;
  events: UkmEvent[];
  url?: string;
}

/**
 * UKM data in the browser's local memory from UkmDebugDataExtractor.
 */
interface UkmSession {
  state: boolean;
  msbb_state: boolean;
  extension_state: boolean;
  app_state: boolean;
  client_id: number[];
  session_id: string;
  sources: UkmSource[];
  is_sampling_enabled: boolean;
}

/**
 * Stores source ID and number of events shown. If there is a new source ID
 * or there are new events in UKM recorder, then all the events for
 * the new source ID will be displayed.
 */
const clearedSources: Map<string, number> = new Map();

/**
 * Cached sources to persist beyond the log cut. This will ensure that the data
 * on the page don't disappear if there is a log cut. The caching will
 * start when the page is loaded and when the data is refreshed.
 * Stored data is sourceid -> UkmSource with array of distinct entries.
 */
const cachedSources: Map<string, UkmSource> = new Map();

/**
 * Text for empty URL.
 */
const URL_EMPTY: string = '(missing)';

const EXPAND_ALL_BUTTON_TEXT: string = 'Expand All';
const COLLAPSE_ALL_BUTTON_TEXT: string = 'Collapse All';

/**
 * Converts a pair of JS 32 bin number to 64 bit hex string. This is used to
 * pass 64 bit numbers from UKM like client id and 64 bit metrics to
 * the javascript.
 * @param num A pair of javascript signed int.
 * @return unsigned int64 as hex number, or a decimal number if the
 *     value is smaller than 32bit.
 */
function as64Bit(num: [number, number]): string {
  if (num.length !== 2) {
    return '0';
  }
  if (!num[0]) {
    return num[1].toString();  // Return the lsb as String.
  } else {
    const hi = (num[0] >>> 0).toString(16).padStart(8, '0');
    const lo = (num[1] >>> 0).toString(16).padStart(8, '0');
    return `0x${hi}${lo}`;
  }
}

/**
 * Sets the display option of all the elements in HtmlCollection to the value
 * passed.
 */
function setDisplayStyle(
    elements: NodeListOf<HTMLElement>, displayValue: string) {
  for (const el of elements) {
    el.style.display = displayValue;
  }
}

/**
 * Removes all the child elements.
 * @param parent Parent element whose children will be removed.
 */
function removeChildren(parent: Element) {
  while (parent.firstChild) {
    parent.removeChild(parent.firstChild);
  }
}

/**
 * Creates rows in the table body to represent Sources having the same URL
 * value.
 * @param sourcesForUrl Sources having the same URL.
 * @param sourcesTable The <tbody> element representing all Sources to which
 *     Source rows will be added.
 * @param displayStates Map from source ID to value of display property of the
 *     event-metrics tables.
 */
function createSourceRowsForTheSameUrl(
    sourcesForUrl: UkmSource[], sourcesTable: Element,
    displayStates: Map<string, string>) {
  if (!sourcesForUrl || sourcesForUrl.length === 0) {
    return;
  }
  for (const source of sourcesForUrl) {
    const sourceHtmlRow = document.createElement('tr');
    sourceHtmlRow.classList.add('source_container');

    sourcesTable.appendChild(sourceHtmlRow);
    const urlElement = populateSourceHtmlRow(source, sourceHtmlRow);
    const displayState = displayStates.get(as64Bit(source.id));
    createEventMetricTablesForSource(source, urlElement, displayState);
  }
  // Add a thin horizontal line at the bottom, which visually separates this
  // group of Sources with the same URL value from the next group.
  sourcesTable.lastElementChild!.classList.add('thin-border-bottom');
}


/**
 * Populates a table row with the given Source data.
 * @param sourceData data pertaining to one Source.
 * @param sourceHtmlRow The HTML element whose content will be populated.
 * @return The HTML Element representing the URL value to which event-metrics
 *     tables can be appended.
 */
function populateSourceHtmlRow(
    sourceData: UkmSource, sourceHtmlRow: Element): Element {
  const urlElement = document.createElement('td');
  urlElement.classList.add('url');
  urlElement.innerText = sourceData.url || URL_EMPTY;
  const idElement = document.createElement('td');
  idElement.classList.add('sourceid');
  idElement.innerText = as64Bit(sourceData.id);
  const typeElement = document.createElement('td');
  typeElement.classList.add('sourcetype');
  typeElement.innerText = sourceData.type;

  sourceHtmlRow.appendChild(urlElement);
  sourceHtmlRow.appendChild(idElement);
  sourceHtmlRow.appendChild(typeElement);

  // Clicking on the URL of this Source toggles the display state of its
  // event-metrics tables.
  urlElement.addEventListener('click', () => {
    const eventsTables = urlElement.lastChild as HTMLElement;
    eventsTables.style.display =
        eventsTables.style.display === 'block' ? 'none' : 'block';
  });

  return urlElement;
}


/**
 * Adds event-metrics tables of a Source. Clicking on the URL of the Source
 * toggles their display on or off.
 * @param sourceData Data for one Source.
 * @param urlElement The HTML element showing the URL of the source, to which
 *     the event-metrics tables will be added.
 * @param displayState Display style of the event-metrics table for this Source.
 */
function createEventMetricTablesForSource(
    sourceData: UkmSource, urlElement: Element,
    displayState: string|undefined) {
  const eventMetricsElement = document.createElement('div');
  eventMetricsElement.classList.add('events');
  urlElement.appendChild(eventMetricsElement);

  // Apply the display state if any, base on whether the user has clicked this
  // Source row.
  if (displayState) {
    eventMetricsElement.style.display = displayState;
  } else {
    // Apply the display state base on the "Expand/Collapse All" button state.
    const expandedAll = getRequiredElement('toggle_expand').textContent ===
        COLLAPSE_ALL_BUTTON_TEXT;
    eventMetricsElement.style.display = expandedAll ? 'block' : 'none';
  }

  if (sourceData.events.length === 0) {
    eventMetricsElement.textContent = '(no events)';
    return;
  }

  const sortedEvents =
      sourceData.events.sort((e1, e2) => e1.name.localeCompare(e2.name));

  for (const event of sortedEvents) {
    createEventMetricsTable(event, eventMetricsElement);
  }
}


/**
 * Creates a table representing metrics associated to one UKM Event.
 * @param event A UKM Event.
 * @param urlElement The HTML element showing the URL of the source, to which
 *     the event-metrics table will be appended.
 */
function createEventMetricsTable(event: UkmEvent, urlElement: Element) {
  // Add first column to the table.
  const eventTable = document.createElement('table');
  eventTable.classList.add('event-table');
  eventTable.setAttribute('value', event.name);
  urlElement.appendChild(eventTable);

  const firstRow = document.createElement('tr');
  eventTable.appendChild(firstRow);
  const eventName = document.createElement('td');
  eventName.classList.add('event-name');
  eventName.setAttribute('rowspan', '0');
  eventName.textContent = event.name;
  firstRow.appendChild(eventName);

  // Sort the metrics by name, descending.
  const sortedMetrics =
      event.metrics.sort((m1, m2) => m1.name.localeCompare(m2.name));

  // Add metrics rows.
  for (const metric of sortedMetrics) {
    const nextRow = document.createElement('tr');
    const metricName = document.createElement('td');
    metricName.classList.add('metric-name');
    metricName.textContent = metric.name;
    nextRow.appendChild(metricName);

    const metricValue = document.createElement('td');
    metricValue.classList.add('metric-value');
    metricValue.textContent = as64Bit(metric.value);
    nextRow.appendChild(metricValue);

    eventTable.appendChild(nextRow);
  }
}

/**
 * Collect all sources for a particular URL together. It will also sort the
 * URLs alphabetically.
 * If the URL field is missing, the source ID will be used as the
 * URL for the purpose of grouping and sorting.
 * @param sources List of UKM data for a source .
 * @return Mapping in the sorted order of URL from URL to list of sources for
 *     the URL.
 */
function urlToSourcesMapping(sources: UkmSource[]): Map<string, UkmSource[]> {
  const unsorted = new Map();
  for (const source of sources) {
    const key = source.url ? source.url : as64Bit(source.id);
    if (!unsorted.has(key)) {
      unsorted.set(key, [source]);
    } else {
      unsorted.get(key).push(source);
    }
  }
  // Sort the map by URLs.
  return new Map(
      Array.from(unsorted).sort((s1, s2) => s1[0].localeCompare(s2[0])));
}


/**
 * Updates the button text for expanding or collapsing all Source rows.
 */
function addExpandAllToggleButton() {
  const toggleExpand = getRequiredElement('toggle_expand');
  toggleExpand.textContent = EXPAND_ALL_BUTTON_TEXT;
  toggleExpand.addEventListener('click', () => {
    if (toggleExpand.textContent === EXPAND_ALL_BUTTON_TEXT) {
      toggleExpand.textContent = COLLAPSE_ALL_BUTTON_TEXT;
      setDisplayStyle(
          document.body.querySelectorAll<HTMLElement>('.events'), 'block');
    } else {
      toggleExpand.textContent = EXPAND_ALL_BUTTON_TEXT;
      setDisplayStyle(
          document.body.querySelectorAll<HTMLElement>('.events'), 'none');
    }
  });
}

/**
 * Updates the button to clear all the existing URLs. Note that the hiding is
 * done in the UI only. So refreshing the page will show all the UKM again.
 * To get the new UKMs after hitting Clear click the refresh button.
 */
function addClearButton() {
  const clearButton = getRequiredElement('clear');
  clearButton.addEventListener('click', () => {
    // Note it won't be able to clear if UKM logs got cut during this call.
    sendWithPromise('requestUkmData').then((/** @type {UkmSession} */ data) => {
      updateUkmCache(data);
      for (const source of cachedSources.values()) {
        clearedSources.set(as64Bit(source.id), source.events.length);
      }
    });
    getRequiredElement('toggle_expand').textContent = EXPAND_ALL_BUTTON_TEXT;
    updateUkmData();
  });
}

/**
 * Populate thread ids from the high bit of Source ID in |sources|.
 * @param sources Array of Sources.
 */
function populateThreadIds(sources: UkmSource[]) {
  const threadIdSelect =
      document.body.querySelector<HTMLSelectElement>('#thread_ids');
  assert(threadIdSelect);
  const currentOptions =
      new Set(Array.from(threadIdSelect.options).map(o => o.value));
  // The first 32 bit of the ID is the recorder ID, convert it to a positive
  // bit patterns and then to hex. Ids that were not seen earlier will get
  // added to the end of the option list.
  const newIds = new Set(sources.map(e => (e.id[0] >>> 0).toString(16)));
  const options = ['All', ...Array.from(newIds).sort()];

  for (const id of options) {
    if (!currentOptions.has(id)) {
      const option = document.createElement('option');
      option.textContent = id;
      option.setAttribute('value', id);
      threadIdSelect.add(option);
    }
  }
}

/**
 * Get the string representation of a UKM event. The array of metrics are sorted
 * by name to ensure that two events containing the same metrics and values in
 * different orders have identical string representation to avoid cache
 * duplication.
 * @param event UKM event to be stringified.
 * @return Normalized string representation of the event.
 */
function normalizeToString(event: UkmEvent): string {
  event.metrics.sort((m1, m2) => m1.name.localeCompare(m2.name));
  return JSON.stringify(event);
}

/**
 * This function tries to preserve UKM logs around UKM log uploads. There is
 * no way of knowing if duplicate events for a log are actually produced
 * again after the log cut or if they older records since we don't maintain
 * timestamp with events. So only distinct events will be recorded in the
 * cache. i.e. if two events have exactly the same set of metrics then one
 * of the event will not be kept in the cache.
 * @param data New UKM data to add to cache.
 */
function updateUkmCache(data: UkmSession) {
  for (const source of data.sources) {
    const key = as64Bit(source.id);
    if (!cachedSources.has(key)) {
      const mergedSource:
          UkmSource = {id: source.id, type: source.type, events: source.events};
      if (source.url) {
        mergedSource.url = source.url;
      }
      cachedSources.set(key, mergedSource);
    } else {
      // Merge distinct events from the source.
      const existingEvents = new Set(cachedSources.get(key)!.events.map(
          event => normalizeToString(event)));
      for (const event of source.events) {
        if (!existingEvents.has(normalizeToString(event))) {
          cachedSources.get(key)!.events.push(event);
        }
      }
    }
  }
}

/**
 * Fetches data from the UKM service and updates the DOM to display it as a
 * table.
 */
function updateUkmData() {
  sendWithPromise('requestUkmData').then((/** @type {UkmSession} */ data) => {
    updateUkmCache(data);
    if (document.body.querySelector<HTMLInputElement>(
                         '#include_cache')!.checked) {
      data.sources = [...cachedSources.values()];
    }
    getRequiredElement('state').innerText = data.state ? 'ENABLED' : 'DISABLED';
    getRequiredElement('msbb_state').innerText =
        data.msbb_state ? 'ENABLED' : 'DISABLED';
    getRequiredElement('extension_state').innerText =
        data.extension_state ? 'ENABLED' : 'DISABLED';
    getRequiredElement('app_state').innerText =
        data.app_state ? 'ENABLED' : 'DISABLED';
    getRequiredElement('clientid').innerText = '0x' + data.client_id;
    getRequiredElement('sessionid').innerText = data.session_id;
    getRequiredElement('is_sampling_enabled').innerText =
        data.is_sampling_enabled;

    const sourcesTable = getRequiredElement('sources');
    removeChildren(sourcesTable);

    // Setup the Source table header.
    const tableHead = document.createElement('thead');
    const headerRow = document.createElement('tr');

    const urlTitleElement = document.createElement('td');
    urlTitleElement.classList.add('url');
    urlTitleElement.textContent = 'URL';

    const sourceIdTitleElement = document.createElement('td');
    sourceIdTitleElement.classList.add('sourceid');
    sourceIdTitleElement.textContent = 'Source ID';

    const sourceTypeTitleElement = document.createElement('td');
    sourceTypeTitleElement.classList.add('sourcetype');
    sourceTypeTitleElement.textContent = 'Source Type';

    headerRow.appendChild(urlTitleElement);
    headerRow.appendChild(sourceIdTitleElement);
    headerRow.appendChild(sourceTypeTitleElement);
    tableHead.appendChild(headerRow);
    sourcesTable.appendChild(tableHead);

    const tableBody = document.createElement('tbody');
    tableBody.classList.add('url_card');
    sourcesTable.appendChild(tableBody);

    // Setup the display state map, which captures the current display settings,
    // for example, expanded state.
    const currentDisplayStates = new Map();
    for (const el of document.getElementsByClassName('source_container')) {
      currentDisplayStates.set(
          el.querySelector<HTMLElement>('.sourceid')!.textContent,
          el.querySelector<HTMLElement>('.events')!.style.display);
    }
    const urlToSources =
        urlToSourcesMapping(filterSourcesUsingFormOptions(data.sources));
    for (const url of urlToSources.keys()) {
      const sourcesForUrl = urlToSources.get(url)!;
      createSourceRowsForTheSameUrl(
          sourcesForUrl, tableBody, currentDisplayStates);
    }
    populateThreadIds(data.sources);
  });
}

/**
 * Filters sources that have been recorded previously. If it sees a source ID
 * where number of events has decreased then it will add a warning.
 * @param sources All the sources currently in the UKM recorder.
 * @return Sources which are new or have a new event logged for them.
 */
function filterSourcesUsingFormOptions(sources: UkmSource[]): UkmSource[] {
  // Filter sources based on if they have been cleared.
  const newSources = sources.filter(
      source => (
          // Keep a Source if it is newly created since clearing earlier.
          !clearedSources.has(as64Bit(source.id)) ||
          // Keep a Source if it contains more events since clearing earlier.
          (source.events.length > clearedSources.get(as64Bit(source.id))!)));

  // Apply the event name filtering.
  const newSourcesWithEventsCleared = newSources.map(source => {
    const eventNameFilterValue =
        document.body.querySelector<HTMLInputElement>('#events_select')!.value;
    if (eventNameFilterValue) {
      const filterRe = new RegExp(eventNameFilterValue);
      source.events = source.events.filter(event => filterRe.test(event.name));
    }
    return source;
  });

  // Filter sources based on the status of check-boxes.
  const filteredSources = newSourcesWithEventsCleared.filter(source => {
    const noUrlCheckbox =
        document.body.querySelector<HTMLInputElement>('#hide_no_url');
    assert(noUrlCheckbox);
    const noMetricsCheckbox =
        document.body.querySelector<HTMLInputElement>('#hide_no_events');
    assert(noMetricsCheckbox);

    return (!noUrlCheckbox.checked || source.url) &&
        (!noMetricsCheckbox.checked || source.events.length);
  });

  const threadIds =
      document.body.querySelector<HTMLSelectElement>('#thread_ids');
  assert(threadIds);

  // Filter sources based on thread id (High bits of UKM Recorder ID).
  const threadsFilteredSource = filteredSources.filter(source => {
    // Get current selection for thread id. It is either -
    // "All" for no restriction.
    // "0" for the default thread. This is the thread that record f.e PageLoad
    // <lowercase hex string for first 32 bit of source id> for other threads.
    //     If a UKM is recorded with a custom source id or in renderer, it will
    //     have a unique value for this shared by all metrics that use the
    //     same thread.
    const selectedOption = threadIds.options[threadIds.selectedIndex];
    // Return true if either of the following is true -
    // No option is selected or selected option is "All" or the hexadecimal
    // representation of source id is matching.
    return !selectedOption || (selectedOption.value === 'All') ||
        ((source.id[0] >>> 0).toString(16) === selectedOption.value);
  });

  // Filter URLs based on URL selector input.
  const urlSelect =
      document.body.querySelector<HTMLInputElement>('#url_select');
  assert(urlSelect);
  return threadsFilteredSource.filter(source => {
    const urlFilterValue = urlSelect.value;
    if (urlFilterValue) {
      const urlRe = new RegExp(urlFilterValue);
      // Will also match missing URLs by default.
      return !source.url || urlRe.test(source.url);
    }
    return true;
  });
}

/**
 * DomContentLoaded handler.
 */
function onLoad() {
  addExpandAllToggleButton();
  addClearButton();
  updateUkmData();
  getRequiredElement('refresh').addEventListener('click', updateUkmData);
  getRequiredElement('hide_no_events').addEventListener('click', updateUkmData);
  getRequiredElement('hide_no_url').addEventListener('click', updateUkmData);
  getRequiredElement('thread_ids').addEventListener('click', updateUkmData);
  getRequiredElement('include_cache').addEventListener('click', updateUkmData);
  getRequiredElement('events_select').addEventListener('keyup', e => {
    if (e.key === 'Enter') {
      updateUkmData();
    }
  });
  getRequiredElement('url_select').addEventListener('keyup', e => {
    if (e.key === 'Enter') {
      updateUkmData();
    }
  });
}

document.addEventListener('DOMContentLoaded', onLoad);

setInterval(updateUkmData, 2 * 60 * 1000);  // Refresh every 2 minutes.