// 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.