User:Krinkle/Scripts/Perf.js

From Meta, a Wikimedia project coordination wiki

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Press Ctrl-F5.
/**
 * Perf utils by Krinkle
 *
 * This creates a "Perf" portlet menu, and defines mw.loader.findAll() for ad-hoc use via the browser console.
 *
 * Usage:
 *

// [[File:Krinkle_Perf.js]]
mw.loader.load('https://meta.wikimedia.org/w/index.php?title=User:Krinkle/Scripts/Perf.js&action=raw&ctype=text/javascript');

 *
 * @version 2023-09-19
 * @source https://meta.wikimedia.org/wiki/User:Krinkle/Scripts/Perf.js
 * @author Timo Tijhof
 */
var domReady = new Promise(function (resolve) { document.readyState === 'complete' ? setTimeout(resolve) : document.onreadystatechange = setTimeout.bind(null, resolve); });
domReady.then(function () {
	var prevPortlet;
	var perfMenu;
	var nt;
	var ppr;
	var ppr14;
	var pprInt14;
	var nowInt14;

	// https://wikitech.wikimedia.org/wiki/SRE/Infrastructure_naming_conventions#Servers
	var dcNumMap = { 1: 'eqiad', 2: 'codfw', 3: 'esams', 4: 'ulsfo', 5: 'eqsin', 6: 'drmrs' };

	var browserResp = 'unknown';
	var ttfb = null;
	var respParts = [];
	var cdnCacheStatus = 'unknown';
	var cdnHost = 'unknown';
	var beParts = [];
	var beResp = 0;
	var pcParts = [];
	var pcResp = 0;

	function addItem(texts) {
		if (perfMenu) {
			var item = document.createElement('li');
			item.className = 'perf-textonly';
			if (Array.isArray(texts)) {
				texts.forEach(function (text, i) {
					if (text === null) {
						return;
					}
					if (i !== 0) {
						item.append(document.createElement('br'));
					}
					item.append(text);
				});
			} else {
				item.textContent = texts;
			}
			perfMenu.append(item);
		}
	}

	function conf(key) {
		return window.mw && window.mw.config && window.mw.config.get(key);
	}

	function tfmt(ms) {
		// use milliseconds upto 9000 ms, then use seconds
		return ms > 9000 ? (ms / 1000).toFixed(3) + ' s' : Math.round(ms).toLocaleString() + '\u00A0' + 'ms';
	}

	function percent(total, sub) {
		var ratio = (sub / total) * 100;
		return Math.floor(ratio) + '%';
	}

	// Can't use mw.util.addPortlet() since it's not compatible with anything other sidebar portlets outside Vector22 skin.

	// Vector skin
	prevPortlet = document.querySelector('nav#p-variants.vector-menu');
	if (prevPortlet) {
		prevPortlet.insertAdjacentHTML('afterend', ''
			+ '<style>#p-perf li.perf-textonly {'
			+ '  padding: 0.42em 0.625em;'
			+ '  line-height: 1.4;'
			+ '  font-size: 0.8125em;'
			+ '  white-space: nowrap;'
			+ '}</style>'
			+ '<nav id="p-perf" class="mw-portlet vector-menu vector-menu-dropdown vector-menu-dropdown-noicon" aria-labelledby="p-perf-label" role="navigation">'
			+ '<input type="checkbox" class="vector-menu-checkbox" aria-labelledby="p-perf-label"><label class="vector-menu-heading" id="p-perf-label"><span>⏱</span></label>'
			+ '<div class="vector-menu-content"><ul class="vector-menu-content-list"></ul></div></nav>'
		);
	}
	// Vector 2022 skin
	// * Workaround bug in vector-2022 where menus like #p-variants are twice in the DOM (ID is not unique???) 
	// * Fix bug in vector-2022 where tall characters in portlet label cause a jarring change in toolbar height
	// * Avoid "white-space:nowrap" on items because menus have a fixed max-width and no handling for overflow (inaccessible text).
	prevPortlet = document.querySelector('#p-variants.vector-dropdown');
	if (prevPortlet) {
		prevPortlet.insertAdjacentHTML('afterend', `
			<style>
			#p-perf label {
			  line-height: 0.9;
			}
			#p-perf li.perf-textonly {
			  padding: 0.42em 0.625em;
			  line-height: 1.4;
			  font-size: 0.8125em;
			}
			#p-perf .vector-dropdown-content {
			  max-width: 250px;
			}
			</style>
			<div id="p-perf" class="vector-dropdown" role="navigation">
			<input id="p-perf-checkbox" type="checkbox" class="vector-dropdown-checkbox" aria-labelledby="p-perf-label"><label class="vector-dropdown-label cdx-button" for="p-perf-checkbox" id="p-perf-label"><span>⏱</span></label>
			<div class="vector-dropdown-content vector-menu"><ul class="vector-menu-content-list"></ul></div></div>
		`);
	}
	// Monobook skin
	prevPortlet = document.querySelector('#p-tb.portlet');
	if (prevPortlet) {
		prevPortlet.insertAdjacentHTML('afterend', ''
			+ '<div role="navigation" class="portlet" id="p-perf" aria-labelledby="p-perf-label">'
			+ '<h3 id="p-perf-label" dir="ltr" lang="en">⏱ Performance</h3>'
			+ '<div class="pBody"><ul dir="ltr" lang="en"></ul></div></div>'
		);
	}
	// Timeless skin
	prevPortlet = document.querySelector('#p-pagemisc.mw-portlet');
	if (prevPortlet) {
		prevPortlet.insertAdjacentHTML('afterend', ''
			+ '<style>#p-perf ul { color: #555; line-height: 1; font-size: 85%; }</style>'
			+ '<div role="navigation" class="mw-portlet" id="p-perf" aria-labelledby="p-perf-label">'
			+ '<h3 id="p-perf-label" dir="ltr" lang="en">⏱ Performance</h3>'
			+ '<div class="mw-portlet-body"><ul dir="ltr" lang="en"></ul></div></div>'
		);
	}
	// Minerva skin
	prevPortlet = document.querySelector('footer.minerva-footer');
	if (prevPortlet) {
		prevPortlet.insertAdjacentHTML('beforeend', ''
			// Use hlist for font style, but use separate lines
			+ '<style>#p-perf {'
			+ 'margin-top: 1rem;'
			+ 'overflow: visible;'
			+ '}'
			+ '#p-perf li {'
			+ 'display: list-item;'
			+ 'list-style: circle outside;'
			+ 'margin-left: 0.5rem;'
			+ '}</style>'
			+ '<div id="p-perf" class="post-content footer-content">'
			+ '<h2>⏱ Performance</h2>'
			+ '<ul class="hlist"></ul></div>');
	}
	perfMenu = document.querySelector('#p-perf ul');

	try {
		// Navigation Timing API
		nt = performance.getEntriesByType('navigation')[0];
		ttfb = nt.responseStart;

		// Resource Timing API
		if (nt.transferSize === 0) {
			browserResp = 'local cache (no network)';
		} else if (nt.transferSize > 0 && nt.encodedBodySize > 0 && nt.transferSize < nt.encodedBodySize ) {
			browserResp = 'local cache (after HTTP 304)';
		} else {
			browserResp = 'fresh HTTP 200';
		}
		respParts.push('Response time: ' + tfmt(ttfb));

		// Server Timing API
		if (nt.serverTiming[0].name === 'cache') {
			// One of "hit", "hit-front", "miss" or "pass"
			cdnCacheStatus = nt.serverTiming[0].description;
		}
		if (nt.serverTiming[1].name === 'host') {
			// e.g. cp0000
			cdnHost = nt.serverTiming[1].description;
			// match will yield null or ['1'], both of which can cast nicely to a string key in dcNumMap
			// this avoids complexity around conditionally reading matchResult[0]
			cdnHost = cdnHost + '.' + (dcNumMap[cdnHost.match(/\d/)] || 'unknown') + '.wmnet';
		}
		// MediaWiki-specific: config on all HTML responses
		beResp = (cdnCacheStatus.includes('hit') || browserResp.includes('cache')) ? 0 : conf('wgBackendResponseTime');
		if (beResp) {
			beParts.push('MediaWiki backend: ' + conf('wgHostname'));
		} else {
			beParts.push('MediaWiki backend: (cache hit)');
			beParts.push('(cached) host: ' + conf('wgHostname'));
			// Only show dedicated entry here if cached (and thus not shown in respParts)
			beParts.push('(cached) duration: ' + tfmt(conf('wgBackendResponseTime')));
		}

		respParts.push(
			'• ' + percent(ttfb, ttfb - beResp) + ' 🌐 Internet connection: ' + tfmt(ttfb - beResp),
			Object.assign(document.createElement('small'), { textContent: '\u00A0\u00A0 (time between browser and CDN)' })
		);
		if (beResp) {
			respParts.push('• ' + percent(ttfb, beResp) + ' 🌻 MediaWiki backend: ' + tfmt(beResp));
		}

		// MediaWiki-specific: on when action=view, on a page that exists, is local, and has wikitext content.
		ppr = conf('wgPageParseReport');
		if (ppr.cachereport && ppr.limitreport) {
			ppr14 = ppr.cachereport.timestamp;
			// This is the timestamp after parsing is done when it is about to saved.
			// Therefore, below we don't need to account for parse time itself.
			pprInt14 = Number(ppr14);
			// "2020-10-18T23:50:34.799Z" -> 20201018235034
			nowInt14 = Number(new Date(performance.timeOrigin).toISOString().replace(/([-T:]|\..*$)/g, ''));
			// Assume cache reuse, unless same host and under 5 seconds ago.
			pcResp = (ppr.cachereport.origin === conf('wgHostname') && (nowInt14 - pprInt14) < 5) ? (ppr.limitreport.walltime * 1000) : 0;
			if (pcResp) {
				respParts.push('\u00A0\u00A0• ' + percent(ttfb, pcResp) + ' 🧮 MediaWiki parser: ' + tfmt(pcResp));
				respParts.push('\u00A0\u00A0• ' + percent(ttfb, beResp - pcResp) + ' 🖼 MediaWiki skin: ' + tfmt(beResp - pcResp));
				pcParts.push('MediaWiki parser: miss (freshly parsed)');
			} else {
				respParts.push('\u00A0\u00A0• ' + percent(ttfb, pcResp) + ' 🧮 MediaWiki parser: ' + tfmt(pcResp));
				respParts.push('\u00A0\u00A0• ' + percent(ttfb, beResp - pcResp) + ' 🖼 MediaWiki skin: ' + tfmt(beResp - pcResp));
				pcParts.push('MediaWiki parser: (cache hit)');
				pcParts.push(' (cached) host: ' + ppr.cachereport.origin);
				// Only show dedicated entry here if cached (and thus not shown in respParts)
				pcParts.push(' (cached) duration: ' + tfmt(ppr.limitreport.walltime * 1000));
				// Only show  if cached, otherwise uninteresting
				pcParts.push(' (cached) timestamp: ' + ppr14.slice(0, 4) + '-' + ppr14.slice(4, 6) + '-' + ppr14.slice(6, 8) +
					' ' + ppr14.slice(8, 10) + ':' + ppr14.slice(10, 12) + ':' + ppr14.slice(12, 14) + ' (UTC)'
				);
			}
		}
	} catch (e) {
		// Ignored
	}

	addItem('Response: ' + browserResp);
	addItem(respParts);
	addItem(['CDN response:', '• status: ' + cdnCacheStatus, '• host: ' + cdnHost]);
	addItem(beParts);
	addItem(pcParts);

	mw.hook('krinkle.perf-menu').fire();
});