// Copyright 2022 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'chrome://resources/cr_elements/cr_tab_box/cr_tab_box.js'; import './field_trials.js'; import {assert} from 'chrome://resources/js/assert.js'; import {addWebUiListener} from 'chrome://resources/js/cr.js'; import {CustomElement} from 'chrome://resources/js/custom_element.js'; import {getTemplate} from './app.html.js'; import type {KeyValue, Log, LogData, MetricsInternalsBrowserProxy} from './browser_proxy.js'; import {MetricsInternalsBrowserProxyImpl} from './browser_proxy.js'; import {getEventsPeekString, logEventToString, sizeToString, timestampToString, umaLogTypeToString} from './log_utils.js'; /** * An empty log. It is appended to a logs table when there are no logs (for * purely aesthetic reasons). */ const EMPTY_LOG: Log = { type: 'N/A', hash: 'N/A', timestamp: '', size: -1, events: [], }; export class MetricsInternalsAppElement extends CustomElement { static get is(): string { return 'metrics-internals-app'; } static override get template() { return getTemplate(); } /** * Resolves once the component has finished loading. */ initPromise: Promise; private browserProxy_: MetricsInternalsBrowserProxy = MetricsInternalsBrowserProxyImpl.getInstance(); /** * Previous summary tables data. Used to prevent re-renderings of the tables * when the data has not changed. */ private previousVariationsSummaryData_: string = ''; private previousUmaSummaryData_: string = ''; constructor() { super(); this.initPromise = this.init_(); } /** * Returns UMA logs data (with their proto) as a JSON string. Used when * exporting UMA logs data. Returns a promise. */ getUmaLogsExportContent(): Promise { return this.browserProxy_.getUmaLogData(/*includeLogProtoData*/ true); } private async init_(): Promise { this.syncTabsWithUrlHash_(); // Fetch variations summary data and set up a recurring timer. await this.updateVariationsSummary_(); setInterval(() => this.updateVariationsSummary_(), 3000); // Fetch UMA summary data and set up a recurring timer. await this.updateUmaSummary_(); setInterval(() => this.updateUmaSummary_(), 3000); // Set up the UMA table caption. const umaTableCaption = this.getRequiredElement('#uma-table-caption'); const isUsingMetricsServiceObserver = await this.browserProxy_.isUsingMetricsServiceObserver(); let firstPartOfCaption = isUsingMetricsServiceObserver ? 'List of all UMA logs closed since browser startup.' : 'List of UMA logs closed since opening this page. Starting the browser \ with the --export-uma-logs-to-file command line flag will instead show \ all logs closed since browser startup.'; firstPartOfCaption += ' See '; const linkInCaptionNode = document.createElement('a'); linkInCaptionNode.appendChild(document.createTextNode('documentation')); linkInCaptionNode.href = 'https://chromium.googlesource.com/chromium/src/components/metrics/+/HEAD/debug/README.md'; // Don't clobber the current page. The current page (in release builds) // shows only the logs since the page was opened. We don't want to allow // the current page to be navigated away from lest useful logs be lost. linkInCaptionNode.target = '_blank'; const secondPartOfCaption = ' for more information about this debug page and tools for working \ with the exported logs.'; umaTableCaption.appendChild(document.createTextNode(firstPartOfCaption)); umaTableCaption.appendChild(linkInCaptionNode); umaTableCaption.appendChild(document.createTextNode(secondPartOfCaption)); // Set up a listener for UMA logs. Also update UMA log data immediately in // case there are logs that we already have data on. addWebUiListener( 'uma-log-created-or-event', () => this.updateUmaLogsData_()); await this.updateUmaLogsData_(); // Set up the UMA "Export logs" button. const exportUmaLogsButton = this.getRequiredElement('#export-uma-logs'); exportUmaLogsButton.addEventListener('click', () => this.exportUmaLogs_()); } /** * Synchronize the selected tab and the URL hash. Allows, for example, * chrome://metrics-internals#variations to directly open the variations tab. */ private syncTabsWithUrlHash_() { const tabUrlHashes: string[] = [ '#uma', '#variations', '#field-trials', ]; const tabBox = this.shadowRoot!.querySelector('cr-tab-box')!; tabBox.addEventListener( 'selected-index-change', (e: CustomEvent) => { window.location.hash = tabUrlHashes[e.detail] || ''; }); if (window.location.hash.startsWith('#')) { const entryIndex = tabUrlHashes.indexOf(window.location.hash); if (entryIndex >= 0) { tabBox.setAttribute('selected-index', String(entryIndex)); } } } /** * Callback function to expand/collapse an element on click. * @param e The click event. */ private toggleEventsExpand_(e: MouseEvent): void { let umaLogEventsDiv = e.target as HTMLElement; // It is possible we have clicked a descendant. Keep checking the parent // until we are the the root div of the events. while (!umaLogEventsDiv.classList.contains('uma-log-events')) { umaLogEventsDiv = umaLogEventsDiv.parentElement as HTMLElement; } umaLogEventsDiv.classList.toggle('uma-log-events-expanded'); } /** * Fills the passed table element with the given summary. */ private updateSummaryTable_(tableBody: HTMLElement, summary: KeyValue[]): void { // Clear the table first. tableBody.replaceChildren(); const template = this.getRequiredElement('#summary-row-template'); for (const info of summary) { const row = template.content.cloneNode(true) as HTMLElement; const [key, value] = row.querySelectorAll('td'); assert(key); key.textContent = info.key; assert(value); value.textContent = info.value; tableBody.appendChild(row); } } /** * Fetches variations summary data and updates the view. */ private async updateVariationsSummary_(): Promise { const summary: KeyValue[] = await this.browserProxy_.fetchVariationsSummary(); // Don't re-render the table if the data has not changed. const newDataString = summary.toString(); if (newDataString === this.previousVariationsSummaryData_) { return; } this.previousVariationsSummaryData_ = newDataString; const variationsSummaryTableBody = this.getRequiredElement('#variations-summary-body'); this.updateSummaryTable_(variationsSummaryTableBody, summary); } /** * Fetches UMA summary data and updates the view. */ private async updateUmaSummary_(): Promise { const summary: KeyValue[] = await this.browserProxy_.fetchUmaSummary(); const umaSummaryTableBody = this.$('#uma-summary-body') as HTMLElement; // Don't re-render the table if the data has not changed. const newDataString = summary.toString(); if (newDataString === this.previousUmaSummaryData_) { return; } this.previousUmaSummaryData_ = newDataString; this.updateSummaryTable_(umaSummaryTableBody, summary); } /** * Fills the passed table element with the given logs. */ private updateLogsTable_(tableBody: HTMLElement, logs: Log[]): void { // Clear the table first. tableBody.replaceChildren(); const template = this.getRequiredElement('#uma-log-row-template'); // Iterate through the logs in reverse order so that the most recent log // shows up first. for (const log of logs.slice(0).reverse()) { const row = template.content.cloneNode(true) as HTMLElement; const [type, hash, timestamp, size, events] = row.querySelectorAll('td'); assert(type); type.textContent = umaLogTypeToString(log.type); assert(hash); hash.textContent = log.hash; assert(timestamp); timestamp.textContent = timestampToString(log.timestamp); assert(size); size.textContent = sizeToString(log.size); assert(events); const eventsPeekDiv = events.querySelector('.uma-log-events-peek'); assert(eventsPeekDiv); eventsPeekDiv.addEventListener('click', this.toggleEventsExpand_, false); const eventsPeekText = events.querySelector('.uma-log-events-peek-text'); assert(eventsPeekText); eventsPeekText.textContent = getEventsPeekString(log.events); const eventsText = events.querySelector('.uma-log-events-text'); assert(eventsText); // Iterate through the events in reverse order so that the most recent // event shows up first. for (const event of log.events.slice(0).reverse()) { const div = document.createElement('div'); div.textContent = logEventToString(event); eventsText.appendChild(div); } tableBody.appendChild(row); } } /** * Fetches the latest UMA logs and renders them. This is called when the page * is loaded and whenever there is a log that created or changed. */ private async updateUmaLogsData_(): Promise { const logsData: string = await this.browserProxy_.getUmaLogData(/*includeLogProtoData=*/ false); const logs: LogData = JSON.parse(logsData); // If there are no logs, append an empty log. This is purely for aesthetic // reasons. Otherwise, the table may look confusing. if (!logs.logs.length) { logs.logs = [EMPTY_LOG]; } // We don't compare the new data with the old data to prevent re-renderings // because this should only be called when there is an actual change. const umaLogsTableBody = this.getRequiredElement('#uma-logs-body'); this.updateLogsTable_(umaLogsTableBody, logs.logs); } /** * Exports the accumulated UMA logs, including their proto data, as a JSON * file. This will initiate a download. */ private async exportUmaLogs_(): Promise { const logsData: string = await this.getUmaLogsExportContent(); const file = new Blob([logsData], {type: 'text/plain'}); const a = document.createElement('a'); a.href = URL.createObjectURL(file); a.download = `uma_logs_${new Date().getTime()}.json`; a.click(); } } declare global { interface HTMLElementTagNameMap { 'metrics-internals-app': MetricsInternalsAppElement; } } customElements.define( MetricsInternalsAppElement.is, MetricsInternalsAppElement);