blob: 5bec3e1aafc13a1562766aee0ae6655ec8038583 [file] [log] [blame]
// 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.
/**
* @typedef {{
* name: string,
* value: !Array<number>
* }}
*/
let Metric;
/**
* @typedef {{
* name: string,
* metrics: !Array<!Metric>
* }}
*/
let UkmEntry;
/**
* @typedef {{
* url: string,
* id: !Array<number>,
* entries: !Array<UkmEntry>,
* }}
*/
let UkmDataSource;
/**
* The Ukm data sent from the browser.
* @typedef {{
* state: boolean,
* client_id: !Array<number>,
* session_id: string,
* sources: !Array<!UkmDataSource>,
* is_sampling_enabled: boolean,
* }}
*/
let UkmData;
/**
* Stores source id and number of entries shown. If there is a new source id
* or there are new entries in Ukm recorder, then all the entries for
* the new source ID will be displayed.
* @type{Map<string, number>}
*/
const ClearedSources = 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 -> UkmDataSource with array of distinct entries.
* @type{Map<string, !UkmDataSource>}
*/
const CachedSources = new Map();
/**
* Text for empty url.
* @type {string}
*/
const URL_EMPTY = 'missing';
/**
* 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 {!Array<number>} num A pair of javascript signed int.
* @return {string} unsigned int64 as hex number or a decimal number if the
* value is smaller than 32bit.
*/
function as64Bit(num) {
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.
* @param {!NodeList<!Element>} collection Collection of Elements.
*/
function setDisplayStyle(collection, display_value) {
for (const el of collection) {
el.style.display = display_value;
}
}
/**
* Remove all the child elements.
* @param {!Element} parent Parent element whose children will get removed.
*/
function removeChildren(parent) {
while (parent.firstChild) {
parent.removeChild(parent.firstChild);
}
}
/**
* Create card for URL.
* @param {!Array<!UkmDataSource>} sourcesForUrl Sources that are for same URL.
* @param {string} url URL or Source id as hex string if the URL is missing.
* @param {!Element} sourcesDiv Sources div where this card will be added to.
* @param {!Map<string, ?string>} displayState Map from source id to value
* of display property of the entries div.
*/
function createUrlCard(sourcesForUrl, url, sourcesDiv, displayState) {
const sourceDiv = createElementWithClassName('div', 'url_card');
sourcesDiv.appendChild(sourceDiv);
if (!sourcesForUrl || sourcesForUrl.length === 0) {
return;
}
for (const source of sourcesForUrl) {
// This div allows hiding of the metrics per URL.
const sourceContainer = /** @type {!Element} */ (createElementWithClassName(
'div', 'source_container'));
sourceDiv.appendChild(sourceContainer);
createUrlHeader(source.url, source.id, sourceContainer);
createSourceCard(
source, sourceContainer, displayState.get(as64Bit(source.id)));
}
}
/**
* Create header containing URL and source ID data.
* @param {?string} url URL.
* @param {!Array<number>} id SourceId as hex.
* @param {!Element} sourceDiv Div under which header will get added.
*/
function createUrlHeader(url, id, sourceDiv) {
const headerElement = createElementWithClassName('div', 'collapsible_header');
sourceDiv.appendChild(headerElement);
const urlElement = createElementWithClassName('span', 'url');
urlElement.innerText = url ? url : URL_EMPTY;
headerElement.appendChild(urlElement);
const idElement = createElementWithClassName('span', 'sourceid');
idElement.innerText = as64Bit(id);
headerElement.appendChild(idElement);
// Make the click on header toggle entries div.
headerElement.addEventListener('click', () => {
const content = headerElement.nextElementSibling;
if (content.style.display === 'block') {
content.style.display = 'none';
} else {
content.style.display = 'block';
}
});
}
/**
* Create a card with UKM Source data.
* @param {!UkmDataSource} source UKM source data.
* @param {!Element} sourceDiv Source div where this card will be added to.
* @param {?string} displayState If display style of this source id is modified
* then the state of the display style.
*/
function createSourceCard(source, sourceDiv, displayState) {
const metricElement =
/** @type {!Element} */ (createElementWithClassName('div', 'entries'));
sourceDiv.appendChild(metricElement);
const sortedEntry =
source.entries.sort((x, y) => x.name.localeCompare(y.name));
for (const entry of sortedEntry) {
createEntryTable(entry, metricElement);
}
if (displayState) {
metricElement.style.display = displayState;
} else {
if ($('toggle_expand').textContent === 'Collapse') {
metricElement.style.display = 'block';
} else {
metricElement.style.display = 'none';
}
}
}
/**
* Create UKM Entry Table.
* @param {!UkmEntry} entry A Ukm metrics Entry.
* @param {!Element} sourceDiv Element whose children will be the entries.
*/
function createEntryTable(entry, sourceDiv) {
// Add first column to the table.
const entryTable = createElementWithClassName('table', 'entry_table');
entryTable.setAttribute('value', entry.name);
sourceDiv.appendChild(entryTable);
const firstRow = document.createElement('tr');
entryTable.appendChild(firstRow);
const entryName = createElementWithClassName('td', 'entry_name');
entryName.setAttribute('rowspan', 0);
entryName.textContent = entry.name;
firstRow.appendChild(entryName);
// Sort the metrics by name, descending.
const sortedMetrics =
entry.metrics.sort((x, y) => x.name.localeCompare(y.name));
// Add metrics columns.
for (const metric of sortedMetrics) {
const nextRow = document.createElement('tr');
const metricName = createElementWithClassName('td', 'metric_name');
metricName.textContent = metric.name;
nextRow.appendChild(metricName);
const metricValue = createElementWithClassName('td', 'metric_value');
metricValue.textContent = as64Bit(metric.value);
nextRow.appendChild(metricValue);
entryTable.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 {!Array<!UkmDataSource>} sources List of UKM data for a source .
* @return {!Map<string, !Array<!UkmDataSource>>} Mapping in the sorted
* order of URL from URL to list of sources for the URL.
*/
function urlToSourcesMapping(sources) {
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])));
}
/**
* Adds a button to Expand/Collapse all URLs.
*/
function addExpandToggleButton() {
const toggleExpand = $('toggle_expand');
toggleExpand.textContent = 'Expand';
toggleExpand.addEventListener('click', () => {
if (toggleExpand.textContent == 'Expand') {
toggleExpand.textContent = 'Collapse';
setDisplayStyle(document.getElementsByClassName('entries'), 'block');
} else {
toggleExpand.textContent = 'Expand';
setDisplayStyle(document.getElementsByClassName('entries'), 'none');
}
});
}
/**
* Adds a 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 = $('clear');
clearButton.addEventListener('click', () => {
// Note it won't be able to clear if UKM logs got cut during this call.
cr.sendWithPromise('requestUkmData').then((/** @type {UkmData} */ data) => {
updateUkmCache(data);
for (const s of CachedSources.values()) {
ClearedSources.set(as64Bit(s.id), s.entries.length);
}
});
$('toggle_expand').textContent = 'Expand';
updateUkmData();
});
}
/**
* Populate thread ids from the high bit of source id in sources.
* @param {!Array<!UkmDataSource>} sources Array of UKM source.
*/
function populateThreadIds(sources) {
const threadIdSelect = $('thread_ids');
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 entry. The array of metrics are sorted
* by name to ensure that two entries containing the same metrics and values in
* different orders have identical string representation to avoid cache
* duplication.
* @param {UkmEntry} entry UKM entry to be stringified.
* @return {string} Normalized string representation of the entry.
*/
function normalizeToString(entry) {
entry.metrics.sort((x, y) => x.name.localeCompare(y.name));
return JSON.stringify(entry);
}
/**
* This function tries to preserve UKM logs around UKM log uploads. There is
* no way of knowing if duplicate entries for a log are actually produced
* again after the log cut or if they older records since we don't maintain
* timestamp with entries. So only distinct entries will be recorded in the
* cache. i.e if two entries have exactly the same set of metrics then one
* of the entry will not be kept in the cache.
* @param {UkmData} data New UKM data to add to cache.
*/
function updateUkmCache(data) {
for (const source of data.sources) {
const key = as64Bit(source.id);
if (!CachedSources.has(key)) {
const mergedSource = {id: source.id, entries: source.entries};
if (source.url) {
mergedSource.url = source.url;
}
CachedSources.set(key, mergedSource);
} else {
// Merge distinct entries from the source.
const existingEntries = new Set(CachedSources.get(key).entries.map(
cachedEntry => normalizeToString(cachedEntry)));
for (const sourceEntry of source.entries) {
if (!existingEntries.has(normalizeToString(sourceEntry))) {
CachedSources.get(key).entries.push(sourceEntry);
}
}
}
}
}
/**
* Fetches data from the Ukm service and updates the DOM to display it as a
* list.
*/
function updateUkmData() {
cr.sendWithPromise('requestUkmData').then((/** @type {UkmData} */ data) => {
updateUkmCache(data);
if ($('include_cache').checked) {
data.sources = [...CachedSources.values()];
}
$('state').innerText = data.state? 'ENABLED' : 'DISABLED';
$('clientid').innerText = '0x' + data.client_id;
$('sessionid').innerText = data.session_id;
$('is_sampling_enabled').innerText = data.is_sampling_enabled;
const sourcesDiv = /** @type {!Element} */ ($('sources'));
removeChildren(sourcesDiv);
// Setup a title for the sources div.
const urlTitleElement = createElementWithClassName('span', 'url');
urlTitleElement.textContent = 'URL';
const sourceIdTitleElement = createElementWithClassName('span', 'sourceid');
sourceIdTitleElement.textContent = 'Source ID';
sourcesDiv.appendChild(urlTitleElement);
sourcesDiv.appendChild(sourceIdTitleElement);
// Setup the display state map, which captures the current display settings,
// for example, expanded state.
const currentDisplayState = new Map();
for (const el of document.getElementsByClassName('source_container')) {
currentDisplayState.set(el.querySelector('.sourceid').textContent,
el.querySelector('.entries').style.display);
}
const urlToSources = urlToSourcesMapping(
filterSourcesUsingFormOptions(data.sources));
for (const url of urlToSources.keys()) {
const sourcesForUrl = urlToSources.get(url);
createUrlCard(sourcesForUrl, url, sourcesDiv, currentDisplayState);
}
populateThreadIds(data.sources);
});
}
/**
* Filter sources that have been recorded previously. If it sees a source id
* where number of entries has decreased then it will add a warning.
* @param {!Array<!UkmDataSource>} sources All the sources currently in
* UKM recorder.
* @return {!Array<!UkmDataSource>} Sources which are new or have a new entry
* logged for them.
*/
function filterSourcesUsingFormOptions(sources) {
// Filter sources based on if they have been cleared.
const newSources = sources.filter(source => (
// Keep sources if it is newly generated since clearing earlier.
!ClearedSources.has(as64Bit(source.id)) ||
// Keep sources if it has increased entities since clearing earlier.
(source.entries.length > ClearedSources.get(as64Bit(source.id)))
));
// Applies the filter from Metrics selector.
const newSourcesWithEntriesCleared = newSources.map(source => {
const metricsFilterValue = $('metrics_select').value;
if (metricsFilterValue) {
const metricsRe = new RegExp(metricsFilterValue);
source.entries = source.entries.filter(e => metricsRe.test(e.name));
}
return source;
});
// Filter sources based on the status of check-boxes.
const filteredSources = newSourcesWithEntriesCleared.filter(source => (
(!$('hide_no_url').checked || source.url) &&
(!$('hide_no_metrics').checked || source.entries.length)
));
// 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 =
$('thread_ids').options[$('thread_ids').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.
return threadsFilteredSource.filter(source => {
const urlFilterValue = $('url_select').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() {
addExpandToggleButton();
addClearButton();
updateUkmData();
$('refresh').addEventListener('click', updateUkmData);
$('hide_no_metrics').addEventListener('click', updateUkmData);
$('hide_no_url').addEventListener('click', updateUkmData);
$('thread_ids').addEventListener('click', updateUkmData);
$('include_cache').addEventListener('click', updateUkmData);
$('metrics_select').addEventListener('keyup', e => {
if (e.key === 'Enter') {
updateUkmData();
}
});
$('url_select').addEventListener('keyup', e => {
if (e.key === 'Enter') {
updateUkmData();
}
});
}
document.addEventListener('DOMContentLoaded', onLoad);
setInterval(updateUkmData, 120000); // Refresh every 2 minutes.