ukm_internals.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. // Copyright 2018 The Chromium Authors. All rights reserved.
  2. // Use of this source code is governed by a BSD-style license that can be
  3. // found in the LICENSE file.
  4. // <if expr="is_ios">
  5. import 'chrome://resources/js/ios/web_ui.js';
  6. // </if>
  7. import {assert} from 'chrome://resources/js/assert_ts.js';
  8. import {sendWithPromise} from 'chrome://resources/js/cr.m.js';
  9. import {$, createElementWithClassName} from 'chrome://resources/js/util.m.js';
  10. interface Metric {
  11. name: string;
  12. value: [number, number];
  13. }
  14. interface UkmEntry {
  15. name: string;
  16. metrics: Metric[];
  17. }
  18. interface UkmDataSource {
  19. id: [number, number];
  20. entries: UkmEntry[];
  21. url?: string;
  22. }
  23. /**
  24. * The Ukm data sent from the browser.
  25. */
  26. interface UkmData {
  27. state: boolean;
  28. client_id: number[];
  29. session_id: string;
  30. sources: UkmDataSource[];
  31. is_sampling_enabled: boolean;
  32. }
  33. /**
  34. * Stores source id and number of entries shown. If there is a new source id
  35. * or there are new entries in Ukm recorder, then all the entries for
  36. * the new source ID will be displayed.
  37. */
  38. const clearedSources: Map<string, number> = new Map();
  39. /**
  40. * Cached sources to persist beyond the log cut. This will ensure that the data
  41. * on the page don't disappear if there is a log cut. The caching will
  42. * start when the page is loaded and when the data is refreshed.
  43. * Stored data is sourceid -> UkmDataSource with array of distinct entries.
  44. */
  45. const cachedSources: Map<string, UkmDataSource> = new Map();
  46. /**
  47. * Text for empty url.
  48. */
  49. const URL_EMPTY: string = 'missing';
  50. /**
  51. * Converts a pair of JS 32 bin number to 64 bit hex string. This is used to
  52. * pass 64 bit numbers from UKM like client id and 64 bit metrics to
  53. * the javascript.
  54. * @param num A pair of javascript signed int.
  55. * @return unsigned int64 as hex number or a decimal number if the
  56. * value is smaller than 32bit.
  57. */
  58. function as64Bit(num: [number, number]): string {
  59. if (num.length !== 2) {
  60. return '0';
  61. }
  62. if (!num[0]) {
  63. return num[1].toString(); // Return the lsb as String.
  64. } else {
  65. const hi = (num[0] >>> 0).toString(16).padStart(8, '0');
  66. const lo = (num[1] >>> 0).toString(16).padStart(8, '0');
  67. return `0x${hi}${lo}`;
  68. }
  69. }
  70. /**
  71. * Sets the display option of all the elements in HtmlCollection to the value
  72. * passed.
  73. */
  74. function setDisplayStyle(
  75. elements: NodeListOf<HTMLElement>, displayValue: string) {
  76. for (const el of elements) {
  77. el.style.display = displayValue;
  78. }
  79. }
  80. /**
  81. * Remove all the child elements.
  82. * @param parent Parent element whose children will get removed.
  83. */
  84. function removeChildren(parent: Element) {
  85. while (parent.firstChild) {
  86. parent.removeChild(parent.firstChild);
  87. }
  88. }
  89. /**
  90. * Create card for URL.
  91. * @param sourcesForUrl Sources that are for same URL.
  92. * @param sourcesDiv Sources div where this card will be added to.
  93. * @param displayState Map from source id to value of display property of the
  94. * entries div.
  95. */
  96. function createUrlCard(
  97. sourcesForUrl: UkmDataSource[], sourcesDiv: Element,
  98. displayState: Map<string, string>) {
  99. const sourceDiv = createElementWithClassName('div', 'url_card');
  100. sourcesDiv.appendChild(sourceDiv);
  101. if (!sourcesForUrl || sourcesForUrl.length === 0) {
  102. return;
  103. }
  104. for (const source of sourcesForUrl) {
  105. // This div allows hiding of the metrics per URL.
  106. const sourceContainer = /** @type {!Element} */ (
  107. createElementWithClassName('div', 'source_container'));
  108. sourceDiv.appendChild(sourceContainer);
  109. createUrlHeader(source.url, source.id, sourceContainer);
  110. createSourceCard(
  111. source, sourceContainer, displayState.get(as64Bit(source.id)));
  112. }
  113. }
  114. /**
  115. * Create header containing URL and source ID data.
  116. * @param id SourceId as hex.
  117. * @param sourceDiv Div under which header will get added.
  118. */
  119. function createUrlHeader(
  120. url: string|undefined, id: [number, number], sourceDiv: Element) {
  121. const headerElement = createElementWithClassName('div', 'collapsible_header');
  122. sourceDiv.appendChild(headerElement);
  123. const urlElement = createElementWithClassName('span', 'url') as HTMLElement;
  124. urlElement.innerText = url ? url : URL_EMPTY;
  125. headerElement.appendChild(urlElement);
  126. const idElement =
  127. createElementWithClassName('span', 'sourceid') as HTMLElement;
  128. idElement.innerText = as64Bit(id);
  129. headerElement.appendChild(idElement);
  130. // Make the click on header toggle entries div.
  131. headerElement.addEventListener('click', () => {
  132. const content = headerElement.nextElementSibling as HTMLElement;
  133. if (content.style.display === 'block') {
  134. content.style.display = 'none';
  135. } else {
  136. content.style.display = 'block';
  137. }
  138. });
  139. }
  140. /**
  141. * Create a card with UKM Source data.
  142. * @param source UKM source data.
  143. * @param sourceDiv Source div where this card will be added to.
  144. * @param displayState If display style of this source id is modified
  145. * then the state of the display style.
  146. */
  147. function createSourceCard(
  148. source: UkmDataSource, sourceDiv: Element, displayState: string|undefined) {
  149. const metricElement =
  150. createElementWithClassName('div', 'entries') as HTMLElement;
  151. sourceDiv.appendChild(metricElement);
  152. const sortedEntry =
  153. source.entries.sort((x, y) => x.name.localeCompare(y.name));
  154. for (const entry of sortedEntry) {
  155. createEntryTable(entry, metricElement);
  156. }
  157. if (displayState) {
  158. metricElement.style.display = displayState;
  159. } else {
  160. if ($('toggle_expand').textContent === 'Collapse') {
  161. metricElement.style.display = 'block';
  162. } else {
  163. metricElement.style.display = 'none';
  164. }
  165. }
  166. }
  167. /**
  168. * Create UKM Entry Table.
  169. * @param entry A Ukm metrics Entry.
  170. * @param sourceDiv Element whose children will be the entries.
  171. */
  172. function createEntryTable(entry: UkmEntry, sourceDiv: Element) {
  173. // Add first column to the table.
  174. const entryTable = createElementWithClassName('table', 'entry_table');
  175. entryTable.setAttribute('value', entry.name);
  176. sourceDiv.appendChild(entryTable);
  177. const firstRow = document.createElement('tr');
  178. entryTable.appendChild(firstRow);
  179. const entryName = createElementWithClassName('td', 'entry_name');
  180. entryName.setAttribute('rowspan', '0');
  181. entryName.textContent = entry.name;
  182. firstRow.appendChild(entryName);
  183. // Sort the metrics by name, descending.
  184. const sortedMetrics =
  185. entry.metrics.sort((x, y) => x.name.localeCompare(y.name));
  186. // Add metrics columns.
  187. for (const metric of sortedMetrics) {
  188. const nextRow = document.createElement('tr');
  189. const metricName = createElementWithClassName('td', 'metric_name');
  190. metricName.textContent = metric.name;
  191. nextRow.appendChild(metricName);
  192. const metricValue = createElementWithClassName('td', 'metric_value');
  193. metricValue.textContent = as64Bit(metric.value);
  194. nextRow.appendChild(metricValue);
  195. entryTable.appendChild(nextRow);
  196. }
  197. }
  198. /**
  199. * Collect all sources for a particular URL together. It will also sort the
  200. * urls alphabetically.
  201. * If the URL field is missing, the source ID will be used as the
  202. * URL for the purpose of grouping and sorting.
  203. * @param sources List of UKM data for a source .
  204. * @return Mapping in the sorted order of URL from URL to list of sources for
  205. * the URL.
  206. */
  207. function urlToSourcesMapping(sources: UkmDataSource[]):
  208. Map<string, UkmDataSource[]> {
  209. const unsorted = new Map();
  210. for (const source of sources) {
  211. const key = source.url ? source.url : as64Bit(source.id);
  212. if (!unsorted.has(key)) {
  213. unsorted.set(key, [source]);
  214. } else {
  215. unsorted.get(key).push(source);
  216. }
  217. }
  218. // Sort the map by URLs.
  219. return new Map(
  220. Array.from(unsorted).sort((s1, s2) => s1[0].localeCompare(s2[0])));
  221. }
  222. /**
  223. * Adds a button to Expand/Collapse all URLs.
  224. */
  225. function addExpandToggleButton() {
  226. const toggleExpand = $('toggle_expand');
  227. toggleExpand.textContent = 'Expand';
  228. toggleExpand.addEventListener('click', () => {
  229. if (toggleExpand.textContent === 'Expand') {
  230. toggleExpand.textContent = 'Collapse';
  231. setDisplayStyle(
  232. document.body.querySelectorAll<HTMLElement>('.entries'), 'block');
  233. } else {
  234. toggleExpand.textContent = 'Expand';
  235. setDisplayStyle(
  236. document.body.querySelectorAll<HTMLElement>('.entries'), 'none');
  237. }
  238. });
  239. }
  240. /**
  241. * Adds a button to clear all the existing URLs. Note that the hiding is
  242. * done in the UI only. So refreshing the page will show all the UKM again.
  243. * To get the new UKMs after hitting Clear click the refresh button.
  244. */
  245. function addClearButton() {
  246. const clearButton = $('clear');
  247. clearButton.addEventListener('click', () => {
  248. // Note it won't be able to clear if UKM logs got cut during this call.
  249. sendWithPromise('requestUkmData').then((/** @type {UkmData} */ data) => {
  250. updateUkmCache(data);
  251. for (const s of cachedSources.values()) {
  252. clearedSources.set(as64Bit(s.id), s.entries.length);
  253. }
  254. });
  255. $('toggle_expand').textContent = 'Expand';
  256. updateUkmData();
  257. });
  258. }
  259. /**
  260. * Populate thread ids from the high bit of source id in sources.
  261. * @param sources Array of UKM source.
  262. */
  263. function populateThreadIds(sources: UkmDataSource[]) {
  264. const threadIdSelect =
  265. document.body.querySelector<HTMLSelectElement>('#thread_ids');
  266. assert(threadIdSelect);
  267. const currentOptions =
  268. new Set(Array.from(threadIdSelect.options).map(o => o.value));
  269. // The first 32 bit of the ID is the recorder ID, convert it to a positive
  270. // bit patterns and then to hex. Ids that were not seen earlier will get
  271. // added to the end of the option list.
  272. const newIds = new Set(sources.map(e => (e.id[0] >>> 0).toString(16)));
  273. const options = ['All', ...Array.from(newIds).sort()];
  274. for (const id of options) {
  275. if (!currentOptions.has(id)) {
  276. const option = document.createElement('option');
  277. option.textContent = id;
  278. option.setAttribute('value', id);
  279. threadIdSelect.add(option);
  280. }
  281. }
  282. }
  283. /**
  284. * Get the string representation of a UKM entry. The array of metrics are sorted
  285. * by name to ensure that two entries containing the same metrics and values in
  286. * different orders have identical string representation to avoid cache
  287. * duplication.
  288. * @param entry UKM entry to be stringified.
  289. * @return Normalized string representation of the entry.
  290. */
  291. function normalizeToString(entry: UkmEntry): string {
  292. entry.metrics.sort((x, y) => x.name.localeCompare(y.name));
  293. return JSON.stringify(entry);
  294. }
  295. /**
  296. * This function tries to preserve UKM logs around UKM log uploads. There is
  297. * no way of knowing if duplicate entries for a log are actually produced
  298. * again after the log cut or if they older records since we don't maintain
  299. * timestamp with entries. So only distinct entries will be recorded in the
  300. * cache. i.e if two entries have exactly the same set of metrics then one
  301. * of the entry will not be kept in the cache.
  302. * @param data New UKM data to add to cache.
  303. */
  304. function updateUkmCache(data: UkmData) {
  305. for (const source of data.sources) {
  306. const key = as64Bit(source.id);
  307. if (!cachedSources.has(key)) {
  308. const mergedSource:
  309. UkmDataSource = {id: source.id, entries: source.entries};
  310. if (source.url) {
  311. mergedSource.url = source.url;
  312. }
  313. cachedSources.set(key, mergedSource);
  314. } else {
  315. // Merge distinct entries from the source.
  316. const existingEntries = new Set(cachedSources.get(key)!.entries.map(
  317. cachedEntry => normalizeToString(cachedEntry)));
  318. for (const sourceEntry of source.entries) {
  319. if (!existingEntries.has(normalizeToString(sourceEntry))) {
  320. cachedSources.get(key)!.entries.push(sourceEntry);
  321. }
  322. }
  323. }
  324. }
  325. }
  326. /**
  327. * Fetches data from the Ukm service and updates the DOM to display it as a
  328. * list.
  329. */
  330. function updateUkmData() {
  331. sendWithPromise('requestUkmData').then((/** @type {UkmData} */ data) => {
  332. updateUkmCache(data);
  333. if (document.body.querySelector<HTMLInputElement>(
  334. '#include_cache')!.checked) {
  335. data.sources = [...cachedSources.values()];
  336. }
  337. $('state').innerText = data.state ? 'ENABLED' : 'DISABLED';
  338. $('clientid').innerText = '0x' + data.client_id;
  339. $('sessionid').innerText = data.session_id;
  340. $('is_sampling_enabled').innerText = data.is_sampling_enabled;
  341. const sourcesDiv = /** @type {!Element} */ ($('sources'));
  342. removeChildren(sourcesDiv);
  343. // Setup a title for the sources div.
  344. const urlTitleElement = createElementWithClassName('span', 'url');
  345. urlTitleElement.textContent = 'URL';
  346. const sourceIdTitleElement = createElementWithClassName('span', 'sourceid');
  347. sourceIdTitleElement.textContent = 'Source ID';
  348. sourcesDiv.appendChild(urlTitleElement);
  349. sourcesDiv.appendChild(sourceIdTitleElement);
  350. // Setup the display state map, which captures the current display settings,
  351. // for example, expanded state.
  352. const currentDisplayState = new Map();
  353. for (const el of document.getElementsByClassName('source_container')) {
  354. currentDisplayState.set(
  355. el.querySelector<HTMLElement>('.sourceid')!.textContent,
  356. el.querySelector<HTMLElement>('.entries')!.style.display);
  357. }
  358. const urlToSources =
  359. urlToSourcesMapping(filterSourcesUsingFormOptions(data.sources));
  360. for (const url of urlToSources.keys()) {
  361. const sourcesForUrl = urlToSources.get(url)!;
  362. createUrlCard(sourcesForUrl, sourcesDiv, currentDisplayState);
  363. }
  364. populateThreadIds(data.sources);
  365. });
  366. }
  367. /**
  368. * Filter sources that have been recorded previously. If it sees a source id
  369. * where number of entries has decreased then it will add a warning.
  370. * @param sources All the sources currently in UKM recorder.
  371. * @return Sources which are new or have a new entry logged for them.
  372. */
  373. function filterSourcesUsingFormOptions(sources: UkmDataSource[]):
  374. UkmDataSource[] {
  375. // Filter sources based on if they have been cleared.
  376. const newSources = sources.filter(
  377. source => (
  378. // Keep sources if it is newly generated since clearing earlier.
  379. !clearedSources.has(as64Bit(source.id)) ||
  380. // Keep sources if it has increased entities since clearing earlier.
  381. (source.entries.length > clearedSources.get(as64Bit(source.id))!)));
  382. // Applies the filter from Metrics selector.
  383. const newSourcesWithEntriesCleared = newSources.map(source => {
  384. const metricsFilterValue =
  385. document.body.querySelector<HTMLInputElement>('#metrics_select')!.value;
  386. if (metricsFilterValue) {
  387. const metricsRe = new RegExp(metricsFilterValue);
  388. source.entries = source.entries.filter(e => metricsRe.test(e.name));
  389. }
  390. return source;
  391. });
  392. // Filter sources based on the status of check-boxes.
  393. const filteredSources = newSourcesWithEntriesCleared.filter(source => {
  394. const noUrlCheckbox =
  395. document.body.querySelector<HTMLInputElement>('#hide_no_url');
  396. assert(noUrlCheckbox);
  397. const noMetricsCheckbox =
  398. document.body.querySelector<HTMLInputElement>('#hide_no_metrics');
  399. assert(noMetricsCheckbox);
  400. return (!noUrlCheckbox.checked || source.url) &&
  401. (!noMetricsCheckbox.checked || source.entries.length);
  402. });
  403. const threadIds =
  404. document.body.querySelector<HTMLSelectElement>('#thread_ids');
  405. assert(threadIds);
  406. // Filter sources based on thread id (High bits of UKM Recorder ID).
  407. const threadsFilteredSource = filteredSources.filter(source => {
  408. // Get current selection for thread id. It is either -
  409. // "All" for no restriction.
  410. // "0" for the default thread. This is the thread that record f.e PageLoad
  411. // <lowercase hex string for first 32 bit of source id> for other threads.
  412. // If a UKM is recorded with a custom source id or in renderer, it will
  413. // have a unique value for this shared by all metrics that use the
  414. // same thread.
  415. const selectedOption = threadIds.options[threadIds.selectedIndex];
  416. // Return true if either of the following is true -
  417. // No option is selected or selected option is "All" or the hexadecimal
  418. // representation of source id is matching.
  419. return !selectedOption || (selectedOption.value === 'All') ||
  420. ((source.id[0] >>> 0).toString(16) === selectedOption.value);
  421. });
  422. // Filter URLs based on URL selector input.
  423. const urlSelect =
  424. document.body.querySelector<HTMLInputElement>('#url_select');
  425. assert(urlSelect);
  426. return threadsFilteredSource.filter(source => {
  427. const urlFilterValue = urlSelect.value;
  428. if (urlFilterValue) {
  429. const urlRe = new RegExp(urlFilterValue);
  430. // Will also match missing URLs by default.
  431. return !source.url || urlRe.test(source.url);
  432. }
  433. return true;
  434. });
  435. }
  436. /**
  437. * DomContentLoaded handler.
  438. */
  439. function onLoad() {
  440. addExpandToggleButton();
  441. addClearButton();
  442. updateUkmData();
  443. $('refresh').addEventListener('click', updateUkmData);
  444. $('hide_no_metrics').addEventListener('click', updateUkmData);
  445. $('hide_no_url').addEventListener('click', updateUkmData);
  446. $('thread_ids').addEventListener('click', updateUkmData);
  447. $('include_cache').addEventListener('click', updateUkmData);
  448. $('metrics_select').addEventListener('keyup', e => {
  449. if (e.key === 'Enter') {
  450. updateUkmData();
  451. }
  452. });
  453. $('url_select').addEventListener('keyup', e => {
  454. if (e.key === 'Enter') {
  455. updateUkmData();
  456. }
  457. });
  458. }
  459. document.addEventListener('DOMContentLoaded', onLoad);
  460. setInterval(updateUkmData, 120000); // Refresh every 2 minutes.