1// Copyright 2022 The Chromium Authors 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5import 'chrome://resources/cr_elements/cr_tab_box/cr_tab_box.js'; 6import './field_trials.js'; 7 8import {assert} from 'chrome://resources/js/assert.js'; 9import {addWebUiListener} from 'chrome://resources/js/cr.js'; 10import {CustomElement} from 'chrome://resources/js/custom_element.js'; 11 12import {getTemplate} from './app.html.js'; 13import type {KeyValue, Log, LogData, MetricsInternalsBrowserProxy} from './browser_proxy.js'; 14import {MetricsInternalsBrowserProxyImpl} from './browser_proxy.js'; 15import {getEventsPeekString, logEventToString, sizeToString, timestampToString, umaLogTypeToString} from './log_utils.js'; 16 17/** 18 * An empty log. It is appended to a logs table when there are no logs (for 19 * purely aesthetic reasons). 20 */ 21const EMPTY_LOG: Log = { 22 type: 'N/A', 23 hash: 'N/A', 24 timestamp: '', 25 size: -1, 26 events: [], 27}; 28 29export class MetricsInternalsAppElement extends CustomElement { 30 static get is(): string { 31 return 'metrics-internals-app'; 32 } 33 34 static override get template() { 35 return getTemplate(); 36 } 37 38 /** 39 * Resolves once the component has finished loading. 40 */ 41 initPromise: Promise<void>; 42 43 private browserProxy_: MetricsInternalsBrowserProxy = 44 MetricsInternalsBrowserProxyImpl.getInstance(); 45 46 /** 47 * Previous summary tables data. Used to prevent re-renderings of the tables 48 * when the data has not changed. 49 */ 50 private previousVariationsSummaryData_: string = ''; 51 private previousUmaSummaryData_: string = ''; 52 53 constructor() { 54 super(); 55 this.initPromise = this.init_(); 56 } 57 58 /** 59 * Returns UMA logs data (with their proto) as a JSON string. Used when 60 * exporting UMA logs data. Returns a promise. 61 */ 62 getUmaLogsExportContent(): Promise<string> { 63 return this.browserProxy_.getUmaLogData(/*includeLogProtoData*/ true); 64 } 65 66 private async init_(): Promise<void> { 67 this.syncTabsWithUrlHash_(); 68 69 // Fetch variations summary data and set up a recurring timer. 70 await this.updateVariationsSummary_(); 71 setInterval(() => this.updateVariationsSummary_(), 3000); 72 73 // Fetch UMA summary data and set up a recurring timer. 74 await this.updateUmaSummary_(); 75 setInterval(() => this.updateUmaSummary_(), 3000); 76 77 // Set up the UMA table caption. 78 const umaTableCaption = this.getRequiredElement('#uma-table-caption'); 79 const isUsingMetricsServiceObserver = 80 await this.browserProxy_.isUsingMetricsServiceObserver(); 81 let firstPartOfCaption = isUsingMetricsServiceObserver ? 82 'List of all UMA logs closed since browser startup.' : 83 'List of UMA logs closed since opening this page. Starting the browser \ 84 with the --export-uma-logs-to-file command line flag will instead show \ 85 all logs closed since browser startup.'; 86 firstPartOfCaption += ' See '; 87 const linkInCaptionNode = document.createElement('a'); 88 linkInCaptionNode.appendChild(document.createTextNode('documentation')); 89 linkInCaptionNode.href = 90 'https://chromium.googlesource.com/chromium/src/components/metrics/+/HEAD/debug/README.md'; 91 // Don't clobber the current page. The current page (in release builds) 92 // shows only the logs since the page was opened. We don't want to allow 93 // the current page to be navigated away from lest useful logs be lost. 94 linkInCaptionNode.target = '_blank'; 95 const secondPartOfCaption = 96 ' for more information about this debug page and tools for working \ 97 with the exported logs.'; 98 umaTableCaption.appendChild(document.createTextNode(firstPartOfCaption)); 99 umaTableCaption.appendChild(linkInCaptionNode); 100 umaTableCaption.appendChild(document.createTextNode(secondPartOfCaption)); 101 102 103 // Set up a listener for UMA logs. Also update UMA log data immediately in 104 // case there are logs that we already have data on. 105 addWebUiListener( 106 'uma-log-created-or-event', () => this.updateUmaLogsData_()); 107 await this.updateUmaLogsData_(); 108 109 // Set up the UMA "Export logs" button. 110 const exportUmaLogsButton = this.getRequiredElement('#export-uma-logs'); 111 exportUmaLogsButton.addEventListener('click', () => this.exportUmaLogs_()); 112 } 113 114 /** 115 * Synchronize the selected tab and the URL hash. Allows, for example, 116 * chrome://metrics-internals#variations to directly open the variations tab. 117 */ 118 private syncTabsWithUrlHash_() { 119 const tabUrlHashes: string[] = [ 120 '#uma', 121 '#variations', 122 '#field-trials', 123 ]; 124 125 const tabBox = this.shadowRoot!.querySelector('cr-tab-box')!; 126 tabBox.addEventListener( 127 'selected-index-change', (e: CustomEvent<number>) => { 128 window.location.hash = tabUrlHashes[e.detail] || ''; 129 }); 130 131 if (window.location.hash.startsWith('#')) { 132 const entryIndex = tabUrlHashes.indexOf(window.location.hash); 133 if (entryIndex >= 0) { 134 tabBox.setAttribute('selected-index', String(entryIndex)); 135 } 136 } 137 } 138 139 /** 140 * Callback function to expand/collapse an element on click. 141 * @param e The click event. 142 */ 143 private toggleEventsExpand_(e: MouseEvent): void { 144 let umaLogEventsDiv = e.target as HTMLElement; 145 146 // It is possible we have clicked a descendant. Keep checking the parent 147 // until we are the the root div of the events. 148 while (!umaLogEventsDiv.classList.contains('uma-log-events')) { 149 umaLogEventsDiv = umaLogEventsDiv.parentElement as HTMLElement; 150 } 151 umaLogEventsDiv.classList.toggle('uma-log-events-expanded'); 152 } 153 154 /** 155 * Fills the passed table element with the given summary. 156 */ 157 private updateSummaryTable_(tableBody: HTMLElement, summary: KeyValue[]): 158 void { 159 // Clear the table first. 160 tableBody.replaceChildren(); 161 162 const template = 163 this.getRequiredElement<HTMLTemplateElement>('#summary-row-template'); 164 for (const info of summary) { 165 const row = template.content.cloneNode(true) as HTMLElement; 166 const [key, value] = row.querySelectorAll('td'); 167 168 assert(key); 169 key.textContent = info.key; 170 171 assert(value); 172 value.textContent = info.value; 173 174 tableBody.appendChild(row); 175 } 176 } 177 178 /** 179 * Fetches variations summary data and updates the view. 180 */ 181 private async updateVariationsSummary_(): Promise<void> { 182 const summary: KeyValue[] = 183 await this.browserProxy_.fetchVariationsSummary(); 184 185 // Don't re-render the table if the data has not changed. 186 const newDataString = summary.toString(); 187 if (newDataString === this.previousVariationsSummaryData_) { 188 return; 189 } 190 191 this.previousVariationsSummaryData_ = newDataString; 192 const variationsSummaryTableBody = 193 this.getRequiredElement('#variations-summary-body'); 194 this.updateSummaryTable_(variationsSummaryTableBody, summary); 195 } 196 197 /** 198 * Fetches UMA summary data and updates the view. 199 */ 200 private async updateUmaSummary_(): Promise<void> { 201 const summary: KeyValue[] = await this.browserProxy_.fetchUmaSummary(); 202 const umaSummaryTableBody = this.$('#uma-summary-body') as HTMLElement; 203 204 // Don't re-render the table if the data has not changed. 205 const newDataString = summary.toString(); 206 if (newDataString === this.previousUmaSummaryData_) { 207 return; 208 } 209 210 this.previousUmaSummaryData_ = newDataString; 211 this.updateSummaryTable_(umaSummaryTableBody, summary); 212 } 213 214 /** 215 * Fills the passed table element with the given logs. 216 */ 217 private updateLogsTable_(tableBody: HTMLElement, logs: Log[]): void { 218 // Clear the table first. 219 tableBody.replaceChildren(); 220 221 const template = 222 this.getRequiredElement<HTMLTemplateElement>('#uma-log-row-template'); 223 224 // Iterate through the logs in reverse order so that the most recent log 225 // shows up first. 226 for (const log of logs.slice(0).reverse()) { 227 const row = template.content.cloneNode(true) as HTMLElement; 228 const [type, hash, timestamp, size, events] = row.querySelectorAll('td'); 229 230 assert(type); 231 type.textContent = umaLogTypeToString(log.type); 232 233 assert(hash); 234 hash.textContent = log.hash; 235 236 assert(timestamp); 237 timestamp.textContent = timestampToString(log.timestamp); 238 239 assert(size); 240 size.textContent = sizeToString(log.size); 241 242 assert(events); 243 const eventsPeekDiv = 244 events.querySelector<HTMLElement>('.uma-log-events-peek'); 245 assert(eventsPeekDiv); 246 eventsPeekDiv.addEventListener('click', this.toggleEventsExpand_, false); 247 const eventsPeekText = 248 events.querySelector<HTMLElement>('.uma-log-events-peek-text'); 249 assert(eventsPeekText); 250 eventsPeekText.textContent = getEventsPeekString(log.events); 251 const eventsText = 252 events.querySelector<HTMLElement>('.uma-log-events-text'); 253 assert(eventsText); 254 // Iterate through the events in reverse order so that the most recent 255 // event shows up first. 256 for (const event of log.events.slice(0).reverse()) { 257 const div = document.createElement('div'); 258 div.textContent = logEventToString(event); 259 eventsText.appendChild(div); 260 } 261 262 tableBody.appendChild(row); 263 } 264 } 265 266 /** 267 * Fetches the latest UMA logs and renders them. This is called when the page 268 * is loaded and whenever there is a log that created or changed. 269 */ 270 private async updateUmaLogsData_(): Promise<void> { 271 const logsData: string = 272 await this.browserProxy_.getUmaLogData(/*includeLogProtoData=*/ false); 273 const logs: LogData = JSON.parse(logsData); 274 // If there are no logs, append an empty log. This is purely for aesthetic 275 // reasons. Otherwise, the table may look confusing. 276 if (!logs.logs.length) { 277 logs.logs = [EMPTY_LOG]; 278 } 279 280 // We don't compare the new data with the old data to prevent re-renderings 281 // because this should only be called when there is an actual change. 282 283 const umaLogsTableBody = this.getRequiredElement('#uma-logs-body'); 284 this.updateLogsTable_(umaLogsTableBody, logs.logs); 285 } 286 287 /** 288 * Exports the accumulated UMA logs, including their proto data, as a JSON 289 * file. This will initiate a download. 290 */ 291 private async exportUmaLogs_(): Promise<void> { 292 const logsData: string = await this.getUmaLogsExportContent(); 293 const file = new Blob([logsData], {type: 'text/plain'}); 294 const a = document.createElement('a'); 295 a.href = URL.createObjectURL(file); 296 a.download = `uma_logs_${new Date().getTime()}.json`; 297 a.click(); 298 } 299} 300 301declare global { 302 interface HTMLElementTagNameMap { 303 'metrics-internals-app': MetricsInternalsAppElement; 304 } 305} 306 307customElements.define( 308 MetricsInternalsAppElement.is, MetricsInternalsAppElement); 309