From 6fda61af70663e4e9075188f8d04ee7b33691dc1 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Wed, 21 Dec 2022 22:16:18 +0100 Subject: [PATCH 01/55] chore: replay metrics collection - initial script setup --- .vscode/launch.json | 8 + .vscode/tasks.json | 8 +- packages/replay/metrics/.eslintrc.js | 11 + packages/replay/metrics/package.json | 21 + packages/replay/metrics/src/index.ts | 42 ++ packages/replay/metrics/tsconfig.json | 12 + packages/replay/metrics/yarn.lock | 624 ++++++++++++++++++++++++++ 7 files changed, 725 insertions(+), 1 deletion(-) create mode 100644 packages/replay/metrics/.eslintrc.js create mode 100644 packages/replay/metrics/package.json create mode 100644 packages/replay/metrics/src/index.ts create mode 100644 packages/replay/metrics/tsconfig.json create mode 100644 packages/replay/metrics/yarn.lock diff --git a/.vscode/launch.json b/.vscode/launch.json index 2fd396c8ddbf..598d4363c1f7 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -37,6 +37,14 @@ "internalConsoleOptions": "openOnSessionStart", "outputCapture": "std" }, + { + "type": "node", + "name": "Debug replay metrics script", + "request": "launch", + "cwd": "${workspaceFolder}/packages/replay/metrics/", + "program": "${workspaceFolder}/packages/replay/metrics/src/index.ts", + "preLaunchTask": "Build Replay metrics script", + }, // Run rollup using the config file which is in the currently active tab. { "name": "Debug rollup (config from open file)", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 6e797a064c61..e68b7996f8e9 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -7,7 +7,13 @@ "type": "npm", "script": "predebug", "path": "packages/nextjs/test/integration/", - "detail": "Link the SDK (if not already linked) and build test app" + "detail": "Link the SDK (if not already linked) and build test app", + }, + { + "label": "Build Replay metrics script", + "type": "npm", + "script": "build", + "path": "packages/replay/metrics", } ] } diff --git a/packages/replay/metrics/.eslintrc.js b/packages/replay/metrics/.eslintrc.js new file mode 100644 index 000000000000..5527b97558fb --- /dev/null +++ b/packages/replay/metrics/.eslintrc.js @@ -0,0 +1,11 @@ +module.exports = { + extends: ['../.eslintrc.js'], + overrides: [ + { + files: ['*.ts'], + rules: { + 'no-console': 'off', + }, + }, + ], +}; diff --git a/packages/replay/metrics/package.json b/packages/replay/metrics/package.json new file mode 100644 index 000000000000..3ecb4819bce2 --- /dev/null +++ b/packages/replay/metrics/package.json @@ -0,0 +1,21 @@ +{ + "private": true, + "name": "metrics", + "main": "index.js", + "author": "Sentry", + "license": "MIT", + "type": "module", + "scripts": { + "build": "tsc", + "start": "node ./build/index.js", + "dev": "ts-node-esm ./src/index.ts" + }, + "dependencies": { + "@types/node": "^18.11.17", + "puppeteer": "^19.4.1", + "typescript": "^4.9.4" + }, + "devDependencies": { + "ts-node": "^10.9.1" + } +} diff --git a/packages/replay/metrics/src/index.ts b/packages/replay/metrics/src/index.ts new file mode 100644 index 000000000000..c769557e3d00 --- /dev/null +++ b/packages/replay/metrics/src/index.ts @@ -0,0 +1,42 @@ +import puppeteer from 'puppeteer'; + +class MetricsCollector { + public async run(): Promise { + const browser = await puppeteer.launch({ headless: false }); + try { + const page = await browser.newPage(); + + await page.goto('https://developers.google.com/web/'); + + // Type into search box. + await page.type('.devsite-search-field', 'Headless Chrome'); + + // Wait for suggest overlay to appear and click "show all results". + const allResultsSelector = '.devsite-suggest-all-results'; + await page.waitForSelector(allResultsSelector); + await page.click(allResultsSelector); + + // Wait for the results page to load and display the results. + const resultsSelector = '.gsc-results .gs-title'; + await page.waitForSelector(resultsSelector); + + // // Extract the results from the page. + // const links = await page.evaluate(resultsSelector => { + // return [...document.querySelectorAll(resultsSelector)].map(anchor => { + // const title = anchor.textContent.split('|')[0].trim(); + // return `${title} - ${anchor.href}`; + // }); + // }, resultsSelector); + + // // Print all the files. + // console.log(links.join('\n')); + } finally { + await browser.close(); + } + } +} + + void (async () => { + const collector = new MetricsCollector(); + await collector.run(); +})(); diff --git a/packages/replay/metrics/tsconfig.json b/packages/replay/metrics/tsconfig.json new file mode 100644 index 000000000000..9aa9fd11b24e --- /dev/null +++ b/packages/replay/metrics/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "target": "es2020", + "module": "esnext", + "outDir": "build", + "esModuleInterop": true, + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/replay/metrics/yarn.lock b/packages/replay/metrics/yarn.lock new file mode 100644 index 000000000000..1b1b34da02f2 --- /dev/null +++ b/packages/replay/metrics/yarn.lock @@ -0,0 +1,624 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/code-frame@^7.0.0": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a" + integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q== + dependencies: + "@babel/highlight" "^7.18.6" + +"@babel/helper-validator-identifier@^7.18.6": + version "7.19.1" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz#7eea834cf32901ffdc1a7ee555e2f9c27e249ca2" + integrity sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w== + +"@babel/highlight@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.18.6.tgz#81158601e93e2563795adcbfbdf5d64be3f2ecdf" + integrity sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g== + dependencies: + "@babel/helper-validator-identifier" "^7.18.6" + chalk "^2.0.0" + js-tokens "^4.0.0" + +"@cspotcode/source-map-support@^0.8.0": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" + integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== + dependencies: + "@jridgewell/trace-mapping" "0.3.9" + +"@jridgewell/resolve-uri@^3.0.3": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78" + integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w== + +"@jridgewell/sourcemap-codec@^1.4.10": + version "1.4.14" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24" + integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== + +"@jridgewell/trace-mapping@0.3.9": + version "0.3.9" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" + integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + +"@tsconfig/node10@^1.0.7": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.9.tgz#df4907fc07a886922637b15e02d4cebc4c0021b2" + integrity sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA== + +"@tsconfig/node12@^1.0.7": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d" + integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag== + +"@tsconfig/node14@^1.0.0": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1" + integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow== + +"@tsconfig/node16@^1.0.2": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e" + integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ== + +"@types/node@*", "@types/node@^18.11.17": + version "18.11.17" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.17.tgz#5c009e1d9c38f4a2a9d45c0b0c493fe6cdb4bcb5" + integrity sha512-HJSUJmni4BeDHhfzn6nF0sVmd1SMezP7/4F0Lq+aXzmp2xm9O7WXrUtHW/CHlYVtZUbByEvWidHqRtcJXGF2Ng== + +"@types/yauzl@^2.9.1": + version "2.10.0" + resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.0.tgz#b3248295276cf8c6f153ebe6a9aba0c988cb2599" + integrity sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw== + dependencies: + "@types/node" "*" + +acorn-walk@^8.1.1: + version "8.2.0" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1" + integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA== + +acorn@^8.4.1: + version "8.8.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.1.tgz#0a3f9cbecc4ec3bea6f0a80b66ae8dd2da250b73" + integrity sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA== + +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + +ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +arg@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" + integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + +bl@^4.0.3: + version "4.1.0" + resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" + integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== + dependencies: + buffer "^5.5.0" + inherits "^2.0.4" + readable-stream "^3.4.0" + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +buffer-crc32@~0.2.3: + version "0.2.13" + resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" + integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ== + +buffer@^5.2.1, buffer@^5.5.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" + integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.1.13" + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +chalk@^2.0.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +chownr@^1.1.1: + version "1.1.4" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" + integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +cosmiconfig@8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-8.0.0.tgz#e9feae014eab580f858f8a0288f38997a7bebe97" + integrity sha512-da1EafcpH6b/TD8vDRaWV7xFINlHlF6zKsGwS1TsuVJTZRkquaS5HTMq7uq6h31619QjbsYl21gVDOm32KM1vQ== + dependencies: + import-fresh "^3.2.1" + js-yaml "^4.1.0" + parse-json "^5.0.0" + path-type "^4.0.0" + +create-require@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" + integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== + +cross-fetch@3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" + integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw== + dependencies: + node-fetch "2.6.7" + +debug@4, debug@4.3.4, debug@^4.1.1: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + +devtools-protocol@0.0.1068969: + version "0.0.1068969" + resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1068969.tgz#8b9a4bc48aed1453bed08d62b07481f9abf4d6d8" + integrity sha512-ATFTrPbY1dKYhPPvpjtwWKSK2mIwGmRwX54UASn9THEuIZCe2n9k3vVuMmt6jWeL+e5QaaguEv/pMyR+JQB7VQ== + +diff@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" + integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== + +end-of-stream@^1.1.0, end-of-stream@^1.4.1: + version "1.4.4" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" + integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== + dependencies: + once "^1.4.0" + +error-ex@^1.3.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" + integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== + dependencies: + is-arrayish "^0.2.1" + +escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== + +extract-zip@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a" + integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg== + dependencies: + debug "^4.1.1" + get-stream "^5.1.0" + yauzl "^2.10.0" + optionalDependencies: + "@types/yauzl" "^2.9.1" + +fd-slicer@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e" + integrity sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g== + dependencies: + pend "~1.2.0" + +fs-constants@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" + integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +get-stream@^5.1.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" + integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== + dependencies: + pump "^3.0.0" + +glob@^7.1.3: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== + +https-proxy-agent@5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + +ieee754@^1.1.13: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + +import-fresh@^3.2.1: + version "3.3.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" + integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@^2.0.3, inherits@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== + +js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +json-parse-even-better-errors@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + +lines-and-columns@^1.1.6: + version "1.2.4" + resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" + integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== + +make-error@^1.1.1: + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== + +minimatch@^3.1.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +mkdirp-classic@^0.5.2: + version "0.5.3" + resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" + integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +node-fetch@2.6.7: + version "2.6.7" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" + integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== + dependencies: + whatwg-url "^5.0.0" + +once@^1.3.0, once@^1.3.1, once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +parse-json@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" + integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== + dependencies: + "@babel/code-frame" "^7.0.0" + error-ex "^1.3.1" + json-parse-even-better-errors "^2.3.0" + lines-and-columns "^1.1.6" + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-type@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" + integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== + +pend@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" + integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg== + +progress@2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" + integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== + +proxy-from-env@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + +pump@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" + integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +puppeteer-core@19.4.1: + version "19.4.1" + resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-19.4.1.tgz#f4875943841ebdb6fc2ad7a475add958692b0237" + integrity sha512-JHIuqtqrUAx4jGOTxXu4ilapV2jabxtVMA/e4wwFUMvtSsqK4nVBSI+Z1SKDoz7gRy/JUIc8WzmfocCa6SIZ1w== + dependencies: + cross-fetch "3.1.5" + debug "4.3.4" + devtools-protocol "0.0.1068969" + extract-zip "2.0.1" + https-proxy-agent "5.0.1" + proxy-from-env "1.1.0" + rimraf "3.0.2" + tar-fs "2.1.1" + unbzip2-stream "1.4.3" + ws "8.11.0" + +puppeteer@^19.4.1: + version "19.4.1" + resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-19.4.1.tgz#cac7d3f0084badebb8ebacbe6f4d7262e7f21818" + integrity sha512-PCnrR13B8A+VSEDXRmrNXRZbrkF1tfsI1hKSC7vs13eNS6CUD3Y4FA8SF8/VZy+Pm1kg5AggJT2Nu3HLAtGkFg== + dependencies: + cosmiconfig "8.0.0" + https-proxy-agent "5.0.1" + progress "2.0.3" + proxy-from-env "1.1.0" + puppeteer-core "19.4.1" + +readable-stream@^3.1.1, readable-stream@^3.4.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" + integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +rimraf@3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +tar-fs@2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784" + integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng== + dependencies: + chownr "^1.1.1" + mkdirp-classic "^0.5.2" + pump "^3.0.0" + tar-stream "^2.1.4" + +tar-stream@^2.1.4: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" + integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== + dependencies: + bl "^4.0.3" + end-of-stream "^1.4.1" + fs-constants "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.1.1" + +through@^2.3.8: + version "2.3.8" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== + +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + +ts-node@^10.9.1: + version "10.9.1" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.1.tgz#e73de9102958af9e1f0b168a6ff320e25adcff4b" + integrity sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw== + dependencies: + "@cspotcode/source-map-support" "^0.8.0" + "@tsconfig/node10" "^1.0.7" + "@tsconfig/node12" "^1.0.7" + "@tsconfig/node14" "^1.0.0" + "@tsconfig/node16" "^1.0.2" + acorn "^8.4.1" + acorn-walk "^8.1.1" + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + v8-compile-cache-lib "^3.0.1" + yn "3.1.1" + +typescript@^4.9.4: + version "4.9.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.4.tgz#a2a3d2756c079abda241d75f149df9d561091e78" + integrity sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg== + +unbzip2-stream@1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7" + integrity sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg== + dependencies: + buffer "^5.2.1" + through "^2.3.8" + +util-deprecate@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +v8-compile-cache-lib@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" + integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +ws@8.11.0: + version "8.11.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.11.0.tgz#6a0d36b8edfd9f96d8b25683db2f8d7de6e8e143" + integrity sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg== + +yauzl@^2.10.0: + version "2.10.0" + resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" + integrity sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g== + dependencies: + buffer-crc32 "~0.2.3" + fd-slicer "~1.1.0" + +yn@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" + integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== From b333fbe3fd4910284942504b2a4b76560b7b023c Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Thu, 22 Dec 2022 11:04:12 +0100 Subject: [PATCH 02/55] chore: replay metrics LCP collection --- packages/replay/metrics/README.md | 7 +++ packages/replay/metrics/src/index.ts | 55 +++++++++++---------- packages/replay/metrics/src/vitals/index.ts | 26 ++++++++++ packages/replay/metrics/src/vitals/lcp.ts | 38 ++++++++++++++ 4 files changed, 100 insertions(+), 26 deletions(-) create mode 100644 packages/replay/metrics/README.md create mode 100644 packages/replay/metrics/src/vitals/index.ts create mode 100644 packages/replay/metrics/src/vitals/lcp.ts diff --git a/packages/replay/metrics/README.md b/packages/replay/metrics/README.md new file mode 100644 index 000000000000..bbf8d8cddcf5 --- /dev/null +++ b/packages/replay/metrics/README.md @@ -0,0 +1,7 @@ +# Replay performance metrics + +Evaluates Replay impact on website performance by running a web app in Chromium via Puppeteer and collecting various metrics. + +## Resources + +* https://github.com/addyosmani/puppeteer-webperf diff --git a/packages/replay/metrics/src/index.ts b/packages/replay/metrics/src/index.ts index c769557e3d00..bcbac9065c78 100644 --- a/packages/replay/metrics/src/index.ts +++ b/packages/replay/metrics/src/index.ts @@ -1,42 +1,45 @@ -import puppeteer from 'puppeteer'; +import * as puppeteer from 'puppeteer'; + +import { WebVitals, WebVitalsCollector } from './vitals/index.js'; + +const cpuThrottling = 4; +const networkConditions = puppeteer.PredefinedNetworkConditions['Fast 3G']; + +class Metrics { + constructor( + public url: string, public pageMetrics: puppeteer.Metrics, + public vitals: WebVitals) {} +} class MetricsCollector { - public async run(): Promise { - const browser = await puppeteer.launch({ headless: false }); + constructor(public url: string) {} + + public async run(): Promise { + const browser = await puppeteer.launch({headless: false,}); try { const page = await browser.newPage(); - await page.goto('https://developers.google.com/web/'); - - // Type into search box. - await page.type('.devsite-search-field', 'Headless Chrome'); + const vitalsCollector = new WebVitalsCollector(page); + await vitalsCollector.setup(); - // Wait for suggest overlay to appear and click "show all results". - const allResultsSelector = '.devsite-suggest-all-results'; - await page.waitForSelector(allResultsSelector); - await page.click(allResultsSelector); + // Simulated throttling + await page.emulateNetworkConditions(networkConditions); + await page.emulateCPUThrottling(cpuThrottling); - // Wait for the results page to load and display the results. - const resultsSelector = '.gsc-results .gs-title'; - await page.waitForSelector(resultsSelector); + await page.goto(this.url, { waitUntil: 'load', timeout: 60000 }); - // // Extract the results from the page. - // const links = await page.evaluate(resultsSelector => { - // return [...document.querySelectorAll(resultsSelector)].map(anchor => { - // const title = anchor.textContent.split('|')[0].trim(); - // return `${title} - ${anchor.href}`; - // }); - // }, resultsSelector); + const pageMetrics = await page.metrics(); + const vitals = await vitalsCollector.collect(); - // // Print all the files. - // console.log(links.join('\n')); + return new Metrics(this.url, pageMetrics, vitals); } finally { await browser.close(); } } } - void (async () => { - const collector = new MetricsCollector(); - await collector.run(); +void (async () => { + const collector = new MetricsCollector('https://developers.google.com/web/'); + const metrics = await collector.run(); + console.log(metrics); })(); diff --git a/packages/replay/metrics/src/vitals/index.ts b/packages/replay/metrics/src/vitals/index.ts new file mode 100644 index 000000000000..7dbcabf7b140 --- /dev/null +++ b/packages/replay/metrics/src/vitals/index.ts @@ -0,0 +1,26 @@ +import * as puppeteer from 'puppeteer'; + +import {LCP} from './lcp.js'; + +export {WebVitals, WebVitalsCollector}; + + +class WebVitals { + constructor(public lcp: number) {} +} + +class WebVitalsCollector { + private _lcp!: LCP; + + constructor(page: puppeteer.Page) { + this._lcp = new LCP(page); + } + + public async setup(): Promise { + await this._lcp.setup(); + } + + public async collect(): Promise { + return new WebVitals(await this._lcp.collect()); + } +} diff --git a/packages/replay/metrics/src/vitals/lcp.ts b/packages/replay/metrics/src/vitals/lcp.ts new file mode 100644 index 000000000000..5c5c835dec5f --- /dev/null +++ b/packages/replay/metrics/src/vitals/lcp.ts @@ -0,0 +1,38 @@ +import * as puppeteer from 'puppeteer'; + +export {LCP}; + +class LCP { + constructor( + private _page: puppeteer.Page) {} + + public async setup(): Promise { + await this._page.evaluateOnNewDocument(calcLCP); + } + + public async collect(): Promise { + const result = await this._page.evaluate('window.largestContentfulPaint'); + return result as number; + } +} + +const calcLCP = ` +console.log('running calcLCP'); +window.largestContentfulPaint = 0; + +const observer = new PerformanceObserver((entryList) => { + const entries = entryList.getEntries(); + const lastEntry = entries[entries.length - 1]; + window.largestContentfulPaint = lastEntry.renderTime || lastEntry.loadTime; +}); + +observer.observe({ type: 'largest-contentful-paint', buffered: true }); + +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + observer.takeRecords(); + observer.disconnect(); + console.log('LCP:', window.largestContentfulPaint); + } +}); +`; From a65e18cd0e727f60c6a2c4f0b9e808363c811245 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Thu, 22 Dec 2022 13:31:28 +0100 Subject: [PATCH 03/55] cls vital collection --- packages/replay/metrics/src/index.ts | 5 ++- packages/replay/metrics/src/vitals/cls.ts | 36 +++++++++++++++++++ packages/replay/metrics/src/vitals/index.ts | 21 ++++++----- packages/replay/metrics/src/vitals/lcp.ts | 40 ++++++++++----------- 4 files changed, 68 insertions(+), 34 deletions(-) create mode 100644 packages/replay/metrics/src/vitals/cls.ts diff --git a/packages/replay/metrics/src/index.ts b/packages/replay/metrics/src/index.ts index bcbac9065c78..b43ae8b05a9d 100644 --- a/packages/replay/metrics/src/index.ts +++ b/packages/replay/metrics/src/index.ts @@ -19,13 +19,12 @@ class MetricsCollector { try { const page = await browser.newPage(); - const vitalsCollector = new WebVitalsCollector(page); - await vitalsCollector.setup(); - // Simulated throttling await page.emulateNetworkConditions(networkConditions); await page.emulateCPUThrottling(cpuThrottling); + const vitalsCollector = await WebVitalsCollector.create(page); + await page.goto(this.url, { waitUntil: 'load', timeout: 60000 }); const pageMetrics = await page.metrics(); diff --git a/packages/replay/metrics/src/vitals/cls.ts b/packages/replay/metrics/src/vitals/cls.ts new file mode 100644 index 000000000000..be89902513c3 --- /dev/null +++ b/packages/replay/metrics/src/vitals/cls.ts @@ -0,0 +1,36 @@ +import * as puppeteer from 'puppeteer'; + +export {CLS}; + +class CLS { + constructor( + private _page: puppeteer.Page) {} + + public async setup(): Promise { + await this._page.evaluateOnNewDocument(`{ + window.cumulativeLayoutShiftScore = 0; + + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (!entry.hadRecentInput) { + window.cumulativeLayoutShiftScore += entry.value; + } + } + }); + + observer.observe({type: 'layout-shift', buffered: true}); + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + observer.takeRecords(); + observer.disconnect(); + } + }); + }`); + } + + public async collect(): Promise { + const result = await this._page.evaluate('window.cumulativeLayoutShiftScore'); + return result as number; + } +} diff --git a/packages/replay/metrics/src/vitals/index.ts b/packages/replay/metrics/src/vitals/index.ts index 7dbcabf7b140..fe4c03082377 100644 --- a/packages/replay/metrics/src/vitals/index.ts +++ b/packages/replay/metrics/src/vitals/index.ts @@ -1,26 +1,29 @@ import * as puppeteer from 'puppeteer'; +import {CLS} from './cls.js'; import {LCP} from './lcp.js'; export {WebVitals, WebVitalsCollector}; class WebVitals { - constructor(public lcp: number) {} + constructor(public lcp: number, public cls: number) {} } class WebVitalsCollector { - private _lcp!: LCP; + private constructor(private _lcp: LCP, private _cls: CLS) {} - constructor(page: puppeteer.Page) { - this._lcp = new LCP(page); - } - - public async setup(): Promise { - await this._lcp.setup(); + public static async create(page: puppeteer.Page): Promise { + const result = new WebVitalsCollector(new LCP(page), new CLS(page)); + await result._lcp.setup(); + await result._cls.setup(); + return result; } public async collect(): Promise { - return new WebVitals(await this._lcp.collect()); + return new WebVitals( + await this._lcp.collect(), + await this._cls.collect(), + ); } } diff --git a/packages/replay/metrics/src/vitals/lcp.ts b/packages/replay/metrics/src/vitals/lcp.ts index 5c5c835dec5f..e7903f126de5 100644 --- a/packages/replay/metrics/src/vitals/lcp.ts +++ b/packages/replay/metrics/src/vitals/lcp.ts @@ -7,7 +7,24 @@ class LCP { private _page: puppeteer.Page) {} public async setup(): Promise { - await this._page.evaluateOnNewDocument(calcLCP); + await this._page.evaluateOnNewDocument(`{ + window.largestContentfulPaint = 0; + + const observer = new PerformanceObserver((list) => { + const entries = list.getEntries(); + const lastEntry = entries[entries.length - 1]; + window.largestContentfulPaint = lastEntry.renderTime || lastEntry.loadTime; + }); + + observer.observe({ type: 'largest-contentful-paint', buffered: true }); + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + observer.takeRecords(); + observer.disconnect(); + } + }); + }`); } public async collect(): Promise { @@ -15,24 +32,3 @@ class LCP { return result as number; } } - -const calcLCP = ` -console.log('running calcLCP'); -window.largestContentfulPaint = 0; - -const observer = new PerformanceObserver((entryList) => { - const entries = entryList.getEntries(); - const lastEntry = entries[entries.length - 1]; - window.largestContentfulPaint = lastEntry.renderTime || lastEntry.loadTime; -}); - -observer.observe({ type: 'largest-contentful-paint', buffered: true }); - -document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'hidden') { - observer.takeRecords(); - observer.disconnect(); - console.log('LCP:', window.largestContentfulPaint); - } -}); -`; From be39bc556dc37a45cf1641f00ab6292a83e6187f Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Thu, 22 Dec 2022 14:24:38 +0100 Subject: [PATCH 04/55] replay: fid metric --- packages/replay/metrics/src/index.ts | 2 ++ packages/replay/metrics/src/vitals/cls.ts | 9 ++++-- packages/replay/metrics/src/vitals/fid.ts | 35 +++++++++++++++++++++ packages/replay/metrics/src/vitals/index.ts | 14 ++++++--- packages/replay/metrics/src/vitals/lcp.ts | 3 +- 5 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 packages/replay/metrics/src/vitals/fid.ts diff --git a/packages/replay/metrics/src/index.ts b/packages/replay/metrics/src/index.ts index b43ae8b05a9d..321f268aa647 100644 --- a/packages/replay/metrics/src/index.ts +++ b/packages/replay/metrics/src/index.ts @@ -28,6 +28,8 @@ class MetricsCollector { await page.goto(this.url, { waitUntil: 'load', timeout: 60000 }); const pageMetrics = await page.metrics(); + + // TODO FID needs some interaction to actually show a value const vitals = await vitalsCollector.collect(); return new Metrics(this.url, pageMetrics, vitals); diff --git a/packages/replay/metrics/src/vitals/cls.ts b/packages/replay/metrics/src/vitals/cls.ts index be89902513c3..96a4056e2986 100644 --- a/packages/replay/metrics/src/vitals/cls.ts +++ b/packages/replay/metrics/src/vitals/cls.ts @@ -2,18 +2,23 @@ import * as puppeteer from 'puppeteer'; export {CLS}; +// https://web.dev/cls/ class CLS { constructor( private _page: puppeteer.Page) {} public async setup(): Promise { await this._page.evaluateOnNewDocument(`{ - window.cumulativeLayoutShiftScore = 0; + window.cumulativeLayoutShiftScore = undefined; const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (!entry.hadRecentInput) { - window.cumulativeLayoutShiftScore += entry.value; + if (window.cumulativeLayoutShiftScore === undefined) { + window.cumulativeLayoutShiftScore = entry.value; + } else { + window.cumulativeLayoutShiftScore += entry.value; + } } } }); diff --git a/packages/replay/metrics/src/vitals/fid.ts b/packages/replay/metrics/src/vitals/fid.ts new file mode 100644 index 000000000000..ca96905dccb3 --- /dev/null +++ b/packages/replay/metrics/src/vitals/fid.ts @@ -0,0 +1,35 @@ +import * as puppeteer from 'puppeteer'; + +export {FID}; + +// https://web.dev/fid/ +class FID { + constructor( + private _page: puppeteer.Page) {} + + public async setup(): Promise { + await this._page.evaluateOnNewDocument(`{ + window.firstInputDelay = undefined; + + const observer = new PerformanceObserver((entryList) => { + for (const entry of entryList.getEntries()) { + window.firstInputDelay = entry.processingStart - entry.startTime; + } + }) + + observer.observe({type: 'first-input', buffered: true}); + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + observer.takeRecords(); + observer.disconnect(); + } + }); + }`); + } + + public async collect(): Promise { + const result = await this._page.evaluate('window.firstInputDelay'); + return result as number; + } +} diff --git a/packages/replay/metrics/src/vitals/index.ts b/packages/replay/metrics/src/vitals/index.ts index fe4c03082377..e3f060085f68 100644 --- a/packages/replay/metrics/src/vitals/index.ts +++ b/packages/replay/metrics/src/vitals/index.ts @@ -1,22 +1,27 @@ import * as puppeteer from 'puppeteer'; import {CLS} from './cls.js'; +import {FID} from './fid.js'; import {LCP} from './lcp.js'; export {WebVitals, WebVitalsCollector}; class WebVitals { - constructor(public lcp: number, public cls: number) {} + constructor(public lcp: number, public cls: number, public fid: number) {} } class WebVitalsCollector { - private constructor(private _lcp: LCP, private _cls: CLS) {} + private constructor(private _lcp: LCP, private _cls: CLS, private _fid: FID) { + } - public static async create(page: puppeteer.Page): Promise { - const result = new WebVitalsCollector(new LCP(page), new CLS(page)); + public static async create(page: puppeteer.Page): + Promise { + const result = + new WebVitalsCollector(new LCP(page), new CLS(page), new FID(page)); await result._lcp.setup(); await result._cls.setup(); + await result._fid.setup(); return result; } @@ -24,6 +29,7 @@ class WebVitalsCollector { return new WebVitals( await this._lcp.collect(), await this._cls.collect(), + await this._fid.collect(), ); } } diff --git a/packages/replay/metrics/src/vitals/lcp.ts b/packages/replay/metrics/src/vitals/lcp.ts index e7903f126de5..df4e55476197 100644 --- a/packages/replay/metrics/src/vitals/lcp.ts +++ b/packages/replay/metrics/src/vitals/lcp.ts @@ -2,13 +2,14 @@ import * as puppeteer from 'puppeteer'; export {LCP}; +// https://web.dev/lcp/ class LCP { constructor( private _page: puppeteer.Page) {} public async setup(): Promise { await this._page.evaluateOnNewDocument(`{ - window.largestContentfulPaint = 0; + window.largestContentfulPaint = undefined; const observer = new PerformanceObserver((list) => { const entries = list.getEntries(); From d57e56c11c7292ba551c8e5b37e151a6f657e1fa Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Thu, 22 Dec 2022 16:22:33 +0100 Subject: [PATCH 05/55] replay metrics - cpu stats --- packages/replay/metrics/src/cpu.ts | 61 ++++++++++++++++++++++++++++ packages/replay/metrics/src/index.ts | 20 ++++++--- 2 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 packages/replay/metrics/src/cpu.ts diff --git a/packages/replay/metrics/src/cpu.ts b/packages/replay/metrics/src/cpu.ts new file mode 100644 index 000000000000..4ea0c70793a8 --- /dev/null +++ b/packages/replay/metrics/src/cpu.ts @@ -0,0 +1,61 @@ +import * as puppeteer from 'puppeteer'; + +export { CpuMonitor, CpuUsageHistory, CpuSnapshot } + +class CpuUsageHistory { + constructor(public average: number, public snapshots: CpuSnapshot[]) {} +} + +class CpuSnapshot { + constructor(public timestamp: number, public usage: number) { } +} + +class MetricsDataPoint { + constructor(public timestamp: number, public activeTime: number) { }; +} + +class CpuMonitor { + public snapshots: CpuSnapshot[] = []; + public average: number = 0; + private _timer!: NodeJS.Timer; + + private constructor(private _cdp: puppeteer.CDPSession) {} + + public static async create(cdp: puppeteer.CDPSession, interval: number): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + await cdp.send('Performance.enable', { timeDomain: 'timeTicks' }) + + const monitor = new CpuMonitor(cdp); + + let { timestamp: lastTimestamp, activeTime: cumulativeActiveTime } = await monitor._collect(); + const startTime = lastTimestamp; + monitor._timer = setInterval(async () => { + const data = await monitor._collect(); + const frameDuration = data.timestamp - lastTimestamp; + let usage = frameDuration == 0 ? 0 : (data.activeTime - cumulativeActiveTime) / frameDuration; + if (usage > 1) usage = 1 + + cumulativeActiveTime = data.activeTime + monitor.snapshots.push(new CpuSnapshot(data.timestamp, usage)); + + lastTimestamp = data.timestamp + monitor.average = cumulativeActiveTime / (lastTimestamp - startTime); + }, interval) + return monitor; + } + + public stats(): CpuUsageHistory { + return new CpuUsageHistory(this.average, this.snapshots); + } + + public stop(): void { + clearInterval(this._timer); + } + + private async _collect(): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const metrics = (await this._cdp.send('Performance.getMetrics')).metrics; + const activeTime = metrics.filter(m => m.name.includes('Duration')).map(m => m.value).reduce((a, b) => a + b) + return new MetricsDataPoint(metrics.find(m => m.name === 'Timestamp')?.value || 0, activeTime); + } +} diff --git a/packages/replay/metrics/src/index.ts b/packages/replay/metrics/src/index.ts index 321f268aa647..878a7fca57cd 100644 --- a/packages/replay/metrics/src/index.ts +++ b/packages/replay/metrics/src/index.ts @@ -1,5 +1,6 @@ import * as puppeteer from 'puppeteer'; +import { CpuMonitor, CpuUsageHistory } from './cpu.js'; import { WebVitals, WebVitalsCollector } from './vitals/index.js'; const cpuThrottling = 4; @@ -7,23 +8,30 @@ const networkConditions = puppeteer.PredefinedNetworkConditions['Fast 3G']; class Metrics { constructor( - public url: string, public pageMetrics: puppeteer.Metrics, - public vitals: WebVitals) {} + public url: string, public pageMetrics: puppeteer.Metrics, + public vitals: WebVitals, public cpu: CpuUsageHistory) { } } class MetricsCollector { - constructor(public url: string) {} + constructor(public url: string) { } public async run(): Promise { - const browser = await puppeteer.launch({headless: false,}); + const disposeCallbacks : (() => Promise)[] = []; try { + const browser = await puppeteer.launch({ headless: false, }); + disposeCallbacks.push(async () => browser.close()); const page = await browser.newPage(); // Simulated throttling await page.emulateNetworkConditions(networkConditions); await page.emulateCPUThrottling(cpuThrottling); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const cdp = await page.target().createCDPSession(); + const vitalsCollector = await WebVitalsCollector.create(page); + const cpuMonitor = await CpuMonitor.create(cdp, 100); // collect 10 times per second + disposeCallbacks.push(async () => cpuMonitor.stop()); await page.goto(this.url, { waitUntil: 'load', timeout: 60000 }); @@ -32,9 +40,9 @@ class MetricsCollector { // TODO FID needs some interaction to actually show a value const vitals = await vitalsCollector.collect(); - return new Metrics(this.url, pageMetrics, vitals); + return new Metrics(this.url, pageMetrics, vitals, cpuMonitor.stats()); } finally { - await browser.close(); + disposeCallbacks.reverse().forEach((cb) => cb().catch(console.log)); } } } From cda998c1e9acb6d6163f37c927c7e6a8bc74ad44 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Thu, 22 Dec 2022 17:24:21 +0100 Subject: [PATCH 06/55] metrics memory profiler --- packages/replay/metrics/.eslintrc.js | 1 + packages/replay/metrics/src/cpu.ts | 61 --------------------- packages/replay/metrics/src/index.ts | 34 ++++++------ packages/replay/metrics/src/perf/cpu.ts | 43 +++++++++++++++ packages/replay/metrics/src/perf/memory.ts | 17 ++++++ packages/replay/metrics/src/perf/sampler.ts | 35 ++++++++++++ 6 files changed, 114 insertions(+), 77 deletions(-) delete mode 100644 packages/replay/metrics/src/cpu.ts create mode 100644 packages/replay/metrics/src/perf/cpu.ts create mode 100644 packages/replay/metrics/src/perf/memory.ts create mode 100644 packages/replay/metrics/src/perf/sampler.ts diff --git a/packages/replay/metrics/.eslintrc.js b/packages/replay/metrics/.eslintrc.js index 5527b97558fb..3c4c14a69860 100644 --- a/packages/replay/metrics/.eslintrc.js +++ b/packages/replay/metrics/.eslintrc.js @@ -5,6 +5,7 @@ module.exports = { files: ['*.ts'], rules: { 'no-console': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', }, }, ], diff --git a/packages/replay/metrics/src/cpu.ts b/packages/replay/metrics/src/cpu.ts deleted file mode 100644 index 4ea0c70793a8..000000000000 --- a/packages/replay/metrics/src/cpu.ts +++ /dev/null @@ -1,61 +0,0 @@ -import * as puppeteer from 'puppeteer'; - -export { CpuMonitor, CpuUsageHistory, CpuSnapshot } - -class CpuUsageHistory { - constructor(public average: number, public snapshots: CpuSnapshot[]) {} -} - -class CpuSnapshot { - constructor(public timestamp: number, public usage: number) { } -} - -class MetricsDataPoint { - constructor(public timestamp: number, public activeTime: number) { }; -} - -class CpuMonitor { - public snapshots: CpuSnapshot[] = []; - public average: number = 0; - private _timer!: NodeJS.Timer; - - private constructor(private _cdp: puppeteer.CDPSession) {} - - public static async create(cdp: puppeteer.CDPSession, interval: number): Promise { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - await cdp.send('Performance.enable', { timeDomain: 'timeTicks' }) - - const monitor = new CpuMonitor(cdp); - - let { timestamp: lastTimestamp, activeTime: cumulativeActiveTime } = await monitor._collect(); - const startTime = lastTimestamp; - monitor._timer = setInterval(async () => { - const data = await monitor._collect(); - const frameDuration = data.timestamp - lastTimestamp; - let usage = frameDuration == 0 ? 0 : (data.activeTime - cumulativeActiveTime) / frameDuration; - if (usage > 1) usage = 1 - - cumulativeActiveTime = data.activeTime - monitor.snapshots.push(new CpuSnapshot(data.timestamp, usage)); - - lastTimestamp = data.timestamp - monitor.average = cumulativeActiveTime / (lastTimestamp - startTime); - }, interval) - return monitor; - } - - public stats(): CpuUsageHistory { - return new CpuUsageHistory(this.average, this.snapshots); - } - - public stop(): void { - clearInterval(this._timer); - } - - private async _collect(): Promise { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const metrics = (await this._cdp.send('Performance.getMetrics')).metrics; - const activeTime = metrics.filter(m => m.name.includes('Duration')).map(m => m.value).reduce((a, b) => a + b) - return new MetricsDataPoint(metrics.find(m => m.name === 'Timestamp')?.value || 0, activeTime); - } -} diff --git a/packages/replay/metrics/src/index.ts b/packages/replay/metrics/src/index.ts index 878a7fca57cd..f832ecf3f7eb 100644 --- a/packages/replay/metrics/src/index.ts +++ b/packages/replay/metrics/src/index.ts @@ -1,24 +1,27 @@ import * as puppeteer from 'puppeteer'; -import { CpuMonitor, CpuUsageHistory } from './cpu.js'; -import { WebVitals, WebVitalsCollector } from './vitals/index.js'; +import {CpuUsage} from './perf/cpu.js'; +import {JsHeapUsage} from './perf/memory.js'; +import {PerfMetricsSampler} from './perf/sampler.js'; +import {WebVitals, WebVitalsCollector} from './vitals/index.js'; const cpuThrottling = 4; const networkConditions = puppeteer.PredefinedNetworkConditions['Fast 3G']; class Metrics { - constructor( - public url: string, public pageMetrics: puppeteer.Metrics, - public vitals: WebVitals, public cpu: CpuUsageHistory) { } + constructor(public url: string, public vitals: WebVitals, + public cpu: CpuUsage, public memory: JsHeapUsage) {} } class MetricsCollector { - constructor(public url: string) { } + constructor(public url: string) {} public async run(): Promise { - const disposeCallbacks : (() => Promise)[] = []; + const disposeCallbacks: (() => Promise)[] = []; try { - const browser = await puppeteer.launch({ headless: false, }); + const browser = await puppeteer.launch({ + headless : false, + }); disposeCallbacks.push(async () => browser.close()); const page = await browser.newPage(); @@ -26,21 +29,20 @@ class MetricsCollector { await page.emulateNetworkConditions(networkConditions); await page.emulateCPUThrottling(cpuThrottling); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const cdp = await page.target().createCDPSession(); + const perfSampler = await PerfMetricsSampler.create( + page, 100); // collect 10 times per second + disposeCallbacks.push(async () => perfSampler.stop()); + const cpu = new CpuUsage(perfSampler); + const jsHeap = new JsHeapUsage(perfSampler); const vitalsCollector = await WebVitalsCollector.create(page); - const cpuMonitor = await CpuMonitor.create(cdp, 100); // collect 10 times per second - disposeCallbacks.push(async () => cpuMonitor.stop()); - await page.goto(this.url, { waitUntil: 'load', timeout: 60000 }); - - const pageMetrics = await page.metrics(); + await page.goto(this.url, {waitUntil : 'load', timeout : 60000}); // TODO FID needs some interaction to actually show a value const vitals = await vitalsCollector.collect(); - return new Metrics(this.url, pageMetrics, vitals, cpuMonitor.stats()); + return new Metrics(this.url, vitals, cpu, jsHeap); } finally { disposeCallbacks.reverse().forEach((cb) => cb().catch(console.log)); } diff --git a/packages/replay/metrics/src/perf/cpu.ts b/packages/replay/metrics/src/perf/cpu.ts new file mode 100644 index 000000000000..8a7ec3dad87a --- /dev/null +++ b/packages/replay/metrics/src/perf/cpu.ts @@ -0,0 +1,43 @@ +import * as puppeteer from 'puppeteer'; + +import { PerfMetricsSampler } from './sampler'; + +export { CpuUsage, CpuSnapshot } + +class CpuSnapshot { + constructor(public timestamp: number, public usage: number) { } +} + +class MetricsDataPoint { + constructor(public timestamp: number, public activeTime: number) { }; +} + +class CpuUsage { + public snapshots: CpuSnapshot[] = []; + public average: number = 0; + private _initial?: MetricsDataPoint = undefined; + private _startTime!: number; + private _lastTimestamp!: number; + private _cumulativeActiveTime!: number; + + public constructor(sampler: PerfMetricsSampler) { + sampler.subscribe(this._collect.bind(this)); + } + + private async _collect(metrics: puppeteer.Metrics): Promise { + const data = new MetricsDataPoint (metrics.Timestamp!, metrics.TaskDuration! + metrics.TaskDuration! + metrics.LayoutDuration! + metrics.ScriptDuration!); + if (this._initial == undefined) { + this._initial = data; + this._startTime = data.timestamp; + } else { + const frameDuration = data.timestamp - this._lastTimestamp; + let usage = frameDuration == 0 ? 0 : (data.activeTime - this._cumulativeActiveTime) / frameDuration; + if (usage > 1) usage = 1 + + this.snapshots.push(new CpuSnapshot(data.timestamp, usage)); + this.average = data.activeTime / (data.timestamp - this._startTime); + } + this._lastTimestamp = data.timestamp; + this._cumulativeActiveTime = data.activeTime; + } +} diff --git a/packages/replay/metrics/src/perf/memory.ts b/packages/replay/metrics/src/perf/memory.ts new file mode 100644 index 000000000000..2e480a188f16 --- /dev/null +++ b/packages/replay/metrics/src/perf/memory.ts @@ -0,0 +1,17 @@ +import * as puppeteer from 'puppeteer'; + +import { PerfMetricsSampler } from './sampler'; + +export { JsHeapUsage } + +class JsHeapUsage { + public snapshots: number[] = []; + + public constructor(sampler: PerfMetricsSampler) { + sampler.subscribe(this._collect.bind(this)); + } + + private async _collect(metrics: puppeteer.Metrics): Promise { + this.snapshots.push(metrics.JSHeapUsedSize!); + } +} diff --git a/packages/replay/metrics/src/perf/sampler.ts b/packages/replay/metrics/src/perf/sampler.ts new file mode 100644 index 000000000000..88d2d886b8b2 --- /dev/null +++ b/packages/replay/metrics/src/perf/sampler.ts @@ -0,0 +1,35 @@ +import * as puppeteer from 'puppeteer'; + +export { PerfMetricsSampler } + +type PerfMetricsConsumer = (metrics: puppeteer.Metrics) => Promise; + +class PerfMetricsSampler { + private _consumers: PerfMetricsConsumer[] = []; + private _timer!: NodeJS.Timer; + + public static async create(page: puppeteer.Page, interval: number): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const cdp = await page.target().createCDPSession(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + await cdp.send('Performance.enable', { timeDomain: 'timeTicks' }) + + const self = new PerfMetricsSampler(); + + self._timer = setInterval(async () => { + const metrics = await page.metrics(); + self._consumers.forEach((cb) => cb(metrics).catch(console.log)); + }, interval); + + return self; + } + + public subscribe(consumer: PerfMetricsConsumer): void { + this._consumers.push(consumer); + } + + public stop(): void { + clearInterval(this._timer); + } +} From bcac89ca81c4ce2cd76e728f03b8fdea7de1d631 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Thu, 22 Dec 2022 17:44:55 +0100 Subject: [PATCH 07/55] replay metrics - refactor scenarios --- packages/replay/metrics/src/index.ts | 19 +++++++++---------- packages/replay/metrics/src/scenarios.ts | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 10 deletions(-) create mode 100644 packages/replay/metrics/src/scenarios.ts diff --git a/packages/replay/metrics/src/index.ts b/packages/replay/metrics/src/index.ts index f832ecf3f7eb..9b3f739eeee6 100644 --- a/packages/replay/metrics/src/index.ts +++ b/packages/replay/metrics/src/index.ts @@ -3,20 +3,19 @@ import * as puppeteer from 'puppeteer'; import {CpuUsage} from './perf/cpu.js'; import {JsHeapUsage} from './perf/memory.js'; import {PerfMetricsSampler} from './perf/sampler.js'; -import {WebVitals, WebVitalsCollector} from './vitals/index.js'; +import { LoadPageScenario, Scenario } from './scenarios.js'; +import { WebVitals, WebVitalsCollector } from './vitals/index.js'; const cpuThrottling = 4; const networkConditions = puppeteer.PredefinedNetworkConditions['Fast 3G']; class Metrics { - constructor(public url: string, public vitals: WebVitals, + constructor(public scenario: Scenario, public vitals: WebVitals, public cpu: CpuUsage, public memory: JsHeapUsage) {} } class MetricsCollector { - constructor(public url: string) {} - - public async run(): Promise { + public async run(scenario: Scenario): Promise { const disposeCallbacks: (() => Promise)[] = []; try { const browser = await puppeteer.launch({ @@ -37,12 +36,12 @@ class MetricsCollector { const vitalsCollector = await WebVitalsCollector.create(page); - await page.goto(this.url, {waitUntil : 'load', timeout : 60000}); + await scenario.run(browser, page); - // TODO FID needs some interaction to actually show a value + // NOTE: FID needs some interaction to actually show a value const vitals = await vitalsCollector.collect(); - return new Metrics(this.url, vitals, cpu, jsHeap); + return new Metrics(scenario, vitals, cpu, jsHeap); } finally { disposeCallbacks.reverse().forEach((cb) => cb().catch(console.log)); } @@ -50,7 +49,7 @@ class MetricsCollector { } void (async () => { - const collector = new MetricsCollector('https://developers.google.com/web/'); - const metrics = await collector.run(); + const collector = new MetricsCollector(); + const metrics = await collector.run(new LoadPageScenario('https://developers.google.com/web/')); console.log(metrics); })(); diff --git a/packages/replay/metrics/src/scenarios.ts b/packages/replay/metrics/src/scenarios.ts new file mode 100644 index 000000000000..947d9a4a6cc2 --- /dev/null +++ b/packages/replay/metrics/src/scenarios.ts @@ -0,0 +1,15 @@ +import * as puppeteer from 'puppeteer'; + +// A testing scenario we want to collect metrics for. +export interface Scenario { + run(browser: puppeteer.Browser, page: puppeteer.Page): Promise; +} + +// A simple scenario that just loads the given URL. +export class LoadPageScenario implements Scenario { + public constructor(public url: string) { } + + public async run(_: puppeteer.Browser, page: puppeteer.Page): Promise { + await page.goto(this.url, {waitUntil : 'load', timeout : 60000}); + } +} From 68300fcae9a4bac662d1feed46ce82a56702c54f Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Wed, 28 Dec 2022 10:10:45 +0100 Subject: [PATCH 08/55] chore: formatting --- packages/replay/metrics/src/index.ts | 12 ++++++------ packages/replay/metrics/src/perf/cpu.ts | 2 +- packages/replay/metrics/src/scenarios.ts | 2 +- packages/replay/metrics/src/vitals/cls.ts | 4 ++-- packages/replay/metrics/src/vitals/fid.ts | 4 ++-- packages/replay/metrics/src/vitals/index.ts | 20 ++++++++++---------- packages/replay/metrics/src/vitals/lcp.ts | 4 ++-- 7 files changed, 24 insertions(+), 24 deletions(-) diff --git a/packages/replay/metrics/src/index.ts b/packages/replay/metrics/src/index.ts index 9b3f739eeee6..dc848b8ef546 100644 --- a/packages/replay/metrics/src/index.ts +++ b/packages/replay/metrics/src/index.ts @@ -1,8 +1,8 @@ import * as puppeteer from 'puppeteer'; -import {CpuUsage} from './perf/cpu.js'; -import {JsHeapUsage} from './perf/memory.js'; -import {PerfMetricsSampler} from './perf/sampler.js'; +import { CpuUsage } from './perf/cpu.js'; +import { JsHeapUsage } from './perf/memory.js'; +import { PerfMetricsSampler } from './perf/sampler.js'; import { LoadPageScenario, Scenario } from './scenarios.js'; import { WebVitals, WebVitalsCollector } from './vitals/index.js'; @@ -11,7 +11,7 @@ const networkConditions = puppeteer.PredefinedNetworkConditions['Fast 3G']; class Metrics { constructor(public scenario: Scenario, public vitals: WebVitals, - public cpu: CpuUsage, public memory: JsHeapUsage) {} + public cpu: CpuUsage, public memory: JsHeapUsage) { } } class MetricsCollector { @@ -19,7 +19,7 @@ class MetricsCollector { const disposeCallbacks: (() => Promise)[] = []; try { const browser = await puppeteer.launch({ - headless : false, + headless: false, }); disposeCallbacks.push(async () => browser.close()); const page = await browser.newPage(); @@ -29,7 +29,7 @@ class MetricsCollector { await page.emulateCPUThrottling(cpuThrottling); const perfSampler = await PerfMetricsSampler.create( - page, 100); // collect 10 times per second + page, 100); // collect 10 times per second disposeCallbacks.push(async () => perfSampler.stop()); const cpu = new CpuUsage(perfSampler); const jsHeap = new JsHeapUsage(perfSampler); diff --git a/packages/replay/metrics/src/perf/cpu.ts b/packages/replay/metrics/src/perf/cpu.ts index 8a7ec3dad87a..dcee3940f420 100644 --- a/packages/replay/metrics/src/perf/cpu.ts +++ b/packages/replay/metrics/src/perf/cpu.ts @@ -25,7 +25,7 @@ class CpuUsage { } private async _collect(metrics: puppeteer.Metrics): Promise { - const data = new MetricsDataPoint (metrics.Timestamp!, metrics.TaskDuration! + metrics.TaskDuration! + metrics.LayoutDuration! + metrics.ScriptDuration!); + const data = new MetricsDataPoint(metrics.Timestamp!, metrics.TaskDuration! + metrics.TaskDuration! + metrics.LayoutDuration! + metrics.ScriptDuration!); if (this._initial == undefined) { this._initial = data; this._startTime = data.timestamp; diff --git a/packages/replay/metrics/src/scenarios.ts b/packages/replay/metrics/src/scenarios.ts index 947d9a4a6cc2..4945ea13d0ad 100644 --- a/packages/replay/metrics/src/scenarios.ts +++ b/packages/replay/metrics/src/scenarios.ts @@ -10,6 +10,6 @@ export class LoadPageScenario implements Scenario { public constructor(public url: string) { } public async run(_: puppeteer.Browser, page: puppeteer.Page): Promise { - await page.goto(this.url, {waitUntil : 'load', timeout : 60000}); + await page.goto(this.url, { waitUntil: 'load', timeout: 60000 }); } } diff --git a/packages/replay/metrics/src/vitals/cls.ts b/packages/replay/metrics/src/vitals/cls.ts index 96a4056e2986..57175011d52b 100644 --- a/packages/replay/metrics/src/vitals/cls.ts +++ b/packages/replay/metrics/src/vitals/cls.ts @@ -1,11 +1,11 @@ import * as puppeteer from 'puppeteer'; -export {CLS}; +export { CLS }; // https://web.dev/cls/ class CLS { constructor( - private _page: puppeteer.Page) {} + private _page: puppeteer.Page) { } public async setup(): Promise { await this._page.evaluateOnNewDocument(`{ diff --git a/packages/replay/metrics/src/vitals/fid.ts b/packages/replay/metrics/src/vitals/fid.ts index ca96905dccb3..dac573010585 100644 --- a/packages/replay/metrics/src/vitals/fid.ts +++ b/packages/replay/metrics/src/vitals/fid.ts @@ -1,11 +1,11 @@ import * as puppeteer from 'puppeteer'; -export {FID}; +export { FID }; // https://web.dev/fid/ class FID { constructor( - private _page: puppeteer.Page) {} + private _page: puppeteer.Page) { } public async setup(): Promise { await this._page.evaluateOnNewDocument(`{ diff --git a/packages/replay/metrics/src/vitals/index.ts b/packages/replay/metrics/src/vitals/index.ts index e3f060085f68..1cb2b7aa0077 100644 --- a/packages/replay/metrics/src/vitals/index.ts +++ b/packages/replay/metrics/src/vitals/index.ts @@ -1,14 +1,14 @@ import * as puppeteer from 'puppeteer'; -import {CLS} from './cls.js'; -import {FID} from './fid.js'; -import {LCP} from './lcp.js'; +import { CLS } from './cls.js'; +import { FID } from './fid.js'; +import { LCP } from './lcp.js'; -export {WebVitals, WebVitalsCollector}; +export { WebVitals, WebVitalsCollector }; class WebVitals { - constructor(public lcp: number, public cls: number, public fid: number) {} + constructor(public lcp: number, public cls: number, public fid: number) { } } class WebVitalsCollector { @@ -16,9 +16,9 @@ class WebVitalsCollector { } public static async create(page: puppeteer.Page): - Promise { + Promise { const result = - new WebVitalsCollector(new LCP(page), new CLS(page), new FID(page)); + new WebVitalsCollector(new LCP(page), new CLS(page), new FID(page)); await result._lcp.setup(); await result._cls.setup(); await result._fid.setup(); @@ -27,9 +27,9 @@ class WebVitalsCollector { public async collect(): Promise { return new WebVitals( - await this._lcp.collect(), - await this._cls.collect(), - await this._fid.collect(), + await this._lcp.collect(), + await this._cls.collect(), + await this._fid.collect(), ); } } diff --git a/packages/replay/metrics/src/vitals/lcp.ts b/packages/replay/metrics/src/vitals/lcp.ts index df4e55476197..247ce0fcd3b6 100644 --- a/packages/replay/metrics/src/vitals/lcp.ts +++ b/packages/replay/metrics/src/vitals/lcp.ts @@ -1,11 +1,11 @@ import * as puppeteer from 'puppeteer'; -export {LCP}; +export { LCP }; // https://web.dev/lcp/ class LCP { constructor( - private _page: puppeteer.Page) {} + private _page: puppeteer.Page) { } public async setup(): Promise { await this._page.evaluateOnNewDocument(`{ From 628a877d652ba09206b32f9c6a302bb41a426cf0 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Wed, 28 Dec 2022 10:59:09 +0100 Subject: [PATCH 09/55] chore: refactoring --- .vscode/launch.json | 2 +- packages/replay/metrics/package.json | 4 ++-- .../metrics/src/{index.ts => collector.ts} | 16 +++++----------- packages/replay/metrics/src/run.ts | 8 ++++++++ 4 files changed, 16 insertions(+), 14 deletions(-) rename packages/replay/metrics/src/{index.ts => collector.ts} (75%) create mode 100644 packages/replay/metrics/src/run.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 598d4363c1f7..c840cd5c7dbb 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -42,7 +42,7 @@ "name": "Debug replay metrics script", "request": "launch", "cwd": "${workspaceFolder}/packages/replay/metrics/", - "program": "${workspaceFolder}/packages/replay/metrics/src/index.ts", + "program": "${workspaceFolder}/packages/replay/metrics/src/run.ts", "preLaunchTask": "Build Replay metrics script", }, // Run rollup using the config file which is in the currently active tab. diff --git a/packages/replay/metrics/package.json b/packages/replay/metrics/package.json index 3ecb4819bce2..0a465fd8214c 100644 --- a/packages/replay/metrics/package.json +++ b/packages/replay/metrics/package.json @@ -7,8 +7,8 @@ "type": "module", "scripts": { "build": "tsc", - "start": "node ./build/index.js", - "dev": "ts-node-esm ./src/index.ts" + "start": "node ./build/run.js", + "dev": "ts-node-esm ./src/run.ts" }, "dependencies": { "@types/node": "^18.11.17", diff --git a/packages/replay/metrics/src/index.ts b/packages/replay/metrics/src/collector.ts similarity index 75% rename from packages/replay/metrics/src/index.ts rename to packages/replay/metrics/src/collector.ts index dc848b8ef546..91db6c940270 100644 --- a/packages/replay/metrics/src/index.ts +++ b/packages/replay/metrics/src/collector.ts @@ -3,7 +3,7 @@ import * as puppeteer from 'puppeteer'; import { CpuUsage } from './perf/cpu.js'; import { JsHeapUsage } from './perf/memory.js'; import { PerfMetricsSampler } from './perf/sampler.js'; -import { LoadPageScenario, Scenario } from './scenarios.js'; +import { Scenario } from './scenarios.js'; import { WebVitals, WebVitalsCollector } from './vitals/index.js'; const cpuThrottling = 4; @@ -14,7 +14,7 @@ class Metrics { public cpu: CpuUsage, public memory: JsHeapUsage) { } } -class MetricsCollector { +export class MetricsCollector { public async run(scenario: Scenario): Promise { const disposeCallbacks: (() => Promise)[] = []; try { @@ -24,12 +24,12 @@ class MetricsCollector { disposeCallbacks.push(async () => browser.close()); const page = await browser.newPage(); - // Simulated throttling + // Simulate throttling. await page.emulateNetworkConditions(networkConditions); await page.emulateCPUThrottling(cpuThrottling); - const perfSampler = await PerfMetricsSampler.create( - page, 100); // collect 10 times per second + // Collect CPU and memory info 10 times per second. + const perfSampler = await PerfMetricsSampler.create(page, 100); disposeCallbacks.push(async () => perfSampler.stop()); const cpu = new CpuUsage(perfSampler); const jsHeap = new JsHeapUsage(perfSampler); @@ -47,9 +47,3 @@ class MetricsCollector { } } } - -void (async () => { - const collector = new MetricsCollector(); - const metrics = await collector.run(new LoadPageScenario('https://developers.google.com/web/')); - console.log(metrics); -})(); diff --git a/packages/replay/metrics/src/run.ts b/packages/replay/metrics/src/run.ts new file mode 100644 index 000000000000..786f8e685887 --- /dev/null +++ b/packages/replay/metrics/src/run.ts @@ -0,0 +1,8 @@ +import { MetricsCollector } from './collector.js'; +import { LoadPageScenario } from './scenarios.js'; + +void (async () => { + const collector = new MetricsCollector(); + const metrics = await collector.run(new LoadPageScenario('https://developers.google.com/web/')); + console.log(metrics); +})(); From 9f37006f7917261234d178f4c7064a1ea87d32dd Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Wed, 28 Dec 2022 15:58:31 +0100 Subject: [PATCH 10/55] define test cases for metrics collection --- packages/replay/metrics/src/collector.ts | 41 ++++++++++++++++++++++-- packages/replay/metrics/src/perf/cpu.ts | 1 - packages/replay/metrics/src/run.ts | 26 +++++++++++---- packages/replay/metrics/src/scenarios.ts | 14 ++++++++ 4 files changed, 71 insertions(+), 11 deletions(-) diff --git a/packages/replay/metrics/src/collector.ts b/packages/replay/metrics/src/collector.ts index 91db6c940270..932fc5f780f6 100644 --- a/packages/replay/metrics/src/collector.ts +++ b/packages/replay/metrics/src/collector.ts @@ -1,21 +1,56 @@ +import assert from 'assert'; import * as puppeteer from 'puppeteer'; import { CpuUsage } from './perf/cpu.js'; import { JsHeapUsage } from './perf/memory.js'; import { PerfMetricsSampler } from './perf/sampler.js'; -import { Scenario } from './scenarios.js'; +import { Scenario, TestCase } from './scenarios.js'; import { WebVitals, WebVitalsCollector } from './vitals/index.js'; const cpuThrottling = 4; const networkConditions = puppeteer.PredefinedNetworkConditions['Fast 3G']; -class Metrics { +export class Metrics { constructor(public scenario: Scenario, public vitals: WebVitals, public cpu: CpuUsage, public memory: JsHeapUsage) { } } export class MetricsCollector { - public async run(scenario: Scenario): Promise { + public async execute(testCase: TestCase): Promise { + console.log(`Executing test case ${testCase.name}`); + console.group(); + for (let i = 1; i <= testCase.tries; i++) { + let aResults = await this.collect('A', testCase.a, testCase.runs); + let bResults = await this.collect('B', testCase.b, testCase.runs); + if (await testCase.test(aResults, bResults)) { + console.groupEnd(); + console.log(`Test case ${testCase.name} passed on try ${i}/${testCase.tries}`); + break; + } else if (i != testCase.tries) { + console.log(`Test case ${testCase.name} failed on try ${i}/${testCase.tries}`); + } else { + console.groupEnd(); + console.error(`Test case ${testCase.name} failed`); + } + } + } + + private async collect(name: string, scenario: Scenario, runs: number): Promise { + const label = `Scenario ${name} data collection (total ${runs} runs)`; + console.time(label); + const results: Metrics[] = []; + for (let run = 0; run < runs; run++) { + let innerLabel = `Scenario ${name} data collection, run ${run}/${runs}`; + console.time(innerLabel); + results.push(await this.run(scenario)); + console.timeEnd(innerLabel); + } + console.timeEnd(label); + assert(results.length == runs); + return results; + } + + private async run(scenario: Scenario): Promise { const disposeCallbacks: (() => Promise)[] = []; try { const browser = await puppeteer.launch({ diff --git a/packages/replay/metrics/src/perf/cpu.ts b/packages/replay/metrics/src/perf/cpu.ts index dcee3940f420..196bc14aef52 100644 --- a/packages/replay/metrics/src/perf/cpu.ts +++ b/packages/replay/metrics/src/perf/cpu.ts @@ -32,7 +32,6 @@ class CpuUsage { } else { const frameDuration = data.timestamp - this._lastTimestamp; let usage = frameDuration == 0 ? 0 : (data.activeTime - this._cumulativeActiveTime) / frameDuration; - if (usage > 1) usage = 1 this.snapshots.push(new CpuSnapshot(data.timestamp, usage)); this.average = data.activeTime / (data.timestamp - this._startTime); diff --git a/packages/replay/metrics/src/run.ts b/packages/replay/metrics/src/run.ts index 786f8e685887..70087ac29827 100644 --- a/packages/replay/metrics/src/run.ts +++ b/packages/replay/metrics/src/run.ts @@ -1,8 +1,20 @@ -import { MetricsCollector } from './collector.js'; -import { LoadPageScenario } from './scenarios.js'; +import { Metrics, MetricsCollector } from './collector.js'; +import { LoadPageScenario, TestCase } from './scenarios.js'; -void (async () => { - const collector = new MetricsCollector(); - const metrics = await collector.run(new LoadPageScenario('https://developers.google.com/web/')); - console.log(metrics); -})(); +const tests: TestCase[] = [ + { + name: 'dummy', + a: new LoadPageScenario('https://developers.google.com/web/'), + b: new LoadPageScenario('https://developers.google.com/'), + runs: 1, + tries: 1, + async test(_aResults: Metrics[], _bResults: Metrics[]) { + return true; + }, + } +] + +const collector = new MetricsCollector(); +for (const testCase of tests) { + await collector.execute(testCase); +} diff --git a/packages/replay/metrics/src/scenarios.ts b/packages/replay/metrics/src/scenarios.ts index 4945ea13d0ad..9ef7c4dced97 100644 --- a/packages/replay/metrics/src/scenarios.ts +++ b/packages/replay/metrics/src/scenarios.ts @@ -1,10 +1,24 @@ import * as puppeteer from 'puppeteer'; +import { Metrics } from './collector'; // A testing scenario we want to collect metrics for. export interface Scenario { run(browser: puppeteer.Browser, page: puppeteer.Page): Promise; } +// Two scenarios that are compared to each other. +export interface TestCase { + name: string; + a: Scenario; + b: Scenario; + runs: number; + tries: number; + + // Test function that will be executed and given scenarios A and B result sets. + // Each has exactly `runs` number of items. + test(aResults: Metrics[], bResults: Metrics[]): Promise; +} + // A simple scenario that just loads the given URL. export class LoadPageScenario implements Scenario { public constructor(public url: string) { } From 2c4aa3c7c815b399528bfd96c2797002109684f2 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Thu, 29 Dec 2022 10:56:06 +0100 Subject: [PATCH 11/55] refactor: split up data collection --- .vscode/launch.json | 4 ++-- packages/replay/metrics/package.json | 3 +-- packages/replay/metrics/src/{run.ts => collect-dev.ts} | 0 packages/replay/metrics/src/collector.ts | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) rename packages/replay/metrics/src/{run.ts => collect-dev.ts} (100%) diff --git a/.vscode/launch.json b/.vscode/launch.json index c840cd5c7dbb..26f846741de2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -39,10 +39,10 @@ }, { "type": "node", - "name": "Debug replay metrics script", + "name": "Debug replay metrics collection script", "request": "launch", "cwd": "${workspaceFolder}/packages/replay/metrics/", - "program": "${workspaceFolder}/packages/replay/metrics/src/run.ts", + "program": "${workspaceFolder}/packages/replay/metrics/src/collect-dev.ts", "preLaunchTask": "Build Replay metrics script", }, // Run rollup using the config file which is in the currently active tab. diff --git a/packages/replay/metrics/package.json b/packages/replay/metrics/package.json index 0a465fd8214c..fe5144a2a59c 100644 --- a/packages/replay/metrics/package.json +++ b/packages/replay/metrics/package.json @@ -7,8 +7,7 @@ "type": "module", "scripts": { "build": "tsc", - "start": "node ./build/run.js", - "dev": "ts-node-esm ./src/run.ts" + "collect-dev": "ts-node-esm ./src/collect-dev.ts" }, "dependencies": { "@types/node": "^18.11.17", diff --git a/packages/replay/metrics/src/run.ts b/packages/replay/metrics/src/collect-dev.ts similarity index 100% rename from packages/replay/metrics/src/run.ts rename to packages/replay/metrics/src/collect-dev.ts diff --git a/packages/replay/metrics/src/collector.ts b/packages/replay/metrics/src/collector.ts index 932fc5f780f6..385a68497cd0 100644 --- a/packages/replay/metrics/src/collector.ts +++ b/packages/replay/metrics/src/collector.ts @@ -46,7 +46,7 @@ export class MetricsCollector { console.timeEnd(innerLabel); } console.timeEnd(label); - assert(results.length == runs); + assert.equal(results.length, runs); return results; } From 945845eb2a872cbe379c835ce9896e073ceb95fb Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Thu, 29 Dec 2022 15:22:16 +0100 Subject: [PATCH 12/55] write collected metrics to file --- .vscode/launch.json | 2 +- packages/replay/metrics/.gitignore | 1 + packages/replay/metrics/configs/README.md | 4 ++ .../replay/metrics/configs/dev/collect.ts | 17 +++++ packages/replay/metrics/configs/dev/env.ts | 2 + packages/replay/metrics/package.json | 2 +- packages/replay/metrics/src/collect-dev.ts | 20 ------ packages/replay/metrics/src/collector.ts | 24 ++++--- packages/replay/metrics/src/perf/cpu.ts | 20 +++++- packages/replay/metrics/src/perf/memory.ts | 14 +++- packages/replay/metrics/src/results/result.ts | 29 ++++++++ .../replay/metrics/src/results/results-set.ts | 68 +++++++++++++++++++ packages/replay/metrics/src/vitals/index.ts | 4 ++ 13 files changed, 171 insertions(+), 36 deletions(-) create mode 100644 packages/replay/metrics/.gitignore create mode 100644 packages/replay/metrics/configs/README.md create mode 100644 packages/replay/metrics/configs/dev/collect.ts create mode 100644 packages/replay/metrics/configs/dev/env.ts delete mode 100644 packages/replay/metrics/src/collect-dev.ts create mode 100644 packages/replay/metrics/src/results/result.ts create mode 100644 packages/replay/metrics/src/results/results-set.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 26f846741de2..1a1123c1aa23 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -42,7 +42,7 @@ "name": "Debug replay metrics collection script", "request": "launch", "cwd": "${workspaceFolder}/packages/replay/metrics/", - "program": "${workspaceFolder}/packages/replay/metrics/src/collect-dev.ts", + "program": "${workspaceFolder}/packages/replay/metrics/configs/dev/collect.ts", "preLaunchTask": "Build Replay metrics script", }, // Run rollup using the config file which is in the currently active tab. diff --git a/packages/replay/metrics/.gitignore b/packages/replay/metrics/.gitignore new file mode 100644 index 000000000000..505d701f0e12 --- /dev/null +++ b/packages/replay/metrics/.gitignore @@ -0,0 +1 @@ +out diff --git a/packages/replay/metrics/configs/README.md b/packages/replay/metrics/configs/README.md new file mode 100644 index 000000000000..d47f3ac8e145 --- /dev/null +++ b/packages/replay/metrics/configs/README.md @@ -0,0 +1,4 @@ +# Replay metrics configuration & entrypoints (scripts) + +* [dev] contains scripts launched during local development +* [ci] contains scripts launched in CI diff --git a/packages/replay/metrics/configs/dev/collect.ts b/packages/replay/metrics/configs/dev/collect.ts new file mode 100644 index 000000000000..b9633e7b174a --- /dev/null +++ b/packages/replay/metrics/configs/dev/collect.ts @@ -0,0 +1,17 @@ +import { Metrics, MetricsCollector } from '../../src/collector.js'; +import { LoadPageScenario } from '../../src/scenarios.js'; +import { latestResultFile } from './env.js'; + +const collector = new MetricsCollector(); +const result = await collector.execute({ + name: 'dummy', + a: new LoadPageScenario('https://developers.google.com/web/'), + b: new LoadPageScenario('https://developers.google.com/'), + runs: 1, + tries: 1, + async test(_aResults: Metrics[], _bResults: Metrics[]) { + return true; + }, +}); + +result.writeToFile(latestResultFile); diff --git a/packages/replay/metrics/configs/dev/env.ts b/packages/replay/metrics/configs/dev/env.ts new file mode 100644 index 000000000000..c2168763ea6e --- /dev/null +++ b/packages/replay/metrics/configs/dev/env.ts @@ -0,0 +1,2 @@ +export const outDir = 'out/results-dev'; +export const latestResultFile = 'out/latest-result.json'; diff --git a/packages/replay/metrics/package.json b/packages/replay/metrics/package.json index fe5144a2a59c..a5f5d38c44c6 100644 --- a/packages/replay/metrics/package.json +++ b/packages/replay/metrics/package.json @@ -7,7 +7,7 @@ "type": "module", "scripts": { "build": "tsc", - "collect-dev": "ts-node-esm ./src/collect-dev.ts" + "dev:collect": "ts-node-esm ./configs/dev/collect.ts" }, "dependencies": { "@types/node": "^18.11.17", diff --git a/packages/replay/metrics/src/collect-dev.ts b/packages/replay/metrics/src/collect-dev.ts deleted file mode 100644 index 70087ac29827..000000000000 --- a/packages/replay/metrics/src/collect-dev.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Metrics, MetricsCollector } from './collector.js'; -import { LoadPageScenario, TestCase } from './scenarios.js'; - -const tests: TestCase[] = [ - { - name: 'dummy', - a: new LoadPageScenario('https://developers.google.com/web/'), - b: new LoadPageScenario('https://developers.google.com/'), - runs: 1, - tries: 1, - async test(_aResults: Metrics[], _bResults: Metrics[]) { - return true; - }, - } -] - -const collector = new MetricsCollector(); -for (const testCase of tests) { - await collector.execute(testCase); -} diff --git a/packages/replay/metrics/src/collector.ts b/packages/replay/metrics/src/collector.ts index 385a68497cd0..791d65acfd94 100644 --- a/packages/replay/metrics/src/collector.ts +++ b/packages/replay/metrics/src/collector.ts @@ -1,22 +1,23 @@ import assert from 'assert'; import * as puppeteer from 'puppeteer'; -import { CpuUsage } from './perf/cpu.js'; -import { JsHeapUsage } from './perf/memory.js'; +import { CpuUsage, CpuUsageSampler } from './perf/cpu.js'; +import { JsHeapUsage, JsHeapUsageSampler } from './perf/memory.js'; import { PerfMetricsSampler } from './perf/sampler.js'; +import { Result } from './results/result.js'; import { Scenario, TestCase } from './scenarios.js'; import { WebVitals, WebVitalsCollector } from './vitals/index.js'; const cpuThrottling = 4; -const networkConditions = puppeteer.PredefinedNetworkConditions['Fast 3G']; +const networkConditions = 'Fast 3G'; export class Metrics { - constructor(public scenario: Scenario, public vitals: WebVitals, - public cpu: CpuUsage, public memory: JsHeapUsage) { } + constructor(public readonly vitals: WebVitals, public readonly cpu: CpuUsage, public readonly memory: JsHeapUsage) { } } + export class MetricsCollector { - public async execute(testCase: TestCase): Promise { + public async execute(testCase: TestCase): Promise { console.log(`Executing test case ${testCase.name}`); console.group(); for (let i = 1; i <= testCase.tries; i++) { @@ -25,7 +26,7 @@ export class MetricsCollector { if (await testCase.test(aResults, bResults)) { console.groupEnd(); console.log(`Test case ${testCase.name} passed on try ${i}/${testCase.tries}`); - break; + return new Result(testCase.name, cpuThrottling, networkConditions, aResults, bResults); } else if (i != testCase.tries) { console.log(`Test case ${testCase.name} failed on try ${i}/${testCase.tries}`); } else { @@ -33,6 +34,7 @@ export class MetricsCollector { console.error(`Test case ${testCase.name} failed`); } } + throw `Test case execution ${testCase.name} failed after ${testCase.tries} tries.`; } private async collect(name: string, scenario: Scenario, runs: number): Promise { @@ -60,14 +62,14 @@ export class MetricsCollector { const page = await browser.newPage(); // Simulate throttling. - await page.emulateNetworkConditions(networkConditions); + await page.emulateNetworkConditions(puppeteer.PredefinedNetworkConditions[networkConditions]); await page.emulateCPUThrottling(cpuThrottling); // Collect CPU and memory info 10 times per second. const perfSampler = await PerfMetricsSampler.create(page, 100); disposeCallbacks.push(async () => perfSampler.stop()); - const cpu = new CpuUsage(perfSampler); - const jsHeap = new JsHeapUsage(perfSampler); + const cpuSampler = new CpuUsageSampler(perfSampler); + const memSampler = new JsHeapUsageSampler(perfSampler); const vitalsCollector = await WebVitalsCollector.create(page); @@ -76,7 +78,7 @@ export class MetricsCollector { // NOTE: FID needs some interaction to actually show a value const vitals = await vitalsCollector.collect(); - return new Metrics(scenario, vitals, cpu, jsHeap); + return new Metrics(vitals, cpuSampler.getData(), memSampler.getData()); } finally { disposeCallbacks.reverse().forEach((cb) => cb().catch(console.log)); } diff --git a/packages/replay/metrics/src/perf/cpu.ts b/packages/replay/metrics/src/perf/cpu.ts index 196bc14aef52..741d752d2e4c 100644 --- a/packages/replay/metrics/src/perf/cpu.ts +++ b/packages/replay/metrics/src/perf/cpu.ts @@ -2,17 +2,29 @@ import * as puppeteer from 'puppeteer'; import { PerfMetricsSampler } from './sampler'; -export { CpuUsage, CpuSnapshot } +export { CpuUsageSampler, CpuUsage, CpuSnapshot } class CpuSnapshot { constructor(public timestamp: number, public usage: number) { } + + public static fromJSON(data: Partial): CpuSnapshot { + return new CpuSnapshot(data.timestamp || NaN, data.usage || NaN); + } +} + +class CpuUsage { + constructor(public snapshots: CpuSnapshot[], public average: number) { }; + + public static fromJSON(data: Partial): CpuUsage { + return new CpuUsage(data.snapshots || [], data.average || NaN); + } } class MetricsDataPoint { constructor(public timestamp: number, public activeTime: number) { }; } -class CpuUsage { +class CpuUsageSampler { public snapshots: CpuSnapshot[] = []; public average: number = 0; private _initial?: MetricsDataPoint = undefined; @@ -24,6 +36,10 @@ class CpuUsage { sampler.subscribe(this._collect.bind(this)); } + public getData(): CpuUsage { + return new CpuUsage(this.snapshots, this.average); + } + private async _collect(metrics: puppeteer.Metrics): Promise { const data = new MetricsDataPoint(metrics.Timestamp!, metrics.TaskDuration! + metrics.TaskDuration! + metrics.LayoutDuration! + metrics.ScriptDuration!); if (this._initial == undefined) { diff --git a/packages/replay/metrics/src/perf/memory.ts b/packages/replay/metrics/src/perf/memory.ts index 2e480a188f16..36baf8ae1414 100644 --- a/packages/replay/metrics/src/perf/memory.ts +++ b/packages/replay/metrics/src/perf/memory.ts @@ -2,15 +2,27 @@ import * as puppeteer from 'puppeteer'; import { PerfMetricsSampler } from './sampler'; -export { JsHeapUsage } +export { JsHeapUsageSampler, JsHeapUsage } class JsHeapUsage { + public constructor(public snapshots: number[]) { } + + public static fromJSON(data: Partial): JsHeapUsage { + return new JsHeapUsage(data.snapshots || []); + } +} + +class JsHeapUsageSampler { public snapshots: number[] = []; public constructor(sampler: PerfMetricsSampler) { sampler.subscribe(this._collect.bind(this)); } + public getData(): JsHeapUsage { + return new JsHeapUsage(this.snapshots); + } + private async _collect(metrics: puppeteer.Metrics): Promise { this.snapshots.push(metrics.JSHeapUsedSize!); } diff --git a/packages/replay/metrics/src/results/result.ts b/packages/replay/metrics/src/results/result.ts new file mode 100644 index 000000000000..bc39269dc09e --- /dev/null +++ b/packages/replay/metrics/src/results/result.ts @@ -0,0 +1,29 @@ +import * as fs from 'fs'; +import path from 'path'; + +import { Metrics } from '../collector'; + +export class Result { + constructor( + public readonly name: string, public readonly cpuThrottling: number, + public readonly networkConditions: string, + public readonly aResults: Metrics[], + public readonly bResults: Metrics[]) { } + + public writeToFile(filePath: string): void { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir); + } + const json = JSON.stringify(this); + fs.writeFileSync(filePath, json); + } + + public static readFromFile(filePath: string): Result { + const json = fs.readFileSync(filePath, { encoding: 'utf-8' }); + const data = JSON.parse(json); + return new Result( + data.name || '', data.cpuThrottling || NaN, + data.networkConditions || '', data.aResults || [], data.bResults || []); + } +} diff --git a/packages/replay/metrics/src/results/results-set.ts b/packages/replay/metrics/src/results/results-set.ts new file mode 100644 index 000000000000..3faafacbdf47 --- /dev/null +++ b/packages/replay/metrics/src/results/results-set.ts @@ -0,0 +1,68 @@ +import * as fs from 'fs'; +import path from 'path'; + +const delimiter = '-'; + +export class ResultSetItem { + public constructor(public path: string) { } + + public get name(): string { + return path.basename(this.path); + } + + public get number(): number { + return parseInt(this.parts[0]); + } + + public get hash(): string { + return this.parts[1]; + } + + get parts(): string[] { + return path.basename(this.path).split(delimiter); + } +} + +/// Wraps a directory containing multiple (N--result.json) files. +/// The files are numbered from the most recently added one, to the oldest one. +export class ResultsSet { + public constructor(private directory: string) { + if (!fs.existsSync(directory)) { + fs.mkdirSync(directory, { recursive: true }); + } + } + + public count(): number { + return this.items().length; + } + + public items(): ResultSetItem[] { + return this.files().map((file) => { + return new ResultSetItem(path.join(this.directory, file.name)); + }).filter((item) => !isNaN(item.number)); + } + + files(): fs.Dirent[] { + return fs.readdirSync(this.directory, { withFileTypes: true }).filter((v) => v.isFile()) + } + + public add(file: ResultSetItem) { + console.log(`Preparing to add ${file.path} to ${this.directory}`); + + // Get the list of file sorted by the prefix number in the descending order. + const files = this.items().sort((a, b) => b.number - a.number); + + // Rename all existing files, increasing the prefix + for (const file of files) { + const parts = file.name.split(delimiter); + parts[0] = (file.number + 1).toString(); + const newPath = path.join(this.directory, parts.join(delimiter)); + console.log(`Renaming ${file.path} to ${newPath}`); + fs.renameSync(file.path, newPath); + } + + const newName = `1${delimiter}${file.hash}${delimiter}result.json`; + console.log(`Adding ${file.path} to ${this.directory} as ${newName}`); + fs.copyFileSync(file.path, path.join(this.directory, newName)); + } +} diff --git a/packages/replay/metrics/src/vitals/index.ts b/packages/replay/metrics/src/vitals/index.ts index 1cb2b7aa0077..892a248266ad 100644 --- a/packages/replay/metrics/src/vitals/index.ts +++ b/packages/replay/metrics/src/vitals/index.ts @@ -9,6 +9,10 @@ export { WebVitals, WebVitalsCollector }; class WebVitals { constructor(public lcp: number, public cls: number, public fid: number) { } + + public static fromJSON(data: Partial): WebVitals { + return new WebVitals(data.lcp || NaN, data.cls || NaN, data.fid || NaN); + } } class WebVitalsCollector { From b0ec7e6e1da0f5bad7e004d96e530068ca0596b4 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Thu, 29 Dec 2022 15:58:07 +0100 Subject: [PATCH 13/55] add metrics processing script --- .vscode/launch.json | 8 +++++++ .../replay/metrics/configs/dev/process.ts | 5 ++++ packages/replay/metrics/package.json | 4 +++- packages/replay/metrics/src/git.ts | 16 +++++++++++++ .../replay/metrics/src/results/results-set.ts | 13 +++++++---- packages/replay/metrics/tsconfig.json | 3 ++- packages/replay/metrics/yarn.lock | 23 ++++++++++++++++++- 7 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 packages/replay/metrics/configs/dev/process.ts create mode 100644 packages/replay/metrics/src/git.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 1a1123c1aa23..6092a79fb1f6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -45,6 +45,14 @@ "program": "${workspaceFolder}/packages/replay/metrics/configs/dev/collect.ts", "preLaunchTask": "Build Replay metrics script", }, + { + "type": "node", + "name": "Debug replay metrics processing script", + "request": "launch", + "cwd": "${workspaceFolder}/packages/replay/metrics/", + "program": "${workspaceFolder}/packages/replay/metrics/configs/dev/process.ts", + "preLaunchTask": "Build Replay metrics script", + }, // Run rollup using the config file which is in the currently active tab. { "name": "Debug rollup (config from open file)", diff --git a/packages/replay/metrics/configs/dev/process.ts b/packages/replay/metrics/configs/dev/process.ts new file mode 100644 index 000000000000..460b11b8e26e --- /dev/null +++ b/packages/replay/metrics/configs/dev/process.ts @@ -0,0 +1,5 @@ +import { ResultsSet } from '../../src/results/results-set.js'; +import { latestResultFile, outDir } from './env.js'; + +const resultsSet = new ResultsSet(outDir); +await resultsSet.add(latestResultFile); diff --git a/packages/replay/metrics/package.json b/packages/replay/metrics/package.json index a5f5d38c44c6..bc216b8ee00f 100644 --- a/packages/replay/metrics/package.json +++ b/packages/replay/metrics/package.json @@ -7,11 +7,13 @@ "type": "module", "scripts": { "build": "tsc", - "dev:collect": "ts-node-esm ./configs/dev/collect.ts" + "dev:collect": "ts-node-esm ./configs/dev/collect.ts", + "dev:process": "ts-node-esm ./configs/dev/process.ts" }, "dependencies": { "@types/node": "^18.11.17", "puppeteer": "^19.4.1", + "simple-git": "^3.15.1", "typescript": "^4.9.4" }, "devDependencies": { diff --git a/packages/replay/metrics/src/git.ts b/packages/replay/metrics/src/git.ts new file mode 100644 index 000000000000..88f0206a759b --- /dev/null +++ b/packages/replay/metrics/src/git.ts @@ -0,0 +1,16 @@ +import { simpleGit } from 'simple-git'; + +// A testing scenario we want to collect metrics for. +export const Git = { + get hash(): Promise { + return (async () => { + const git = simpleGit(); + let gitHash = await git.revparse('HEAD'); + let diff = await git.diff(); + if (diff.trim().length > 0) { + gitHash += '+dirty'; + } + return gitHash; + })(); + } +} diff --git a/packages/replay/metrics/src/results/results-set.ts b/packages/replay/metrics/src/results/results-set.ts index 3faafacbdf47..ca386723fb27 100644 --- a/packages/replay/metrics/src/results/results-set.ts +++ b/packages/replay/metrics/src/results/results-set.ts @@ -1,5 +1,7 @@ +import assert from 'assert'; import * as fs from 'fs'; import path from 'path'; +import { Git } from '../git.js'; const delimiter = '-'; @@ -46,8 +48,9 @@ export class ResultsSet { return fs.readdirSync(this.directory, { withFileTypes: true }).filter((v) => v.isFile()) } - public add(file: ResultSetItem) { - console.log(`Preparing to add ${file.path} to ${this.directory}`); + public async add(newFile: string): Promise { + console.log(`Preparing to add ${newFile} to ${this.directory}`); + assert(fs.existsSync(newFile)); // Get the list of file sorted by the prefix number in the descending order. const files = this.items().sort((a, b) => b.number - a.number); @@ -61,8 +64,8 @@ export class ResultsSet { fs.renameSync(file.path, newPath); } - const newName = `1${delimiter}${file.hash}${delimiter}result.json`; - console.log(`Adding ${file.path} to ${this.directory} as ${newName}`); - fs.copyFileSync(file.path, path.join(this.directory, newName)); + const newName = `1${delimiter}${await Git.hash}${delimiter}result.json`; + console.log(`Adding ${newFile} to ${this.directory} as ${newName}`); + fs.copyFileSync(newFile, path.join(this.directory, newName)); } } diff --git a/packages/replay/metrics/tsconfig.json b/packages/replay/metrics/tsconfig.json index 9aa9fd11b24e..1709316f0ea8 100644 --- a/packages/replay/metrics/tsconfig.json +++ b/packages/replay/metrics/tsconfig.json @@ -7,6 +7,7 @@ "esModuleInterop": true, }, "include": [ - "src/**/*.ts" + "src/**/*.ts", + "configs/**/*.ts" ] } diff --git a/packages/replay/metrics/yarn.lock b/packages/replay/metrics/yarn.lock index 1b1b34da02f2..9d4fa4d0e407 100644 --- a/packages/replay/metrics/yarn.lock +++ b/packages/replay/metrics/yarn.lock @@ -48,6 +48,18 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" +"@kwsites/file-exists@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@kwsites/file-exists/-/file-exists-1.1.1.tgz#ad1efcac13e1987d8dbaf235ef3be5b0d96faa99" + integrity sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw== + dependencies: + debug "^4.1.1" + +"@kwsites/promise-deferred@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz#8ace5259254426ccef57f3175bc64ed7095ed919" + integrity sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw== + "@tsconfig/node10@^1.0.7": version "1.0.9" resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.9.tgz#df4907fc07a886922637b15e02d4cebc4c0021b2" @@ -212,7 +224,7 @@ cross-fetch@3.1.5: dependencies: node-fetch "2.6.7" -debug@4, debug@4.3.4, debug@^4.1.1: +debug@4, debug@4.3.4, debug@^4.1.1, debug@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -500,6 +512,15 @@ safe-buffer@~5.2.0: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== +simple-git@^3.15.1: + version "3.15.1" + resolved "https://registry.yarnpkg.com/simple-git/-/simple-git-3.15.1.tgz#57f595682cb0c2475d5056da078a05c8715a25ef" + integrity sha512-73MVa5984t/JP4JcQt0oZlKGr42ROYWC3BcUZfuHtT3IHKPspIvL0cZBnvPXF7LL3S/qVeVHVdYYmJ3LOTw4Rg== + dependencies: + "@kwsites/file-exists" "^1.1.1" + "@kwsites/promise-deferred" "^1.1.1" + debug "^4.3.4" + string_decoder@^1.1.1: version "1.3.0" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" From d3f93d81b5c01598d8a76d14eda195868f919187 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Thu, 29 Dec 2022 16:17:52 +0100 Subject: [PATCH 14/55] metrics: reading json result file --- packages/replay/metrics/configs/dev/process.ts | 5 +++++ packages/replay/metrics/src/collector.ts | 8 ++++++++ packages/replay/metrics/src/perf/cpu.ts | 5 ++++- packages/replay/metrics/src/results/result.ts | 10 +++++++--- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/replay/metrics/configs/dev/process.ts b/packages/replay/metrics/configs/dev/process.ts index 460b11b8e26e..a5d8a684bedb 100644 --- a/packages/replay/metrics/configs/dev/process.ts +++ b/packages/replay/metrics/configs/dev/process.ts @@ -1,5 +1,10 @@ +import { Result } from '../../src/results/result.js'; import { ResultsSet } from '../../src/results/results-set.js'; import { latestResultFile, outDir } from './env.js'; const resultsSet = new ResultsSet(outDir); + +const latestResult = Result.readFromFile(latestResultFile); +console.log(latestResult); + await resultsSet.add(latestResultFile); diff --git a/packages/replay/metrics/src/collector.ts b/packages/replay/metrics/src/collector.ts index 791d65acfd94..994a8fb2bfd2 100644 --- a/packages/replay/metrics/src/collector.ts +++ b/packages/replay/metrics/src/collector.ts @@ -13,6 +13,14 @@ const networkConditions = 'Fast 3G'; export class Metrics { constructor(public readonly vitals: WebVitals, public readonly cpu: CpuUsage, public readonly memory: JsHeapUsage) { } + + public static fromJSON(data: Partial): Metrics { + return new Metrics( + WebVitals.fromJSON(data.vitals || {}), + CpuUsage.fromJSON(data.cpu || {}), + JsHeapUsage.fromJSON(data.memory || {}), + ); + } } diff --git a/packages/replay/metrics/src/perf/cpu.ts b/packages/replay/metrics/src/perf/cpu.ts index 741d752d2e4c..760fc0990aac 100644 --- a/packages/replay/metrics/src/perf/cpu.ts +++ b/packages/replay/metrics/src/perf/cpu.ts @@ -16,7 +16,10 @@ class CpuUsage { constructor(public snapshots: CpuSnapshot[], public average: number) { }; public static fromJSON(data: Partial): CpuUsage { - return new CpuUsage(data.snapshots || [], data.average || NaN); + return new CpuUsage( + (data.snapshots || []).map(CpuSnapshot.fromJSON), + data.average || NaN, + ); } } diff --git a/packages/replay/metrics/src/results/result.ts b/packages/replay/metrics/src/results/result.ts index bc39269dc09e..6643f48c6731 100644 --- a/packages/replay/metrics/src/results/result.ts +++ b/packages/replay/metrics/src/results/result.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import path from 'path'; -import { Metrics } from '../collector'; +import { Metrics } from '../collector.js'; export class Result { constructor( @@ -23,7 +23,11 @@ export class Result { const json = fs.readFileSync(filePath, { encoding: 'utf-8' }); const data = JSON.parse(json); return new Result( - data.name || '', data.cpuThrottling || NaN, - data.networkConditions || '', data.aResults || [], data.bResults || []); + data.name || '', + data.cpuThrottling || NaN, + data.networkConditions || '', + (data.aResults || []).map(Metrics.fromJSON), + (data.bResults || []).map(Metrics.fromJSON), + ); } } From 67a6dd8c7daba67d2c3939ddb1e292cb0ad11773 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Thu, 29 Dec 2022 17:41:06 +0100 Subject: [PATCH 15/55] replay matrics: jank test app copy --- .../replay/metrics/test-apps/jank/README.md | 4 + packages/replay/metrics/test-apps/jank/app.js | 171 ++++++++++++++++++ .../metrics/test-apps/jank/favicon-96x96.png | Bin 0 -> 8194 bytes .../replay/metrics/test-apps/jank/index.html | 43 +++++ .../metrics/test-apps/jank/logo-1024px.png | Bin 0 -> 305497 bytes .../replay/metrics/test-apps/jank/styles.css | 59 ++++++ 6 files changed, 277 insertions(+) create mode 100644 packages/replay/metrics/test-apps/jank/README.md create mode 100644 packages/replay/metrics/test-apps/jank/app.js create mode 100644 packages/replay/metrics/test-apps/jank/favicon-96x96.png create mode 100644 packages/replay/metrics/test-apps/jank/index.html create mode 100644 packages/replay/metrics/test-apps/jank/logo-1024px.png create mode 100644 packages/replay/metrics/test-apps/jank/styles.css diff --git a/packages/replay/metrics/test-apps/jank/README.md b/packages/replay/metrics/test-apps/jank/README.md new file mode 100644 index 000000000000..3e0f46b66a1e --- /dev/null +++ b/packages/replay/metrics/test-apps/jank/README.md @@ -0,0 +1,4 @@ +# Chrome DevTools Jank article sample code + +* Originally coming from [devtools-samples](https://github.com/GoogleChrome/devtools-samples/tree/4818abc9dbcdb954d0eb9b70879f4ea18756451f/jank), licensed under Apache 2.0. +* Linking article: diff --git a/packages/replay/metrics/test-apps/jank/app.js b/packages/replay/metrics/test-apps/jank/app.js new file mode 100644 index 000000000000..ab961a366315 --- /dev/null +++ b/packages/replay/metrics/test-apps/jank/app.js @@ -0,0 +1,171 @@ +/* Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ + +(function(window) { + 'use strict'; + + var app = {}, + proto = document.querySelector('.proto'), + movers, + bodySize = document.body.getBoundingClientRect(), + ballSize = proto.getBoundingClientRect(), + maxHeight = Math.floor(bodySize.height - ballSize.height), + maxWidth = 97, // 100vw - width of square (3vw) + incrementor = 10, + distance = 3, + frame, + minimum = 10, + subtract = document.querySelector('.subtract'), + add = document.querySelector('.add'); + + app.optimize = false; + app.count = minimum; + app.enableApp = true; + + app.init = function () { + if (movers) { + bodySize = document.body.getBoundingClientRect(); + for (var i = 0; i < movers.length; i++) { + document.body.removeChild(movers[i]); + } + document.body.appendChild(proto); + ballSize = proto.getBoundingClientRect(); + document.body.removeChild(proto); + maxHeight = Math.floor(bodySize.height - ballSize.height); + } + for (var i = 0; i < app.count; i++) { + var m = proto.cloneNode(); + var top = Math.floor(Math.random() * (maxHeight)); + if (top === maxHeight) { + m.classList.add('up'); + } else { + m.classList.add('down'); + } + m.style.left = (i / (app.count / maxWidth)) + 'vw'; + m.style.top = top + 'px'; + document.body.appendChild(m); + } + movers = document.querySelectorAll('.mover'); + }; + + app.update = function (timestamp) { + for (var i = 0; i < app.count; i++) { + var m = movers[i]; + if (!app.optimize) { + var pos = m.classList.contains('down') ? + m.offsetTop + distance : m.offsetTop - distance; + if (pos < 0) pos = 0; + if (pos > maxHeight) pos = maxHeight; + m.style.top = pos + 'px'; + if (m.offsetTop === 0) { + m.classList.remove('up'); + m.classList.add('down'); + } + if (m.offsetTop === maxHeight) { + m.classList.remove('down'); + m.classList.add('up'); + } + } else { + var pos = parseInt(m.style.top.slice(0, m.style.top.indexOf('px'))); + m.classList.contains('down') ? pos += distance : pos -= distance; + if (pos < 0) pos = 0; + if (pos > maxHeight) pos = maxHeight; + m.style.top = pos + 'px'; + if (pos === 0) { + m.classList.remove('up'); + m.classList.add('down'); + } + if (pos === maxHeight) { + m.classList.remove('down'); + m.classList.add('up'); + } + } + } + frame = window.requestAnimationFrame(app.update); + } + + document.querySelector('.stop').addEventListener('click', function (e) { + if (app.enableApp) { + cancelAnimationFrame(frame); + e.target.textContent = 'Start'; + app.enableApp = false; + } else { + frame = window.requestAnimationFrame(app.update); + e.target.textContent = 'Stop'; + app.enableApp = true; + } + }); + + document.querySelector('.optimize').addEventListener('click', function (e) { + if (e.target.textContent === 'Optimize') { + app.optimize = true; + e.target.textContent = 'Un-Optimize'; + } else { + app.optimize = false; + e.target.textContent = 'Optimize'; + } + }); + + add.addEventListener('click', function (e) { + cancelAnimationFrame(frame); + app.count += incrementor; + subtract.disabled = false; + app.init(); + frame = requestAnimationFrame(app.update); + }); + + subtract.addEventListener('click', function () { + cancelAnimationFrame(frame); + app.count -= incrementor; + app.init(); + frame = requestAnimationFrame(app.update); + if (app.count === minimum) { + subtract.disabled = true; + } + }); + + function debounce(func, wait, immediate) { + var timeout; + return function() { + var context = this, args = arguments; + var later = function() { + timeout = null; + if (!immediate) func.apply(context, args); + }; + var callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) func.apply(context, args); + }; + }; + + var onResize = debounce(function () { + if (app.enableApp) { + cancelAnimationFrame(frame); + app.init(); + frame = requestAnimationFrame(app.update); + } + }, 500); + + window.addEventListener('resize', onResize); + + add.textContent = 'Add ' + incrementor; + subtract.textContent = 'Subtract ' + incrementor; + document.body.removeChild(proto); + proto.classList.remove('.proto'); + app.init(); + window.app = app; + frame = window.requestAnimationFrame(app.update); + +})(window); diff --git a/packages/replay/metrics/test-apps/jank/favicon-96x96.png b/packages/replay/metrics/test-apps/jank/favicon-96x96.png new file mode 100644 index 0000000000000000000000000000000000000000..7f01723cee0fa55387387b753b4a4990fb072daa GIT binary patch literal 8194 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 literal 0 HcmV?d00001 diff --git a/packages/replay/metrics/test-apps/jank/index.html b/packages/replay/metrics/test-apps/jank/index.html new file mode 100644 index 000000000000..5c8449143c1b --- /dev/null +++ b/packages/replay/metrics/test-apps/jank/index.html @@ -0,0 +1,43 @@ + + + + + + + + + + Janky Animation + + + + + + + +
+ + + + + + + +
+ + + diff --git a/packages/replay/metrics/test-apps/jank/logo-1024px.png b/packages/replay/metrics/test-apps/jank/logo-1024px.png new file mode 100644 index 0000000000000000000000000000000000000000..84df3e22f6b09e248701d3fbfb11a1f94e11d05b GIT binary patch literal 305497 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 PZ9rawD@)OHv8s5YNAh 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

U%l}FlcCh5-x1s# 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

-oBOEqPck-}P8`y_daoUx7Pc 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

%{xLDf7Y zQM1f4kTo63xr@r`5{BjO2>RfdSQt*MQZD!_emQWJ?nEXm_F~3$NTE}@mJnH zz34T1zky>ZKHFM--|;~}niE5&lK|Qm{rlTZ=63?H=9)Bc5aKcq9z0>jjwdWuWPgY+!0Dx!^=m@qCL7$adGaG813RL`}kb{ zm=jO>)Ia%nTeVzji?(o<5l#Dfi%*})G}-ZX3IE+`EOqO;gaoBks>YRvc&@tyR=2vB zygb&r9CYJXVTq4xzu});EaPumedJPBNsCf%=P8}zgDdEqKjU_y(s2@n#tZ9s!RmVD4`?}`aS|6uz0SKmJU=&v8|B*0TX?d0_AC!caAl?@SBaTtG*R+~hq>gDyu*iXk{|ZA;%-+7Z`6Aglh3`{72t6k z;e(5Z$d~xYmuT`1&3JlzWYABvmfXmTG4y<7$kU?Fcq89LQ|Ht_eCRqkKHu`*O`Z?y zcfA;P*m}?HD{%Y@9PcRX`0@6Z`wF-Mk9@#xg?O7z0OSKVG#9$`W z`NA3PBVY2fB;RDW6esf7hbB(=;FjdUMIZbU1#(m5aBG`$h+i5I$7Lj~Xm5FJ&nAjdiH_on?tt&*yqVphp4vcLFT! zXFgh$a@2=Uh*0Ez$ zwjo>jgvU8%@-a^pnjb%tY~xxXW-|Ba2cDR|^$U;p-2ZFeetP=%zoxevv_vYZ*4{D6 zg?*sL3}t9_65xd2+)l3gUv~mrrk-nn7XgjOG2<=@w)!=Ota-Hrh?EPbe35!;q@bJ( zuq`+=oo76okN5s#)u_=@MQhfos=W!(qH32aYSyZ~H%U-RwMcDhSM3pd&#Jxm-g}D? zi6FmxzW>+%S@I;0PVW1h>s;6Spnsp_Ue2H+o6hbV4iRKo7J^x4paq1*K3nrGKjRo`<6*>r`*xf1H1za~b4>QYS1Jkv7OEA!y6& z-;-8w@w$FsQK^R5r&V&haYvb6PR~X5Qx1GLLwf64Iy19t-LX=K#4+6HC@$e}i@xt= z;;@rXf!enG0Xn2`@GNoWRdJn08&-U|wITphYhx@ai)1?zCAf3#=!bOcOZ}*E%yne7 z`H`q3{BMX0qJmqNSGzKEn^2WC=&vlS$dG>)et_d3y3qjygu65z|1B4tvD$cyvp?)l z%2@k;O%7KD@!js?DyawjH=KXYCQZ$MQ(_nKXS4IZs~v-~n@ySWZEvQXc5E*A*@*A! zo+IkEv2c)?=O8<{n?`!Oj)TQ*t>-!#?8*)~h~@?$eE^ZPi|0E<_!t_%PSH3l6uc@D6!l{_ts*Jzq3iN!M9s|EpN)q_3%?#NsfSFvdv*1=Y z>?}<A9tHvP};w2x0bcWwuj~TRi=MDtk=(vDk&z&wc@q6Hm$$7r@PqZv(z!_FDbhnDD z6vdejI5*S;q}J#NEH;d%f~QT`Yv2aMnkV~Hgl+YYV}+iCY9{FOeOj0#Eq$6{WA=Sw zX2P9&8C+Q^`Zzpce8a)=BArg-L_<-SqNDq=iQcuK&#iUVT=tERd{UOv9nJ(DRw=7HE2r@3hFX0X2-VlBnEjd<@DEEF$^jXD_oU)TI zg>o0CR2L`ZC0&=#jdbhQ>S<`gd(jrvxWHxPh;w8jFShw8^PbkR!8-u~z5-0|K>A@Z zKlT)~Bn;UN_40pINWSb$jH-=kd>vJ9UQ;Y=}+T48P0O61U3F8P%s9KcUkWdsKj0B#{Fa_ zF06na0HIav^%n3c^pqgY2quDplPtC4f^8Lh|d!BW0r1eu4WGDqKXZRY43Y4i!~Ca8+&7{Z9h)yU#(xQ&{Xp zld(ycz6SLR4L9$y`L}|4&kKc?r`QUTPn{As`aB7qE?jp@$YX4zZMo-`hEE5 z{9|T-1+KU)BZs`BNS8oko7me$?Puq{tVvV4@YQ!!CTu5DSLTzH zvH5-Q4VggmQf_RO;(HB&)Eg;w@1@3*BV3+m=386NTteI#6COjTY)vwkA=|kC?N2?U z-I@|kyH*Vb&Vt`_bZ+ZU=IyGZ>Zn;^~ zUCE||%LM=J%!RvP{07tic2I3C>~Y`EI;)r}{^Z3k>s;Tn)EXo>$-(a0I3s-JkH+Qp zzTeNY0d2kR>Ez?lpXbOnkTPU1xWj}Jr*{VoTBrbd^td0y+b1X6mIAuP_qBare&<%7 zav^1wIUF0;F+i8g=55OQr7oo;%%sNIBO&>YLuHS+85%j7^7@erm-oSMO!n zti;(|v-I+}2Z)9nZ@jx%8BL8VH;ubK5l@^^GPccwPs=o04UyDc6Uwr6u+PHaS!fLk z7%1Zgcmp4ses4=U;VpL{I!mZ5USe*8w^#6y2aDaR&%!FdH_gdm;oqkRsrS~uNH6M=!L;2KmUUS0Ik zSqk?He81Q?j-H0}pggN#!V`9hUloyb1g3o(fN{V2*;SU(=Vm{xjV9wh4(8^#Foh8% z86=YJ-#jqNPlzQl68obsOwd)6bwOFwS4L zuC|8vzfbwflKbU+-@y--ky=n|ZHE~x$;<-VGCgj-2fBpT z(znM(T3Q0WUzyM3-eB?s>RlW6-J7dfxm+WYp4*oud%1U0E89QTT*S!#FvG|Td6;<> zm2&&ix-ttu57mlMj%-JpUH-(xnYf(Y$ju+S^{Ub%!lS$Xi~Dd$0X0t4Xo zXqJCDV0H29US+wv-&7{C%CO7|M6QR;0;(%bpi*0|doI+d5@vD}Rlob+2Jyf(@Fv6V zdTWNbLKbpu$)C(zYGdJ$Q#?*4he>)$c~%AKZhn>tbN#GfmzNEouat7=EBswck-X~CC}^J938h^zwu@~Mkdj)`;P&=* z!X=yc_Rzqqm9C!qsqsR-UydkVmiEZVr zf4EeB9t5(RdReJPidZD#f3r^KJG8Y(*J(H{m( zHBygOq2{%wUiI!`W_s5f!(;jsh!r_+@kLr&xB4%2sEt(2i%$}F|6FR^xHjD_pld8V zmOUnH&Gqq42bHU8KYE1Wyv)h={|F80{Ykg_Ps7dpF&Fc7CwFoHP3RqqS-%NDQH$F3 zpUVQgt8~|pAm=%WVkOl#UV&bx_g-i9gwFys!=B7s96NlVw?*^CKiXde<4o})ZB3Zj zR%4KZYVyl}zhhi7Jydv!cOC}srYRqE+TaaPn=UChsX$34g)BAu0tS8J!%vKv_PMB- z39@3&WiR3)VvJ@>#iMv}`Cy}d_OnR#M^b#(>%%M3ew-mf@YC#$aG1$%Ebx-TnTaUw zNMzk;u9;~JqP}oB4=XvZeEFXP$T+oS`|!i3ONckp;!yU4*R=q@DFc9d-=h1KY7P-1 zp~*f%?77KK!7q9}8-+^}Hu5=F8{yv7dEl5R6eqg2o)c5Wghj*C@>0FQuf&P-S1uzP zEz*wSQjAT|an(-WL~50vj;byLZO7X~Z4)Kj*kfmcYp=wEB{z}gfhlv*h8{+)$AerE zH9qtfFU1v? zYvDBV#!TzuKI+q(tHu#Z%v&M4w%G-?kBePu%^9bmsm794mvs~5LlT%gnhKB$FMZAA z(B;oSms$l!sE8Aese`x@F?_CSGqRt=o(D6LDv}43r<_3<7?i$nz0@dvpkr zB7N7SvnMM&)5&ViiMJ5juaeo^32-}KwCCdglbMQiVX z66#pkS%!xBg;HtUW%t^R(fQm>_Z5$@HgM{D#$4jWBu?Xf2eSimP6Cl>koQ6D+;AJ= zY0%g900b_tRe|5cl91kIg)GDF6ttJw$eiQ1>vFR+uPIAI-zl>+jXtD9f1_fNUdP=j zy?ukTWV9wHo*uN)cO1WfVVi`ZVB|%JKi-1Yjh?_<8FrW0Oc1gs#=RrpOZmC5MSqUu z_uhQ*_tm~%_qv3n)V%cKJI(C9cAuKy57?MHDPf{nhzoc#FFR6tl~N?HeFkWouwFr( zq~hErsPCd0nQ%Jmqw9%40l&_Ui)?ST0Kd;?+G0UhlG%c)zer^Sekce!`&i;}u{k<^ zE3v2Qeh_u}Q`X2j&<_mRNId1ekOai()=4H*+@`=)%-Eh#Qe9{Au9y9w=%-NKQHwfE zWt@^em%1N0@rR`+f^2SoEQoe?L9cOY06$8v!La4!t#a!wqp*ah+*_Grg%fzJR$UCxt&zE9SlO}$#h=3ZqKzXqc=xbw`Q zOU^!cr*p_jEssa=%3_5HGrO6q-e&{fzNOvB=H2@S{^zpJqtZWa8Q_=cBXEK^xZVss z>S?0cxN`Kt0bWYl5L~Ac> zmCCddug2&zB}|#t;S3dpfR&;D#Br(8_D-K0B0vWA#7NFpA?JQ3)$S;6O*0mLkFK#8 z=w=BT=L}@$2clY|%v{HgUci>hE(#snSmVzifh%2EH+O5=V?n(D~ZRHN9( zyv>eu0DTRiC2Ys*D5ddrtpjFnr7+S-nUQF4VN{9@Z)5~sTGni@Zk)j(hU?6NCN;-k zc<*dCJ=QG%272zz^I6#??_XlF1wY}bATRxovBB5Y;U)`OE&I`XK6n2lRu_`OV_kTu zy8?|)kNI+E#fIk-Ys40O7yguRX%#W>>jIbst_#ZW^G)*1i0gG|lz!ck?YjP*HaZ@N zUVr#?t&wyP`iepriVbDbd?aIWU?3IztZLBx&}kDwzfxAr$oiSo?6O-#qavX|%e3Ag zNbUHh#pM3M6UjH|7p&2jEFzuN?t)SGL1g!M(zDAly?TRfw}#Bj$vFH3`M>z~*nRB` zb2A8+HSZYx#iEe9Xjg3JBmYfYkg)zJHJ#{F?xqtqY42-bH0P+YC_3;&`Gde7P?%@b zt`s%&R1sp8#{d+*>|lG*B9mzML8I>uX{4zILO*S46Fqr#)$r-MTVQmq3gv#U#=SZ5 zcaMVEk1f?4`_03_M|p3y{t=!s^BA%#sKTSNe=sm)vQ?iI)y!hE+Qv*4^mm1VOpFE& z$*X!7PDQ>j)Ny3undDQz<4PKL4GMVGP-9$Y30v2uXKw}q0DsMdZ=SUKAJ9ToGJ!cP z=VWc968jq`#uJ+|-BD>kyR{!O9s#(}>SQ3!YYUyYd&KDNGHp`aRIO~>aNi$iid-!# zl&35_e&P}!fFW}%0JI^Xmk^1ddtyF-(K9?o!|?>*4fPQyYr!Wh8$tI3!_4wgYi2Fr zRwuC9=&uqbGzl4T*0r=V_!Nd)cAHk3V4;nCm{d6|$GI+~^D3d>z$CHmi@(LlDukn4 zn)R)0rR-C%+-nk+w3m=9na{g@U)ui}LtLl-Jy?cTyE95zuCMobd&Hxn(ywe##iDzg zN*|l5C7Lmx{CkLBm>$~iN|$pdKa&S{nE6d9K6qsm`CQ)Jhl0c1T}(rZ#C5^O?l!m zLD=5HOnDRA2UW{?-3jm)kr(sOhL8@VQ^35gZ_mk*A-@{3x6jKtJ=MERD!dwoWcmRmb4_^vZHVmsT^uF+IAcN z{`#qfAd#t`W8li>59i$HC8#ux)uKT6VR1rJo$1RfL1MP}9)?Q}=KajpQS&thT_qCuiMroi{hsBxB4e6-V4Nn)gVRkmm5;l{d z@0?)x$|WTLicGTHfGp^*8tVnYm;d{a_F*+fdZMptc%gPd)D3(^d_W*=_AMLnP>yAA zO@aM{r*=$Wj9ClBhl)87+I?eu%Fg75b4N-b7N7{HGY^}~4N2cPOlsNE(t&;;Ql*8# z$h)+i5j<^fJC*#}5%cghQu(05E*``){RXrOxc5s@-wa%OQ{UOV{9*NM-8)(KL8$`nu z*pVp2gD>5DGCLdQ*F9JF(1GAnIF-!sY}W2ChPpd5&ljqDwT{@cB;QtgefNJi${}|A zEm1#Wa9O$LtrH;`;=SCjnqUYbty z)O~!ao}$^l2_!=VJy%TcjU3*Q>)}jf??MpyLJno*vUw@7wpa$s)~@diKq6Y<=X6g4 z*63Z0m}-c1Dqha={)e_V`13IF=Qsv2C-EKTPwFFY0cjUrE0nH>BHVB#Ecf(}E+)33 zq<}NtkCUw$MVN>r7|1B19!^uht$GSRSjj= z9k${`|2_XoO6%^xQNpwFGd2y~S9gN_dTe@9IbXl+vcm%OxP09n+VQhd7eIgApQr6s zwJgq;bdepV_q#jx0LU!!=|hxsO{u`ES{TO}#sj{5#1C-4IpfxaPIfPd21~2TiS8IL z`9+nT@!e8`o;vPE`h{}OKyfZc=QYgq0r3Z_b9(3T0V5X&45&v*9bY6Ps1Q#L{Pj(n zA_mxgVVMouSC)fX(%l)qbN^`HC*sG4P($W`OMFn1vZ80BV_Lo!E;A8+t(~<_ju?}Z z$ffBkX54#Qhp2ktXrUEDX80sC9kz6uPILf3t9-S2!Ly^jXPu0Mi z>`b?cfyRLqSjF58+`%v##=vVIhss8^VMda`88e&|o^^Y6v?8X3KX3Xs_>h6P>Px`u zm#L1#ayJH_t4*dAqmxSWr}(g95d{TX!E$O`4|0Fd5*wcl{$_uqm6lrMAsf=RS>fSR zJRDWWs4fs_iD?=ok5HkeSn0F$S^x>V+rkKJ{|>5Rl2Htc>6r|Nd=J1EUu)e zh?@Y=ZV_lJZ}BH!K0h=0ZoO0ccpAE)k)N;MlF;|{(0+^HgH6sq##CR%%fb)CK~p3q zYv}>qRMcvfb&wnp0T1CzIKl3;E9Oz)-V&APW%9cr(C|@7bIqy;e*rLsF1JHQ%j@t} zu{uY3t8W2!GG8dSyJ{E-ZSh;DANTAnwJ1mw6oADqEO>SobaUwL!G=49bcu=!!=#@S zXKN78bN4^FXT4UGFi_f-@enUzZ7Mnal4X;W ziZeJBYya*y)k2v!&~WCGWqR#95wSP#y_H$c>~?-X^rsY0j&pI0NHxrFML`M{j3{#% z&hNS#!rAWNz8lVQ*3Ho2{EW2LQ=ubm0NNd*ksFG?uhV-M_0o%Nrs$)cP2676l|)uPOlyUry^n|CZvUyKmc(LY($P}^0(Ep^N!m!PyRzR_tK%jFeqZuzbN>lx z2*1-mrR51q)0*S>T?0wdpLep2O#uAd#)B7&7)s>xUv8n%XdA~IASwsEGS#?QS#0Lc zv>=%wazTAjGgwQ)f60PNSOax;Zu9F}+L9cPPp+LyhM2FuL5QNu+Hb@HL>=M(dwI9# zc!TFuoUvwfkeg4GAPaW0>-K19L^nS!5xUL}Gdaca^T0H1p+CTXa@w>5jiE+uYLc3O zW{kqb#R1mxiaevF&cf_mMU0`mrgg6Yze{Ij`BeJdc&PmOl!E@hl%k(Bqtfy2JzvnQ z43Y93Uv~Cc?{gC*|8bi=3>3D|F)-F(M6q^--SaZzz3NLSp5&;_lSOu1dY_3EDUHGJ zVxZS2de+6StY3!)z_Q2ud5otzwj${pbnoX2B=tpQVwAhelZ!k^y)wYv7bh=FM%|`2>-uJoIv(K4b(;JU3#dQh0 zQ{(6(;hRH4gARoI0XM}Y;Q@RZRo?vcs5brR9P-l@+}#euSDuHk2Eo?;2?q>Mi`FMm zAX0Ru=5lDGnX|78^SJ+Q84`0+*yX`|RAf8oi~Zsen|%=Ba&58C8J^&>ZkD@ncl)=E z3st~{J(&=}szm&1TCvxX3-1x=BhR~U`lVOr^xa(koW3Qw%P{uDtXZQi48O_<=i-+Z zTM^U8@E)jI4Xc!#_lUjN+8QC++}s5Y6h$d!6?FhZ6$+Uw9L+H7cO*Ip=jkX40Y6zI zPfWLrICG$vnq7zEH|IyfkE!67Gl+XW#j{?&MH)Sh{$#HR@R(T%QL;cb)jS7f>YrX# zx%DAp@9W?0TGGiqO7(IwG3^!KA`N?tSsxyeZnzkW)9;jvF=+D;%&D6Soc*wH}~lozsn)Cw`F2G+kSnDfAJ>Y0pP?BOXee{<4UZMT=RI zV>+ggjz{usZ=Ekw^A9Iw$_k^sgREF8g~y#)M`luStff-VWodQ{B?Uj5zg{o4${4E& zaby`2EY3CzUzh;h3bs*PJ)0W!N^0{*v${DTX178`9lq#Vu5(XblfFMgmTo@rMb23P8^GGxwI3v`mr`gg!ZKaoR?@)2Ic; z3Sjpzf9hvic_l>1`)})azUo)ox5T8}uJjh1mEZM8MtJp8r)G8TbuO&Dy?s+Why}NADka~QoITp=wg{`|Yfw4mD#*?X+ z_?=%;mp^P~S9Ls0cYMKmQN?I3Y)RjvZ5aW>?P#V}@LWfjjseNHl!%$F=$C$PKxYVC z+U5LJpZR;W3+!eu_-s*P||-(>|vM|+CsY9OhGRlotX zs7=g?UO4E;Ij5iaoQn$mo5}9*Ka|~9D!ChKxwD6$Gj``m?&0USVSY8~CK{ztkNUCH ztBm8MaJ8-a)gm5cisOqkT*v6ME|tpO^wUh}%er00BnaHy{m8JK+uX|OjJ`?$^YEHY zdy%B^-d3Fdo3-aqKTfeKcG>ZAh52X57gJ@nBWQryj4H^@$ZW*W@qOm62;|Yw470k+ zwFl$c*Ek9}@j8MP+E+zgXx4-fpB|@IIRg=7D=DK~@0hs&kJp?mHW~x6KL6RhzuFhv zzExF=kl4+QYO3{tu70{M)GzFx3!)T&tA0w1A;0g%@yx*KX8swU{<$J?a_#)*1eduU z6Z4oU@veR!d{=4!sEDF2_FNKxc_O*9nL*$8vq}3PF@#%#B-K4^w1d>U2LA)+hnL-- zzjf=!;pCx(xw2XF2+K!_S!_xzvQt-FFEmOyZF|?vEpnisJBHa!O?}9Y+R{{S`-xo; zPmFcB`nxExkQ&A7iBc){)mU?7zE^QBEl#F?uU(oB+tiTv)^v`@H0~N42(bU!?zl40 zW?jOA=!rOEoUUY?y-jtyc>P63!m||0Rq6shR1{(-;2Hs;jc_`aZ{XeR=aHu}fjf5j zdG{AG6O=nu5sdc?Np%HD53Ecl7P7wD`pto(=kcz~uEjPRUJzayfQ4yWP}jhDCv~#| z8UUnWTep$2&~s0>A@!y7a|QcO9k`=^%U7e4=Jdv(xy6_`oJhWDR4;BdDB_(4=(UUn z-iMk8P|06pt065-JHoj}aJbLwhb(vCF(LSW97e4STq(9je9k20Z+g9Hd7DE8{nOM+ z;yPowA+(&aqW@`ztRy8kxhnA;xo;`MJZ|865~adb{X-Vbaym-x758LfA2exC5u zrJOq~L;+|>dI5snN0|V?_sf5BXh3|N4ieOV36Rq?q17Pee%mzsqeiW%EHVqum$!|V zSZ|ZJp3y_u%6;8z9QO(L=|ro^1eYD<56JQW?BhrY!tt5OGDf!^}7zl2a{ zHOVH$0rYK+8g!9nP)Lmd|EcoepVj0?Ul}i{;6sB#?SAuCpX?xBZPd1@iYe_5h(5qR z9D$*vNsG9A>zdx)J0s?<5|`fT&&g7$!C(XW9^qm$eY!mhpK|kxVpC?6z-|_QF0o#> z`8f4kbN?Oh+&Y!R4)OH1xBkYxhV}Wr?!)9cC)I}^ z#}61}+5MMJ?Gq72e`VR*Y3O^M9m9>1h!$DqJpW_Is!H|7QXG*JENN zPEaHO*sm=PGw}alW>xr%1Bd^OJAd%08b;OxW;I@+BXFkja?%Q>ITx3s)LmhL#Ik;F5kKt$V%^5SeSUNT1R0A-g{$J*OV;aeorG3Sr+rq z`ec%99e=c5v(L(x1z9owL?yzH-c8WkI#lq{)(Pud{xvJLfWPFR?Ib+QIdTJJsSBRK zZIYK}u^d%V1+tey3|bz$z*n_GIzCpd&zw6|eQKQg;ek%sQ7aF>@$ zm8MFHIENB>J{D4TF}#9urqA>SPxi6dN7P*(BD?9{`*^BPs9j17M!-zw=|J1IqRR%~ z`lxY_F=H;}+IcQslH7y-9l4v@`hWm&Y)@LcR__@Y}!*Hvkdvs(OkL9ZW@cP|^y#bBy zD&$asu5npKKU_R#GikGfiFU_%i3yMf$ht*lYEIw|FMyi9i zE5$PTg1FUUh5DpQ30&na3^Y@TbMl~+t^sv~2twj-_^2!chy}w*Ju8O_ew6A~i1Mq_ zA7+&EExoISF#Kjnz!5H6(V6ixRz>5U>#$V_!Kr^tCWmIYC@j9Ak;*uw<8GZ_UO%M?Zhn=~E$4||@~rvDji+OTnJfYi zsI99CS^}kFU)N|bYFVVFYXJK`Jouy3iw3s1vUOtDTOu>>II2q$T|o^L_<|E5e3ed zp7c5n=Qv>4$HC9S=#k{o`)hS}YG}piRM>%x(8j?@;=!%*%E0xVWXA3@sv*hiijXLv z_fBdwIy9VlN_aR&a90Pr8eJqXtKG&%(F7TZ?kT4lSE~GWZJfEDkSKux!XLI#^9ou+oa>P!hYM zO(ihlg8b6ja|Rrf$UhH0l-d6%anO;QwYe?6d9MCtIhnWB0!helt0v+N4$!( zzlg8;)+2sJl?T5~TWZ7E6KeAFsdis9?L<<(qlwo2y10{fV_!|1iu!Q%tJ>F{gg!@g zIM1p}F(fwE4_KtoVv?~o`~JN|;JUFEdfi^QF5bhPZ~+&zUA=3jX{XowaCN3;!5i30 ztqxwW(3TTKqT-3YZ4LYuzQ&@krdHA?Y)XWN~AS$RU-hmuL@8uWIqDks`|Bq(aW$XRLgNh$O zIseq&H`f_du-F{5i9jsVk<)(~(Jt8`^q+TiTZ^|@a2FZSc18wJ2D<4QB))N*M|Ci< z$O73Z!szgJRqETO7H9l%59e)H&s zFLCw;tA6Qf%4b{}8;E1^lN{zc_NF~faG`!d>Gf~;4+l?xP7cDAUNb6X@k{AX>A7qu zS6|^curP+H=SGb5Cxy^rk;kqJd~ZF3UdwNiXkzLrHOd0a(W2qfdR@3OImePB#k_UC zDq6Pvf@a8(%YL_hmjT3uufSlVVf(UFt~H8hWXP%SpKDL3iHgod602a5;q<2cuVr|I zHIxYuze1W72iP)`l9U5q?lm9{wXYZ_XP~Uan^6_r9jI*;+LiuwZL}4j#P_l0>}FR0 z9f>}7-5=H>@{%fZ^r)c*zD$s)a;*R6r;4uy9piP%oHkx@KL+bP9c_O4lXv5j_gB_t zhDK`R+=jG<0*2-mQrskq%6gK_c!FO#=6vq6y62XC!_;Cwd)`J#Yo809QoNWS|0#cO zs2V_9+%sOsOEB3JCO$Fk()v;l;J&3A$WS$OK$yy6@|<{jBZ?D)YuwL~5pBUH^9q+g z062?A+zal7)T4>+v1_Cjd4m^{l&gE8iO6HPRs)R2?-Zn`Um(l$)@cPg>c&JQiXG5Q z4BYKfrVmE79lg1n)RLhI4|oi`Tz&?c_|3r%C6(wG;omGv#b?VIiJji~83lG#6Gd~6 zyxSxf<;Gh@_bu)w-6+Xz$KdRgy~K2&%;z{{5L&45T3y3I-8jRCgT5_A@G z9$Q%>j?Yc}5`(13%{q2Q8b6EDi zEhNG+akt%VCVTj=M_2y}ti6F8ehEi9O=1Y;39bZW$d13l$DNKOkj%9QpdEqq$qy%# z#Y}(0JKc=asO+W6havz*<>x^I=83bFZ{AiKN_<=#B3{z*U0;0EO0;l1Z(g%`SZD2` zzZA{rGI1f5Iqqo`JJZ~Ld=v?9{f+~JDfCO=#L9h-0H&nbBJGM zyMgxVKgCGQWY#6^!N*L81AM23JEfMv(_4Sd@i-lBqcF$fsZXln#eK>t{xaNw;fW8$ z>;4ePF$$V`GR%smF%#Iuhdp%>+xh!7<^od$Vj$et5lMa zaX_z)yL+1OI?@1CJ7Mv=YV)3bo$lRjWK%)nzbsW&GVA*#>(8`~>`?U?yjXu-_R=~S zxxM0m2xMnh5ny#Qq{^CU|NUXy&j>nJi~F{Z%NY%={P0&LvHOJPVyL!EIJ7Xw)k?s1S}hWQj8b^IzjY zXnoEqp!E%jv)QjWDGG-es9(jUEPD8H->->+C_L4!N-R$$c8T7OU@o)j^7Rkuwi}KX ztC2O;bR$rv%jx|S<2t2jGe%# zrZ)tsT44kv6(#`<&e)yMa4eVPY3=zhlpAOAEA(S%6Q_MwSj}7B46K0098dAz2G)z* zx^Eq#7-7NT5m9~ky2HE7yLLPO8wDTCi#63Xt-C}n5SchE96usugC@N36|dM#>)A7G zViwW*P6aO4%kAVC%_39Om-5w6XyhJV$(vDmQUlY_0>buFNgN)b9VskJ>8cdK9b|xc zeGInR2GV|wkt8lncCTRBssKE19RE@4-uT%4bKrco?_kdUM7fTn$lSSPJ6O1gVcY6e zaN47fy(9`_-e1!Z`mU9_9&-B;zhApZ30~NX7aqS1Q94oLy}Q@mFvnWg&n{ri)y2?e zC(@y%k(H_?)r<=>l--CZh9Mr}haZLc-6qlWy11jVpR@mcNy6>&B)INZz_ zAN(l#NzMm$PK8|c3hRgTuPF{+lS9+vLg&@?!w?PCyJ0TH+_K%@4=(zsiyS8Iw`;=M7G ztIv|8FtglKyV*#Ap_<^+IYro@qt)u+R4lCwvr*-1>(#tgqu3c zqI#d@-IIgJ6!f`1VEcO-I`w0)+)#ZA+i>d$Z2=ju9Kj1X?+{@jIVWzfDm?+Oj^G6% zWdKTW~N=x!(1hM8eum@rV%h&ev$36|~HkWX(rQ&<4KS;jQYcxP^4Vp?_BZH$xK^9k&8p9I_9z*d$)vN zvJLIXYkQO!^uAv_+{MO<*HTZ!O3{dneB~>J^u5_qa(D?`8oTZ<%OHGC$kj~$Gt=#N z?w9BeRWXlS#QGdR&9Lh8xaV9*v9Z#90m~SPUrTSlEmGhA6Z9%i=$ISxy!eCGSj0@2 zXUocZBz}SSteL*N@ux7Ct074`-^Vu#)qW2L9;~-^xAZ(42wiFWq|Hw!e}T&V%Jmr3 z*?S+GT=HzA>^!IOoQg8zbLwZ{;V>w0WcR>DNqjfd1giItsbRy`ZvDY*zBKOcmf?0Q zK$q0)rQII>C6^1)hT_$z<+V~!RNQCd8>5qENT!aGskmAAE;v0w>6nM~PKss6*l)`% z(7j)&stYE)N-DoRb<{>eAx@|D?)D56@cEpGA_Je*dCTf*rj&9o-C@DR#x){H$cnMO za_F<4G2;ZW#DpJkO(8CDH|ZK5a4i#-v^@-6>$!>ZIRl2mG|F)*=FDL$gS)%rbZmfY zUqE7~*|ZM!lMePHv`DT#CAJ4m8r1N@<_@hJoQ4Hvx=Zhz<(K2Di7wzym2VorV14^-gLmV1U8Mx;D_iZ&_r%$+rt^uEHNFfMV(can_!^3d6|huMc`+C+?Tn@>>ui4|92Z-T5~(2=X91o5JGn25IIdsB&#g-*5hQIsYISJJ^C}~>s)fH=tf2R$NVbBv1Nj_}9!aZpQ?vez(C1F} zRawXLnX~8*>$5ZUISW_i7FYrf0?P%u*GO99?xbLj=C@XB*L5U^e^U!JLP;0Cb5#hi zKC)Tn(h>Oh-UDF@d>8eefGGU-OPz+4eKNzRMf6Suc5NJv+XI32kQ{n2S(-!F#D$51@7y&>ME+vAz)cD}dn#1t3@R zDQzAds-KGiz|l|}+-Wr$>&Qobt`6}+*2b1|>{pUozS|oDXQv_>rXF%@IG*n9uVq*q z*tEcK+rtBi18PjLpf>}1o#Tn{uuaUMjJ`kxL20H+HJX3$3PR*-anX&eiN z)u1Z%7LGs%C0~Ybf(;a*7a=(nRVyj5w$WW>xeCfID*j{zWK5JOYBv0y$oXo|2{!P9 zhn$rig6_^oE_Wqv+GWwj>E>q9$0T?7mpVl^;^gG6igs40Sxj-yTWvYJXH#sgEKW>o z*=iS}8ix8*X+m=wZP^3CFO}o=S;1Z_N=4m;L1oh75eKXfC~BAKwE%Wv*#*jdDW6E- z2;n)kXnej-Z=O?}TS>VWM)HrL0PyKsgn&fhD^|>aOH*aQLw!H-YBTf>?pb-WAA5}%TUu)Wz3@KefuyeSP^_`7>3 z|IPjQOse*RseH9QrhqGyiTLT`(r<4WWy%Y#_2>$`itt+(EIKxbDjxG2(}&1ACm*uU z_@|(%1gc7Zjx}eJ61L5*t2N_JbID){v!IhV<`CLWijNFAwWZ1-Z1^?*+TlGs_BA2F ztFzjzr9ac!EKe?;)z{^V|K$?~^_hS5eJ-i3j($%;nSP}vSQyogLjBwEX)+s6Kj({T zYY5!Kc&)|x(o;|Tk=_f4|0}ARe3#!29nU^W`{pWN5k(3SXY%JlL3Ms7h(Y%zPYv5t z-K?*IVa`tnGBM&zGzUEtFUn+(^(jy2F^g$`nb%ES<51Fx%^z(Ckaiz8b7h;EkEqy+ zkO4z!`Y45ZI5~*CBLL7|Qd0wL#`Xz{)}K1uIp^L~HjbdfGo7A+#G`RS>1lQhe9W@G zV%Tha<|+F7c%nCysy)#@y<#K}AN(eLp#fi_1s}ZQr?x>9l+Qf6aix3X8x(!yq0&R; zE%$PKn=1Mt@#pQCQ<38bnaS+wxKZ;OLDLuplS4WHksCRYT6S1vm#_;^ zVde?Sn?I5~%*~bb_7bMT6UWx9bqWj}1d?~a^Cgkn+KrDt(=jxRzN0n7&)1D(Ew1@8 zZKVY{;Ix%OV*7ES!G~unH7}1L;xM}HufEpf>asR^NEPrn&-&8=zMhj!#oKCftq~Gkx)6>! zykyT3!gt=w!MjzX1x*!7_83N~uk^J;7n3?oISvws$G4#&Y zKrKP!nNAI_Ss6)#pH0`B+jd4Ole0K{6YOm{h{68t%u^<8&8KaylqJ=~TPielVyowD z0)={Q;9O-C+VAa*(mr-S34?ZF*D|^-4s12OXY0wmBRhdkg3ZUxvxK~Rv$@@Lv-zOp zhuKCQubv?g4KGTr8}2N`ZC`(gO-qhq(WZ!H10qU#ZPLApwNcwItyH z^sxyl#XkT?rCUr)1kZHSn5~{M1FR;u1^m1mggvz?6VV z!n}4wedmZab@cs#SYX0s@{)siEAK&aTh*O$a1GgMijOnyVR=E;jJ(&1%_P9#>;{Py zHgN>DzwohH_+E^Q)b0oiW7P_Hbg(`JwiKD^wl?D$14sV=LSM7PAe-4^;hAo`Xbnp` zt^WM$<8C%agB#!<nk3q6{r} z%gcc~>mb)wkMLJwn!yz+A?M>^bX?l1FYlnJs0zubJ4B}O0z~mdxj^}6wl$rh4dQI% zY8P!I`{9`DRfOvHwL`+%5TBV$WDjrKP31HT$C;WOU_qES9p>wEm-!E zL%AD4N~=yU0N2;(V5+{kAp7VA25D!i>32L|C&w?79xPufQNFz|(3N8*@OJT_v^!i{ zJyfteW7=a9NnXiMHk;iL`>>&SKZwfib^m*{4ur4xioVZ$^aLV>{D%!94v)+LhThO; zP9WLfEM5MY@%*;}VJ_do?*QA1-yX|8m9x{OmDy8N9yg*=o^u(p_qyp|LMf@ z{~%VTwK_PmWuweZBCkW5LBPTvH@^VlR+_0gLTE>!{p~Rw*TYyft66stazyLa3k<&2 zti3?;slA>aEjsC!q~hjcG}!AMc_!UYoWc~x{p8DN`!TPn_~V6OOO;;nu9Cas(z7CP zjRaohEd%Jjw%pBV%@Q2qi-JBfhgC}$w>Vw3z%`;fHVIsZzD6sx3y*)+%a`C`FahR%;Wr_o_`|Q?-hsHbvF0y;qUgp+;;$)ZQyG zk|*Ea@Bcr~dCtknIeC{C*SSCUxCZs-k8yPQ9ngD3pu_2l873#H8L!)FQxNnjhGVu|(@+gGJEfik$=6x?M zOZEJBm1CV80x>wj8MDdL`N4CPC-9h(kU%H2yt#2XirGZk9nQL-?DG36s*>Th9L8|A zzfSFj-u-Yx@5-*n4ANkLbkQr@;o8csX?Vu>yFb^JSfD=%kNCQH=f92nSGqKppR_Q6 z!7tKivJS-+7=oU-oOeC|;gl`^r`1GRkV2}V2x%c7De9Hb5Zul3m}}+50l}=@@+Rn@ z*k!)m*Ox6j`J;yw#ruT*o@o8Y5RVNApwRoC&YSWwI)5=I-Rzg2N(DqUC739CMEBQFJZ@L=yJ`|reT_K+3b7)k~6s}ba_cK4e+HdCMy!rI}<(H$0myQdo6tj`P zcpII@HBeTjWLLx=!LN7zC^75uv+S^ZIO>PR5WTPhbKj;NEo>B7Ru8%^`TJE&q^uP$ zoPReYKlM761w3WW{W)2SDLYgzCy%_X*W5d@-Sg{@?exap=Dg#tM*dO8^r1CYEeJ#!U( z>&QfD#Tl=ybWQKSi>C_`);f~2nIM~sQX8>W7^%Po`WkPrKt}>VgU>$INrkdE4 zEPC#%1iv_j1dbq@VV)E&vAV!TfO<|iz41*a9_P#mczU7>@bq^S37~Sn`!S9?NeW`M z-P7I5MbY!~u%XiA=uImSQ>tMIQ>r;fS0BN-r>3iJU88(Fg{_x3G-2l!Toylmr2AP} zT51h76}myCx1pzwbzd3xxLfH6L{5&u+!%D?MjAkNoq`MQ&Wi`wC?1bhQztIIlHHIy z8SqI(T+2sJiXKlY1fDuRYnl~4)-3^h2Rt!oIuW_$^k|y-vi!;K8_Ipd2su`~VMW(8 z1prH?M2(XfLjZ|abSDn;t78-cPZO7&TF!4J(d5YBSfEsm|M)QyLA9oC`PDQosf4>H zfgvKYdUv9$>YN@>z%jhg@uT=7J{g=<1jk+-bktmx9iR&{zIu5akZEN8wVmf*9@{l% zPGC2u57GI&o-m*pl*>AWixtcHBG!CFtS)#@$M9oUVc9(3Q<7b^cs}4y%t1=_mG-Pe*EjFMEKHb}c8#b-*S#Dby*p;Vdd{r?xu##H z`geLR(|&Xxh&hO%F zQo~z}c~EiZSKvgMnwdn3hmHf7_%Im|@sII|Sy38t*2tglrkr zSCO&xe^Yh#q&j|wa7jDac^fYYrrq6zw74nnv7o994o_lSVic}w((fHiEk($jzHf3D z(0D04{UQG9`oinhSC&~BoBiIJ?MY?F^hZ66_Kw}3ZX&4?B>Z*y`wo5LC>N`OcGDMa zftGh5{#OHy9p)FxLG)c_4|*t?uV}2QHV!&NAFxqT_zAJETbc=YIt|74c+{2h+$><1 z4^mneucV+39=)5o4>Ih=#& zN6EnaC+eSj#NM!U@&O?$mOD-NE^OX-Xlvr~2gI*qV-$}iGHxE8Q3+pb{TGOA`OJ^6 zihx75vKhmmvL|jG0JpLycw52!+pL|)UJ(GBqfFN*=|aEl6|>Zi>$imEigRpN`krJE zs{dBUUkkz>NJ}TKGcu;rFt-VZ1I5lwXyuf(mfN;IdWtQ=>$`T~wH_8v1f8fq^J}Wr zMmzqgexQoAQy-Vv?eJQy$A2cPir4v-nehC)*6&J$KKqWa&8v0is*>a_jI>&3e(nD9 zweMm?8CiJN*NoIGw~bJh<9Z!)^KT~mxJ!3StmW}@%XUFjU+q(_AHoe$+*3izbbH;n z!_3NEv!9JiuJ-sV(0YEL5q7AJ?xQ^2ME5PCrxi25wM*~t{oHj})#*GaEwGadT&J_) zOSSH9X}F1yYq1J3EyzmNd*c9cUp1wFi0&v8mUElDe5-&P7Lk;Ww7S^-L7-p&&IM$A z^3(Uzk*Glqqr21T(#dIb_l(M<(JC!(I+8Ciil{gNR|TLodtKc62OSNqni92%{q*4G ze{%RG=))-Sj20*G-yf;i*0SOchx-0QaV0l%9$SOjEB4-UHlk_<^4X&7R-y8ak80j2^6tDX*_}dcogu zu;krj=)rpPo->mOJgUz#JZPIbxLyq){cE!F;{J6ov~lni30F+3BIyTu0bBWc9411@04S_p=w=Kmh+ zrsgw+fP?>5lX>T}D*?JPUH`gR6g^+(tRkBFZCOmv;y|nxeNZZ45dUpBQ3XM<)y8zr zM5%EQOsW&3I0%6gaj+aYCa=^I3P6us<@72Hyc={5oYF8t7r!y9yl8}G+ztO%Cl^E9 zo-cNEVJdyPG{z9)GCFL7g~}oC1`>(1b&BQjY(axcFleRWFX|j#Nsid)vINCpG2|-OlPa5-KQC8~7`0-ba@?1_<-liu<u42>kszuW;w`^FN}HUY8tBa#fOk zOLs-jqt}`W!*#mFp&F|XKL(*U!lmf?{-Rm>YMMVT%HmedRet@9qZ+YmvtY%cX2Fw>< z@VFgul8PNQT+q{v@C5JGwdrf;z71YA41xl%RfT;n)ewYhUfc&l?Y?DA2cdkorkP`@x2%vOBNyUF0rWzm=uvcPQ@c{ORSS7xUCI`@UfHbJwIgZCLiH=hx>} zlRdSXdLq+9CD7AWa!V9h8BO46#`2E20NdL8@T~rEkL$7L#d^tgEI#Wmz^`a(^0ZB6 zy!Gc1)0EY1b2_&mTLt6g|Fr}~4PC}$%-&GKTRCsz#-v0lY@J^&KVN^=&PudJJTs)h3c4VSSS z)*jdVoUhO0ON5u&MOt&j+}@c}XmNy#!(cO({lpCp{b3aSgG6qZ)?LfbMxbv>t*<%v zBHU?p4LTRbew9`v?|LXE>=3;Ohe-nfdMDZt&B%7-#)Iz^;L$R zErWPXXbu4r#WsmYM*;%g^9< zdA>AyLms<4J!(?R!HK!Zl(`G+(>6&VNep*wz)_f3|52%HEnDEViOU>DKsGUg4UtMc{`-T4lK20;v%XD=gs>CeF54Q!X3e|h~s@f^XR>nuu#%4UI$n$(1 z2I-D`iCJ^4tU;s|k&!zK&Bwsem<|%_=SBI^mvoA;~eL$|5z&gHrsq(@v18-dndg5&QAWE zXl3=Bu6Xu)z86P3&O)Z!os&XpO}68pC9*Dhw$sGLGO4*`AEd?B>lfL3tnljtl5|TM z5i<7A(F!aQFE~iq@r@x64b!VHiTmQ-Lt`J!1GJt%1C&h8B2xWSo#5A0P0xp3Q+#ow z8(DvWWJ0R21`n*&yl!J3gx_42bo#G=tykpm8I%d<+RBP2+$#JUltL-y@F-`)cM@d` z9>tRY^h`#oUqz9+o@GUc5HA8j{{xX7BtN5$ymv7vcT_FJjD>;U?#>8Xch28jlSVO{ zTYuWREb=j#y-|fw8vMV{*eT?{0wH8V3aS5c_C}NqG$)xS;Dcz#gm*3k^C@! zyBTCrnDD*fjHt`lYdqD`HpB9Bw`ptLlVfH^XyJf$s*IOR#JhwdKB7pVb-_(F^q7hY z`j01fYocdk(U(r43Ec>@b*hD?st1*AW+CR9wEMDUnkkT%8W=L)PDfvul|U2cVrSv}$JEwI(eD&GjF%a!<8dSe;%1$D5xLp;Yw_CRz~Hs5#R zM*nnv^!?H4Im&jBt^`Ru#_lzc59dvJYlMw2<&U__5Yw@)_6s4+8};OE;WX!Vk(gR# z^`M#dBx5~p?q3>S;p|jmaq62w^h}xQAlMm*q}#aEVbZQi5?N^?+mL(@1?!l#ToSWtP?cKnt4$HC`BTpV7tv1CaxO$oO{!O#4( zmMzuc*|tk$Zk-()W_}bQ3e=cq|9()GvmCpvr^!?N+go*}FnScHkYK&mHS5@i)9Q5S z32*51{<_NYD7ZlH?C@^F;Egqp!OAnCCq>UaO~DR%(7mX4)uvyYB~?#MvD76L+-Kq6 zb;q~K+Fv9h0C4`V?0Qa@PFLX#+D{^Ta%&=sTORY>muc40?5KE?Q6479S~w%xO{2_? zq@!aTn~^cq-}rtR1`oZQ@;3C9DO6MY{a2Z?vFMX3h!hhGrFNksc7J^HSm7*O9BeX% zs`k!Z*bG>FFQyrBLJ|*SEmZao0y|j^NLRo4d@W>taPO3JcUiT{*D06=$5o)vel&e0 zwG5Ww1D9{rqMF;wd?24N`w#Nr*{a+7^HhL7^5Ve_8MT0ypV)W83zb zqw`bFig?a{=S1{)Q0-Qh4y-yAULFN1D%uy9JZ{psNd!8K1OIYm!s*$Lqc~`MSlW@D zP!E>#SXq6Pn!tZUO+kv>e74Cg4*R*k9LNhr`;%KidjR821w}$`#Qzd9bFqesJ3VkP zVZlx+HPjO|+SLEQFwG*sbo2mVw=%(+1NaWdgYeTXjc2N|;WwEe!oL3={aTBx?Kun7)wic5sc;;J?T zaZ|c~ESO**_cZ_MpOAj#vdpdfcaeXvVe}$_?YnO*lM>wT>04H2CCUj^WEI=ElSSlN za1PwvTa=uX-;yDHZ#N^nEy{KJ@NIAXX85AMa~CZo@JNMfe~S^9sKbZnXrY=@lU)$i z?lsJk$347|WE`od@>)F9jA_LvsVTe?s<=GgIeOvpbzSDp*8qnQ*ZH#KwAX)-5#q8% z>X8RyB zBnTb9l{~k-aRNFW8x8rC1zwl4iTns)ePpnCuhV)!>e*#_!nNK6+RI6PJc`d1qieLC zyLUhHEywK3f&`}qFk1J9W&pVJX>K!qykcV*ew}@`LC#hh3=*e#JX-UKB&Upgq4kI? z*aYyM27~P~7!tsYF#{DZ$NJZ#0#9q@FAX`M4Kaa)&(Gpjo7N3_yfV8X22cbs1%B;m zwj>iVw7o9jaaF}NUzl0^{^7qqRJDtPC7jXZ&X(L8-{pknXKe~6?E3tW;S5VmF6^gAgSJ9}>9hlAyS8i{*H+1!5%y6wInf9riq2601dR(MbHn&Z0Maw=y9OnQJ!Q0&**CrYJJ z*w?Rn*3&J zpMmw?92ZqBg5W0>Z1E&N9m9b{WptT0slG3`MJQhhWKbW!q^eQEIJSZ@iz||D(ITcN zQrKzabTzg9QtFu56C09IJzhEsLw*K>hi9H)zRoWic=WxSnpVg<+|G%tul_Vg)_9J` zs7G#x<^Jj3imSTSKTMHYAaPtha{Wtg(l$%5P*S*7zM(rDT!0q+m(*s99VV5!D*6X5 zPIZ_$6i=u_GaLQgK@P|@C3bQU4rv48>ESv{wSjR7fpI@^B>|fRa)d-sB7r4GaMaq! zlKgIYniKbb5vc@GWB;3Fy+JOoPUs({>3u@}!MdN3PH);k2$<;^Q)kfFV`K8v4l>aIv!gd+1yw|U62}@isrdFqt+s|td@oG{Yw|)i~Efr83&ym z{0^vTUcs$OTRSBd2t-CV^UKWTZa?d2j9S22b?B@P1;lYC|5@D*PhUu#wxt|RlPlex zeDUh;nPVH$+qKQLTDs(f%M|dkCo0qK^<*!0w@od-{2}UektJDv5m293+y3S>s zwV){pKRAqqU;icBT()B?5Zcp_5GxXNHW$wb_@|X4Z<;Wg^6l3+fc8C}ZE{WzIrCKx zRMFOMwQbaRE63pJ9}-4lJBA6<0Zp6W7Lt6G2X3&7vae(K{gfF+mJE6U&^ASs!eL;_ZTSA}y*Fb@A|8+)S1BJ_L4#;X$?p6jgwW`lB znSXSb-{X<1$SCRVHBr~Bi_oYb-cW($FwS`+xh25E`Xa|ifPT~=K*P*wdZ})I*w0#C z%imxy-fNb(b7mH(95AI(dzoIoTq$pM@hUT??b1{j&~Lh^0TW5SJuJhluQ;roO841E zIVb*G)4QoYz-*1%-vfNWpsAvXQZ>3SIES@PNO-Xxo5C&vSojM)SwniS@l;fPh%*O0 zIMVug{|641@#VX^ncpYzq=CDSeT4kQ)-__YbQycHn>-l2U`8R#phJ_P(AGN=Vu7@I z91%82J>kRn`vKL@BX!u64XyM)e#BIz7<)X*R6ncrzHVqg5<}Y_>`$lyaH6tNQLo@~ zx4Zo22eVIH8@H~asjZBhhQ9n19Xnezz7W>7i{o;au>IZ{BB&J=ie=avD1r z%H!LO`hhuAOjyzgqoM{)RUKTXflu2G4?ZfeC+f9-ygt{T`Z!#KXz=`(dbyP_%`znu z5V~wR4>}f>+d;yr7MMZCeYC0{qVn(MQUHf|@OSb=veyzn@jCdaCJJ_pu~x~qWizQy zQHXblov;Guy??I}DXmAIxD*yi`|{;)mkoJ;TKfBP{fN`7p~`Ag)Y0*HzO%4uhuc!+ zT3uJ7HUa{kSHRdi(q&JYRav6<=K6(ojZq;avyWWQo*Wri)3Tt3k(h#Zj!vY>?g;iF zBIXxlsXOY9&o5;*@nrp_+fO&x6`x@akAN+F3Ajwv4f*(Wh*oFng~D~Xl(_6aDnFNVwLzX`bSqaSi(GMSl%Xk++)3g4QPX|Wo96W}&f_D1 z{Hm=9CLQ)4GHFzGVhH-P0Ke-Ygld=&4%)Rz;39kip@7UTi z9Bub`c707#RpWXRQi~k)F&$xAkf7_WPo!*0f<^I{2`ieChpU81|@a_?Up3_8S;eRn4GxIUu6TR{hfUfF;!dK!94@UY(ixAlrw}Z zQo>gHq1cs6Db7NKHFH4Pizn-%z`BPU^@pf=<=5#<^^vK@hI0siACsMw`w7lPt=~I% z_8lZhIdSEW|g@H4q5a(O78eR5aCM;I9! zMPpOg&0e$i!|zpw&(_1Lu^B`lns9ZNnpZ$xXQ?-I3+fQ&Wt2^ZkvfOsGcTI|!D)}9 z$7{`{0rBI+oLm4?nfpEn{N(q_w^MA=u}lKK^Mm;gNwB59=BK=o5UI1z@`0Nf^5^;8 z?5k@OEDMiVud0F8rC>3Feu2Y}u-n;Qa7+4NIr~(i(`U@^` zEd^ z8~wbp^DrP$^L$RD9nLB>%7&EGR{xH42BQFXufRVoErd<}+C36r#<@!CZscDZJds&=qEhuG~7 z%GqvWuy#R^{MCBemIidO!FirUkfxdT5SMTq<>zm?3p!*!yANod!VF})^ARz>yrWd{ zL{Kesur@wga}RLVlrK0F9N{dbXVuCzM8c9YbBW&Iof-2eU%2Y9YvZFCoR5|#vC^Ob zvY6PRyQ|)sP`iknR&R-!J_Q~z-WR~gOQ>rHrv6O=+i6_v^o!@%anl5qZ5b&h^5fHW zmJ*|K?YD#MO zqH+s~?hRVmt6ti}DDuqI%?kgI;pSS}@_W1--sf5I#6i=+VEn}`w}uBneps+JK>En` zh*-0moIlK^TAU*?d@|7IUdOyw8+y6rh^PyGTBkDl+wTgW6k*dD?qwG-9MjkoroQG0 zH05j7>bPbKD7Kx?5tXFtXcu?y;ZRR{ZYWmM*h$^DwYQdFwsYVWK>iMDy-z{wJt3h- ze0+ukyjMqdifcG|@bbJXqT$HqcYN4Em{Ap$dqC*Yee& zRn66t{8rMfL}xeV*%^vqANrF{ZXFzj=`I5?+oTg5s)r>%S8cA%#h?lKexV`x=aZ2G z?fkgan-59eW+L&o3yN8v5h59LnQ9Q|+7HosFNL{+@M3+HU2$$FFsL#7HABHh!m#Uq-RvGzzIm(FGGi z;tsS2*fVlD6IHpM*b+r(HbJ&+Ma~fS^9ReeyUj!;!3fenSp?fFsRANn!mT@i2xSm2 za~QgY%53oNiYJ6SJ7P91eV)X1&MF4lU6TNDOr||>*VC0X*MB?&7a396)st0jCxYn_ z(>UVj4hnT}bu?c^*!rLT<}s1o=SGY2jy+m^1&aiTseBw<49jL`fzP zm{on)eZ_yvS1qS*-fiv|xu;|ffB)e|TSj=(hLcG@VxD?vYrnIJ^RbBIm*Ha^Vq2`` zwO}%T#Cn`)6CYlmYzz& zz|wr-3}K>m5mCB~W)4@08`3c8I|!1{tAkZWnqIGQnGR6-fROx;*&GkQ-FN=VUsT`+ zqO0GF_NGc&!V2;WyhE%0>5N_@M=kWfd$vA>4zPIv-qIa=*V24s`$T6-OKfDcx&`#c z6?^WLvtg9qQ9k>3;Y_eUz1VV{%&GhHI$lG?rYO(*mlk33+lU>_t56UGx)3)G8af(c@6YTCAOAd;VIm?~$ z*o=snL$Q%+9fUqjgSrYXYsChK1I# zgNAIOFjZjZc-1&c#kQ3omOM!$h#e2dqehPj+t1T#8fu(3M7oB|pkvxc{73FhFZ5@3 zlp3PoNb0yY*ryXcxW9}YxEzDo@?o*}ZEIFr+mYFGk1d%oRtB=mTkDii`_mmOrnko? zliTR(>9^j7c+1QE@=TNDGTqRzgFg>Sl6`Gn;W$8Q?|hXl0Dc%&QSF@&dEh|aVuUYq(XAuuKGFh$*z?WtL4 z3y%wFOoL@Q@C()Oj1zA+D9l-H?F;-dmg!E&<88a~MIlE5xct zG3dWn5#)j1%XWQn`g`88&bvTBy2U!PxT5*TE6h}3*3w+LLsoBIlk^jVH? zV38tlD)U_$v3Q6Ab2W|7-CvbKXYHwU`9~j;p>c?sLGCL;=n)&DxbeXMEM+mq?^apT zzg3O(sd=MHQ&nZVmDQKPj5JYSL3XjkvP$$5Igk7GqmYK|#wF$KifdNke+9<5fbTh} z+n#&pV}n>}E?B$zi<~`1Q$i&YJxN47Tl*qUpg9s;Rt_~pFZ)#ncn(Im^znr*vBa-( z2xGS*&V}K1j^j}QxS*O99zl8i*tycRL1nW%7u(D93OF9YmW2OqYxM@bQ3VhzlJm(? zXE@v%I(d})=}FMwldkRq?d)L}`wmv`&0np31HmI9GC$a|dJvve1Z=ii2Chbq;s{s; zAFkNSuGuPLFl2-_pot)#xJ)1Q-8Zm0u|7_CeH{*SiQ^lMRmb520~@cVXh_{Mf2cqg0{3;ZaqZ}`-l(g<@A9SjBB1S}*v*y0f zT<_cNc83hnAlaT&byU0fXuUd=*FKeJW(iHaoqifPZ~0MvxLE|95NGv4{M#w>eN6|8 ze81botv{+6j37F`VI|S;;==1ssmJW563A%dp5i``Y?TdLf6y2t#IhMQz`V=9VzUAM zw@Tp;yG{KKIY4QosZ8c}19fFKruLy_?t0}l$4KJO%U!*sVNXR@wv5>Z1*@q%x_Pmj z4NiwUmG|ZQx-9V+iN|Ja^k5~L0<8C^Hmif=SR||Y>F1}RAsxbQWW9<5@mbTN9$cF$ zxqpk;Noi0XgUg%qW<%%tTC0v+G!>AswWf_2iZ@ix61%Ni1MuoRl+;a+wiC+^-oLof zIX5|7UG}0feo&tvZ*n>*kpSv6fe+Vpe|W+8sJWKS@iEV{8%xQk({Pp^_BC6eX{uq{ zAu|FqE2H?f5kTWf9k=ssj?L|?T$Nd!ZNw`98do{1+L2-dB3GzeJd_!tajvTVIRL^A z%O6+h02Ycq{S#FS%q6_O2x}5dxm@EX5*H&9-|7?{VV&5+lwvQqfos>uKo3bRTeHaB z1o0D69YJdLU*jVfl#L%o0%AM0m9%N8#V1QpMb0A=S#uGw7w*@D{YKELiGeeAQd zCPKLGsCBAYGD3E76X4P+#&A%?Qf2m$EB;yf)PO(&!J$sJ**f%hZ~7>4%@uF1zXfV+ ztGiFj%xgmx#{}p3n~TgGH|-UEC<R4RQ;y!&PH^~G;aGG0lWGu#!A2V_D)@s9rPp|Z^S*sX=R4}NWdzEp3iyeIt>GO#$ zQEU)8dhkn&Z1St;4d-ZoR*~kQseI?e_3YczjgTeY#AtzeTYD8GBMFKD7Vai!(ce4o zA}YvG`HuATL%|IuST0M?3EzemA=#u$)n}Ef!OD!ZNC$WFem$}3TOI>$nz9(rU9^uP zLQgSPTkb>5$zIN7yKt-bAr9p^n-s?LZouR^7{YBg@U8Og5*8UiN^il=AEuSxH*L_> zBeKqoKikQA#tG@}+kyU+k8Q11!m_db$VUvb`bC#f|8vyc! z%Xqmet0?@c6T6V*Bi1i%!vfj;11u=%yy(2c!-`c=Y?)kwZ6!3J8E097UXIeUgQuJK zO5!Y^=!E=_rlyu?Ze()m3vn;@TqtCfdf`4d*mNY7B-k8(5+VS zIDM3`q9i1Omye^S36?`tjW&ctaHO4EEl^u;=6#*(=>@)fl%Rm<96SEqfg84LUBqKD zcP=GHXxdi>_uX*NDsalC3VVQvfop| z-MH=!(P-ag2=Y}4i&doBcGjUWXHk|2Waflmg;HZ$;eJq<9Xz9#rYd2r&!M&MAkJHJMhZ? z9OPZ}*>@LT*>R$C-)sT8DD~I_44=(LtqJDct=Wd&!)g_6W*J7(Z42$GdwZHL^eJcZof6mss5$PNZdX8hK=5u@a!wLxq!QsW~<8 z&-X$j#uT&&Z?@<8_+!NC93vsWWCMV51biAY*l>TrAv?eLMA0wakBb5{fo5(c=nrpr zbQXJ55=qo^C65!iKvu5J_tD=|1CzlRiA!m)^Wxb4c=FQ?f_h=3yep(w5{KmF| z$QW$nu|g6oqYM<7o1+Wp6DHkcI4k16GxKx33rNt%rmN**@(GYUTtOZ>9?x=cIL#6E z?+vhv0c+Mz_vo)M6s9qtwH{Jn06ORZ>D?;gbZy>;1NkBe{fi6I>Wds|*H*<+8NNDU zholJB4o*>ey-jX{r6gewj9nx^lekMIAq|lsSb7lMLchINJpA>8k*uS>3)=w6*~1C; z>YQ|Lwpx2OV%8v{Z>|NKHb0|+-#;6xSjGn(>N4E_8c%ZP~k}@T685&hLLE zS}~;ba@_oqTOw=4mTYCE6hX9=xuJeOy-9yQKi_Y;3r%6fxo-d&lRzc8GJ%+*SRJAp z@Sy8w;x{tgR&6l{H!o_m)qh?h&(SEolE@wZl(;=(@LS@7WK~)Hye}pVN!CFHkyB58 z!F3w(hKq_~E4+OBjB-9Rp$-8N4sYshl|Dn9=tvA_f~$5FPoswHjy&qxk4hzzhW9kS z89cLyy{ZxowfVM$BVYIMHBotVEku#?(P-J@lXR`T|9P%}oba@ZeKCjlfP`OP&@p%| zjD|}1egI2h>p$kyC>xh5f^nq8oXxrDWi(sH%<0Lj2A;&}9dor+V18m)fhvd5U*pc4 zlZOQ=YlcHtU)v$5sL@wL=S-rfwJ(4t=?Q^92`1?g1jg7kK@j1j;G^9puAq;aB;Wk) z@!wXbjl9ib?ZYttDf>)O1$#bYSDcrJl3DHOEyy(7Wjcx`d(X!2I(+n!z+G#MOu*d_ z42<<`m|qn!u)9Y4#9%qs1ptYpaM;V== zSGybPfzgpVGaZHLyQ|dUdDSZxpY|?}OvgeG(TYWWe)C1uisuitvxk+oq+cZMM%6oZeUU36cyS|nEcEd7<>j@B%~}xrG5NYS^^jsED3~RuP_j2i`^t?pGXCpjXF`4ld1xmBlJBIH{dZsP z=!bpUWuE3n{%>uO)rzf5ia{Cd`-aqvq!=H9akHeQ&QBhW9tVN# zwmD=I2^a_2byl zK&$p?ikwA5jBLZi+#8Q)9eYNObmY;?Za_X9%wvf*(rp+ep?eL(c>zp`4@D?n4kQDrj5reZSpa7s z*4Z_RwgxH3@%MrL5)YiX%OKXdRnO<%OlFW(@&7TUMdm{2LFILax^GAX?jR2xUN$)qI`DK3?G&7H4AEFWwSS${`J=N$m(|j* z0BiSd zGwO)zn82y?wlV@k*{zxHX~LKlBMI)01ny#|njaabC6BA3uK4I!ECFO_N2qJPJT`*w zK(wkq&)9gIjpgdU+6GtVYn&Mggh}j(EG&FEzSnZLfW4=0JJn1?Du7A|3CpNGGzrmbLv9U;7T>=M0?r^&C#1^%30Mu2fweLCF_f+1aW5J0Kdfzr zfeYj-ztg;3x)rRj_dQnvU!8k)cwRPzz$u?KmV>?FDT|%>%F&_XPp>>@mhUByW%4__ zcevx{FR6nOmsWq3A+$TJk9n|?l>$$1Eg}sJg%j}(*^n>v4Z@TAe<&0#o z8Dm(N{d-dMIV`oE1+Ji@&x}H{1CsCjrXQC4n4Z5Ti)`l$&5j+KC76Rac=*_ukBJ)h zHIl7WMn7y%%9r@7qs(bF?=SXPHGL?j!<+y&g;xhN_YQ9^uTB-N0)6!j9?!|R%WKr1 zeT_knmOWlmTfKSJpcP4G=3yI9onZF79#(p|TD84Fj*Wh8{Bi)ntT^d|V+2bMe(&83 z>f=+yy@GMZIGb@UKbJBV~@Ydkk5kWX*XC7+^3WGugYB2mydCu=@#-MNN~{gP}SK(8asD5pFjORzF`q^k*J!d?JWP-D-L5U$`k^54^eDABMV;VX6zQ0 zd^KlX&n>^kmS(QTe1j74H%$XaB?tQtzpgZek4fAJg4c4#ZEZ6|N_E!aU4Akj2zGwB zCR|Ny<*ZvdL6bi~g(}|V`{T8H)Xtp}ST+KT;bVVvtu{OMdL69&O*Y4}7kln)@DKqCckK-#r=Xc#5-aSXqSEY`r_Y~l_{cDUb zw~l!?~8udeVqTzXhJs0JXL+M4u>DHZ%SCS7e>35XM8IyzoSU%*`3=1#r`9 zy3irIFPtoH0jm`2YUHt-nvJ%lEcx|GTqK%;8~A7U0)+maMq)~Mk(WzMeBV!D&Bo`K zb|QkVD4*oeenD0*CIj%&?@knV6w5|KIQ--eRRc6k8+)rE2`q-_&w**+Zx~G+#);h& zrt^!}`(%l)un?wmEyKv$C%MM0KRfdQ`JFaE#v4Y<1JT&@Atwsfca}GBJFIcoI0aq0 z@rU%#vk=~(a`zqnpc=e@o5hBJO3E#1vA2RAN$mCFBA~Tp8d{OL;C&VWap^CXt^2Po zXK_Z!<=PHWIT@vUp6z~Uw489ieIi3~*2}j;8EmzZanbhZe8z7U_i=yjLC^#-Ao{|K zgj;>i`BE#c?IE&il&Sv3>yYti9pZk8&16%FNrB$wO^3Fa+tnP6y#@ z`I1-(s>%x;JtdUB7NRW6|CxO&h(uZ-nLD8IwaTGHdG*7l{zQ4t)TrD&?oQQbK-y0j zLuhR7-bwd8zg?ArTr4x_q}KE_vFa$g2hq22JN-bEU=O9v5d_Delx>6XvmyuHIuN0` ziAMxKV?QZ=quuxD(&3F8flBQZTReRZ&5=;h?a7@J@Z^A$x(&8OZd^fT^VZeF%!knv zBlXG75?o{M9!rYS-9E_)Gd!Y|&-Pm>YmR!$_psVWspsSB4j#{-O|%rySm)86Um=(u zO8mWSME1vJt=m@5J$z%TYOLSCjG4&5i%HfH@*see(ANxggQZ5*Rog(x^|FKO9$hK9 zou&~aRJY&{IH^CpW%c15z?(n1=0fsx<1Ra)p-snk^p1*#!;^f74%Hw@woWD{?WI0M zg&`3qO@+&jO44!zp>7W}PpdnI46Ik>dY72d%9HgDh^l|icoCt_M;wF%Oobn8B_F3m zdAQOblBX*Mwb}N)r)_lUxLdzFysSU|T8~kff$dt(-4-_ChjCn?5#C()g2W`2pbme? zN#Qg8um*X^Ux>ve8LjMMfB5&hV;!{7XI3Hit$^XreaS00ZnbrA#^3)=N^;sSBjxQl zqb96GK=;|Q$STzM5+8F@l->@>3E6K%-_fr5y#@QLL^j7bC@Qwz5toRP8A{RcC? z`+;43hMtvOMGQ=cabc326vA}zk_4mCBos%}J|zjPCP)aLsuRetHyQGmtc5bDpfTCb!*EI#O&VvY^7P6?SYY% zfdArSj4ZRO*YHo`Vm+iL4Nkq@Dn-0-)x2tgHfBLM6=e>J5$|06ijkK0)^{+K?YE8l zOg`D~pKB#*XtG^7-tkubW8L*#^ZhgWqv2=IALKdgN^6AP?+Vn;niUvbpM8(S%=rEl8N~b_s?PeM2{&%nG|~#vF_6xI z2$CBr5~7444MRGVZbo;fAkrWLBHbaQyG6Q3Ni%AL4c>j7Jry41alzL4XyBbcG6HIOgislE*98=-FwSexrm{cN)~Trs6Wy9oXD?nh%i@iA+E zCGI)m&GW`KlavgZCzW&S9eBOIs4 z5fPp+Kz$Od-ah87@je^#5LP}#sbGU(%~JoxVubRJLX1+C!BeOpaG8en9Ef3V%OxA> z&@AC|xM|b>8a#r%Svx{W*de$oMsKy z5%uY~$6-t&HtMzE*w-XUd7sNnpu2i=3g+|H=aO&E(iG-%1d$|EUd@T5-O_FQFz{gamWvNj`w3awU$|5w)ERs@R;I$bO~wjF!<4+*AJ^b6T%*; zhsABxr>4V@lG^2ekSiLai_+~mFL$e`BaH9ks7l~BTPJ& zEsmfwNe`>2HxIhh^#s3iuMn%|_R(dJ{q&;hd)@+))i3F5hA#P3MLtVPPR) zr81Bj6L*Vd`hQRO&_hZsS5>vu(;~UNoV^9;P+;!>vHLrac}FH@OKsG}7@L@$+@kYG zO32JfU*?^4jXi6YXp;t>M)oH|vBHD92i$r2fqB9@qe4jj34B0jRmSN z1$O2-I=&vuO1ti=ureuPUoNn*ZGg`qHmLBw(XNWqb7CcsiZ${ZT4(ti#*Hyh?uBQj zzK37q8GQHY&4(lK{Ta|SLh2V>WFO*J;yygbyAx@)a9q$8HWBc+WpCti_DXncjV2DZ z?Ow<8=xz0W0@i@tB3V|avml!3Vx;2nwCkgBb7eGf$^O1WR{|AY7k5d?XCU2>Gnw;XPT2>!8$H1Uo`o#>`+%R7a8a$ObUm95p8*8cKu zL0qyLZU`u8T!fE>4R}+?%|LLS4-ttbgf*HQzAS8y|NaZoY}mwhUzY}5b@x*RW&+7p zo=++GbKrRp{oWfYVR3%`<>-1ab?!zLN1Em&@4+E`iE0Ral|0YoAinB;MAJ`@4h{L= zuG{plwQ1ExCMqA-{`?{G+Ak*temrad%9jn?0cI`kx49d=zxUBVMT>hn49~pD1UhC9 zN3{FamX40pLa+TN&eC8-(K|1r>ohS^j(&Pw2O^N^#BXS`bP2 zla(!9TjmA)JkcA!xT~2D8{`V{Vu&2o6!-i}bjUxw;b*zl( zZt%+VUR^Z=0dPw*6_b?tKn4|RtHSH$eZ9e-EPSNyPuMA~uEh3dT>#A?^kXVg{g|@< ztJqa&)khMZS_v$G;mxX$TfmN0m?O%VwV$<30@h?0;4C)`Mr;ATL5rvYqbRNP8%b`N zMhr(hNhv(X9LLFELlocW46tM1ivqVLiRvw*i5#Gdhc0$`tH#?U!yz7Vr32|0y#2#HwD>5CO4i9@YHm+`GkvE@lnc~RpJa-v zAW6cgPF3f|QP3o@QPIS|`40Wsq!KgwG_BN))q6Ed(*mgc*5|ZSwZ4XnUGA&ZRNSP@ zy$DC=^i-MMTV5f2jY*uD!1gaC!yz7O*`Xz(c*==PN(gbkoeIr85#aCI+nli_Vo9e1DeZo}O4BV1!M7j;@Ycg;V zLg9Y=OG$~`kF945@$i`ar*dCEm8`!aap4O+< z-udxW$UVqd|xYdYnd(e3rM3Z)Im0 z?cS^5GAtaYkzqFJk39nqOo;2F#U1Ac^OKe7 zOfSacHE@3Aafug{n&m$8j#%JbpuP=LJPR_GlFqameX|C((@ZDCzh)$Pq3jw=4=H};Cq``bx3@0GTtAFQuX-)ew&@lO zYL?&E=&)+?yLDJPu07H?q*sHsy@jQqtRF|>;*>yLns>D>Xi^JsT z@~&zoUPBA*)76Z94f&9GvYO$+JD#X1g3~<28}OCA_Z6FqaxOo?ZR|PW=X@Ta^o7{l z%LlE_E?n#&6_n)h{2xVz2w;lB(I+v63tA!yM)Mq*7a6ko+qEJON}Y&*?0=kz$W;)V zjV&OxWWQ5u0qC9mVy!!uNIW1M?4we>T+8%KV&dIg3`j)s;;EKeVr5|^V=`ll^xjXq z6I;&9fKq4aN({nxw_jHI$$GBfWq8mhoWpS6dmJEVZ!RHY%f)?q40hstabYklaG-M7 zmD>N3zLDpvAG7bLrQd6d5ohU8(hs03AM0*I{8 z)uCW^X5h>M#ohAHP;#*GGXd43bBfvopD63uvB0!!KJxwpZFO~s*syo*e_JlxGuhm9jIA6?(&#`xHe(9R>WaIXiZuN}wv(lrdR}uwoUz;qncZo{hmBg! zPFzKrC-?AQvWPJb$gPs?Hb#cLtF71_Peo;7-<@m;p=eNy3_iAe(M9^{cTCGaRvhV& z-9gYfcDNPCjh%0?LHB+^;E6*;R3@RcuqW&NbS%t(2FssGLsaOOjbvH0EVoGrNZcA@N%g0Pjv)BT#u z))`iLlzINLnS%_cBz~PbXe+E(Jt-Dg_wC|TkaP(Djl3~&$ViP_(xjs}{4q~_2@bWN zOD|=2;O?wg`J`k|c3aEUJ6HR`rysG*A=YuT5_9PGVB_oHc;62=s$AzQn8hn4eo8EC z^*zmb$}!d!t><}-tyaKCBFjQn+RLT$mFeBsp}8+uI;cw=f%&v?PYSKt%J* z-~Day{E1>;<$g`k3r4^GueBNEy&bGFstW^E!s0u@+|vh$KZlYXd>iirFRy*7g{_Vh z1(*ECPGj}(bSgGdaBtHNfOW$fhSy6!Z*_m3VDK~Pnlzz>P6d7DcPfEKMvE&qnJS+0 zSAmey7_hWuVV;+%6yV>WzmI)v{+FQ6S)ILcK>@snJsKUQ+7dW1W9FOVG>|J5L*MKz zk|4%^-mdEL#=M(=b(=PcTsed3?$o2^U;k{N$o0gjINs*dRp6EoToJ(PUSaaXsIG+( zvBk8;sQh2hZA!#{&v@;0LH9p6VB+bNGH7~ca!DaZQ*_9UZD2GJYnH%}c6tE0b6Imy z*cPr8A;JeKw30nGK`Uh(%a+X)=aYrx3{ZX+qFkyfxjmN9jC=B$0v9DDPu!9&tbkzw;5|T=&sC(n?m27Sr5igJ(W4(sRT>eKa|78{Be<~_)5f8eB{J2?QUIGKv= zZG3i&*bsM9xy^upY_q@3c~Ji?lbP$)XZv71y-_DxfQAV6zlNw`-~Wa2U$Tf6N(iXR zb+Ac+zF&qX;(kHWb>TR1K*&@CXmRog9?_EG%_7ww#K)(_-M*8b@}OK#<>;_ii3Jc4 z(4KuaehiJCwCk!X#l*s&bkf|&;KXaW&fNw0vj%m z`WGJfpi^#x{!W_voR-AvAIW#3UD2JN0D`sZd@bS=n#~MkN`aDktQX}-2*QQJRjZX5{4#;pU6Y>n%n=fBK6EtLhS*}xWdLbNq5u3qq8AwUHqUbK^Gx+iiLFcU7uG+Lr z`8chVelT>FO(!=TTb^AaU7nQ1+2M3gz(QBh92oDgJeTwQLP$|08qSGCVk;MIdp*k7 z?Ja9PY&$@=Vy^Z~LS1friA^;)3KW)F(#ov1Ag`y22dtc1420BZZ<5el(p3iem?FYGthp5C* z)qJ8Cy-?#blD2ps*Up6TZG&=^Ov-g>(hwP}pUc1)L!aH_4<@)wC_1|HZQ{w^|=5qaeT&~8OHGD}H z&k}1zn$vQFo7A-YF1OM~V`Aa1II{*`EOAF&kAGb}-SR_e1W3#Lu7POa8UHnb$`Y1~ zwN3B0Nhc0--xAK3`}(dsnxOf=cAR6WD6k{Q)dQ13lxpYWGG-u@v|HJ^Ca=tUT! znvA_gDA!*V+M5({q_qWR zZZ?BLFXfZ|00Gs5tEDbV|AH9whQ9ek9^aBR2E&u!ykZaX)J2CR`yt~v(TBG0K=5wf zd(HkX>i@#qD62GMyR9_Ios0m!Tg(0ou?f#JMpVU*A$cDx&IjEIuy?qmlKgMG0$tP| zdGba2utXw}YM4$OeH*|zAw}3)_8}0W-gnY)8^>eW4PR|Jcl)M zixhF-pHA-ut|WiS|LWRHpe&b4e&V+&53RVW=MfKR14U1M$9~}Diqtw>vjMv}3=%~K z65}8vtxHZ|I-WE!$rX*l%MWiM546_WMROT8)U-%nIDPeQczE7x-+Y$N-^g2uLA9GmQ=NwoSI2|vttPV&6a4%3?omwxQGSa_c*dFk7m6q%2 z)@2{cj?Q7{OzRhd#`iuFDxqxlhyn8}b)d68D+MPn+4r-%8=uaahueqr$WvUmCh3s2GinNnu$xMvx~;n3;pt)N%>6r452w;;ZBzEo+diHfbKT0T{BLVAtA6h#02P_-~9MHJ9%3$X1 z(2my?RSyFa@HLpXxywAZ3Q_-lX!#r5Ph$c_x3;=z-q2>ILi6_2PG*d~RZC+Da<5Ct zvc^)CkGS{6C0`J(E3S4F7)nBLCM_S0yq%XjOYYfv02&0#fue=@5XXR%4#8q5m#dFbz>bh3w<=5>^jd{_@b z3!lrPYvL#An<*LV)uxZWE~efro&D7PFWj{T0*&ah>lg2jp59%BK9{!sbXZpj4#X#s zTxTd+P$yM6s*)Ov8kq?UoW^g6XF2A_2+9JMRN%e+eSgR z5Ea>E*e7_n(_2fpNQB3k?f?&4(i%(Mv`S8*{i z^fu>u1-Ny?MBx6%V21{z^xyluS zgtZOlWil__GvV4t^eg<&JQ3$^HwK+fGoOdGh2lT1`A@VFBiAQTls`jJYJrC$vwIHsc=1wrgLN;vq6W%)e^aB0UsxsW-K{*iw&X?Qp z=kM_GmAT-1UIn#JAB8f4&ktqWsQy{`Yan(!%pEqKboyh|gMOM1X)_1xCsvWqXF#8B zSRzh*lj4~CPu#}zjz5Ui>j*=*Hz;8_(K~iHBMe|WyX~`3w^aYzJ}SE=)@5>LB!97m0Vnr+1&;w3IXQRpha-w~ zLKSU-nx;ftar5%o@XN;Dv*;skpykqs+;KA5__=~K6Z>ea@;U5|^z=F8q%uiW1_WZopKCF(CRn=Ee?(HBO%G7>-YZ%4 zb}&J~p1E?MV{#-`qFjw7;+wFw4fe#|1;RX}(D}K<)lL4_#FUZXi=*0Z8$-lCbW0ok zn-{0?@Lr9f@9fDNA9gi0q|lEfP79aMEBOpQU93R_(nu=j`!y%4_b&CcM#M@N4jlM+ z@?9->kogps>JM+U#IhCfdj10G9NM9pLc@QJ9Os(ioi$C*gTI;;D~rgr#zY1FwioY{ z^Q0gu3?ug_q+@w^q+vSQ^0#n8h{o&{Ki8=qnI=0;MPHT78`2tPxE8PVk5^cQYc=cF zK24=P_QLkkg>%DBSSh5t`fV%kg(f)nFtFFYe>*Ocuy6$Jwgjf4<)W-O9ID$tY9&1) z{CSCxW|B8Wkv=eY_9(%*a+RS*po1fT`2*`N56izdR_$3$u%z@1A$wRm{_J(@$*F4j ztXZJ@rd0{wq}%7~DW<##uDQFlqzg!zhx}c8L&oc!nKmrKFz|4A{%U(@X}{kGh1nMj z1wW}gLXkeHu?;i7-?b4JPBq)o8s0Ffoq6n^qxkVqvQ}`!nLpV$UDm-qw8U4dVef>+ z^lmXq>|@mdV)dU-660k77?xzh3mbnfQ@ioSbF_hLBa{r#YE6&wc-C@_w#iC^_7`eU zgd9P)s_1jhi}UDwkXh3$cg=Y|Q0tCO@Qnzz74xHVNEW&$?Q`9>UFYA}vWzRL2cL$; zlb+uB+hve08xpeZvEmr5b)6@yDbu13i46?5x?cZhg;%$r|IZCClDIaxCmW9?O`NU- zUSRvuG;I+c3Td&c-ygLhDJn?n5rgImm zzO`H0AYm_@hv_x7Nr>(k&PNGv6b));aOLDWWgbjifA{CE%Ac+KN@VSJ**;h!>yzSq zvUV~e^j^t$g1F-l7Q$om)Pj4$98Z4i+0KJ!Y!MN!+&!}7$y)ShynnxCwrF~xPP8tl zxv+bH+ryo*VMHDO;fr8q=o?B!zUEv~E%E^(O(H;FMf0VeWv{+365 zVlJAeYnuO}7+VhI*oF>!e7^AUy0LUNZ;TNYyBqqdxv1m5@vSX8y@U?8lj408XAalC!i`dPmbk0t@a(I~UYKaUIl-97hHFsTw#Rg`Ctm8gj?eqZMqTs*<3g## z(tDFq?kzY8NSR)SS~|orj8ONy9Ad!O?$4pAw0qxg?vz&vmEv~fBAqgl;!%wtQX&6)L$B4)o1*JsvvfA@!N{f^$H4Qf;Ki zYhUoI)h~Yk2A?ggCW@^50!Jn$Hb9@`4ZH@s9{!2EY8|*|4(FNM%4x?+ioLP3F6`>E zc#tbS1dSm2k7?5JCs$)v76ipcA}?Ht6$D%goYF{*)p6RjtT&mTC1S{;ZZvnsZaNyT zS>-5ic$LuZKBom!SZ%w12^NzAzyctQFE%XbJN`(Zv9yROulr;#;|wodH-kN$+CLjbsHsdF7bNNYgZLD(1yn3^CM{FHv7l1 z&s*v*ER5+4V-CXO9(QyPJ&Gc=VPQJkDIL)wtxId_pprZ6Jg_B3zPk%x?O$~W<-N0d zp{Eu~s_v7W^K!ru6ttO7CQAo|(gs~r42(;D;fkKPL}Jrp3n(*cxC1v8LNnB2(KCyD)G z|0^6QRs%+T6t+2ZzU0S5$mv~*|8nx(8sFfT=e}e0UBotI?Qyggh&$y<1n*B5_rUiv zyz_%=hKhvA4BUEi4YWU=QgajAL2EllH^<=02D~pB8t$Zbi+(FQ?hP;Gr%?MvCvu|l zG)R#rX2|k(=kSu@@soEdQ3ZM?8+_f!Qk}hgSmJ2)c&s>Ky7y zDZ*zjMN~qVbRtM}uBZ0+bdK0$+;r9ul*Fq(j-$)+C0q9)CAx&5DqCC5=0R< z@#NhvKG^+#EP%^@uUPZOn15LrGgAGV0&^>SsZ;Lgs9|1(s;WMP6ij*j$B;+2Voz_& za(9E)zS94@f(3HwnC((5I)$ZOZ=Z^=t{X2N=mr075_1u~88mVPLO|q*;2JtY@(Oq> zR1mV7u#|NZA2|=+L!JQ^;lGA#mbGvGRm(wR@cXrek$YDOoL5Fi*673lv@@~^>?Q>r zdCq%-t!Cq)x42kEq+wh^$ja?Q4aCHn9N8Pl85>#eFu}yo55C~UOjAr-PsD5UPY!nPZ5y9e z8S&(Ki<$-)cv)jU>~*l3#(l`)3n6`fW%jR3i_6_IX6PX>r=Yfynn~(#_^r|HM~;cO zG(a{kf2KJ7>IRNfUIBAUv9DE>8+lAGLC&tnW1P#mAiFPDVF3_~*zeVHV&hFj>Rslj zY~;kh;ro$Im;o&4V`S{aPqm!KnKzt(#0h4lz7L+{(Lfu{n(5ARpEIc&wms^>s}{B5 zPRXQpC5q?Q$^~2%`UybA4JBxwnQ1CGwDL|=TwfC3(U3A*@Hl1T$&Xj(`c@1Id@}t` zd7K?qmcX<3vN-nLH(NvbQBpPUy*M{e;7h2yW)uU^ZIe;#M@K{B2z+_)IO zlHqwe;*_K?nrhKcj~YGq8IJ9zV9$*;H_4bMpL$vACmd3yeeU(R*Cqq-=+<|LAHIj} z@uK2UQg1sY0&Iegd3H?LKsOTKCi*1&Gr)x~tckr?Z@XbTaHE0Z`kV4?1Q_@9c!VTq z4qEaJACx_US9gl#2o7WgJiNTVcjz|O%BhnrNvx}AUVqiLV%l*_5_lZS?;?W@gXO_3 zs1i_cn*rPTf!UD8f33X^)qTnrHDmzn3U@Pke{{k^d5^k|TvCFmlKlW*`bYLa&@~?D zGNfM~uJh4d9&VFD)Bz6qYZg5NXj+-BXK%YxIjY!PYY!&f^SuX_v%26NKsufBs&a3$d3Hhl@rybmA`XhyP5O&HMMK|~ zN8>Ig06X3{^pJ$AB5OJX+h3JQGE>okNrr4&Jc2sgM~}0O6UY=_L~qMQEIi0sPVYM! zyc7UuCj`l92jd6O-LL%$4>yUv?>;viRJ~RoN-^BSGo=1ye`coCITQLiP#6p8`RJfs z?@T1S9`&LiLS9_)=2^7BR(=Bw#hB#@4aK}#pPq4bkIa=bEx^T2oX#Qwc=%7I<8ck_ zWl6K1iv_IbzBG`}Eg~`bwX~$5&5*JjqfLMouLwr(PkYd;30uQ`Mu`PPizUifAo67p z@Pm0|Bm!Z=y#IG?P0Yx*p6L<`ncq-vVBV&D!6nBAJ9dShMR8Ea&1 zhuvh5>$35%_4d4nyfGi0)j_xZDKt1xd1VTilYPf8?;J_SUBfq1d1_R*!t3O|ocQ|)Ad%+5TVfhNrZq!4m;<2<3lQq=*YqmD*>52kWe_*k8xq($=Y+%%!#T&f0WZXR6e9PKaIP*afNZZelopWodnW)H?0;JNm ze`q`;@mErd^v!W}3_))8Phw$N+m#e>;#{nwW6Sg5^hSR^WfwNU9ns6Q2NneV=G#j7 zj}n_5@qlL$x(8E>G?@8%9 zL@|(2=3a=Ap#f1azo*H>S!Ns1K|X3;WHv%V<2w6&R^`a9WWn0mKl162g)DmFvaJ%Yi z%Hq@l6gPE@voG;7Wi^2ZDEWPL1`yN3)No6Rr?*t(VA(JTWNi59Pi%Qv^HRNVT+gWK z5ociDV8o9Lzb1xM&-Zyb97P;t;~{^tnxj1>W6SsQcQ3k#=yR!?$sdu+jAX81PeJhu zo7Xz7xW6ggD-z_ttXs=iFszlOw<^vZQ2$4It0BW4kpc@cNYU!$ozKy%IC-;P0N~MO zISveSyO;3iEtFUSMnB3d6aW0UrDDitG&f+m%Ku>ns(o{9xyNu=Ye`3Rs&kh=xB2WwWu=6l;c|- z3)MCco3I00A|UqBfvy?cGd#33AGS^9yn4?*;5go1=P)vz@|rfgd52GxPr|-TsI9w( zwJ_&w{k(f%muaCdtRTt|96CJ{4Q98}_CARm>}MsMjh*lo7@RWwsWn}z5ywQeXc0Re zy{!D&HbyOlHZFGv^OxW}va`B$PB zJPf)$^i9uB?ApBo4D$eIn@}EN2Ly?0E3>l8L!FGOvn~t3TT7aFfsft9)IP{cWl&xa zD$f4QV8TYIvSeg!`@v(7Ztk3Dp$WLG(mmA%aRTj3j)WAgOoYUSN_3i{Hobqp8a!@1 zuQ5$kUi-I-$@@PW)mb-qkn|9$7LZf`VTO$0jycdAQx|^-dS0XK#nOB#KQnP)Q@%R+ z`6wr_s-3EK$qR%W>^Z^Y$N3*_mW;@*xop6R+izM_eD#Qh@;<((+2Oq^V4XurEK_W4 zU1r>0ZJgq-(ZD}naGro!KFJB8kxhA{0_5U{_#PwM4V}8AmOoZG*{u|>%DVG|S z?CCObMzyO$$a$AQK)ZV~i@l$gXe_`R!}+Y#&^gm!B@MDpFxurEUqPovpd!+9-M!SC z%(A{W>IPSQ68gjLK{n{bv|mnVSdb?)|_ukXb8HnpM3@B z-(U1>ZIlWMMyFcQNTco;!`stXd#GCnhV?3wU zYq8%}9Ew+_iZxjf{56SpDr(gZa{Pkt=kg$;oIBSt*ZzP=sqHNL2AwFP1 zffi32ts>0}94}C$mri<6P|pE(Q1M&TbAOU>EzK6AnsO;YlFj-`sD@|YQ1y}?3uhSs z{eyTv9RJ7noJtug{9e!i3&Je&Ugabl2a>d7lKBTCL{4&qeE+<^>!kqvmqxE}7m=0R zuz=9EL)drxd>4?Kof$g=zRJuS>`{jC zBnAljH^8@A1pS!6%-#mD!?Jn8wkA9urv(SEE-c2c49_p{_G0EF;XGn>ZHAQLdw)O)9S6hHGiu9rXViKOvWqC04zHtQ@Gsd)OWb$cZ+bp_P|1X& zw>19rTiq0Ag2bNn+rNqJWn!krFVHc7=b!aMv;Bii(o}mTR$-v(63|ky>1T+=)4$m; z=pw-9=R|pv1j=8E#M?gr*Ye}Cz1v34L24^CtJBo&8`N)oaMy*M4B$&T z=WjAFaj7nk{oB2!>#*RAOwl$(kgeHW_{%voi%`L?gIil@B(Law%Q{0VaZdhac$Rfm zd@vh1`8LtDTk_-j`30kL-g9L8kk<-6yo4%d43w%5P}mKPg^yQl$U4TKT;*siPa;W< zu(J2I9PhW4rH{UQ1SUtCilsSIthv-s$(I0{f%~L;f%nhN>vIq+L4hZ-OHz8H*CP45 zZG?@KFskQrO*7(*rr$N+vUYjnZWiSkK0x7jeud+>22J2nHJzW>p)o1XT`t%Bfr`rH z!1-TnWYlndp(H>PZAO7OZ8M#>x9bh4`4{UUpPL+Kf4_iag9`uFAel-TUtHU#kQ(BB zF5G|trd}ml<(5`$I!=?J({OTs)TQ|#rSJz0SH{P~lRv3K$jvX#Vh4FO6f?+E=k@J^ zISqFHzLP67M5@0{36eap8oViC&fTb%UI5M{FSB*CXt#`n>6yV19LCwHG$2M%bv!o(D5JqhBt@ zYZIux7UHo{J@3zZj+i7!2#5#sCW7j3W1$7|!LMtkr4KYck%!IW1OA)J*jW-X0c)BY z9}vNog^~xbKE%DAP;Kz1nd_-zV30)5`ou=#ekct_5bQ67?siKxv$!U{Qwb3H?B7nl z%j1Rfot)7Vn_4yjonQLl1sY44REu3&bm`H8UdCY=Cx3 zaI~*edq|mpp7qbymcM2S`G^yo>}%To@j+O`_9IhJawy;1q0T9Rl*XHr;%tSxW3;nW zlnYd$pK?ja{2vu@LU10!NX*~L!=?8@48P&vrvuUkW!0CJsyRqaf?z0KF`ll9XhH(< z%}t5n&GqURLWv5`lhe4$9dH3}7yWy^JCED@bD(N$5YtQAEuS0$?k}J5LleJTiE=)Z-5pxH8p5AlbCJr z?mX~2*-RNor)DQL6bYV7hnYxAkLww5Qw*f;eU(O~rHlQ&({SZ3-rez?Di1rGsJPQ- zO2sLlUOl9Y(85Q#mCJcySOuG7!&^3*S?63$r*65)FE}-hPwUNMWF56_%+3X)DzOX-u(BW9MyeO~`*R2=(lG5ZImS`p z7P*LjAB?I)#i#t?PISsA6(Z(f9_SVB_4$*i8pwHcGd@5WBT{i2jh6AVm?ej=v&dr; zOAZzAxlO0OODTuDkfr@=f@AMKccH6OA3QzfXp;FoUFd+sb0hsXXEuShWC($s&rZ@T zcST^s+Jou&wtH--4rA5Yindo8z}ML_fKE;bPqBQ}`BNZ2&SLi{eH0-h^$Js*5jmuU zKpJgpcnT2?yQRO`4q95!Qpn)#PJ9f_=a$XmT2Cd>qn-s7XG;X(N{kH=SaN;_==_-2 zmjpN)A7K@h0vhbxw9ym}8j!AxKQ)vl=1k($UoD2CnUULJ!)b!5HD8}3`P+&QT%+|l zo)cE`B zSl8HgHfdj)9z#8Xv#gz)$;~y4_QgqCnDc$rZgx9&7rCFgXK@_Q2mlO}Rb6{zWlr3I zM?^4cglv|CuFwm1e^aQ5K(&t9Nfv`siVr8%7_%Kx3fxx|+YUR!mKR_~MXII5Dlbhs zA|O2#D3XA&wQnW^c|GrL;KCywX6uhzVOtUluib_3NL5C^ttH6z+>bi3QGze1CW6#U z@3$hUA_Mp3m0wSjSb3w^4jy^U}>!QmyG<(#*?qc=@UCq~Bda*u=|6{`XIJV0o zKl$3!3 z5NnWDp~n6Uud0}->7~iNDlp3?x7o`KH!o~|_&(~HN^w}|FcoB(#Cgt5-T&ktL3hr! zI?kv!iXfUV-i>OM5U5_6@71$wn=U<-LI@|^s^*>;lrQDfJGRvL>-?(jmg8Jw$xhkg zjL{#L)L~)M`03$=tK=tV>mR4qKhW|Z{9)Dn%#Rar68bAof`OJzWhuqgM3s(d_89*s zFZ=I`Q8pr1_4Kf)5=8P@Uuc*4uN_lk4i|EJudz{b`2b5B-4;kS0^ql3jSIluosLk%& ziF#lApa^YqC0+&%&fJIHLchPX^SAOpwDlC`QU;mt(Jyc$5suH{2##s7=Nso74NH?N$rWAUi)#M7a9ofiWoj0zu->Ly1BtbH3+LUvrCukH$S z_hBXS#YKV9V1S0|=+DaR8;OK8uu0JL3e2(GR%pfm-D<#fxT*Kva`UL4y!NL= z?M9!xcjaB1^%j@a#~IuQ8^thTxT61bMYhqzhg;rDQLsfrOL=&H6&gUynP(;Xlb=^g z&twa&eJ9f?Mn^>jHJl)DW@TZ?Id$F0YqF?r=KQ_|deLbd57ICHUDj@$Ne@r`E%(8V zoZme0apqlC%qiWuM-@7m!&-5)_-00B=4wm)rtY^*m&`AfZb$N9pi?qHYUC-^!RR_(dMyBqP` zTic{Rbt}vGYLS<>0bPP2GqKd!i$;LP>aP?_6(>30yAO=c7ju3QvBf8<-nFTq+$ZKOntyEd~G3lms%z@QAk zr)1qWE>JBieBc70H11T_`SARVPW^+O3YUjS*nlW4sqyl}V@+q-t-{maoTA~%!0 z*f`#a`$**5CW8cpV{Mg58j>IM>46Kl*XjmY0L}_z138 z%DbNfEdA3wrGlL}BLh}Q)@vxwTcD76RBC#nLyg1|{3&z-jCkb9ksK*P>ZNbI&kh;a z_dfeZ6}|EEZ`AQIa>ectO^fpZzsVz36y>KXhGtVWSpeyd%P)K|UAKHL0w|PabLMv< zdD98(Gf#(e2Q*w!mB5=02(5w;p>Ob!@`;0 z&ovjWcgn*LO_Zo*`lr}nXQRg@O3LTW|GsU}HIJ|V9HnUa8)Z&RgLWhE+Pfxih<8z-$*=g$CPth28 zFVKv{`p62wV-mH(tKXOf9O0}5%!ZS3DvjMHbS4O91H%3>6x$ro$eWQ_4fInDJ^`rK zJ%m+#d?@w|BQoIZNvTm6XOOg^za>7~8u~LEH=6wBv6uUyEeLmAN-zG6P7N!>{t1@U zmLibv^5bs_`MQ2o?wu^B`gSgBgZiM(uM*GP&OyHthy1Sz;AI*+r=I$(p1J0el9hXY zKha%;;Rx7sig$GsmdlH?Bc~oOegjGvwE0$(gP<|l*?62Na`~T1%#q%IYi_E+xF%Wz z9tYU+TU@2V@k`9P-g50S6;L1);l&ZT?W)iW=zWxN~e z$L_ux8EJ79mO8kqIEU_CV=}4B+MLkiM|R3B%%Zai*50Z_xmA>$Wdyuj3++HK@9oN^ z-GR~cRFTSn4nXYvL?9{vx*h39by1j{ zYT}8!i}T>_eSTYIg$<@qF;5&xwR8pSw8_vxc4Y@ry&4AI6>-(xYmreq8!m>Fb@UGR zw40~$jNO6}LH!-?<)YOJ?%6`xB$WD5-%luOv)*M*{QI5C^u0v~$#chb0zK92I&m1> z!5U7zj+G@xEZp*XcvNx=hJetLbA4x8=#2|Fuu;h~%YEEJEn5N#fb_5V|JX^6JaJ?2 zaeiLx#x~?XE>Vl5>cD=Ba_-|81Z_~u(~LXjg8w!%r!WH~(fG#`J)E7o@2Ji(<^XIs zM1zr&Djpm3#45zR{=1^xNtDQ+&B+Awg8(UZ=aF`nR3Yjcx+`K$yM}r9zyn#Y`%8$d zjR)**#qcra_U1!S{ldwv*D7x3pE>c4NhHM)Vw~73;YN^2k5a4B&^aW(gDT)d0PFSG zn!8+(l}kD%`0ka^@-CED9ul;2hH5e=&3S=pL;a|v2?(T>ucVC!`-!-RD z8hv-J`GQR(s%E6OKVeu@P1V)9z$s4W7mdSslAZCM=%Y&#T=55`Vc)!g>XEm&Y)^N# zZSx4_(}kY7Y$Lna0RiPVw4+9$iM}rq-?fgumi|JDR+*YnkAP~C(@*t-$DgnuU!hCl02RL@PdMOVxK z`O}^Q6+;pI zl)d-i9LJXIE$f6NBO-gt-ZPtngJbV;4#zq7)9>Ej->>IHbc=;q-wR#W z%rr8LZQK-M*ktGx)=4wR08i`}$~X4v_FGRX zOp|=z?FT76`v~Rjkd6dQ=A%i0s`+AY0QmeQqC<;tiT_4eJea)r2C-~-b@GRbqQZa9 zi=xi~wS)p2P#ZxTtMSE6Pm|gxcKh)!4%p2we_CEb_4IS?2A(Ah(nXc*b#}r{XG+)8 zkLK2h?x^236gR4&f6IPpHP)Wj1t^vj6&ZWYnRu+q-rg%#h+;Mq8<2Zo8HafGAT}jr zb$X`XYoGL1KPlbFDg~*o*ItS8zPZFuG(#n#=DmWCj@(Mp{= zMJRvFa4QFGMDzm#99*5s$1%qHb?0og>-rxVWRP%R@NH74(o-YEq!6^6^4BQ%T9;t( zc+5f!`F~)}i?f)$^AP=(AEq=>RS92l#lL4n<{3gbqM%gi^slWo|AYJ*WOct$Htmo2 zySK$qgo7tMrZrx31dk~dNl^UR&?^t#cMRMY@PzS4&Nx>4rEw~ zJ^$3waE!oJSn)$A&I8B;yWagnI!~f;50gV!!1AV5)UKXwK$iKTl)*@f16a;kjmTN4 za3!<5X7w#6?)Oty7|&`M(Hqs09h30v7wn^P^ zY0#O-D=*C?oVogv$vU3FiV+G*S?E*-XNjmj4C=X#&?ByI`Oj0@q26=l^h&VC`r+W*ff2k z_7}JA<0(@lJ^1?0xgX{=|oA6&DYXB z`@!F*9C7&Az{&%;s}H#39glgmS=&lQy()LbE~uU~(B7uptvOOicckEv^qPFT*^oxJ zZ%=yV!Ez+ro70$JYR`7U{-mdL%D>6yyUEul5nfFv^0~1K5UtBz-mw(tX`idWq%<^D z{@8iHaEGjsaor1#1O#Mq=a=@1Tc9lYBS`F@5HVP2Er|`Uoztb z#cTq0KIGvT!26qWw9Kn4?fsA1czAGLo(dOt+dwq?PuxbD-ff*0K2~TEJn(L(V1fY^ zH`n99Xd;~HuGCDxR}g@B=u)8Pnz7Ztm1G$)#(F;qd49_|yp%NC$kjEzBY3fP+kpqj zT2Aab?>424o~J21`5YdAJ$#m)Tut<`UR$4{#1i$D#;UL4sadwst2!&B<93ak066(j zsh{rm`se&o!->nr%RI5uvs3u*>I7Q0+cY?JMRDyg?uhZYuF=uh=rt`VrM}nF9hRZ5 z%In+RHA963C=xp#>AqqETeYPI$a53Z)~pHN`UZ~+*glA?jVye;9A#c7$$`ymgqJoH7gY% z^h*1Tg=mPhIG}9bgO@@yl3f17a?TCpH#KxGPpD+CMw* zK&%b}s2eewjS6y}&VXI26lk$LsPn){gB>o#vGu;D@bS3NUOU5E3_c4vRkFotX4}lq zYImTn6;XiNc61rA*bt5Yun2O~>(qD?e#yH{#KMn1Un2f>Au+q--cS6x$5R?&P0YYg zaU$l-jqjf>1}%D>4jB!~{S31+s<+U7N=@cPYfMFczqC|ES>TR+d;G@^29O#1Z91&w zK2*j1RUT9I>0blP?-_4KnG96jm#|xMumKA8jn&EU>xEj|PE|8hyAn1m_T!r`9;c7I z1U|Jnh*9ed^XjYXNbNkye;z%?`!e`uXzYx_%Uo(?2Rs;Yng8;DkAR43?bUeg6s{b2 zza)s>im9h=`R9!iYH91}GcTfBPe5??z($2$$?`i&iT3Ao-*?Lk({~;M2E1H?_t1w zPVpjhgHPF26`->pZoy>bXfJjyVx4i1_qM~+okIis;LCA}TxW7SR8xM)MxgXk|# z6F}ab{{hjA{}I2N(5X()&wqmc8KWYqeN_M*dU*bnqU&=fpMc9(Qx4dgL)||`5YX(m zSGCijWOt;yaE&rZ@@ZTK6imDU7|rQZ(-uG$n`-eQHL<=}cEXN; z$^gmRxd~Pm!Hg~N6}43h+3Qi!h>lkLRovA9up1tX2jR1N&U-JoJ(kDCFZ$jEvJI{g z#nO@Nj*X)C$B|1*7s-r_B7FiK-UaVM%kN#%MX0+=1-Ma~ z+N5o;uEcv(HcjELKg4QdijBL(Wn8rj<9tv%E5V(m?T7E%E+Z9Bhy~tC&8>QN{&>qT zL{Ku@v{Xg<&5G(RWY~xvM^btf-ngOsA30NLQFNyKZm=-vji;#C5Ubn$EvM;z!Y*u7 zX5zfXEkBf~zg*3&6@ypTE#OwIhs2v zuF7aumU33%b*=4DjlM_1Api-dxm`A~*=X7=4fk%*t0nOAZWc8q8gE!}=pH5vwQK~G zAD9q|vZ7uB8p2j0Rh!pKZt|_V5Sy%-oN*sSA!dyg(8RhHt252+7!t=u2=3_w8JDyz z$r5o)`4mkr^)|}6l$tJ4(7_5TXkuFCBLE(40QFA=vS&5DUO^QD{MZrUy^sNQz=>aR z*eo3FMVjP@2%?ssqg)(tGhy8dP@M9Toh#reQRQePTm$;W)}vY1`7OjHnkJ%FUI)_* z&hMF6NGqA1SISY|XyfSz4&VrdazXxGd(@-N`o<8Ut=YZW=0@raciOO#Qm*a$K*(v7 z(fKV#!`QsO_X@V(@6MU9qNVAVdcp_&7+JS5qylvJcrgGh$^96SuLF{EeH6tkc?YUz zwtp*|o8_HB&CI8}9eOYN<3NYulrqpKcF6JaWk!d*>uR#CPg(9C zy}2D*(s%)5=hWAG!ke8VC(OG+i6sz~8G z$X6z|W=t#gfS@naOiI(~Dy3d3M_QqllN7(SC7h(6uT&*<>%FCriV3BXZh^N>FUZnF)Dv zK}Z{!6i1f_$w7EqL!i{$CV{)2%)EGYhr1kNz4=g^E%~eg!2p7ghkt#8e-~|}cA-3J1mI=&l!Kwl5 z@H)JJf@I$bI(-Jlv{d@NM1EQFxEqar*yNA)f%ykWzv&BleF{VZ?f5;3i&Y_>L1Oq* zW|tHWJy(kOVn_{f%c3p0b}4GP3DoI!j16rGX>rH;A}_7pN2D}1O1O?hu`vqZ)kN}% z`=&`vn2z{up2U58tlPvS)-feK(C3EL#T|RVf@P9d=eWHNsZ?URte8ks`Z4Q`GZjn) zN9?XGTspk0RhVfK9CqY(fhfIg^pMhYw;NyN6S{ov^bZX4Wz|L6L_cLx%xm6Ncd}R| zWHnM_x`=5@pYCW9adH3YXHR^?dd`)Slww7yqvPo(EhO=xy3+b+yYXfC&J5@z?K!7o z^IcG#`~4fztOM<_f+9O_3=ErYfAQy|u;!$FiJ*HKF6Fv=RK&#mM^iweUX<+Jy1`dm zbHz-QoX2(hk)(KiW6eOM#;X1+{nGCI1)QXKqNuem*PgJP#&)Mh3@IyHrAuH%oP|Cq zJ9U9+PJu9v*a``wBVCyB9a(EWc_?7zo|6=!90%l<_wptsnNB|*4h=-GdZb}9*PB6u z_wYka_oF4i#=MpD-(gviK6DVr@_di$FG<5a632t{{8|K=44nU>jr93SW4SoQwT};? zil52fa>_S{Kv?&K8{RH|XGmYNblzUI^eJldBR@AIUOZ;sY-&IMVVWbk+}qJpcG9l$ zuV!jRsZkCnUNpWYXPVnVprM@F_$-8$JM|75~mrq}RtBdcpU z@Azj)8vpcD7VGfezNfltJkbkUIXz~vzkX%K7zsd+Oi6@#6J+OvB@7CGsPJ0x$w|xd z^_0#dVw&7PB5n12XNY+}MZHgGtPdU}8GL=pvPY-2E<0I?VuY3fmLBPnipjuFI#CLh zKH2oqu3|zr*OM?T6e8xtH=+-v?C1Hrx1)<$bZKsMYQIE`J`*(8CW1$A^7oHe(ioGL z@^yK!HZWa=)>g}s8-Gr~bSw$}r+4mMzZPdA^t2;IHu0UaPF-MFNy7`qtIe!fev{sD zAztk))xIirr}uw8HqqQE7s}n?#~7XxRMXK%+MX%VX>_V7qR9P!nPe7wZ0>4=MIOR2bXvJ z?uxqIVw9sG@FKus>F5eR`jUBt72uR8vXAyowwcVleX3QF?w z@Q4BEELrYA${W(Xlq9HX;p*CcQ6`1oZQSC8yypCEPySlT=h>Jh2n)#0*V+GvAM-YS)o|Sgql~Zb~znLMPAI8TqnM?yv37iWTL-bQ|^Y)~K>VM-saj2lb zRHkfm3cqc6IU1iG-DgE`{-XKwj%|`f&CqChVe3w+wXYY0k)8zG_g@nw?pC0mNhDdF zm)_T}_v}FMmhP81yD0vRX2OaxkmdfhobZ@J=HsrZdOJjr%;)!iW$#r6K% zuZ|c~V#ndXJ6Kofs&+`VYU!OR+ON}XKN~fX*F1tKb9>rc+q9$|FRVAL z7GcsjU}H1Ky23)#SAd4a0=b0c9>DR@9I|;jDj&mUH`s2}k`Qr@eL{0p+voM~#_Jir z5^XfVX_gZqZvS5e+gEtpfyq~0t((J{cNkOaI!fWotFO?k4qgKHrX{OlEvRWFk zbyW=iHIB+qNpbnB5B4E72&%;Mek&T{0gJ53k*#sJDH%zx@TJR)5$RX*yXJb?kf7eb z+FVzJOLh%dt_`%rcY&pl=jD6^?tZtg8uY6#g(xt^O(lr1_xGdS4cvwJrJ|k?-K>u8 zGm3F(pLvt@L4oqHK$v>=kcwQceo4?#w z>ul=b)A=CQoyGKY3G|;e|F7KWTaXWRlMZh0uQCdHOXGyBtaJxg1uY&3A~uU8R(=He z{gJA&J4*34Y$9X=@6Q&1UPL(T`rtEpT-sI>66nl!?dHHgV$iMh@A%F{S4S;ADS53Y z<+zDUa{T^K6^(FPIiKa-TWAo-#C|I}Y3W{o(0VleiBQd{0}L2VCka0N>AnSFS~)jG+k&@uw0|@?Xm`?1TwhVFG-%|EH!$m<^&=1dv0L5|0 zVd3sokY+6vD5K7N+~*GX=y{#fDFTKlesP~6xel<$YB)cZ5(zny_tRu-DGT=C5siwS ziI{RP{^cl;rq%QMmfmF3DS|mgO`B28&g@Zc8r?S&>GBx^i!!;*&5ui8?o33&&F-MaFbs)F$zlgdxZT9z?H1Hj&s>^3neCin} zn<&xW$@@wqc4q2%>#ujkmBq)tU)Pnx%R+94Ju5hGa-MIwT7Td@!YhdLVw8TQ^$sL8 ze8(><+Dqz?RB460hQG{x>mK>*yd7?;m7cr3Ul< zC*u<5`73IH{TYgyPp8+E^9`~&_qkB|O_6T$mOK~+cGMDs|BlzgX7W%pz75Qn?+;q& z3%lok!RpKhU1UK)d3b9t!~SzWD!nBmg>_4n;1K@X0~R9rg#^pCP>Vm$<~gAZ?3(_} zkW0t$0^P`Bchq2)|L}__Gz;aQ<$XW zOodt8IV=t;5O6;Z@UQ_6T-5X_JMb*>UOCd*af?67oP^`8PS3>|PYxC#hf%jF*qhHt z6M#HfwqDPcD*)KCUBh74v&~5{y;p_Ay&Rxf-a{>=wQjB;xG+m3cLyyS=&iYaYpU|h zlqDJ6KJ7ABg^OI|!3x{z(Sr=~#EQc|daObZj%pupvcQO{?$?5x+@F6zqAYVe796j@(h|_FV5p- z*3CucLxmi0M&K;kG`;g8&)G?mP3zpI|TR}7}3L#(3=kUUYz zhE?&Js*E`xCe0e|mx%QH5f4n4@6lXWAPq_tnntZ>+t`I)$7%+|HHnfgo>Z zo{f9*`clkaKaIZbB!toQ@I~1^n3?`KzqQ2CvCiVe4Mu=RQs(sOTV>J_Yg(_4NxS?o zQ*ErNdBPDhgOT9QxgR6~&Fd_-Ypf-(5b^Q8=946s9vdAJa(6dV;!yaW9S4oEHp@2$ znqMlY*c+eETs=Z$+toryIE9}l!GECjyKVyaj#>bFHk!K*GOT$fsx{^X)Vc9YFAcAM zIST8>FUXNIWytw2X>G5x`QI}?Yp4Qtx8MIb+*-rMr~?XjiniAS>5|LhS54YJuv*!O z9pLVob6b%WgpTk*fxRt6LB!VRL;KGoASNOp<_1%1JND|cQs1*zJ^%X^5}0s3aZMBZ zh4U()#=~4fj~2T(9K-<2JcU zYMwf4BONjFr-U(6hdRqDk~rs4jir@rLP8x=TVjPPs>?D$fBg;jAF%oDw)35aMOl5X zJ2RS<*bhW{BXW=ta0x~DE3?U;XSm%Zw+cR+{va>Y=C!E06tP@3n&6yp=LIKc*xzsN zKVDH${CZ_?tl{022onpdY7_B15n_U>^FB$XFnIhXhw=>#OR4tXtW?u=4aU!$-?s}N zNimi?b;XfjZN2V3zub&30fnxU!pZ5rT(cc9>}?7KJlVM;rrad}+kO;#nGi*ha{c0l z4kU<1*^g74AZ|VE3PC31UQis>y~hJQfp_W;o2?PJjzT1<&SbPy_$$>j&cO^_cn`8} za!ETuN-eX5hT(bHB;AK+k!WW|vsBq1mts7`S?C~W=F-G@6_z@1*4Y~bS=W=!9pSq? zY*$&r_SWiX{myDEb7u^Q@ot?lo!+n^TG-Gh@}rg~==yzV|A+&OKa;X2cYwr){8mby z+NDIB5m~6?pB~^GeKcveVarB(xKYgyuoD$6hb`&-qu`uNDw$f#h$w>DHXWT@U{L`w z&RN)D)^ol2*d3R_u?xw~UZvW(pDSDkVt6`m6|ispGx|$o-sjpSgwe#9o@U=*!roIj zUcSDnW-by=s}SmQV{HUbPm$y0f7{<-wkik=M3;06LLXhBUI#G7!~C>R#aoTt7lr7M zI9-Z|o7^Kv4O>fZ4v6DdQzYaNe8TEjD@l#nv5AX)_q~TJg4@%QUqFNsXV$(Q_I=IZ zmn)S>A~CB7_E%0sR>hB^ZmzIyOO{NYSCK!JHfgO=I>$ZIX9b&f0>f4f^cIgbR-PEf zMY;v4Rn3qeypxGGv)lHtT)uDmodR)wO4s0pA5)%~);z`cC7nCT?aGUsQ5F1TbFD@u z$*^^iDk*>Tf=K7kyHckdJf%xgaSGyFt9r*|&T{LX*?`AA_4)o~RQ6KV|Dg}*FDca#7XCQX7P$rWak!E{hzK*$DhT36$mG5Y zQR?J7^MazPSk68_CtU@VXs|LLc0Y=%3(Y_ ze=dDNyt&HBQfaXUKhyFCOl>repr>z)Fss8Z{$_oWsfQ;2LFQvoM4CUEe>74H85rfO z{QoQf0J@wnRgBGa7VYl3I9R@UXnk3q-HOf>Er5gsjIQsvj4s0AN^uI_ra;l;zibl3 zXc-OY9@qi1S-Xn*-3@mRfJ~wD_U(N(a)J;1 z%oQ^;f>2s2A8GHp&X`D~*(^fN5^6c($v|-x#!2N{vK(;mRV1u6wCxY-qDhM(AEWT9 z(61Wfu^{}|E3Ag|Q&W8Zw#vhnk8^THjA}+EeYHbwp{0WYR`+jeogBYX?4X?qljvQZ zh$LkcxTQcnd0#=ewJfFiU3#M~r>7U!^ZjHwZ2lcX%^T?~mHfB+B2{#*t!2E55F`HT zNUYF^7vJ@X(4Xn_!$==hP;~yC2{*Umx_!{&Ej{W=kNxm^!K`1tpT4z!6WG<>e5@!W zr<1@Z!NO!Icq1R3cGGOsAKv78ciAq0T~UD9wan%Xy{fdOu6I|+36=M$6<1BSirQ@x zm#ETNHIC1wm)~>*&Q#yje-x4(=dRHt`D;pSws57KD>ZewX5l0Vzpdp{r|OcBHnSBwWkh_UaSoKF?WiV*sUSaTbvR8twLOvzK#M z6=#2*GG2roniA)M`bUB-YjTfHPlTZBa`CrR8F|~k$&I%J13^gNgj?1Z)(uF-u=iR9 zO#OoqcC|2)ujsLpUAZmGiS=x-DF4VydzvA+VGoEpW9H^vn;NB5x3FQXio}Fr91NYX z_-zQ=Vuju#2-zKJ#r|92ob;C6NtPu_{Ezl-9%ci3AE}Db(zhsC=jk=I&XS$=82=%S z=?I!V&>M$iOkB$eP_H)snWpTD5+T+8|BC$H9GZu)#2B+!G$J;wrm=ztfS?nJQs%&w z6cF~z!E7!49@O4q)KAWwow<7!V!+pGj4A(%V`Z@%dg-LeT+PX%{UD^!Q$hIYjb$83!eErq5x=JW9UfK|S{j5@TZH-C zdzTx?O-7#g#s;@HbM%Tgn_sB*=PkbEVuM%&3l08VrQ=`_6G3pMS8$ueV6>i#=-lMJw=bkmEje0AcaGvWZ9Ru>@Nrxn^O!DZN<<3@ z9OXj2f+_&l+K9MshAi*jI=`S7o805$7M8H~=xoU#1No=DmZ0CGvy8J0 z{(KGy^^~|%+df)f>>2~CNKTKkIXy1=I)a(Z0{lDb*u2s|0*rCuskZOfU^V=Dvqsjd|bN$)&_Xj7T3kVeTt0jB=?aYiHOShF0>IXx!4 zGDqt;y792ReS%A9Kj2@G2;{#Z+?mC{xuBwOvZa*dviW#1y0OpKuT0-EzQb1~07CTs zUx>}On}=ABe*sDM0w13j)uakza@8jQvvCU_B@l&vYwXN0~+An^e|Z`{0etB0Ma z`D1HDj{Ph_DTERjWMdI!effl~f6wZDn&=QEAw~m{M{elh=Pw$Lj(x@KV$0uesPxqy z<{uEp$H-BxbxktUzgcVfqplU4FHMzT6#al=oOAeTPu;E5zXAgfE$UQ23~k%kn!RHw zXkL;i<2YWh$pDUI_ivUmk3VRg7}*=wkuJCKe768T<7E#pn;ZfEO@SLwv9pM=`AA3a zhKEXh{<%Pqsj&CAsVZa>WVAV- z?UVIpH7W5DFLi_nQgGPwt;Pv{IDcp9Q2suc)Fj|LMkiIpkm>6a$;aXBUQbN&PBFfG zEdTkHmDU>gS(HB03ms>9`~&W;BV!-Rv_05YQ2gZ$LzSk9_9Jn*>jawHVLWI_!t!)z z0_7N1i*2986+!t{=ad?efFT_Bt^BRB76OtTO z%VQi~!>K`ylkz~h?}vGKGyV7e_K)<80vD zb%yv>+~&&Rm+V&8v1`+wlV;@ZNTlq9ZcTf{jR`EqFsY|(V!>!(-2qo?cgw3f%@}2P zei%1L`g&af!bDYnuSP8-)-ds?FF5faZ4b(>K%QS`qDd7XhBpt12oZ4Sq+G6&AB zr?sY+ZE0Ns>T^k!`QA-yq+t_vSBkarUSi$?w_@T$luWR81pF{q zdg6iSbACoOb9ws zH2xhM=#_GK3S*R+LGeH~kq|%9uGzyErwv5CchMRl#_zgU7A54n6vE8UFNBQACC{^@E?t_cP$3>v~ zrzAPT4ZCD)B-gT0wN2ew%6Cm3sufef&K;+q|Ry;HS<^wr~xg zDK>4Iojo~kmv5=SR->DSKsCY?Y`GA7TWTN(M%NeE=(ZYRXqzi+u}Z+rLm6BurjHNs zR5ndWmzT+QU0gp-Hyl22B=~^YhC&LFm%~PEgA`9aaJqL+F7DogqODjV7?0Bfg8lXD zoQ|Q(gG=u^ROM%BtNU*~xSj+_Q}XNTn7y6<99Bc5>wa}0jWC#X){nDW!6Gf`RZJgfZ~Ebl$$2VYJW=kjO16W(~d)iW+b z&e^xY7n}VARMzf;(0kykwR!yCixj;_H@W(IsVt#}&&#Z`Hp9p?gs7UH2bX0Ha4d1h z8|d}_6{Zh7VJ%1)?0yFh*0tGrp)7G4mA2sRZNr9tH2>@2*LB3y$t~X>_wQ7|ba;t# zrnn@3s+;D&cK|%2FM5s!d0M`F)J^S5{lZnRn>xGtCEzGUt5RV0Mn-+k_#{ zeb+o(+2cT8Wyh8)QYDK$>}?P11zs2~x~8}} zD4O{?-rs?JltzQaY;8`E-`%=M5p+%7qjX-7C5GzZfXCj~pUU&4`Oid8a)- zqHoJ$4?iP&Hz#_hrK9q8nv{X;Mw*|(A~}9uz;`MpF)9u&+-=wupaJom*lcNEF8lR~ za&*}6A6E57H6j2kVbwY(ZnAYiwsphhbxzY5@5Lz)F*8*6r^f}&eISNaUFr#w8>zmz z!3KNg6J$D9KvHY^k4>2`JM1EmUOjHZhc950Wn3-+(Gq0WMg5AR{q?nD^#H}|L*VlE z2^bPQ9>wgPXVXy267IIS9;Qm_7os9AyT4s!qRi2!&xQzu>xqaC@Oa0ObH3a10$hd< zQqKNMIR_eSj^akDOD9}V$-=1*8&Aapv5yAfnGe1zwHrk%mRm}F0MUwl6igbFY%s#D z2B}0S-kpoXZvUUQ@JmG#fWJJ{G8$U8(W$IO(z6*dTO zqo@asv8MskowGeBk?46UzT^b$aQkd zR!a+VJE_5g>#Sowo$lh(16XTF|mSC5M^-@3)IvNTVCYaXcJVyqQjMhS0(((*2Ca$o%BT(B^&pCc>z z#?SbPF!&qqZ%$A1-T6fSx)bv){E#2-kr!2umfu~GTyZr<4OeJ2x!%)gXd3@BQn~qc zOl=AaxZSKg1#Z0OXzUS+$U*hBKgNAqS{Sly|O4iyT17G{#@?Pst?Duz7Ip?A0H%f<{aFJvD9pD_FNL1U$SXapKo{5CuhD!4GF0Bd zzkQ4vKPgHyJliv(XL6Y9f$P;AYcVe!I?0ezI6ASx+t*QO!4IwmY>r%aOL~b1&6Xt*<{Z_t6^{2S+{D7pZ{se*>0pXOrQlLm|C+wFHC5WElD>SgW(3R zlf~p}I+bZ29tN20sB&K?y^KS7?= z>ani;pd5j|r*=(7gO~Fb=(}k$*}N4&e;Jk!CXvHA=O~a(i->#ETc_S6y64Hs3ik-F zzoJ{o)?O;S$-Mtfb}gj@nmiM4v}wgTFRXaT6SO1?T{A9SYQnygCpu&pPW`dM+p

Ni`pLw>Eo&YHJ1q+}Ftejv`&pj&B>aT_XOn#pSw}`^En^(Cz3+{$Q&od- z>x>}}wY&_Y5T?!GZESe`jM0Ou?WSr2_DoT-SHBY^%*6YWBgp6*VgT5NB7`ad4ryunhrBLRT!Ktth!~GU}&vN&Frv(~G3-3K@NsJv# z44N9FuU=c1u-8C#jCBikP6w2}!d2g$b$uYQ8ho8Y102r11gXULN>>$eaY+JHsUJEw zRa$U`RU6sys~sh+SqAqm>kg68w^Ca{(JR<1i>HHorxB>5Y1^;{4-8CJi^$|cgZrCY zYogVMmFZ2rh2mzmr)z^2uBKDi4NdX4V^EwUPD{hn27lSI?5o~Ns~*4IspVN+E#Kw4JKu%S zdP*iFT~9NzjagId3NG0o|Nh?4S2P&CKNUi}^P#)BLl@N^7-p#IND(`_i)swl;a1W+ zAFV28f$lv#Jv0!&G8NWQdk@l=s?7O;$KEfaR+tOrd)0D}I630CNT zbTO;Z7BL=Te@*HxHsezJqfIoE(zh8Biik#TTw4OJs>Knhw-ihwTg>obQ8n{$qcuUXqq(db+qB zx7heuMaj&PyK*;*l4@i-B*ojXm)>0cvTJ%~QeS5>t}Dvp9`*eec{7>ImauuszD7MG zoJd=s;DJW9{hjo%%H-u7h>1QPX(l&N%GCO;1SpEH>W*%oBuN_l81b{UWG^B9IDx{P z%4VHR^j^{9N>-F&?eYzCug8PYBbmyxbRct+z0|HBd~!O|?~?0J_tr_K|CDe1t-Xpn z>ftaMs@>&l(h6+av>{pkLP5H{@<}T%ti<4^sj&F7k3LmuOon!|Tpyz3t3U8@2q!s) z{vg{3cMRpbbxwMK6Mu73$3?UC6m@kSUG$5`*p~sJaP>*)Q=y=9Vp7iwZDse8eKwB2 zrlQCzcJld#Yf6|nn&CKMV3%f18G2k;{L-yW9iT{m$$7a53)I zzdFlNamZ=*(#8cL^6v>HXzpYSCvCFLA*(Ky(g(wM)Ro1xj#kUffYAB?0SkpbOF-Dm z6W()a*#WaPlNTeTQ<_6mI;Y!HpAf#^VE8#i4v)-=H{k=lDUHb*?l(9 zmB$H)?NsPGlw^~s+8sGpN9Z2fG8-XP6jIjcY*>Q7)TBZDskyAT^o zR4BL3lmPapb`B7M*x$Y-9V(E~du~VQr(jjYKm=1fZXSXpXZX_ z^0GP-k!$E6@sSqkTo`tAl#{4>mKrj*IuMBP@IQ8{^c1|H&Z7Nje&4;@%&b4o%9gg} zdGrC9#rrKS;db5toMM7*$tJVL2e&Zl*@fteU(Q}=&UUJRf#TI1Qt8K6cPxNZX&^Kp z`y!iCN9TrFG!=4WYnpgJ!0@xh2>W~JyFcg4FZ2wjQZDo5{l0tE2ZML_Ky;<_`JI!x zBu5P9<7-;4Bb*$gyUcDfj^SBa`)p3rsgEi%XJ)xPUo6Cm3U?@89`n^-_9cq7TqyZa zyTGWL_x2(P+T@-6@>g#`)^&M1Ni0u@+*eQX%ta0Br)C~hl>LAux8VnzN>v9F1kVTe zmP!cbm)n0MqVI-G7fFOy{jkmfly19B9S$;O1CeBpNXx4{psvPS`4fG|6xY{qcY^^S zgh(M^t)hIXM!>pB1f;vQ%L#Qox(CH*CfTgXBYTy!wnQI3^#Dtx=4X;iZi25S74n;z zhRz|aUxgTc{9mp<)VREj*GDi~%J{PP^nSYJWm6C!6hem#5arW=V76%#fMMSOCk;8x zbvVjzeY_VijdSe!Ia}V>imOHuFLW4@l~r@Jo-CH_03Pd%rX7H;-H@-ESE0u9-P?hm zOo#Xa4?&_qw?tOYpX4F*>wcx zGX8k+IWCMz-Ri-|`!N>P@wp1lxt8%Tm@~a2es$lJP>jMh2di64Rv^@RGSc!y2>92mj zwz+FHp-&Ar=eWF?X_!1G)W|w{C6!kUnY*J`q-pF%r8Tzoqj{F3XeK|Dcddz}2AjwS z-7&A;w9!83`tkJ?aiK*fa(?sp0kEog7z>-Nl)Yie=7}LDbD? zCjTSln%V>_nX-AZmM%Y=Kp6t%h#PCsUS%ZEjn-na6>_~Wm zlMBd0vpk$}vLhTy?hpso)%cI~*V;loukV3y(hbozL>;EnI3Ugi^wTQVV=pM+E{pN|S2neHm7e3`^XiqTi~{y6eoR8;r?Ee3QNN59-j+^IAYD>HdAS!q z_f~O}urpY19qX`8^P)^;0>G(akQYSFZI2vMI}TKt#ot|CauIj{%%A%i0| zLbBUTMw6nM-~KKkQx;1k5ozD$m6JFLBDyZtaHN0v{_vcJ-6B_7o9l>+!i0sZ3n4+` zU-yPM+_nOaFWV;@MU%NpXl=F!j%<_Nyq;YOc(8j;l*H6;1@8xO&tqJ^Ps4FCmM{_*6y$?sY>9W?ld8 z_+g9cQgP#{=F2J_j><|wQ?}bI51A`3#zxAuX0(bFL%k5`02!M;PbgaZ@f}v-Mos0@ zK)OM{pWHedg7&yMgDprEDb7ea^QxOAEa8m{lh(^Or~`M7tzH4=WlcT~T$wpKsAY>t z11d}J{F^!OHGpS0{To||_W7*EzX|1+9hkmsqS%*jZ`s)4YJI!haB~oGHN7I(ph0f- zj{JOyyv;?aaQ$|3t6RjuWv>-Tp{`dc-#ot}s%0Li2ANR-0iie}M-FwpvU~Uj{CAvQ zYddT`#p(f^!UT~#$$4p8jv6a4jUc6i*>1C64XbegeNVsx#(9d61{NzTy>a?vDI$WMrZ06a;-4xbWdCjYC4rd(|MMM;6G zm`Xk$*}T!@m)H*cJ<@?>`<%2RN_}LrAnUd&-^kOC9euynGCZ%;h|97afrXsH%Vb?bh;{12R6nD|+KUy8{(yj8mX_U3+fN>6e=1E(g0OMz zngh;mD|ykrLp=O-_?v!5_isg+oa8B3aDLHBekGh#QhuuYs7rOPkzrx%RaAFqhTO&Y zY5dD0z^@ivGb=RwArYS?S&>+K|1PJ;$@6+C7ht%t#gkk;jA`gd6K5Yvy#SrHNt>T; zYnZiWaH?*Mt+Z%z#WcLQF24p;ov-PA)UF)B#v^*mf}xl5_rYwP<#g;FFP1hFs7TX~ z4Bm>YIo}`m4=8vTemybG$NOhHmKDlQ9k*kVhCIr#Fv%Hq_8Vn*w|%tGy978gJ*6gPbKY4zPWmW60R<>IT+@q>fM5UTly-np-xYS0Q+e)vAw#Y${bNavrSt zUVz{6n+qOTTG-=yrvSaS=+jyNwfwW zAIPk2oXfORo`lnR?8DhS)Q4!PY2N3LNWb1@@w0C26c^$@uK$UAj(w)@L;eBr#KW;f zvs!EYTV#I!CK5xs{`W}2QK8(1*Htq10{lnx#_=Q{Y*o7_aV42;`yfae_MAlcsypQ+ zn*odEzQj1GYRDAw4%<0LA3!m*Mc(=_e@`J`L-JPfs}N2NDo9AozkU*#Dvieqr0UWM#EqmHDGN2eSi0HKiqNb*ptuuz3cp(=j*yI zM|}XrfWZ~+gBZ5bWzO>$-r{{yk>avSHFaa|*6q){gqWa1>c#dlHmuw#j5U;%^#*d* z2Y(MSYn{Kk)#SlN3o5MTa`klTM4Vmkzv{zZvR4m_y|d`cwLcskBg}{+dy5I!_8O|m#jH!;G%51 zp0~HBffQD}kspfOcNEPj?21U9q(w_UF7mtWA!T#Z>he$^RYzR@*J%nhL;l?Na3v(e z(YpT=b6eg9-DbKetMV6*_*17v6x*rqWSr!N(yFnb-#$k^U)iL2cIEC+QJP55Lyn5@ zZ{U=z_Y~mkBLviAS=XZzy{%QXpvr`~?)$77Um|UpQzoN6WAflcpO?lMI!(QkQJuFp z(vX8~+B`7x@-Bnv2QQOrU&Ij8w8mJpkel{ybr?zu*RAl)S$jpu_OhJoA)o*t=%(2Dryk#vrJ9TvS?wK=5@D#@`6qLG0P#^R zrL|6xSH*2^#d;YYeik^b;j^>J#+DTbYx7!(`r%_E-_jsu2&Lkp%L-*c9%p~{#IX-+ z;5u6=kU~es0^SW|D?KW(Xr>C-z(3ifVPO$}z)9;;0zwZY2+MdUR7SJQNccLjd$_Ut z=6cozu$4`CZY4~ig4&bD<1=Ptac(F(NQOsD$7>is2C@o4k-x$4fc3^A$`pnO?N^fq z9AR9xDJeXpVuY!k*!yX2Aa@v{8%99`Nk-?^miv#78-+8OT(fdh=o$FaGVKLswti&4 zT|lC3U9}f2c)ev)2)K?;>D#s%vKqzO9OM4l5X$23NEO(u9XH3&OYfN1HN4x_q{w}S z>6)3hi8t;E9?|@+SlXYNm$PONA2bxk(@oB5EukwO0p#z!X6Y2nChC1-;s$|Ia_7Lk zrH=|mWJ>o}A3^TR0P?aghhlOu?p zI)|gz<Gf3*KCk*Qk!7Eio#~F(XteKG7p{)QHI8Neu5f$;QA#NDZ`nT1U&XMQ zkkXEC^x|v^?IvsNhDVnz?fx1b!dY;|Qck@F&o%{L9}A>pq4=7qmJMcniV?R3dS;@%`$Pm7I1(MQxyj zFS{5$%ZcPnXpla|~7O>%Hd zxVkRv2$tUJJ^M@C$&d-7cV^@Kp$R?nWcVD_W-!wy^5%7!V=*brJ<`^b-*?9L%yEix zCvGxxX8eKlfRoZ_n!ILqg1tfgY5q*^;qR+%N{sm5$4JwN>Jxh;m0VW;oWd+J5k%pc zOii)IqpMt9s0v>B&DOg4z6%s;G|{T-po@4Y(whpI*fu)()C7Y7C3X`oMDBiwBbxD` zI_lPYCjL%$t$ANmK^L+zVz@1zN%ATu0Y)nf0 z?sV+T9|`RLs#8w;?h2+9A|s(rh@O6iBOL=?)AAoa5+>Ib%`UGOs#3WGMtGuK_lX`< z#lG2h-fz>@_?tUq-??`~b0?=vmm zHp_S^6FWuu_{QXqg-zi@lIT-{Eyo{f25(fa9-kuvPiOUY8wGVxLSiadM<6Cx-c_cT zom2e9gHyrK3Pq6xPQ#mHJLNulC+jq~2ks#xE7O_o+uOY49+=tqGd(J54vN>UdfE_~ za&D*Y_jTa%r``wh1iec1uw+ac%W8i!o8XyZXzfjYJ$%}otPRqAAJ5fw59K@lqB5ei zwQrU0ZwEkMJvjRTIQw_)EGHi6LjR!QRTlo17O09($3a55gs&EPA)pXvA&?3dP``cL z>2|Rsv%eK)$MnXp`JD18ANO$W2d|W9zq5K9Ui`z)UzAdwzqU=D8CR@&NFyZ0S`b@d zEuIsCz}_o9?k9=lDqa{@^phNuq|KKMUX3>Gxwp=&ztF3K7<_6(&^a8UQI(_FPkB&% z;Pa0mD#a&7(LTI#h_4I0Mv!6N7gb=qh197iYE|{va%#EJ4LQzs_^{Npg7T zDBUL`C=^5Oe|N#g?t1mRaW2*Luv5Y^8#f@s^BsN*6SItI8HKa5%%C91BGgp7tuKDL zCyW8`BGY4k+RE_sGfGll16vAFx_CD$ug#JnmH=bbMdOVT-s`)sy`-jZX8Gmq14<(w;%zr3h~a1Q zatK12pu_j4KrZ#KPA8|#pZ;r{dnFDY!NS`MtH2g=5OZ@;elyDkK&ViPwO3inp&^nB z9#G6=SLcVQsm(o=dpTQZsy@X|YahXv~>B9lR+-3rz$-r-kivSv)oDAyU$BN z0w38S5UX7s2_)o*_ji)} zYaGz>JjFtE-2jdknR9z{Uz|l~uDx;y`)%@#TkT4xXE6WyGg0PXn6LWCrau(VmJeI< zDqF4ktJ0Cc6id2R6TnjY3B_csWS$?Li2d{SId~UPE&kD>m&?d+ZF_8Y@P;u?rpBH6 z3CFt8rKxx1Z$-yCT?1oAUg(E~pYw6GypfR^@0mF?liH4EPPhlQCTLY-ih@82h6ky7 z2ZM0dnvo%+~8Fy(>g=+1cDJXtpYmGfJ zigPJp9T`pgkoYstao{t(&w2@4jN)Sw!P57?a+u5ZD|A_MsfK55d{<)!AjbS{_9p%w z5;qbGxom}ZAaJ8)Y}||gT5Wx_5YA;Na+SjtL7+#O1I~!@;&o)D@*jk?Uf1rl7R7XI zRZ}MIqU{4;E{FBOiIpd|GL_*5xFn(!a4ezBZ|7@s!eY2gb5MU2jq}XwLNN&svrf3G zzLT4hiYIC9KfOceL$xMNJ1`g6din0_7)1f(diCyUS${( zMZp4h z<0WcRX2Mr?V4gDj=zxH8W+D?CTS2_`Dpyd;6>&9-3;g%-h3_0taoZ2*C5vy1CaZ{P z+|=Q{l3JHtXsdXB@GbjCBmO6%uVz1WUa;7|pR07N-E<6jj}Iwb?|S62ZdrN?-l2Ny zS}cA}NrgIdbKq0ZzbBEx?25%O!Gx|%FqwSlxs=buM#!OvSF zV{^eS4#lcmpKm+giXiUat6twOtXB2Bb-(0n5#ya7tkyhH-n@k)?ZvWj^GLX)5nh9x z^_Wsz6c!+ex0=vnN(8yF&_@E_jGHCj1=1hh7Nm|pXohEGoo*uOb5EB4Q|>5`$5H0r zetDH$4Qx^SZfCKWm+H#e9pnx^D2J`LVuOYj@g5)dGw+l{sz9@WHYNeH2I~@3t_okI zDFiHkY7fOm1nh6>t9wY($U2jc78u_o!UZxshD+@+BL1su@Q%fJ44kzw2|%#v2IzWi zY0#6Hkmp)H&3Bg1tK)^VtnzU!wuC-8*wVk&P?%xfo@rheC_D#}dO5lj<;CA#VvZaK z)V8;>>U)UUuzP5hvUT=La$<_08~xW1t6NAyHk0vZ=vKyF=qAId!X)-DDw3}*cxrOVy9Jy5Ci{0xUftn>lJ{8A4c=Ns1_fwP}jE=3l$&Oc327+{GS9venT zCEUMz|Kf3u6CxKli=>ii;?dVlo4NvYandX%+p{RSgkdC%ElmKSCtoJ@$G9yIrhyMp zlR6GkD&EP2T`112A@nG-tyuKHcj6Gc;Hv`2=IbPtD znvFjjm}EyIl}6_{35@MwXJ_~{l{jY&gCrX0Mn+KenjX#l!3D6);{Aa(A8NkhBK<}k zdHt~SyWV$~Ux57Hh^D0l1w~?eWq2HovxrLhFx$L}>B>6g<`G(og6H`?{S!jXMNK<> zugay{-35OU)yMh$9#Uh9f)2QcmFBv*(UI3-FT|J9E%&F4SNm(Cz*rsHY4~^8_h|B* zR>Jm!@^h`modl!f)CZRoiroMf0~bGPSzu(9e|6-4>d8*;DX9I7$B(?StNfTvvgX5O zOT`lew#aDR7$N4|;v==EwQ6*^e7q+uBj*2ZRqF;Qlof1N1z`m{5n>?z>>dM^fsM@z zlawGy|K*-cRv8cc^44^$Gz$`_=8=#I)ft|DePzlIcOd<3ipswW8R^XKu&`1f$!;p; z%f7-^q2m6&Du~SpyJ75!-5uRfVjb~3^82ROopqfN>`D-xVURRSFOC>6RnIoBo)EPx zzccn7%!B=fOrF+JYlgz}tTCcROLhQgxd2qegz*4y84jbk7JGIr<-@Llo~3mb}jjR~AGz zcH(JOw;s3r*enE32Fu*s&xCeDc})eRf4rFq^LsiG(c{3g_Mx3d_86WHFc z{83w8P{+0G^NBkO(GX_>7rBVV;>i_pO&7h~(?_Fdf{3eqw}*d{zb6eQfMbAH3PdSe zIQi&VLZMY^D-$4p04MPtVl&!Et%!ugCQ0x55;XW8U6W=GV!bOhIzvZ^pa;^+(6h`O zB5IF}N`>N-`!^TeA3AunqgA)Z@g|Bdw@3Ms1$Y%1Rf?MK8N@#X+uDwCE=H~~c%A)# z%rljSPH{*!$4$>{93B-RbztkiATrIp9J%%-_auX4%G3qg8K-dApFF`vH@=MJ*Ihdg z4CFvXG(SV1Bu1Fnxk>7*d}{x5BU4em#741nzMaKhR3ELEe20_6PwPE-ema>`TlD0!b?e$3LF!|wr=*-nsg?P^Mt$kY6r#8Ma)bTe<>TNo z2jBhe6_ni!UW;*`%}8y)R6v_jK77$Y*uv*^D#4qyE>G1w^78IXr9<-Xv$HqCdd%RJ zS}vMaLE@2Ifb@%{#eFQMA++f)fspkG`K)lOc`;d2lIt@t^ZhO?x`cY|NupPEg0^jJ zTjx@+-KSNGz~pj0UsO9CSiN-1W>~_rwXPjgm=$m;b@mPN!r%AjQ@@@?=?08nSYoHg z*;{ZnK(BFjLiKnqA8jDh_)IbV6ok7;(OGbo&88dt?yTSo06lO}pFX0iYU!=>p_3Ps zXf%x2$g5rBP@Os3Xj=JwvW+J z?od!LhoV@T^O&!Re3$?R! za^FxI#|HRAdqv{$Eh8UK`vy(?r&Ep{kGgM1iF2uaXlu!9dBh)nb_4%HcVVpXr&W5}PIo^07o5fxBe{KWb#%@}C8Hv< z88{#?8h0YwDq6wlm2aZoNn!aE^lpulX0uY^@`Fe+-Bs>CGBkw2UmK?Ck5EOFbi-x$ zqlJdAahrmKQnEjp&XIn7zAX-~9f!1O0@(F4%vdgrE`?7du0IZ%jhLg%E?Hp!H)Em+ zqIFF3_nZH%t#KJ&3UOmRHgNY_eIMAe z6<(X=0C{fUx&k!!_YX-OQ=ECLK;s_NWCon3R|4a3KcQXSZnFU}|9k3!(L0O*RYq(1 zBAPuQAib2%rDGRJ;Z}IPu0L}NR{~pFV%g1p$;ySLwRc7@nVBzJuSLl~CjD1yt{9!) zCcBQkJ=O`k*Y|{|Zb3^Nby0~F|dd!R&s8=OAl=Z&tt5SZ^6VkzHaw{7h@{~q<- z=s3uiL&)Ya$2Z)+hueDkebqL?w~Ee|2hm^F&Q3kOmLV&D(blCnLb}qalZz;EoxaINh{R>Z?V_j%|>azDIqrVx^{^QL*E7a8+?kQ`_ zNNlm`&^UB3c5`-VW7lWJT;faImn}T{+t?0X|2o52^H+Y+7Fz;+UdyX@Xcddesg+4d zniBCcYId-#)n~q`mL{F zFzNFje`LD4AN+8?#ad?`HMN23YU&Jt;yC zNdxvz_0-+22Ka0GFh>EKb0ZDr>tD}w4vL6KwcQI+N111eX&qp>5%~~OQT&7Qu0r3v zo=nim9^xxgMW9rE(;}TAzv>B$P4}|gK8ls7$>IqV;x2AByVTH2YI1l;#51x5-k1G~ z)Jc-;Uh1F{&3{RhM$Trv@Q*xf;Aai4nbVzlCNJ4c+HJ4y*F?QdX&~&nyd5UC<=U>5 z+P+jU5Xnk^)NYN|1IyIZ#t!ju3v!<6Zq{rKAYV6AR)y6}_~vAkGAIaM{^N*D-5zV+ zk-@%<62NKBxj!wn?&ivDIO_?lm~HDpnzNuQPef^W8hLHi#*l1Aw7}<4Hvvr=3$7ZS z%wl35nGs?R)s84ngZcaYHiFk8*=k+%9Xkrkbo?gT=#-rY@eiu^Ra{$QO$R7`jtWm4&-4p2O%R#kvV9OrD!-5;tPw*4VU#66Eu-&ipCO7aY5iN&J@VSCkG+}VO|>*8sdD^?fqr*< z6>==;XDWZQ@PWbk7&k>?COcccRta0_@OAQ$IeAnyVTqs{86}VMY4e5^?Z^MkBMLxR z{Q=!Fkn-0l{RrmFOrv!zR`6_hR7aGd*ED<&8zR(+kUBEd@4Wc6a7}iUlEN8FjgSy? z9`$LgJIRy6OV8*HvrIv_)cl78eDQYyo?}*^%@dz_PF+1WxeG15XPb-qWV|APqQL3pm8+3AfK=AgHRi-6Naz#ZWvvfP?9-kBXY&O=Dzv>4Fk6WkmBJWxJOsqO*WbkL^--H>6$wS*C7^Z&` zY1UGBH`E1}PTHuF`vAO&MW;^w$z{U{c1{~O z?&v!joj%^{r?X%i;2Dd%erG=oMTsR1#dy9U9XP4(a#)5__n;UmOyhp0QXNR@l;%qq zE(7{c{pCgQUrOq&i(*LZd z@spDW`=?C&>xfmAl-x`Eaj{=)hJS@x$Hh7r@8F`2LuJIKy4a4ag6hcw)hc;;R^cMI z>k}-MM*Ra8S?E(Dl~=9Dj|=6S8{Q9TN!_P@xydgnp`Lt`FcP&XiKKk&$h}#qeOT-r z={!v=9SD)kYnMAouW2|d2X5_%quCJVtby@NfyWdr zN|8siQPC~7OJ#PC9WI>p{4WEGB?f?o(9#u9jedP=Mn6onDliCgF>|{n46d#sC9^Y{sB|5hVXl%mazA+ zK9$xE3i3%L281vgm9+aEi+!u4K4136uspA`;Zcwv$Qb{`r!l9iig!&VW>#Up{!N4+ z8|-hqXq>sByKh;Jno{DO#>~ZbtlSClX3qm4eR`u0IeC;idZ9#oHwktljFttlIU+F> zE%2qNO|B_Au@pKT07d$M7^1k64iBs0V-8besiO+GPQc*g^qt+z)&oJ>T>GEyi#WNO zo`^VUIG-HDy#^x>0|EBZ?!72JB=6ytO?MsPP@=_KS1A9I8~vpxdbXGX_Cf-sAldN@ zC`RAdw*xcqSAK60g*+`c);VtkRGrpd0D^!7@v6l*=0@@;#~jEDD5J_ya;Xy80FkzQ z9na=W7(iwO-XXS*j#lz>5L59pW~H4PEYN2eqoy`iOWI3+Ikiu3)CdIfK2i}86?$@2 zFdwaJaW(vU-_#|~xyNb=OQalkB#J?^eK|aP?&i;$FsU|0`$AyIPmCZuwF+>eqWXTy z#jW9y`O?m)MOP!VOI#HSyj}1;p?Z9;h4Y*Gi@D#A?!e9j*HKEgo1EAs**W;vv* zWbO^?2xHuf{4EuXUgqEu(nu4B&GRy?A{M=zOeWHAH@K}+F zY*}?vOP)LTyUqzYj}MLSl78y(joE^2h+7y8LISVf`_lC6*Qr_LoZg=`y)!Ks3GQ#%FnvF0C>+R1BZj&k zz*l(WwAYi@xC2{^x4Y|olIOYbi}x6|6fRU{ymvmQ?9ji4LZ^w`CT}0s zBuiPlSTb;RZ%Dh{&qr=oPC#vn9>d-0t-Vijei01fr z##1WLy`W@SVp_h7_6kV8I~Hx#bNj@|S&KcR$lGkk)3>U(g7abgkbTKqW4qDP$&+NN zN?)s~?wpyX1#Q4@$BS1V_FV~w2^95Ia*j01%W>Xom+aO6pmIQ{|33KeLM2lhYpkxS zFfJCicukZc-mdrs*Ah;=A4K3pl1>zzkAKd75x*GCO{_U*Fx}qn>FodGqZBRE@e=hb zfc6W9=k{@$nrnF3%V)l?<#rrTD)xQvrj0FHK*k!^?v%aryFU1uO%p5}RZHHB61kp$ zKFY_9l%X`-F{rqUMUq-3etuIj3g)w6(b|`Q!dE{N(#~ZcLLE zw{{1>nsV`iR*b;qSc6*AT7M6n(CZpu)Kgr>|7Wo~`IDM=31OT5pVeIzc~d|=`^y*k}OGpB#C;j z4-R%;?uBEY(Bf!Fm}T1G`P$Wm79d{-H)UQtd`BHSFE*N&kCiS))9votG>H^bn`|GntknOv_;bF(Lxz2RCf!pKEED z-${PjU1K`LK!5uHCKh}#znH$zo(t2s&b?X(v}sQreWh~E0Cl_3+CNzOBl4x}ZPL^% zCN#6^e!TaO%IlLcHI8vg@Y#hUjnFIYf^oQNMrESv4d`|l_v`e>0Y5B{&bKa2U;6ZA z$vbz_hDgACpX2eiqbEpF6sPzQ#Krf3G){vCn2PL7&Aq z9lNw~-}fM@8`m|_?0nq2ZtU?GcWtV_$L+FaPyETvc12!Cy=V!Yt`)T^+01w6L59yf zR<&6PwPrFD7-EVe#Ya93ud(4iYQ>ZD&X+MwJSU%$28O?f9ej@JOL(&=tmfp|1B`0w zOH~}K*uiicx>vcXr0M#7LpSifP*#iL4Q+CnqI<~vpDg%~Lnw=Ef$vcS>@#HQmDmGR z>ZkVslhHijmUsT1qoFsVuKbq=+NKwsdM+!Q+d~V%g!;fM;1}XGehYIwt`tWtq8Y7= z`A3Pj$~K~G*C%SXFe9;ySvDY+QgQDB3O&92 z+k~&bxm-V0j{LeCBhwKCAUm!3WmC-uu;%e5H)GZpHY8=czpB zl+kSip!e*+R@)f^bRF&0u2fjcaV*kkfvXy|!fE|k)aPV@ z{u1saFnWqmWOxy31n4$22SS7CKDgMOnWuZ=?^64|ALA#-xT-4}*j^COOWThR0Pa<` z%Q}{*zLTa$-lrl%{bk>(xG{%`a*0)}=!orNq1KRAeeH zv}s2=U2GaI^JCZ^CMGR&l2`r!0ppJZcOF#5JC{&>T=@)9Cr3b4 z$#<& zJ;NnFxZRHQX-QPmpPl)>9Q8`>QC1GfGih?gO#cPNp+ZercNh&<%y(V^U!q-9T zXw+xZhTcKQ>;xx?P#9e-8`rL14Hy)W+cN&`=$S;|iJ_S)$KL zwKt28mq=h&$&ZI`^TF?Mii%<2{0Lq^Y1!S+nOyF5*OSo$uPuGs%gAmWBpKg3Qc1 zN3n1zOds5{Du)3;4lfKlM`II2f0*X8x|xv=;JR~l7U)Vr|8#;LuksvhX_r$>YIb)e zfd<5)>%96T!_vsT`zjQ>c-Cy}S%ls?&}%-*yzoA`^p^a%{O;+me`>jyEWhR^@*os} z&3?rNpNIRGp@JTsqqOJxy|}wZrfPI#3&GLNuRSeLh@_6ykcNs76~4ULwPUk3K?6ua zj#-*aivL8q8Ao!u)~2t+KTYT8 z7%loxbn7$}G5-M)8fw4x%qK`?78&_;u$TA2i^Z@DM>~;Z8jilqfWKkpV=_1wwe3hG z0?Bb1hNh8E9)L$qh77qOCk)HhYp4snF1|MNx=J6sww&nHFpRKz8}2;E^88ct%S!s+ z(x!^zcSyPCIMNw<+x$VH*tP%rpHLRyF4saIQwKgO^1f<0l0y`ivtqr zwN~Bvx#SMyXW2K9$l2yYZ`tuFp?ln=moUrt7J9=;Jy28F`b5)jU zdPFP)4e+iCV#wK9j#FNuC^aNzDLNKsjsNE`soLaDd5HWc7}X@#RVWCL9jV;82m)l7 z%!6HQWuy$k;?p?_2YDV-Bj^nysSVE*i%(~_SCnT-vJ(ByeykK9N7St>9Y`w-f6fEj z+oP@=V7a0e<7rm8Jh`h_J&}aXxe61f&G*s)%4&bXeQi*QDQdH@g0V7o5CWkE z^d*IV{lXynVQ|{wuLHKBu{Z;xc2NrZc1)?T*J&A*a{e|j-6;J z$N3?BylZi6qk!QJ*@%!D@s>4_ejP8{0X?9NU_&7=eGBG!#EHUx0rl%6#j{+K1eHNP zNd`Z#`Oy4D7D9pOc>n`hE@_SNJK;*i>i4)XZzMHnzWW8!d1{gqycFkU>oh4K!y}bt^;W}YuU3Dx%OT2VN1wSq;XAR@ z_z!b*Zh;$-$sL~)VAPVnIHTo2iJq*C*)Y{e*XPTAfNT+Ze-28p0@c0pUuVegLP)u7 z&OMOiz?&-C#1Ytr74$gVvsNCEU#4QFG_T-NR?GffMV$X;TUntYeuLa;KO_QoeG7eX z$jw_Qms3Ih&Lu5x6t5H*Z6t_GAsK#H6&%`%<7#v7dMJJOLWFE3Mn?Km64VLTx~>R% ziNE0ZGu_C0;WCpQ=-l#UE^}6(r1ODdXVMQdxKGHtuHp1>ROj`hUnzm@4!^o{<$}Gd zVpHtG8?oKpY~PVk(KlpW^^Evo42}c=?pkG7u#SJ%Kd#Yt?n`oKegnSlG#c5cn7QB) zpiZRY+8gI>ZagYPAx;q1eizOB)sVaU&QAMkdyea8C!%tr5-qK4cXV&3Nj#U*Da-8P zkPOHJVto@!l8)m09YUxUo#mR?q0(oHJ$Pqi9|Z+h0L65Y?* zC>0NS%Nje;D-m*4kPP!eopAD1#}w&Qm67vHTFL(c@b;+X9i-=##O52zZyGc;5Rto= zcOfhp9V75HvJ7BFa=}>*)PIQmwm+0tT!k54dV3zjy~!D6N3rK|&{TVwT4$jL;fN)m zI%`L*;-E?c%Y@iTQ1eNE(SmM7fyk$&3`G;wT*kaC$$9Ndd~T1L(engjIK3zGrjvDBF8vG&Fa&5w-s=39w1XwhxM+>YM9zAD{6rXJKWFE(2f~aC= zc}QExg5sRS(&#E5$BPv}L&cBm`8rlv8sDw{vy-rvTGn6Usm2ZZ)l$0HPw|V*#>MVJ zU3hL7r_|b!)2W`L&-7EZFn~Y)2t$p0Koj>?Cb?S#==ILUspZ1K-cqkaETorK(Sx7~ zDLj#_S@I6*e=z4bG={s~KroVC4nlT03I(%ycxp{b*@Bnb;q*So3dd1XumQQ?R1s?$st6GRNGKOi++3d;nxx{;Y zE3W*gdiq%zWM6`+iSEl%o~04i{YsSY6*nmALP5l{foaU1f^fGT1#4WLjwSO}WNHS< zRmN79!;rtK1p`DGb&@}@ag%vcNYgorOi9E{$1-xNcEX8^+@|>&_`2${4n$WcAe0QZ zuLeLuL1f^e@tjQIG@e(qzajfltAu3`kB{6n;_M2(u_0SU85byCWqK6=CMym2JtKh0 z#kMlsJd>DwLEA3vC={Kh>zD;mNWY7SmVq{4ikRi{jx+va&VV z)MrjF=&F>Aw$FJh8(svzMtJGIKyrLrl{3&J-OXa<>+z>9V*bk^R(V$1`Aky$<+~77Z7D#FFttg$D~h};nJ31i&}qAw%4ct9 z373SolAQg`P3g7_)91rjQhm?)hvv3pvUTK`g*4^0dn!rD%UCo|*~3 zXQmiKxq=M0+Sq0M9m&QG{qhRr=dtZ3YS-7o9l0P z66xtFfuRc2P6BrTFiGIOBS*5nQyGH}N!_}|5kf4E*1DWtGG zaVv}Z7bmTTH@HW`b2tk`+=(XNTbL;)P=X#zb_2;}zZ};R+fgh+<7*k8{HA7Xs%)v=-Xh>>PP>RgB!S_5bsZxO zW`jGrqn-s;^bfK|P?BOScY8eQ4*RMxQ#C3{_Fa7QJ`eU^;w3mh!(Uxx9~-(W`f{f7 z>a4I$aIWwM)7))8bcnYRul64Sy&mnfr!VpGC9L#-JuS~2O<~BVrq1{!d}? zTDI{}*6fnuc%js`+AxR(NKatTt@fHC3oH26FMWSW)nAr;kn_mzuq%j5N|-3b#w&t1 z-VY;gzacdND`KGbUGm=`R1rV5=yJjhy|%P)14Ut{N5^|mM>B0gt>S-P&pcaW7l<`5 z5A1!Wm;l;hs$D&%Y0PKHKZi7)w-s%xFv`6J0 zGtY`dXWOroydkDgD?rVu$w>@AhQr5{zr^mmlrR#zTA(RC(o!fn9P*#V^UjG~|5(xd z0l-0^8|j*PGc=0jbT(U~@jsdXKrtVM{wz83KYxGE6H#16r*oe3Rj=V{KwI$jruTct zu=*WoWG2{mZ_Cvj;kWuEbP8pPDDURv^C0I0vCEjz{Iz?ahFNipM2j19fZaX(R!=G? z{^wq2dvxvE$Fmt)nkAlL(r)wNtgm&ef_}OiZ@n|-YGN(! z+ufcpI$vsZen?!}R3PLnWw~Gl2p2T=O%E+&B$}7g&p1JmSiQY+=uL9L!WOZTxK^}S z{OlT&Tf*2H!f*?T$pqW4S`35BG=e+R_Aj}jVzZIFAvad*?CigXgo5p_@}Dm1_@5X) zjQ1}yUEQmi90>Hbm6MvKy{N{PHM>|#S9F$9-wbo@J25Or&Mvb_64zL0G^@|L6YiDq zkvUnG+z`FDd=k*PCy`q(4v>oWer3&bV5rKM-K8m+wQ;9tWswY8+`s`R*z%#F1C$x} zM{TAGW=@b@s&Iuz&r~3HbUD9oj{hW_!u;M730YU=*y>p)CZ}FlR(w6=k}D>nxvEG_ zwGAdL3Fww}QU*1J@Y1}8fvRr4Q3Ju1XQKaOeH|aaFYY|R@dV*Ph*^UiN5Ewg!7ROo zGv@JXgMg+!gUf>|USDa7Qd>9extUl0V->Bf3EX|=WXXPZKfnMya87qH^T^PDiyOc? zLw=CXMdYkMcG`y?80Whjbn50rSk5s$P{Q}@&_qEexT6f5-G;23(9D|7j~d!Z zgNPW6>Z3`AtIJ^&^8fH12^0}I^wQ?T1!DG)9|gemZ?3BLz%@p+;z!4&=c&ZqIRKZ= zCEZuBv=osz==HnyC?Ov`29*yV{(13-6d3}%ta*6XSoNqX#EhE;JMte zCb=Yf8+oA~iK*@g89q3e6tWRlGXfSPtBn3%SWn2!t#;ouw49suaaY#=Ye*aS!0Rct z;3Sh4`hN15He>y}CX3yRg*(>(Lz)fKy-TPxA&EvJMqIr)=X9V@zpyN?aR3{lyzK0^<-voMP!Glc9Ef{Q?8!{oj*^Kc8La-#@=f3* zNM_L`Ew}4FMIO7tRszXVc6|L=cxm`vAk=$G@`n#mH**5O8Vd6LFI;4^MRs@5M6t^s z*LH*0J;IKYepOtix^=xhU4Bi~Ijo$Oocgri($@4TpLL|OS3^;z;)f^{e{}ur;(eCY zlp~LX_*sCI%Wq(FxjfVU<8ns0sQa#CjM~0AB#q`$txB0-o=~CZXR>s3eXUw$s1-dTHeD z_)p%}?mgn{Es48c55jCjre@LwcM*iIKF3)Li!uEk9YPXFnTaJEMC|D{G}8H@H29Kq zDRMSNDYgFLQNNM6T7n_6Yjs`6MnZUrs9qh>*tmhKUYwgvC-ewM)A&z^=(szXp&|RQ zo#}0j$<}+^7jzAr{B_oiWkr^t_N2_)%;9Aw{{slgO3hEf{#g9z`qpnzfe)5?=p@;_&jWjFZ>!@on!`DGeyXgFmgq@37KGCKf1$MBOEK;l_%_w0){ zL?06f>M8BB{+rw&&j}<4J}oQ9dzh10C~?Oae}K^4_sUf91i3q^e5O^sU6jdI5i}9M zXJy>|;D3SrOCm7M%5M(Q9r!C^xN!IO?5Xdl!2J4BheQydUrybUc>Gax{m;pvGzkMA zAQov<`gY$WeA1&GxgMdx3OLH_P;2$SGZaYZ?d#+FIh@sta?M}UeT|9mt06v!!$dTI4eBO=7prbJR z2sZNb{AKc=MU~P`wa32ZRb8ADu|QHIzH?6CmhiG(?pnHhTMuho8Ow2CI?V z-m-Kdiz|3J)<{kDjE+-zUHg`oHnYU#>(yw`IiJ_5-a&b$^*`paE+B;$hpRlfmG7Kx z+O)#`Na-&f$vvuNj4g}oh^zncuiXVmm%*3FgAA~Ua@+{Hi_f>HBZ{3?3d6M$2w`PW zY9zm+g;E0=`}+PxAwW1%s{0&LD?B2o5IKx@?7!{!@3x|aNW9!`Gnf$~5PbwW`m5O3p zeIiMSayF}cN)n2s!d4_j&ZikB?UR+$Lg?U--{0Y% zpTBM%^WbqG-tX&ry{^~uw(YoKd5+*{(#4xnDl&|sQcqum#!7B*#O}p$DR<;`Ts=8; z*^1-$yCi15*AD#D=ySV9C}dl}OrCgbe_0lj&y(0^GUfFqsiM&B+g09$GYxoj5$Ymo zl#nb~{N8#dnfPM1jESF&EIR4Gye?DwDi(*mN81XW=dSrk-sCLcr{=QIVfRAix>U|S zU)Xx`4)Mr$n6UdDBj0}4;D1IIyUs(%>7y<_Q*LYmteSM6P`cF{Ti?(;DT;PX|ci9`4bIf@g_2-TLe^)_Zi?@})Sj z69(eB_~HJ0o~Mgr)a|PV6e(8ue`|i0teas#SW+;emP@EnuM_?T?}&C{%+*9!FY~Lv_WOZl#}G$^Z}!q& zfRMx8F#_mwSFZ#PyU28n2c7xX~c`x0f8rKjwd ze=MmNJ4po$D7bEgpPw;QT)4FpdEDUCr9ZPaUTCfytxKP_A^5)O0sV08lbeJEqN;xU z?BjGT>c^-;+NdI&*-oJ5Gw6xmuT~^PjT5^w+)SD+6Q|un@K}A2Q6Q%{qy_^ zq17 zTmD%2hB0AU|GSzP?Kqd67&<+pqK;r`W>q?OeP3w(Q7y8d#L6<68k}#flv;An(&B_z z1Y`v?K{fkLE*ad$CuvINnKxTJJWy0$Fu>_5!rQzfW-s_m+4$|Pi&`BZAb%n%DEp+S zJ9bBex6Z(egEuGbytixvu6BN`n%Hn8{_<_moGt2Y4^E!9pf21}JH5F<2SP+0Ab&aH zoH;k!Si5iMuql$L81$?m&+x2Qg&ZTY7@MP^Rahz%!Twgw{FSS<`SHHhfoO+w#Gm9RS1;YS(7xb0gP&lzoO5uy zbov}?)eY&X0FIstyyO5pdD z66XHRYb|04gWoG@1!QxD35ANFSTUXT3LQ?G^wt?i-X^vo8`YnAw#D~VB(^>|Y4f`w zFuc&g3mnXg^4q<&Qej!bg8hef9T|M`?W~`M zx3uK$^$Y3C^r6=~-?9+?93YPPD+mwRQCm!U;>58e??_uWPk{w&DI{Z|cFaFmx|8Zd zs8KlUP;ADK3vlKj;wva8dA9z!BggtZYSd_BG~om#n(x=AK>rb0CfEX zzmF;={_npk_x0&ElDdy#u?Cf^=JhAMdc@8hh+Bltyd_>T`gH%k#Rxl~^B00?OO`^wV?L?OV6OY z%zOa`V8OEC{me?8hZ8Ret-!56n}VM`E2+V~{>CD3dD6U}_e5C#fW^uqVX<15ijkl8 z_qn;$Z3ieyfwIradS}}!ue^zI8ql9?Bp}re+ja;(!FKIrUxwYsPG2ms&_%oG4K?^- zREb#|)8X<+_QKwpW%d8@)E9U?|(_5qX{C?w@N`lAp+=XEJjD z32XCYr78d{%*r*Ugzy7L%ms?E!VLL{znp?3c|S6!y_^#B6mGw9h-S&7D&VFpmyZWk z+FdV@E5;i8!KB7N^-A>qEMIRrC;hV{`x02Fq*n^ouXH$isPg*@Le=cKD z%D;<+)}AE|+v@;BBiPA>s9%31y!t_&2Cyz3e5f+j>(#_+?>wkMspiG-SKSBaGzy0o zWdWE}yVMx>IoiG&jBj@bay<~VNtDGOB2G+L8_g8fk5bZdm(!iafejFb z1c8T@G(*2^EB(ogzZTy8vlUH4j=N*Wg}g|i!BfNDu}X23$<3uj$q4oMnfnYmAP75_ zZyt!6H1kLG0a8OIitacXvUWk*=~hR9@L++zA7*oaRi7OnVz$*?V%*nl*>Ry=^jZge z#GJre`0TTo9X#d5x?7hz`#>GCm-{EIB$_P0^0BsmShhOrQL!xj@hfqPxn#Zy{Y`t7~R6uiO0 zmdOvr^eizz9mQ@)Z$--%PMgyjDN~%W2ijYnPF9(1xz+jiO%&B*6Ny=nOxh9T3GLc17*|LURs zu7H7Ay%{-C8zKCutDK@tT#c*A}n@ZIhiB&D}@w%bCqA^KH9`;7{dn99*bCGNi+ZiOA6 zU>-hw#MvT{v7(P#!D7JN)`3K8vQ9ex*NyHZ1iyYN$;gxt^`p9@Ne<41#`BUb9#PGc zM))VQ$Acz}N-18z*wKXv2imN58M21uSo=AheJ>_{K4H1_H1(^ZoD!ztD67?4e{$ik zOucSfdDH6BE-lM%0|^RpXwlcV$6n7sXOye7|Io1(Ri|Ga8-NaQU3XIA4bWU zNnxNhGh*;GQNG?cJF` z+RV{5A*KI8K!Ac_oNekBE4gXD*Pqe@+KBM10Z+<8wMBf4HtdcDI`hK(@JLc731=bW zmJO|bm5zC4ioMpgOQT+;A0B?K(cnF!J|e=p{D4OF)ecS`MUaBDTS`P&8x_)ec*jjP zw)r%onux{FwRWrKlXofxOqJQ@+d~bG)z)Zdo;G|U?^mI4p`rrZDUOG92C3(ewNJ=y zB=N!+Di5~b=T0vScI^FJ_L6tEWqm`R@8@eWvI=j&X)d)+|}`ImN(O?I=#CqtsMIonC7&{#jEIRBDK5_e>?SqK2)m+nadR zNt+pR?;xYz&-Y1^Jca!R=vOJoCipXuK6+Z|P=n+0H&X2lizv=QN-!W9;rD6sYL#a# zNWwJ{woXDjIcZ(sorRI7L_$u~?T;OXvypD5OGB4MEegeDJ=_Ccas4L2)8>I5gqy95 z4978UFj8u7ZkySSfX}T}_5@U4`fzN5`BmX|h;tlYNx=oZaY={zcB*8Zc2P-xc3i#$ z2_v>r8nxxDMpR%|O>c~7)UeeyiU#oXLFcm$L^@X8qJQ>sDQ~{9uJRr?Z8#eU3%3A< zc6E_mmwhm!^tnW%5}m<<#xeqS*jQ`(k!Xxwd{!1C(fsPTR&Ed#%_|5ct&7K6}s z5op5RIiSU4Flrr~e)6sV=_6>m%SAzG^4ZKbX0_50o~=8g;$VsPCr8qQD?e)12y% z6s4G)z@40^KR<3Ts98_yzuvDxwXAun`k($Oo!%<j=wUj8b1Uf3wN{Gw~X)TYx3-ms-vjHAL#2OHtF36t9Xc)y3e%;uVh z{f;&k)F%|q;S;JOcNjz)F(pV<(X9r(14(90t5)p2uT!_HJysuUQubX02f;8)acTvt zz}*Ym1!Nb?Y94pMNh>YIxUWn#DW>o#h+CQ`s<}1kr{gv~vIe!2a`Qtm;W?OI>iV>` zv&qf01+Ukq^kX1I+yH}6EeCr%hOy8+K_7-_Z8hk#IDq&y1L6hM=<9@=<1H8+Wd?MZ zV%|b=yQrxh%hfx75`cICmM12PmOt=5fZH>j^QbAV7l7APxCCxq(wVAfT&R$#oo2-z z)M%?(f>jUb@&^4VAeNrNA=dsA%y84~_YwaMM8g1s-famToX5cY)C->C7aC5Mv@ctF z25;;yZFd+mH?Rhc3C!MZ@wKlWrm#P9f90Z?6ARPdi!0YVibwu)_Is-GJ7?)Y?R@n= zHgo0W7CCdDz4{#Mb^H0h-^LYWSLMa8&GOMVUj7jSYsRcuLENtV@O{`d&*2?CYsk4A zOSveU|3!%nXh-jH%d^*rALvby#>nE?K!cm#p*;2nke?>`M}V?~}5* z?4zyupv2dN4V}Y&42mqxJ$I<%w#I@IBjx2TVQ&mpoxG!G}e=?XfK@|6b zF_-NwOep)YI=pOd_gCldw|Io+6GeA%Q>bfsKsqbiZ)Z)m*f{!dWa9{G z5w0M)4<`H?`dsapxv;E?x94(mNQ3?ZKa-@N1!w)JfK9iW4Gut#Tfv7HTPoaCl;9$F zEzn^xzX8s~;|CwZuyz0;?oh&P#PX0l)Y?7gFbE>$DUF(Kt@wE&l>TD7jug#HTBKc{wCkm{0;vqj3`&toMII{q^lu2SgfR z@N17hYKQ z@LVyZB&A8hMKSF%e_}1?X=j6$*TLw1>sb|ZAglD(?zoHuET=43C$G$ulcyvROhKl1 zEI#t2^FVyHoE+0I$By)ARir07e7AnT#)6qQilh*uKOWo{Ej2R~JyTZ9-|U^=awpx71+*h};n*6s1sLMqI!*?gZ~sb) zQti;B&I<|54~i}hW_vk|W^Jr~50J*(;$(?Jw?F3aVoa)A_4KEOq2Mh5APX|N(_ti# z6oqsnJT2$`0TmXT{R?C|Cz~CTx*4{(gF|aV1G{f;baeCE5O5Lv_F@ANMygnF|05eX z%Zh!iHEy0Bz;=7++sk0J+OLeSAnHj83+hpBvG-fFKGA>283*AJGL$146mVtxZwwb$ zWk?WJ5%p59=}R{@IB<5lXl<%qG|@7i7=9ZL;NTqKS->Cf3|!5C zuJ^MJB94q(W}vC|wa$W_*rM}WPhWqQ^)nGphuN5er7$MbxEg>51-wWThmgVC0!S@2 z*Uf|lV^54tS=SJ)Gs>wfQhK3OS_Dp<4Z`v}MyCww5)9{h`D;I%Cecz~lQQ@cMb?378PV8>mVrmPmW@ zIZ1g*K{~6gT6gK?2AnbYbkm_~2I&hID`1+K~ZrcjDw>pcm{_BbHs}080MK=&ngq7rm(*kBv zl-t+EB+)U0eq1aFlxf$t`X`&!>;4mDP@Ni8-CVU)gPP8)137KOVl6z}j(ST6bZC8& zN^7zmaqo3NbTdQlQbQHxfSX??rNkZYv0JO^HEC$(6rgEd)(3R3g900YNiV_rD{Jq` zlK0hg%vXi}rjbdO_|isdVA$4zqL-32qya4-kj@mv9}QL=ceO5=2s-@ zv$Y>i{;Ln%1QB4)3ALx)cKl;wm$#mOeRyx(wS)xsl87Bw`!6sS9neD_!pbiAZvMsUv(Lkogu@vov{&GnL#CX=`m0Pz7WerYxL3E7kb; zFx6*1)t*#YD|>`PPs(PV7y}q$4nH(oyIp7L_q};&Wafhw&}&)>MTHpx#f%8<~#eaOm){EY(=z zCANhfwc`^!KC!v|ZoTT_1hv?%bbO>g`AT9ZH8GTbtY6B1H)8#Zolr7ZLalIBYCEVO zj{?mL*ABOQ$JqiK#ST&$E3K&IPnX*dHIU-eMNY=x5WWDF%wO6qZTWK?VOM$NSW@;w zj}V)?4%-XnbKZT!l&+8+OAQl`- zd#>D%qYE-9Q-f0;9dK?(bfDV>q>FGTthocd{|GD2VXbT9WN>=m*rm}jYHy@|c;zty z?;<}WDMQyjXyx-^uAT}4FtXA$JzK2kZG#UJn7ifLxh}w91CLo6Mw1-DDidWsLF$0S z|H?~n4n^IqNp#l>a60rBI}}~gL>F*db89brNeskm@T8*}M zKyq)*q1T7@tMFa3w;-Y*KcnN-t7r`#eaAOD3fd0^10#*on?V5E3%DiE0L#PdlrYfT z?bh!WHPsng`=hoI??@8MDma@R7!E+0kY3ZkXMw=O&M;&(Bm!(8wXV5puQ*2QyIzT7 zj6fj@`L_T1Ei7M6J_|q)0S5rr24tALn8-M;<1SV6Je3t6ZvQ3DXp)ig{E&-p{UuJm z^&vVIsU;#loZ8j;cDBj=+R-BbUSpf|0+(eaSTEA*>0|M*;~uK+7xwZ9J4i3G*g)=fR#MOy@HXSc0(eUS#ES5cM4 z8B`ZuAC^1T$1m7i7k;#j1p%n(4NrQuA)=$yVt!bkobz4ib44dqn)@F%Vt11K8rwv4 zb&sql+8Wq`lTzK=WGL%~D&q98@IH^6dsRUdTVIs8t+mr(Q=iQ!zMRE_pME%>sZ6F~ z73DhkIGN>`s)#y9#15kmMe+%+eX@Jq^;pgAmj)FZvWYEo`jwCCh7KnKbmG1UgbV|i zP1%_u*+Dx3tP*1#X;zknRG>8L@62(J=uz#R3w73RKqxvd-{AC(xaH^CsMufnHhTI; zg~13A4p+0f(~JPzKEH5~uD(Nc?Fiypnp$kE@c2^+h}*^@SlauWG%4guJ5=MOS%Svk zXHE|vJqI0E+?tvHwLP#=aopzOiqP_SJF+ZWaQ)j=NbD^PJzG?_B!0>|x3KK6y|ilj zgfHxw&WFvE9%*3RFQi?_v7cU;@_y{CMIkV^&xiEq()a<|pXmDJE{DJ;HCwA1-`=)k z1*dvkq&5730>;+V_O2mBdhZ-kt zg&(bqGNK$(GdLi@zRyov_RJiV0>ozerXpr z_P>9uQ>TMiMAE8Y^vvx7%zxMu3B9OANl}|@AOzm`7j&2Hu|7}=3aitnilY~u1h$S| z#KePktENuB`gUP_4U5mw@%)P}*L%|-q)+g6eTGMJfie*!ivc)?@3ErLM{b+VtwJQ) z*y58;U+*8*V#-74w|^;Bn8vBI*uHl7iwu%XI8YuXKd zCGMBrE}9#|TR84raVR3rz9}bCqdBGl@CN!xn3EP6@Q;GiXzVZB1xFVr%3L%}TC}5*x4r8R@Pl^KM|u$&-x*Je)AIjwp^w znwG{Y#@n(hYW2GIHX&fNB0cuFt4Bv2A-cN^U4+WV-i{ca+F*?92Ebu3;gl(Yf_cnzUP8 zr`sNaR<(n8;z6DKR$*F8s(+)L9BQE&yY@_5d&`8jR5D0Xy~oB>Epw%!e|;S;$m*Ir z>*q%A$>y!Luj<{|I;s&pMP}*iZMb&4&n5CN%e9^5br~TY8>(XMvhZl z;*o2a+$AOy5D62XaY^G32!CiA8akMW$*0hsf-`-%#|z>bz82L>bo$q*8T>M1QMfvb zEbE{1!Tt}(N|&8m(Qw0dJmA%0trNW40oc)B{IyQ5kjrmsem{a5{|WZto}Zwob)k4c z5W{rdw}zdfw`STLn49tg%I>4UQ?aocgrshskp~6bo<@=ZzP82C&TAs_gwNme?7w4Y zv0Zr2UFxYhPG4uewLwuQ4x;3NW**JbA9-J78*KnBF7iaf;+*$CeC!+`R+Yp%$VL=f z_~g(9^l|Vsk0E$zz&XAb=RFhRr&lKysHxben`OD{*!EMOQ^EEe+|x~b(n;;tB?0|n ztD6r$Zn?bXon2HS3m{~4r2W?`qL_x-*6Ct-|5%63%0JtB+364$-87C(4(< z#Z9kVsDNe-bX%?rndok_h^0Qg?1zb!oTQ2RK+HtF*HtP$yrI^&z$zQQBUq3Kn;#Rm zleQb(8-e~!+h?mA#K{(o$YB9&U;}ng0%jkPkibp)s?Jl&mlx?Qh zWpf(>hW>1TGOqhdd-?*JjP?FBC45TKX6vEpvv)p(Y)L z5f~Lb<{GPgRMdNOn+4^Qqf||-|9FP?n>ZDX^OIj_t^pG<3vex|^~C${j-~nGwW}|@ zAK8YH06V~>&k;_?;&$t^VrlmgC2)HEd;Id$V0I4oOyHCulAms2MI_ghyfsJaPcDIX zD%(D7o+ITf@S>DknF!(LU?1txy;<-DiOr|_J5f$xXhcinXrQ!4lz&#h0id@-#QeHp zLjq~q(YB`WHMgh#$p-WNk6*bo?fC)(qfIUt%qn4EVRoyI z5GmR|QfPVc<+m0P|EbEE&wjkOG_5tltjdEW_p4CL2(;qQIJlie1bfwRjI!PAC(__D ztT&gqzNaA);jY_1UnFQ7A35`%Zb|jznJK)2x*PgN*|QhFy;PgThgCWYHCo*l<;hX} zxZ%pnWgnCRqM%&qy_G*8A`4|g#c)ONVDCimf$B~ce~xz7qB$UeYecie(CixMas2Yp zDwYtq#T{_D>mkEKe4|?RntJc+ymoIE+#UonAV#_tq*%1L@6n(4IaPfdAnV`S90YXH zt>gjBBo4ekVtMPSew29O^aAV=7hMf8<_!iXYD=>Em&U8IfiJKy9zr}AV+(3j>g8(T!S1N@ z?wkrK$N|w17#gLJ*!Yo^;M3d@Ziq`DPbF^#`7B|qh^Ey(zhCGZMIgXnm>@k4YP4AS z>6&tJ26pXB`dns9R-3kQFEZLMU~+qWONm-Ss}>ncLQ78`XWV+7=0W)S`rFzt@ZI>ot--CG-(!Q#e^j7OET$XS+G$Ggl0<%QKvzquG* z*}n=_-)+fIcv5%UIGp2jB_#5?PU;;k)T;7^_v;pM1FQfr4r+67!cf&9^c9clLK4{8 z<%d>pyu8dm>M|%{n$~Et{C)D=AQUtFCn-M!4esW}VMSU9%uJS&g;ua3l_#*U%JnIg zVq_dxs%5g|6mJ64TEirljQ^Z~UrsY8ZRgb7r5&!A*c$NYL_;b~7KaJ5ZZa2jj%{xw zO5t})#dTN2ZDe{$-1LG?=M~)M7}`()8+VcCHsFc%7WLjB{W>@i*B5d4QgFj7NC$CYFm2UQjUCqX;;;Ts()fJ{X(NOK{J#;tA#EwsL7KPOF{k$tWiN zIwL0I{cQ5x!0duymRf&ES>VEY;Gv&Mm}`Hw&qFWgyPuR07I>hzVf!aWn;gq z_a6KL=~RDz_}?ceFIiE8QIW9i5x=HdO|FKlJJbCCh*ppp2lvb$s~OP-iTceOR%@A8 zU$-Clw3>Z9*Rhd%@q|SYG9x}>Kdb#Lt;bDJez+gE^>c%Ka`E!*`+TP{H?HIuEPg%z z+_OB7M{6^T;;}8-niZyk@8}idK9zoak?42tc89-5ZMLu8#ZTp`At&CU$sVg(f=d1` z5SLsXdy7f}ME}64Q`_^bc5Fl5kH>>19l4Y(o;plDu_8R)Ieazyt0`J@Mg zs5#tlVop;xHl>g~{40w2U#|gAfIXq*^wmRnBtJ2RD9&zoxY^nn)5=iDvX3o^xS4y= zoU;jC0#(I)FZS7xc+sV4ur+ZwFfZh!qf@F=V5@NI=*@FW+b6A`4!>*Vg3^BE5w;YZ zijCqGX$|tvLr4>|10$i(d-$4}_l{oz76G)P)~E9|HG?|q#;=$uvI$_)jrEr#mFG|T zRSv@~ZBJglGbpmI}`T4+Q{<84uO!WYneF~qjZEnAu zAe**6M4R*Z?l{$udrx#+J{68a-6rxS^bu2SEV1CJ4E!^oAiJI97is$huu`l?aisVn=weGlbF z-B#4`dhWziT-~nQ$OSZl^=V-vgm^`l<&E3KUZm;pO{?xRJ*90^hG8S`(tGI(abLTg zhf8RQYun^`HwLjDa(UOhH@$emcgDDln;T%Z`tzsl{C0=0Yk(YeSu)Ob@T1p9m6D;6 zJ;>n~UpG4kP-WSS;*=90@1KhbARm~70bI+NPm4=GQo`AJ7-He`w&5@pddb6gFK>0> zH-tnx5?(E9T&vrDzspyAc6ZlX%i96I-8p?U*__7d7ktxgDh1C@I67cnkRP?zw;1Vi zLi}JA>Ede&SrmdUvs4_%7d@`QaQc?5R=idD%>^W5+IP$>&IY{}YSxWDsn^!C65V~n zBK?Qlx*Y?3JE4@R);K2*E!zs= zITuMF#0x7Of8bX%vh^}PpOkI3pVM*0@KL?*JMXk=dFS3s<)6IsLkHS6( zH#YK8Xt7EU#n}<;ZuAj`L%lOSdj&HNw)&X(S19m&K_NFp zXxX`M#s7TlQqKBGyERwJp==$#u7VeXRHmk1bQ@9h|I^VQ6~`FAxWc1B=Knc9yV#0s z4-9mm=Cw%5!g!m!JVpukLG(sOWza_ksNnMnpObnO;ejO;z~)$dB5v71Ysgopy~nvl z_39#e>(hfK!PhT`PI}3I4ssKCB2xtKxBl~5YaU*EB~@GM?%P+%clAwuhBgZ}#Ddo> zH9nmYludYPw5wtN{&6RV)i2hEkZYD}LbY>*Hc`Kf6N}y86Nh$XEL60u5t>KZ7-im8 zzGE^G5^twA<(W|pj#atkV2Nc1_EDdERw&1NS7BnmKVsGc7TO>&G*iqLZtcZVQrvHs zzBH~pR0=ec)qw{0H#PROTQYJo4q90+FL?yyc&z?O znh6px66^bNNd8_iH0JE;)K*qYec$@8U820#<+fR>mg{~2{05s#N_$shQ(j!F@=5sA zJwi+rMpV|SkDr^SqY{u|W5w45YF)-xq6SXwC!mVUo=6ZhRLP`Jl2XmHl1=OJMl;}j zs~rbYt?i_?8w>7KTu|fSQAl>GJ%9!D63s09x|| zqvtKHfu_R8usjso@CO!R9PJUs`RW>n9VKND9r%cJ(+AFFTW&B+eI??u*NR*A#}!HO z?aBormv=f2pD0DDU11|#`}`AlHJ=C8NB%LM#t|N2^=LY)x>$jq4#dpEq)1%T7cher zk>~v^n>~T&S!|>CIbmeZMyS;|ToaA>lAw=hNI}XyZpuX;>zRSI)` zs!hxU>Mxl#hZVCjLPnSMDH}al>oMnFNzy&|BUG7fPfV}OAHiK*-g@HonkV=CFo&mYrlqa;pzWt_t~KZ2!L;HY zwXJKS&dKg&4NAxO(_p`+R;PJWeya{^f$Q%+ijE0q=bCsu&A~rElVN@8aA(y*NBL<` z<$|(^3+=;$7`HfYEyQi&0 zpSHf!b=LgUK6(89X;)(L2vZS9stwksp=R2QUg{5xyIWrF{3tt%4DDr)1?F!ib5%9b zFDe3M$mO|<^(lmV9M7qj>eRQbN?J~5_4md&-%5s+u$VDq`&(l_yH_6TJeI$S)zxe5 zw0I0+z-&k&7yn)&o68!*E+rxh#-FsLEAwfVS4v9b(=KWoOKsA{cB9X0%ttC6PX$|6 zL0pt%wc?b-iG%F6M*@$79?8zSAO7Zo0MBm=j=Rn5hv^)v&ag?K+!eSK0$sjaB~{br zxcS8jzpHw_(ao(&{T=TPMWo1vTw_^z{ zT^p&rJ>u|b_r1=;A86$-ZXk}=sN?`G%U2817A6<4rME;slW!9}|9NKCYA1w;VUnA{ zK*F7_s^eY*o0_GWES8HazPfkyPi1HJ5QbJ>UWX4oc3hEHl8g3abnFRxW+&(Ik0$uy zrG-dsWJBLfDzvoTI0bE4Cu?l7*khlz{v%}?#sGzfj>FO)y|PYF%u)5zODfF4)3E)Z z&KootWy8r3)Sa7-cJzvKthc)8y@<;%*1KLsP|G?H-XTthzEg|!cngY!d(o^>RYhI0 zcACEPdKU&Ry|UuyrVk?#Z71z>UtIdP$VwOUIHKrPQiLIYzCkSf;@~RjZ zXmU)-a%Cu82xO1TCF47Ls*^gRrvRD8zG<*Eiol%6wD~qxY@!0& z?x1C+@hB^Bjq?mWfOLCm_Mth4ORD_b zu*`+TRe_n;9MLnI9y;Lt>pv-GtHXI)a&D{EI!b#5$0MmF!*HHgK}+3h&QUm6erYKRi?sp7fOORWqZL*=W!H8?dfIx+<<+p) z_VClGBg>SG7_!%yyn`MA-P8wpVEIQyCxikzTDBsHd$ooSknF$a88tD2+kIy`^T^qT z_@t;M^&oAY$gTzR42RH&wlatqR+(XK74lv6s6&N3*AFu3$f&SF#kB+)A^K}ZM}9r% z&kj<~g{OS~tbWSX>}9B&{J)if?PpcN~ocFeqpGJi~&7O;J8i~D3 z@B0*-O%e$Hs1J@bIiYoAAw>+QOD`Du_+`{>G7`Af@R_?a&KA+yqHt?>P7tntW+B15 z&#Yl!X)($#pxq29H_!~ot8r#Gz@jV78H!51$l-%#(O5k^@QZO+oLc#2Pk;S~ykAq< zKNceUmTu5C6}J`{ueSIb`|mbnCNlfS^zTi}De+2>>t`@1hpZMybeRt%a8S?DeLWyk zF{1eDuo+J#6~1-#4o!zhn0+_$_++Op>c;QqzTJ{G6TeP}!*RK2$BEF#UpeKwa8e;s zt!{h0H2uYyvK zlo+UkA81e^#puId-tw;;PPwkrc`+fb!RH>Ufc!zvy2DNJ@rjw#SHo&sXIu#w{ASK3q=#e8}yPhI>H7wPyA;Ct6yw0{yWc&W|L{*`6X zcj8__h!39E(kY*I<#D$C^R}P989Fa-;iWF>XrRt(WVDUt1!uP~<}n{^g>zfFK&h&M zdb0r(FCyKVfjsVmD8AJ%`ZV8gbWh*q{DC`KEm@NAVj+MMz}TV+L!PP#nLL9 zkGRjj#J44oAUeh8^ofVF78BSnw0N{}IAMmUiiDPbP|GuyJoL+dOIWUk|5Yx4KfOgW zqG}LY9Jd(c1CYnsiB{up3H|VUozAuMI;JV1eBx&A$Mt2t>9O3%yR|I5cUzgw8=QBa z0i`!v_>t1_5G|?{14%f=bgTj*H~h!>kL_`JvI_GMpo}Qz9PG-7ZQd{J5-+)CJS*Ygf`5;t$vUeaJUe@yfzJlyBPPo?Xy=^`(>^#0Us{`Oh@ve(dy;|Ehs zBm?b`SIf?>9de$beY;w@0v9k?-OP3 zqf1`~Uh27{V#J-eRIv@{8lmA9%ovi!h?F?JchP+Sn_>-tvQK$wqv)F+n&|V??={x$ z0|O||6m<0Zi<1p!X<8iWDYU28Mccj4VLOFu4lTJL;7@{@f|d8$!;CDKE?WT+0aqJW zOZdHB*TIT$U(Ola&UP~r1;TU8aS9T?OxWF;T*W?VLlqv zb=^+8vEhz(@YRu1$Igci2%FSAiEPlTPxh%5Q&;{q&e+rw71ivWolKsbbo?0cIFnD| z-&w5tG@RvAqV(g%xjG^Yo*GxJt;DlnzsNR0YdyM4I)nR;Q=**bR`_e8VC-+&-Ry zIX(u{(EwB=Yf(HzXXp4l8a3s)+TS`MOak6KI=p>T)+pgI_%dR6*-!*cfIkp#zNl#|s-{q{xqt_TWQ-LfMkkmm;|P~ew|1uWX0 z322=5#Bm|g8IpMQNV3xG&&tuQqr^@pqm953Wy7*bC>7NZw>t9AWcodPs7^)oT#8cO zb7GXcLUE>`K!ieq$2;NM+wrt052=H)d>On#Wz;}ve)0Z{zE`JJ|{ab5FJ%Ugk zdhXTrnadXx$bFR;V+lXrbA!8kS|=RPj?piSB5J)&-L+ktlNCuy$E2!zz68W2P!iG; zzeoj4u)#QF99jH;D8dK15z#*6%y3LA+m(^>An!VGg<#Cy8EcK@Z{|7&UW+X;zgD)k zj$L=kHr7jSRO4^fY$>LUFV=qeKG0A@-IC*8`@LI3JMC53ajj_2!n!+@cR@$ItTMl* zI(h9X!MxW+UtFwL-ba7;>9{L@WS)89d1kKs*ooXRw^hxpA)eUuG`|Veo1wQP?3{U^9cJ9j$%yGLk9gwY`tKSAQB&7t{KYv(vGua=QoB}I8|fzfuOd~lTT*%1HC z>0jz@T@U6RHD+YM-I(d1qj8)1O_S8l66>Zuej7~Lxx;-gpbFs>gt)<8p9y{Xu!ZEIEM8f#M+GNaOnD&rQ@N*AO`7+g?^IdQ zVJ#ivyT#B-)W8jn(}oj)XqYAVsVtb=Hj&irqjX`~;`;aBvkj=3lQI0*ExqwzJ@xGS z;)foW8$+#cyKKxMSOoS&gjOmqHA$biWDHWZb%zwV;wigo0Z?t33EDG1Xv zTtg1yA+^6=_+^YzEuxZ65ckrYPtG4>#htKy6#9~zKJPblHO zmx5cPNc98f0g2oZ6=zFZjZ(7BD1nNwyA!FT@q}E=ef6_f9A5&9`UAb4^WK*q*Q9n% zVUraDZKC`ys=hoN$~NqqRC=;i3R!0A=_#pD$vT)yQe=rD%a|hjzRuWY$Ww|Km6CNV zSxQKDgRze#`!cc(hQ>A+2E%OM?Rnqt_>S-J568ic>%Q*u`klY!ywg{Qp{I8s?wdQ! zh%_~|vk!HIM2d0=Q!YXnd_8bkGQJDDoQ6QJ!~Cn4^6G~20@>Gdg%X$Vtbh?&idDXw zVu_4y<)mgs4FY1Zgin_F)9>LX#h2wSuTS9T_P}K#WdV4m#O60X{J3E|$4Z{r-D|L@ z2u)TDcCh%9)h$Bh;rD1Ra!gY2SH7Cnn}X>T7vHJ((AS&N$jP3~{w<#DTl*h48*GygJ|2T% zcbi2i-h9Qr@y!Jo!>NTilN)rO%{=;vfny9%zI%%5{MMd@oB=6jJBL3~%K29h;}J`! z_%g4pSZVNpDsdQNt2zD?5w=*`&-eMFGy2VC^5yv)Et>F1N`$hLk#*b95G1cdsz`N9 z+90${92CC!UI6Fxos^|_KPo{n3<|PL7W^huB2@yc{NMZaT>m*hEZ#cZH_r1)kSAZ1 z{o}JQ^Ua`Yahzxz_GT*K+bcx%rZQ7fSo5T`W

V!2CBh>Xbwx#vGzR&>pggy5>m; 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
lI28%|hF9ycar^H8e{ZTW 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�wjyWVZWzhueiS3}@y9CgxvJ#sdQ6b>TE; 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 K=3TLR^8WzivOVAc literal 0 HcmV?d00001 diff --git a/packages/replay/metrics/test-apps/jank/styles.css b/packages/replay/metrics/test-apps/jank/styles.css new file mode 100644 index 000000000000..1f340f179d0f --- /dev/null +++ b/packages/replay/metrics/test-apps/jank/styles.css @@ -0,0 +1,59 @@ +/* Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing permissions + * and limitations under the License. */ + + * { + margin: 0; + padding: 0; +} + +body { + height: 100vh; + width: 100vw; +} + +.controls { + position: fixed; + top: 2vw; + left: 2vw; + z-index: 1; +} + +.controls button { + display: block; + font-size: 1em; + padding: 1em; + margin: 1em; + background-color: beige; + color: black; +} + +.subtract:disabled { + opacity: 0.2; +} + +.mover { + height: 3vw; + position: absolute; + z-index: 0; +} + +.border { + border: 1px solid black; +} + +@media (max-width: 600px) { + .controls button { + min-width: 20vw; + } +} From c019cc66bf053f89192b65a3db04e9723173b061 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Thu, 29 Dec 2022 21:21:40 +0100 Subject: [PATCH 16/55] fix JSON serialization --- packages/replay/metrics/src/perf/cpu.ts | 28 +++++++------------ packages/replay/metrics/src/perf/memory.ts | 12 ++++---- packages/replay/metrics/src/perf/sampler.ts | 20 +++++++++++-- packages/replay/metrics/src/results/result.ts | 14 ++++++++-- packages/replay/metrics/src/vitals/index.ts | 3 +- 5 files changed, 46 insertions(+), 31 deletions(-) diff --git a/packages/replay/metrics/src/perf/cpu.ts b/packages/replay/metrics/src/perf/cpu.ts index 760fc0990aac..152d488a592b 100644 --- a/packages/replay/metrics/src/perf/cpu.ts +++ b/packages/replay/metrics/src/perf/cpu.ts @@ -1,24 +1,16 @@ import * as puppeteer from 'puppeteer'; -import { PerfMetricsSampler } from './sampler'; +import { PerfMetricsSampler, TimeBasedMap } from './sampler.js'; -export { CpuUsageSampler, CpuUsage, CpuSnapshot } - -class CpuSnapshot { - constructor(public timestamp: number, public usage: number) { } - - public static fromJSON(data: Partial): CpuSnapshot { - return new CpuSnapshot(data.timestamp || NaN, data.usage || NaN); - } -} +export { CpuUsageSampler, CpuUsage } class CpuUsage { - constructor(public snapshots: CpuSnapshot[], public average: number) { }; + constructor(public snapshots: TimeBasedMap, public average: number) { }; public static fromJSON(data: Partial): CpuUsage { return new CpuUsage( - (data.snapshots || []).map(CpuSnapshot.fromJSON), - data.average || NaN, + TimeBasedMap.fromJSON(data.snapshots || []), + data.average as number, ); } } @@ -28,8 +20,8 @@ class MetricsDataPoint { } class CpuUsageSampler { - public snapshots: CpuSnapshot[] = []; - public average: number = 0; + private _snapshots = new TimeBasedMap(); + private _average: number = 0; private _initial?: MetricsDataPoint = undefined; private _startTime!: number; private _lastTimestamp!: number; @@ -40,7 +32,7 @@ class CpuUsageSampler { } public getData(): CpuUsage { - return new CpuUsage(this.snapshots, this.average); + return new CpuUsage(this._snapshots, this._average); } private async _collect(metrics: puppeteer.Metrics): Promise { @@ -52,8 +44,8 @@ class CpuUsageSampler { const frameDuration = data.timestamp - this._lastTimestamp; let usage = frameDuration == 0 ? 0 : (data.activeTime - this._cumulativeActiveTime) / frameDuration; - this.snapshots.push(new CpuSnapshot(data.timestamp, usage)); - this.average = data.activeTime / (data.timestamp - this._startTime); + this._snapshots.set(data.timestamp, usage); + this._average = data.activeTime / (data.timestamp - this._startTime); } this._lastTimestamp = data.timestamp; this._cumulativeActiveTime = data.activeTime; diff --git a/packages/replay/metrics/src/perf/memory.ts b/packages/replay/metrics/src/perf/memory.ts index 36baf8ae1414..6c6e907c5e75 100644 --- a/packages/replay/metrics/src/perf/memory.ts +++ b/packages/replay/metrics/src/perf/memory.ts @@ -1,29 +1,29 @@ import * as puppeteer from 'puppeteer'; -import { PerfMetricsSampler } from './sampler'; +import { PerfMetricsSampler, TimeBasedMap } from './sampler.js'; export { JsHeapUsageSampler, JsHeapUsage } class JsHeapUsage { - public constructor(public snapshots: number[]) { } + public constructor(public snapshots: TimeBasedMap) { } public static fromJSON(data: Partial): JsHeapUsage { - return new JsHeapUsage(data.snapshots || []); + return new JsHeapUsage(TimeBasedMap.fromJSON(data.snapshots || [])); } } class JsHeapUsageSampler { - public snapshots: number[] = []; + private _snapshots = new TimeBasedMap(); public constructor(sampler: PerfMetricsSampler) { sampler.subscribe(this._collect.bind(this)); } public getData(): JsHeapUsage { - return new JsHeapUsage(this.snapshots); + return new JsHeapUsage(this._snapshots); } private async _collect(metrics: puppeteer.Metrics): Promise { - this.snapshots.push(metrics.JSHeapUsedSize!); + this._snapshots.set(metrics.Timestamp!, metrics.JSHeapUsedSize!); } } diff --git a/packages/replay/metrics/src/perf/sampler.ts b/packages/replay/metrics/src/perf/sampler.ts index 88d2d886b8b2..80ce931045b2 100644 --- a/packages/replay/metrics/src/perf/sampler.ts +++ b/packages/replay/metrics/src/perf/sampler.ts @@ -1,10 +1,24 @@ import * as puppeteer from 'puppeteer'; -export { PerfMetricsSampler } +export type PerfMetricsConsumer = (metrics: puppeteer.Metrics) => Promise; +export type TimestampSeconds = number; -type PerfMetricsConsumer = (metrics: puppeteer.Metrics) => Promise; +export class TimeBasedMap extends Map { + public toJSON(): any { + return Object.fromEntries(this.entries()); + } + + public static fromJSON(entries: Object): TimeBasedMap { + const result = new TimeBasedMap(); + for (const key in entries) { + const value = entries[key as keyof Object]; + result.set(parseFloat(key), value as T); + } + return result; + } +} -class PerfMetricsSampler { +export class PerfMetricsSampler { private _consumers: PerfMetricsConsumer[] = []; private _timer!: NodeJS.Timer; diff --git a/packages/replay/metrics/src/results/result.ts b/packages/replay/metrics/src/results/result.ts index 6643f48c6731..eac564dc882b 100644 --- a/packages/replay/metrics/src/results/result.ts +++ b/packages/replay/metrics/src/results/result.ts @@ -15,16 +15,26 @@ export class Result { if (!fs.existsSync(dir)) { fs.mkdirSync(dir); } - const json = JSON.stringify(this); + const json = this.serialize(); fs.writeFileSync(filePath, json); } + serialize(): string { + return JSON.stringify(this, (_: any, value: any): any => { + if (typeof value != 'undefined' && typeof value.toJSON == 'function') { + return value.toJSON(); + } else { + return value; + } + }, 2); + } + public static readFromFile(filePath: string): Result { const json = fs.readFileSync(filePath, { encoding: 'utf-8' }); const data = JSON.parse(json); return new Result( data.name || '', - data.cpuThrottling || NaN, + data.cpuThrottling as number, data.networkConditions || '', (data.aResults || []).map(Metrics.fromJSON), (data.bResults || []).map(Metrics.fromJSON), diff --git a/packages/replay/metrics/src/vitals/index.ts b/packages/replay/metrics/src/vitals/index.ts index 892a248266ad..68e08ba8877a 100644 --- a/packages/replay/metrics/src/vitals/index.ts +++ b/packages/replay/metrics/src/vitals/index.ts @@ -6,12 +6,11 @@ import { LCP } from './lcp.js'; export { WebVitals, WebVitalsCollector }; - class WebVitals { constructor(public lcp: number, public cls: number, public fid: number) { } public static fromJSON(data: Partial): WebVitals { - return new WebVitals(data.lcp || NaN, data.cls || NaN, data.fid || NaN); + return new WebVitals(data.lcp as number, data.cls as number, data.fid as number); } } From 5658c6ccbd9c1a45199a3e94c3ad24bd62b92714 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Thu, 29 Dec 2022 21:23:02 +0100 Subject: [PATCH 17/55] yank-test scenario --- packages/replay/metrics/configs/dev/collect.ts | 6 +++--- packages/replay/metrics/src/scenarios.ts | 13 +++++++++++++ packages/replay/metrics/test-apps/jank/app.js | 4 ++-- packages/replay/metrics/test-apps/jank/index.html | 2 +- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/replay/metrics/configs/dev/collect.ts b/packages/replay/metrics/configs/dev/collect.ts index b9633e7b174a..2bdd4592ed61 100644 --- a/packages/replay/metrics/configs/dev/collect.ts +++ b/packages/replay/metrics/configs/dev/collect.ts @@ -1,12 +1,12 @@ import { Metrics, MetricsCollector } from '../../src/collector.js'; -import { LoadPageScenario } from '../../src/scenarios.js'; +import { JankTestScenario, LoadPageScenario } from '../../src/scenarios.js'; import { latestResultFile } from './env.js'; const collector = new MetricsCollector(); const result = await collector.execute({ name: 'dummy', - a: new LoadPageScenario('https://developers.google.com/web/'), - b: new LoadPageScenario('https://developers.google.com/'), + a: new JankTestScenario(), + b: new LoadPageScenario('https://developers.google.com/web/'), runs: 1, tries: 1, async test(_aResults: Metrics[], _bResults: Metrics[]) { diff --git a/packages/replay/metrics/src/scenarios.ts b/packages/replay/metrics/src/scenarios.ts index 9ef7c4dced97..289a28a9057d 100644 --- a/packages/replay/metrics/src/scenarios.ts +++ b/packages/replay/metrics/src/scenarios.ts @@ -1,5 +1,8 @@ +import path from 'path'; import * as puppeteer from 'puppeteer'; +import * as fs from 'fs'; import { Metrics } from './collector'; +import assert from 'assert'; // A testing scenario we want to collect metrics for. export interface Scenario { @@ -27,3 +30,13 @@ export class LoadPageScenario implements Scenario { await page.goto(this.url, { waitUntil: 'load', timeout: 60000 }); } } + +// Loads test-apps/jank/ as a page source & waits for a short time before quitting. +export class JankTestScenario implements Scenario { + public async run(_: puppeteer.Browser, page: puppeteer.Page): Promise { + const url = path.resolve('./test-apps/jank/index.html'); + assert(fs.existsSync(url)); + await page.goto(url, { waitUntil: 'load', timeout: 60000 }); + await new Promise(resolve => setTimeout(resolve, 5000)); + } +} diff --git a/packages/replay/metrics/test-apps/jank/app.js b/packages/replay/metrics/test-apps/jank/app.js index ab961a366315..fd02c57cfa0e 100644 --- a/packages/replay/metrics/test-apps/jank/app.js +++ b/packages/replay/metrics/test-apps/jank/app.js @@ -25,11 +25,11 @@ incrementor = 10, distance = 3, frame, - minimum = 10, + minimum = 100, subtract = document.querySelector('.subtract'), add = document.querySelector('.add'); - app.optimize = false; + app.optimize = true; app.count = minimum; app.enableApp = true; diff --git a/packages/replay/metrics/test-apps/jank/index.html b/packages/replay/metrics/test-apps/jank/index.html index 5c8449143c1b..7cc6426010de 100644 --- a/packages/replay/metrics/test-apps/jank/index.html +++ b/packages/replay/metrics/test-apps/jank/index.html @@ -33,7 +33,7 @@ - + From 64f446fed5a7b8ecc3b59eeb20669fc336de1501 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Fri, 30 Dec 2022 13:26:56 +0100 Subject: [PATCH 18/55] collect jank test-app metrics with sentry included --- .../replay/metrics/configs/dev/collect.ts | 6 +- packages/replay/metrics/package.json | 1 + packages/replay/metrics/src/scenarios.ts | 4 +- packages/replay/metrics/test-apps/jank/app.js | 4 +- .../replay/metrics/test-apps/jank/index.html | 2 +- .../metrics/test-apps/jank/with-sentry.html | 56 +++++++++++++++++++ 6 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 packages/replay/metrics/test-apps/jank/with-sentry.html diff --git a/packages/replay/metrics/configs/dev/collect.ts b/packages/replay/metrics/configs/dev/collect.ts index 2bdd4592ed61..bec5af33ec8f 100644 --- a/packages/replay/metrics/configs/dev/collect.ts +++ b/packages/replay/metrics/configs/dev/collect.ts @@ -1,12 +1,12 @@ import { Metrics, MetricsCollector } from '../../src/collector.js'; -import { JankTestScenario, LoadPageScenario } from '../../src/scenarios.js'; +import { JankTestScenario } from '../../src/scenarios.js'; import { latestResultFile } from './env.js'; const collector = new MetricsCollector(); const result = await collector.execute({ name: 'dummy', - a: new JankTestScenario(), - b: new LoadPageScenario('https://developers.google.com/web/'), + a: new JankTestScenario(false), + b: new JankTestScenario(true), runs: 1, tries: 1, async test(_aResults: Metrics[], _bResults: Metrics[]) { diff --git a/packages/replay/metrics/package.json b/packages/replay/metrics/package.json index bc216b8ee00f..65e57f10da7d 100644 --- a/packages/replay/metrics/package.json +++ b/packages/replay/metrics/package.json @@ -7,6 +7,7 @@ "type": "module", "scripts": { "build": "tsc", + "deps": "yarn --cwd ../ build:bundle && yarn --cwd ../../tracing/ build:bundle", "dev:collect": "ts-node-esm ./configs/dev/collect.ts", "dev:process": "ts-node-esm ./configs/dev/process.ts" }, diff --git a/packages/replay/metrics/src/scenarios.ts b/packages/replay/metrics/src/scenarios.ts index 289a28a9057d..a87c5acebef3 100644 --- a/packages/replay/metrics/src/scenarios.ts +++ b/packages/replay/metrics/src/scenarios.ts @@ -33,8 +33,10 @@ export class LoadPageScenario implements Scenario { // Loads test-apps/jank/ as a page source & waits for a short time before quitting. export class JankTestScenario implements Scenario { + public constructor(private withSentry: boolean) { } + public async run(_: puppeteer.Browser, page: puppeteer.Page): Promise { - const url = path.resolve('./test-apps/jank/index.html'); + const url = path.resolve('./test-apps/jank/' + (this.withSentry ? 'with-sentry' : 'index') + '.html'); assert(fs.existsSync(url)); await page.goto(url, { waitUntil: 'load', timeout: 60000 }); await new Promise(resolve => setTimeout(resolve, 5000)); diff --git a/packages/replay/metrics/test-apps/jank/app.js b/packages/replay/metrics/test-apps/jank/app.js index fd02c57cfa0e..23660eecfd9b 100644 --- a/packages/replay/metrics/test-apps/jank/app.js +++ b/packages/replay/metrics/test-apps/jank/app.js @@ -12,7 +12,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -(function(window) { +document.addEventListener("DOMContentLoaded", function() { 'use strict'; var app = {}, @@ -168,4 +168,4 @@ window.app = app; frame = window.requestAnimationFrame(app.update); -})(window); +}); diff --git a/packages/replay/metrics/test-apps/jank/index.html b/packages/replay/metrics/test-apps/jank/index.html index 7cc6426010de..bcdb2ee1acb9 100644 --- a/packages/replay/metrics/test-apps/jank/index.html +++ b/packages/replay/metrics/test-apps/jank/index.html @@ -24,7 +24,7 @@ Janky Animation - + diff --git a/packages/replay/metrics/test-apps/jank/with-sentry.html b/packages/replay/metrics/test-apps/jank/with-sentry.html new file mode 100644 index 000000000000..7331eacfdd7f --- /dev/null +++ b/packages/replay/metrics/test-apps/jank/with-sentry.html @@ -0,0 +1,56 @@ + + + + + + + + + + Janky Animation + + + + + + + + + + + +

+ + + + + + + +
+ + + From 601d67af383208bfeabead72ea461ea53bbbd4df Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Fri, 30 Dec 2022 17:38:58 +0100 Subject: [PATCH 19/55] feat: analyze collected metrics --- .../replay/metrics/configs/dev/process.ts | 18 ++- packages/replay/metrics/package.json | 2 + .../replay/metrics/src/results/analyzer.ts | 104 ++++++++++++++++++ .../metrics/src/results/metrics-stats.ts | 41 +++++++ .../replay/metrics/src/results/results-set.ts | 30 ++++- packages/replay/metrics/src/{ => util}/git.ts | 4 +- packages/replay/metrics/yarn.lock | 10 ++ 7 files changed, 200 insertions(+), 9 deletions(-) create mode 100644 packages/replay/metrics/src/results/analyzer.ts create mode 100644 packages/replay/metrics/src/results/metrics-stats.ts rename packages/replay/metrics/src/{ => util}/git.ts (81%) diff --git a/packages/replay/metrics/configs/dev/process.ts b/packages/replay/metrics/configs/dev/process.ts index a5d8a684bedb..5a4aaca0dbf6 100644 --- a/packages/replay/metrics/configs/dev/process.ts +++ b/packages/replay/metrics/configs/dev/process.ts @@ -1,10 +1,22 @@ +import { AnalyzerItemMetric, ResultsAnalyzer } from '../../src/results/analyzer.js'; import { Result } from '../../src/results/result.js'; import { ResultsSet } from '../../src/results/results-set.js'; import { latestResultFile, outDir } from './env.js'; const resultsSet = new ResultsSet(outDir); - const latestResult = Result.readFromFile(latestResultFile); -console.log(latestResult); -await resultsSet.add(latestResultFile); +const analysis = ResultsAnalyzer.analyze(latestResult, resultsSet); + +const table: { [k: string]: any } = {}; +for (const item of analysis) { + const printable: { [k: string]: any } = {}; + printable.value = item.value.asString(); + if (item.other != undefined) { + printable.baseline = item.other.asString(); + } + table[AnalyzerItemMetric[item.metric]] = printable; +} +console.table(table); + +await resultsSet.add(latestResultFile, true); diff --git a/packages/replay/metrics/package.json b/packages/replay/metrics/package.json index 65e57f10da7d..15c22c39ed70 100644 --- a/packages/replay/metrics/package.json +++ b/packages/replay/metrics/package.json @@ -13,8 +13,10 @@ }, "dependencies": { "@types/node": "^18.11.17", + "filesize": "^10.0.6", "puppeteer": "^19.4.1", "simple-git": "^3.15.1", + "simple-statistics": "^7.8.0", "typescript": "^4.9.4" }, "devDependencies": { diff --git a/packages/replay/metrics/src/results/analyzer.ts b/packages/replay/metrics/src/results/analyzer.ts new file mode 100644 index 000000000000..d1147627fea5 --- /dev/null +++ b/packages/replay/metrics/src/results/analyzer.ts @@ -0,0 +1,104 @@ +import { GitHash } from '../util/git.js'; +import { Result } from './result.js'; +import { ResultsSet } from './results-set.js'; +import { MetricsStats } from './metrics-stats.js'; +import { filesize } from "filesize"; + +// Compares latest result to previous/baseline results and produces the needed +// info. +export class ResultsAnalyzer { + public static analyze(currentResult: Result, baselineResults: ResultsSet): AnalyzerItem[] { + const items = new ResultsAnalyzer(currentResult).collect(); + + const baseline = baselineResults.find( + (other) => other.cpuThrottling == currentResult.cpuThrottling && + other.name == currentResult.name && + other.networkConditions == currentResult.networkConditions); + + if (baseline != undefined) { + const baseItems = new ResultsAnalyzer(baseline[1]).collect(); + // update items with baseline results + for (const base of baseItems) { + for (const item of items) { + if (item.metric == base.metric) { + item.other = base.value; + item.otherHash = baseline[0]; + } + } + } + } + + return items; + } + + private constructor(private result: Result) { } + + private collect(): AnalyzerItem[] { + const items = new Array(); + + const aStats = new MetricsStats(this.result.aResults); + const bStats = new MetricsStats(this.result.bResults); + + const pushIfDefined = function (metric: AnalyzerItemMetric, unit: AnalyzerItemUnit, valueA?: number, valueB?: number) { + if (valueA == undefined || valueB == undefined) return; + + items.push({ + metric: metric, + value: { + unit: unit, + asDiff: () => valueB - valueA, + asRatio: () => valueB / valueA, + asString: () => { + const diff = valueB - valueA; + const prefix = diff >= 0 ? '+' : ''; + + switch (unit) { + case AnalyzerItemUnit.bytes: + return prefix + filesize(diff); + case AnalyzerItemUnit.ratio: + return prefix + (diff * 100).toFixed(2) + ' %'; + default: + return prefix + diff.toFixed(2) + ' ' + AnalyzerItemUnit[unit]; + } + } + } + }) + } + + pushIfDefined(AnalyzerItemMetric.lcp, AnalyzerItemUnit.ms, aStats.lcp, bStats.lcp); + pushIfDefined(AnalyzerItemMetric.cls, AnalyzerItemUnit.ms, aStats.cls, bStats.cls); + pushIfDefined(AnalyzerItemMetric.cpu, AnalyzerItemUnit.ratio, aStats.cpu, bStats.cpu); + pushIfDefined(AnalyzerItemMetric.memoryAvg, AnalyzerItemUnit.bytes, aStats.memoryAvg, bStats.memoryAvg); + pushIfDefined(AnalyzerItemMetric.memoryMax, AnalyzerItemUnit.bytes, aStats.memoryMax, bStats.memoryMax); + + return items.filter((item) => item.value != undefined); + } +} + +export enum AnalyzerItemUnit { + ms, + ratio, // 1.0 == 100 % + bytes, +} + +export interface AnalyzerItemValue { + unit: AnalyzerItemUnit; + asString(): string; + asDiff(): number; + asRatio(): number; // 1.0 == 100 % +} + +export enum AnalyzerItemMetric { + lcp, + cls, + cpu, + memoryAvg, + memoryMax, +} + +export interface AnalyzerItem { + metric: AnalyzerItemMetric; + value: AnalyzerItemValue; + other?: AnalyzerItemValue; + otherHash?: GitHash; +} diff --git a/packages/replay/metrics/src/results/metrics-stats.ts b/packages/replay/metrics/src/results/metrics-stats.ts new file mode 100644 index 000000000000..ec4071caab88 --- /dev/null +++ b/packages/replay/metrics/src/results/metrics-stats.ts @@ -0,0 +1,41 @@ +import { Metrics } from '../collector'; +import * as ss from 'simple-statistics' + +export type NumberProvider = (metrics: Metrics) => number; + +export class MetricsStats { + constructor(private items: Metrics[]) { } + + // See https://en.wikipedia.org/wiki/Interquartile_range#Outliers for details + public filterOutliers(dataProvider: NumberProvider): number[] { + let numbers = this.items.map(dataProvider); + // TODO implement, see https://github.com/getsentry/action-app-sdk-overhead-metrics/blob/9ce7d562ff79b317688d22bd5c0bb725cbdfdb81/src/test/kotlin/StartupTimeTest.kt#L27-L37 + return numbers; + } + + public filteredMean(dataProvider: NumberProvider): number | undefined { + const numbers = this.filterOutliers(dataProvider); + return numbers.length > 0 ? ss.mean(numbers) : undefined; + } + + public get lcp(): number | undefined { + return this.filteredMean((metrics) => metrics.vitals.lcp); + } + + public get cls(): number | undefined { + return this.filteredMean((metrics) => metrics.vitals.cls); + } + + public get cpu(): number | undefined { + return this.filteredMean((metrics) => metrics.cpu.average); + } + + public get memoryAvg(): number | undefined { + return this.filteredMean((metrics) => ss.mean(Array.from(metrics.memory.snapshots.values()))); + } + + public get memoryMax(): number | undefined { + const numbers = this.filterOutliers((metrics) => ss.max(Array.from(metrics.memory.snapshots.values()))); + return numbers.length > 0 ? ss.max(numbers) : undefined; + } +} diff --git a/packages/replay/metrics/src/results/results-set.ts b/packages/replay/metrics/src/results/results-set.ts index ca386723fb27..5597d423810d 100644 --- a/packages/replay/metrics/src/results/results-set.ts +++ b/packages/replay/metrics/src/results/results-set.ts @@ -1,7 +1,8 @@ import assert from 'assert'; import * as fs from 'fs'; import path from 'path'; -import { Git } from '../git.js'; +import { Git, GitHash } from '../util/git.js'; +import { Result } from './result.js'; const delimiter = '-'; @@ -16,7 +17,7 @@ export class ResultSetItem { return parseInt(this.parts[0]); } - public get hash(): string { + public get hash(): GitHash { return this.parts[1]; } @@ -38,23 +39,42 @@ export class ResultsSet { return this.items().length; } + public find(predicate: (value: Result) => boolean): [GitHash, Result] | undefined { + const items = this.items(); + for (let i = 0; i < items.length; i++) { + const result = Result.readFromFile(items[i].path); + if (predicate(result)) { + return [items[i].hash, result]; + } + } + return undefined; + } + public items(): ResultSetItem[] { return this.files().map((file) => { return new ResultSetItem(path.join(this.directory, file.name)); }).filter((item) => !isNaN(item.number)); } - files(): fs.Dirent[] { + private files(): fs.Dirent[] { return fs.readdirSync(this.directory, { withFileTypes: true }).filter((v) => v.isFile()) } - public async add(newFile: string): Promise { + public async add(newFile: string, onlyIfDifferent: boolean = false): Promise { console.log(`Preparing to add ${newFile} to ${this.directory}`); assert(fs.existsSync(newFile)); - // Get the list of file sorted by the prefix number in the descending order. + // Get the list of file sorted by the prefix number in the descending order (starting with the oldest files). const files = this.items().sort((a, b) => b.number - a.number); + if (onlyIfDifferent && files.length > 0) { + const latestFile = files[files.length - 1]; + if (fs.readFileSync(latestFile.path, { encoding: 'utf-8' }) == fs.readFileSync(newFile, { encoding: 'utf-8' })) { + console.log(`Skipping - it's already stored as ${latestFile.name}`); + return; + } + } + // Rename all existing files, increasing the prefix for (const file of files) { const parts = file.name.split(delimiter); diff --git a/packages/replay/metrics/src/git.ts b/packages/replay/metrics/src/util/git.ts similarity index 81% rename from packages/replay/metrics/src/git.ts rename to packages/replay/metrics/src/util/git.ts index 88f0206a759b..edcf74af5141 100644 --- a/packages/replay/metrics/src/git.ts +++ b/packages/replay/metrics/src/util/git.ts @@ -1,8 +1,10 @@ import { simpleGit } from 'simple-git'; +export type GitHash = string; + // A testing scenario we want to collect metrics for. export const Git = { - get hash(): Promise { + get hash(): Promise { return (async () => { const git = simpleGit(); let gitHash = await git.revparse('HEAD'); diff --git a/packages/replay/metrics/yarn.lock b/packages/replay/metrics/yarn.lock index 9d4fa4d0e407..bdcdfcb7104d 100644 --- a/packages/replay/metrics/yarn.lock +++ b/packages/replay/metrics/yarn.lock @@ -278,6 +278,11 @@ fd-slicer@~1.1.0: dependencies: pend "~1.2.0" +filesize@^10.0.6: + version "10.0.6" + resolved "https://registry.yarnpkg.com/filesize/-/filesize-10.0.6.tgz#5f4cd2721664cd925db3a7a5a87bbfd6ab5ebb1a" + integrity sha512-rzpOZ4C9vMFDqOa6dNpog92CoLYjD79dnjLk2TYDDtImRIyLTOzqojCb05Opd1WuiWjs+fshhCgTd8cl7y5t+g== + fs-constants@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" @@ -521,6 +526,11 @@ simple-git@^3.15.1: "@kwsites/promise-deferred" "^1.1.1" debug "^4.3.4" +simple-statistics@^7.8.0: + version "7.8.0" + resolved "https://registry.yarnpkg.com/simple-statistics/-/simple-statistics-7.8.0.tgz#1033d2d613656c7bd34f0e134fd7e69c803e6836" + integrity sha512-lTWbfJc0u6GZhBojLOrlHJMTHu6PdUjSsYLrpiH902dVBiYJyWlN/LdSoG8b5VvfG1D30gIBgarqMNeNmU5nAA== + string_decoder@^1.1.1: version "1.3.0" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" From 0df3970f0df08a8f0aea3db6ad12c0aff8d4b367 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Mon, 2 Jan 2023 13:51:06 +0100 Subject: [PATCH 20/55] metrics: ci configs, git & github utils (wip) --- packages/replay/metrics/configs/README.md | 4 +- packages/replay/metrics/configs/ci/collect.ts | 17 ++++ packages/replay/metrics/configs/ci/env.ts | 4 + packages/replay/metrics/configs/ci/process.ts | 37 ++++++++ packages/replay/metrics/package.json | 4 +- packages/replay/metrics/src/util/git.ts | 52 ++++++++++- packages/replay/metrics/src/util/github.ts | 87 +++++++++++++++++++ 7 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 packages/replay/metrics/configs/ci/collect.ts create mode 100644 packages/replay/metrics/configs/ci/env.ts create mode 100644 packages/replay/metrics/configs/ci/process.ts create mode 100644 packages/replay/metrics/src/util/github.ts diff --git a/packages/replay/metrics/configs/README.md b/packages/replay/metrics/configs/README.md index d47f3ac8e145..cb9724ba4619 100644 --- a/packages/replay/metrics/configs/README.md +++ b/packages/replay/metrics/configs/README.md @@ -1,4 +1,4 @@ # Replay metrics configuration & entrypoints (scripts) -* [dev] contains scripts launched during local development -* [ci] contains scripts launched in CI +* [dev](dev) contains scripts launched during local development +* [ci](ci) contains scripts launched in CI diff --git a/packages/replay/metrics/configs/ci/collect.ts b/packages/replay/metrics/configs/ci/collect.ts new file mode 100644 index 000000000000..bec5af33ec8f --- /dev/null +++ b/packages/replay/metrics/configs/ci/collect.ts @@ -0,0 +1,17 @@ +import { Metrics, MetricsCollector } from '../../src/collector.js'; +import { JankTestScenario } from '../../src/scenarios.js'; +import { latestResultFile } from './env.js'; + +const collector = new MetricsCollector(); +const result = await collector.execute({ + name: 'dummy', + a: new JankTestScenario(false), + b: new JankTestScenario(true), + runs: 1, + tries: 1, + async test(_aResults: Metrics[], _bResults: Metrics[]) { + return true; + }, +}); + +result.writeToFile(latestResultFile); diff --git a/packages/replay/metrics/configs/ci/env.ts b/packages/replay/metrics/configs/ci/env.ts new file mode 100644 index 000000000000..3f8e48af6701 --- /dev/null +++ b/packages/replay/metrics/configs/ci/env.ts @@ -0,0 +1,4 @@ +export const previousResultsDir = 'out/previous-results'; +export const baselineResultsDir = 'out/baseline-results'; +export const latestResultFile = 'out/latest-result.json'; +export const artifactName = "sdk-metrics-replay" diff --git a/packages/replay/metrics/configs/ci/process.ts b/packages/replay/metrics/configs/ci/process.ts new file mode 100644 index 000000000000..df0c1dd80fc5 --- /dev/null +++ b/packages/replay/metrics/configs/ci/process.ts @@ -0,0 +1,37 @@ +import path from 'path'; +// import { AnalyzerItemMetric, ResultsAnalyzer } from '../../src/results/analyzer.js'; +import { Result } from '../../src/results/result.js'; +// import { ResultsSet } from '../../src/results/results-set.js'; +import { Git } from '../../src/util/git.js'; +import { GitHub } from '../../src/util/github.js'; +import { latestResultFile, previousResultsDir, baselineResultsDir, artifactName } from './env.js'; + +const latestResult = Result.readFromFile(latestResultFile); +console.debug(latestResult); + +GitHub.downloadPreviousArtifact(await Git.baseBranch, baselineResultsDir, artifactName); +GitHub.downloadPreviousArtifact(await Git.branch, previousResultsDir, artifactName); + +GitHub.writeOutput("artifactName", artifactName) +GitHub.writeOutput("artifactPath", path.resolve(previousResultsDir)); + +// const resultsSet = new ResultsSet(outDir); +// const analysis = ResultsAnalyzer.analyze(latestResult, resultsSet); + +// val prComment = PrCommentBuilder() +// prComment.addCurrentResult(latestResults) +// if (Git.baseBranch != Git.branch) { +// prComment.addAdditionalResultsSet( +// "Baseline results on branch: ${Git.baseBranch}", +// ResultsSet(baselineResultsDir) +// ) +// } +// prComment.addAdditionalResultsSet( +// "Previous results on branch: ${Git.branch}", +// ResultsSet(previousResultsDir) +// ) + +// GitHub.addOrUpdateComment(prComment); + +// Copy the latest test run results to the archived result dir. +// await resultsSet.add(latestResultFile, true); diff --git a/packages/replay/metrics/package.json b/packages/replay/metrics/package.json index 15c22c39ed70..c8f0b82eb4a0 100644 --- a/packages/replay/metrics/package.json +++ b/packages/replay/metrics/package.json @@ -9,7 +9,9 @@ "build": "tsc", "deps": "yarn --cwd ../ build:bundle && yarn --cwd ../../tracing/ build:bundle", "dev:collect": "ts-node-esm ./configs/dev/collect.ts", - "dev:process": "ts-node-esm ./configs/dev/process.ts" + "dev:process": "ts-node-esm ./configs/dev/process.ts", + "ci:collect": "ts-node-esm ./configs/ci/collect.ts", + "ci:process": "ts-node-esm ./configs/ci/process.ts" }, "dependencies": { "@types/node": "^18.11.17", diff --git a/packages/replay/metrics/src/util/git.ts b/packages/replay/metrics/src/util/git.ts index edcf74af5141..f5799904fb04 100644 --- a/packages/replay/metrics/src/util/git.ts +++ b/packages/replay/metrics/src/util/git.ts @@ -1,12 +1,60 @@ import { simpleGit } from 'simple-git'; export type GitHash = string; +const git = simpleGit(); + +async function defaultBranch(): Promise { + const remoteInfo = await git.remote(['show', 'origin']) as string; + for (let line of remoteInfo.split('\n')) { + line = line.trim(); + if (line.startsWith('HEAD branch:')) { + return line.substring('HEAD branch:'.length).trim(); + } + } + throw "Couldn't find base branch name"; +} -// A testing scenario we want to collect metrics for. export const Git = { + get repository(): Promise { + return (async () => { + if (typeof process.env.GITHUB_REPOSITORY == 'string' && process.env.GITHUB_REPOSITORY.length > 0) { + return `github.com/${process.env.GITHUB_REPOSITORY}`; + } else { + let url = await git.remote(['get-url', 'origin']) as string; + url = url.trim(); + url = url.replace(/^git@/, ''); + url = url.replace(/\.git$/, ''); + return url.replace(':', '/'); + } + })(); + }, + + get branch(): Promise { + return (async () => { + if (typeof process.env.GITHUB_HEAD_REF == 'string' && process.env.GITHUB_HEAD_REF.length > 0) { + return process.env.GITHUB_HEAD_REF; + } else if (typeof process.env.GITHUB_REF == 'string' && process.env.GITHUB_REF.startsWith('refs/heads/')) { + return process.env.GITHUB_REF.substring('refs/heads/'.length); + } else { + const branches = (await git.branchLocal()).branches; + for (const name in branches) { + if (branches[name].current) return name; + } + throw "Couldn't find current branch name"; + } + })(); + }, + + get baseBranch(): Promise { + if (typeof process.env.GITHUB_BASE_REF == 'string' && process.env.GITHUB_BASE_REF.length > 0) { + return Promise.resolve(process.env.GITHUB_BASE_REF); + } else { + return defaultBranch(); + } + }, + get hash(): Promise { return (async () => { - const git = simpleGit(); let gitHash = await git.revparse('HEAD'); let diff = await git.diff(); if (diff.trim().length > 0) { diff --git a/packages/replay/metrics/src/util/github.ts b/packages/replay/metrics/src/util/github.ts new file mode 100644 index 000000000000..86376227e781 --- /dev/null +++ b/packages/replay/metrics/src/util/github.ts @@ -0,0 +1,87 @@ +import * as fs from 'fs'; + +export const GitHub = { + writeOutput(name: string, value: any): void { + if (typeof process.env.GITHUB_OUTPUT == 'string' && process.env.GITHUB_OUTPUT.length > 0) { + fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`); + } + console.log(`Output ${name}`, value); + }, + + downloadPreviousArtifact(branch: string, targetDir: string, artifactName: string): void { + fs.mkdirSync(targetDir, { recursive: true }); + + // if (workflow == null) { + // println("Skipping previous artifact '$artifactName' download for branch '$branch' - not running in CI") + // return + // } + console.log(`Trying to download previous artifact '${artifactName}' for branch '${branch}'`) + + // val run = workflow!!.listRuns() + // .firstOrNull { it.headBranch == branch && it.conclusion == GHWorkflowRun.Conclusion.SUCCESS } + // if (run == null) { + // println("Couldn't find any successful run workflow ${workflow!!.name}") + // return + // } + + // val artifact = run.listArtifacts().firstOrNull { it.name == artifactName } + // if (artifact == null) { + // println("Couldn't find any artifact matching $artifactName") + // return + // } + + // println("Downloading artifact ${artifact.archiveDownloadUrl} and extracting to $targetDir") + // artifact.download { + // val zipStream = ZipInputStream(it) + // var entry: ZipEntry? + // // while there are entries I process them + // while (true) { + // entry = zipStream.nextEntry + // if (entry == null) { + // break + // } + // if (entry.isDirectory) { + // Path.of(entry.name).createDirectories() + // } else { + // println("Extracting ${entry.name}") + // val outFile = FileOutputStream(targetDir.resolve(entry.name).toFile()) + // while (zipStream.available() > 0) { + // val c = zipStream.read() + // if (c > 0) { + // outFile.write(c) + // } else { + // break + // } + // } + // outFile.close() + // } + // } + // } + }, + + // fun addOrUpdateComment(commentBuilder: PrCommentBuilder) { + // if (pullRequest == null) { + // val file = File("out/comment.html") + // println("No PR available (not running in CI?): writing built comment to ${file.absolutePath}") + // file.writeText(commentBuilder.body) + // } else { + // val comments = pullRequest!!.comments + // // Trying to fetch `github!!.myself` throws (in CI only): Exception in thread "main" org.kohsuke.github.HttpException: + // // {"message":"Resource not accessible by integration","documentation_url":"https://docs.github.com/rest/reference/users#get-the-authenticated-user"} + // // Let's make this conditional on some env variable that's unlikely to be set. + // // Do not use "CI" because that's commonly set during local development and testing. + // val author = if (env.containsKey("GITHUB_ACTION")) "github-actions[bot]" else github!!.myself.login + // val comment = comments.firstOrNull { + // it.user.login.equals(author) && + // it.body.startsWith(commentBuilder.title, ignoreCase = true) + // } + // if (comment != null) { + // println("Updating PR comment ${comment.htmlUrl} body") + // comment.update(commentBuilder.body) + // } else { + // println("Adding new PR comment to ${pullRequest!!.htmlUrl}") + // pullRequest!!.comment(commentBuilder.body) + // } + // } + // } +} From 468f6b2846cb50007b1cde252b436ce913774a0b Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Mon, 2 Jan 2023 17:03:50 +0100 Subject: [PATCH 21/55] ci: collect sdk metrics --- .github/workflows/metrics.yml | 81 ++++++++ packages/replay/metrics/configs/ci/process.ts | 6 +- packages/replay/metrics/package.json | 3 + packages/replay/metrics/src/util/github.ts | 126 +++++++----- packages/replay/metrics/yarn.lock | 179 +++++++++++++++++- 5 files changed, 343 insertions(+), 52 deletions(-) create mode 100644 .github/workflows/metrics.yml diff --git a/.github/workflows/metrics.yml b/.github/workflows/metrics.yml new file mode 100644 index 000000000000..ebd57236a53e --- /dev/null +++ b/.github/workflows/metrics.yml @@ -0,0 +1,81 @@ +name: Collect SDK metrics +on: + push: + paths: + - .github/workflows/metrics.yml + - packages/** + - patches/** + - lerna.json + - package.json + - tsconfig.json + - yarn.lock + branches-ignore: + - deps/** + - dependabot/** + tags-ignore: ['**'] + +env: + CACHED_DEPENDENCY_PATHS: | + ${{ github.workspace }}/node_modules + ${{ github.workspace }}/packages/*/node_modules + ~/.cache/ms-playwright/ + ~/.cache/mongodb-binaries/ + +jobs: + cancel-previous-workflow: + runs-on: ubuntu-latest + steps: + - name: Cancel Previous Runs + uses: styfle/cancel-workflow-action@b173b6ec0100793626c2d9e6b90435061f4fc3e5 # pin@0.11.0 + with: + access_token: ${{ github.token }} + + replay: + name: Replay SDK metrics + runs-on: ubuntu-20.04 + + steps: + - uses: actions/checkout@v3 + + - name: Set up Node + uses: volta-cli/action@v4 + + - name: Compute dependency cache key + id: compute_lockfile_hash + # we use a hash of yarn.lock as our cache key, because if it hasn't changed, our dependencies haven't changed, + # so no need to reinstall them + run: echo "hash=${{ hashFiles('yarn.lock') }}" >> "$GITHUB_OUTPUT" + + - name: Check dependency cache + uses: actions/cache@v3 + id: cache_dependencies + with: + path: ${{ env.CACHED_DEPENDENCY_PATHS }} + key: ${{ steps.compute_lockfile_hash.outputs.hash }} + + - name: Install dependencies + if: steps.cache_dependencies.outputs.cache-hit == '' + run: yarn install --ignore-engines --frozen-lockfile + + - name: Build + run: | + yarn install --ignore-engines --frozen-lockfile + yarn deps + working-directory: packages/replay/metrics + + - name: Collect + run: yarn ci:collect + working-directory: packages/replay/metrics + + - name: Process + id: process + run: yarn ci:process + working-directory: packages/replay/metrics + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: Upload results + uses: actions/upload-artifact@v3 + with: + name: ${{ steps.process.outputs.artifactName }} + path: ${{ steps.process.outputs.artifactPath }} diff --git a/packages/replay/metrics/configs/ci/process.ts b/packages/replay/metrics/configs/ci/process.ts index df0c1dd80fc5..feb27eed70b0 100644 --- a/packages/replay/metrics/configs/ci/process.ts +++ b/packages/replay/metrics/configs/ci/process.ts @@ -1,7 +1,7 @@ import path from 'path'; // import { AnalyzerItemMetric, ResultsAnalyzer } from '../../src/results/analyzer.js'; import { Result } from '../../src/results/result.js'; -// import { ResultsSet } from '../../src/results/results-set.js'; +import { ResultsSet } from '../../src/results/results-set.js'; import { Git } from '../../src/util/git.js'; import { GitHub } from '../../src/util/github.js'; import { latestResultFile, previousResultsDir, baselineResultsDir, artifactName } from './env.js'; @@ -15,7 +15,7 @@ GitHub.downloadPreviousArtifact(await Git.branch, previousResultsDir, artifactNa GitHub.writeOutput("artifactName", artifactName) GitHub.writeOutput("artifactPath", path.resolve(previousResultsDir)); -// const resultsSet = new ResultsSet(outDir); +const resultsSet = new ResultsSet(previousResultsDir); // const analysis = ResultsAnalyzer.analyze(latestResult, resultsSet); // val prComment = PrCommentBuilder() @@ -34,4 +34,4 @@ GitHub.writeOutput("artifactPath", path.resolve(previousResultsDir)); // GitHub.addOrUpdateComment(prComment); // Copy the latest test run results to the archived result dir. -// await resultsSet.add(latestResultFile, true); +await resultsSet.add(latestResultFile, true); diff --git a/packages/replay/metrics/package.json b/packages/replay/metrics/package.json index c8f0b82eb4a0..29fb646a12cf 100644 --- a/packages/replay/metrics/package.json +++ b/packages/replay/metrics/package.json @@ -14,7 +14,10 @@ "ci:process": "ts-node-esm ./configs/ci/process.ts" }, "dependencies": { + "@octokit/rest": "^19.0.5", "@types/node": "^18.11.17", + "axios": "^1.2.2", + "extract-zip": "^2.0.1", "filesize": "^10.0.6", "puppeteer": "^19.4.1", "simple-git": "^3.15.1", diff --git a/packages/replay/metrics/src/util/github.ts b/packages/replay/metrics/src/util/github.ts index 86376227e781..afe95e479c06 100644 --- a/packages/replay/metrics/src/util/github.ts +++ b/packages/replay/metrics/src/util/github.ts @@ -1,62 +1,96 @@ import * as fs from 'fs'; +import { Octokit } from "@octokit/rest"; +import { Git } from './git.js'; +import path from 'path'; +import Axios from 'axios'; +import extract from 'extract-zip'; + +const octokit = new Octokit({ + auth: process.env.GITHUB_TOKEN, + // log: console, +}); + +const [_, owner, repo] = (await Git.repository).split('/'); +const defaultArgs = { owner: owner, repo: repo } + +export function downloadFile(url: string, path: string) { + const writer = fs.createWriteStream(path); + return Axios({ + method: 'get', + url: url, + responseType: 'stream', + }).then(response => { + return new Promise((resolve, reject) => { + response.data.pipe(writer); + let error: Error; + writer.on('error', err => { + error = err; + writer.close(); + reject(err); + }); + writer.on('close', () => { + if (!error) resolve(true); + }); + }); + }); +} export const GitHub = { writeOutput(name: string, value: any): void { if (typeof process.env.GITHUB_OUTPUT == 'string' && process.env.GITHUB_OUTPUT.length > 0) { fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`); } - console.log(`Output ${name}`, value); + console.log(`Output ${name} = ${value}`); }, - downloadPreviousArtifact(branch: string, targetDir: string, artifactName: string): void { - fs.mkdirSync(targetDir, { recursive: true }); + downloadPreviousArtifact(branch: string, targetDir: string, artifactName: string): Promise { + return (async () => { + fs.mkdirSync(targetDir, { recursive: true }); + + const workflow = (await octokit.actions.listRepoWorkflows(defaultArgs)) + .data.workflows.find((w) => w.name == process.env.GITHUB_WORKFLOW); + if (workflow == undefined) { + console.log(`Skipping previous artifact '${artifactName}' download for branch '${branch}' - not running in CI`); + return; + } + console.log(`Trying to download previous artifact '${artifactName}' for branch '${branch}'`); + + const workflowRuns = await octokit.actions.listWorkflowRuns({ + ...defaultArgs, + workflow_id: workflow.id, + branch: branch, + status: 'success', + }); + + if (workflowRuns.data.total_count == 0) { + console.warn(`Couldn't find any successful run for workflow '${workflow.name}'`); + return; + } + + const artifact = (await octokit.actions.listWorkflowRunArtifacts({ + ...defaultArgs, + run_id: workflowRuns.data.workflow_runs[0].id, + })).data.artifacts.find((it) => it.name == artifactName); - // if (workflow == null) { - // println("Skipping previous artifact '$artifactName' download for branch '$branch' - not running in CI") - // return - // } - console.log(`Trying to download previous artifact '${artifactName}' for branch '${branch}'`) + if (artifact == undefined) { + console.warn(`Couldn't find any artifact matching ${artifactName}`); + return; + } - // val run = workflow!!.listRuns() - // .firstOrNull { it.headBranch == branch && it.conclusion == GHWorkflowRun.Conclusion.SUCCESS } - // if (run == null) { - // println("Couldn't find any successful run workflow ${workflow!!.name}") - // return - // } + console.log(`Downloading artifact ${artifact.archive_download_url} and extracting to $targetDir`); - // val artifact = run.listArtifacts().firstOrNull { it.name == artifactName } - // if (artifact == null) { - // println("Couldn't find any artifact matching $artifactName") - // return - // } + const tempFilePath = path.resolve(targetDir, '../tmp-artifacts.zip'); + if (fs.existsSync(tempFilePath)) { + fs.unlinkSync(tempFilePath); + } - // println("Downloading artifact ${artifact.archiveDownloadUrl} and extracting to $targetDir") - // artifact.download { - // val zipStream = ZipInputStream(it) - // var entry: ZipEntry? - // // while there are entries I process them - // while (true) { - // entry = zipStream.nextEntry - // if (entry == null) { - // break - // } - // if (entry.isDirectory) { - // Path.of(entry.name).createDirectories() - // } else { - // println("Extracting ${entry.name}") - // val outFile = FileOutputStream(targetDir.resolve(entry.name).toFile()) - // while (zipStream.available() > 0) { - // val c = zipStream.read() - // if (c > 0) { - // outFile.write(c) - // } else { - // break - // } - // } - // outFile.close() - // } - // } - // } + try { + await downloadFile(artifact.archive_download_url, tempFilePath); + await extract(tempFilePath, { dir: targetDir }); + } finally { + fs.unlinkSync(tempFilePath); + } + })(); }, // fun addOrUpdateComment(commentBuilder: PrCommentBuilder) { diff --git a/packages/replay/metrics/yarn.lock b/packages/replay/metrics/yarn.lock index bdcdfcb7104d..976135fc15de 100644 --- a/packages/replay/metrics/yarn.lock +++ b/packages/replay/metrics/yarn.lock @@ -60,6 +60,107 @@ resolved "https://registry.yarnpkg.com/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz#8ace5259254426ccef57f3175bc64ed7095ed919" integrity sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw== +"@octokit/auth-token@^3.0.0": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-3.0.2.tgz#a0fc8de149fd15876e1ac78f6525c1c5ab48435f" + integrity sha512-pq7CwIMV1kmzkFTimdwjAINCXKTajZErLB4wMLYapR2nuB/Jpr66+05wOTZMSCBXP6n4DdDWT2W19Bm17vU69Q== + dependencies: + "@octokit/types" "^8.0.0" + +"@octokit/core@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@octokit/core/-/core-4.1.0.tgz#b6b03a478f1716de92b3f4ec4fd64d05ba5a9251" + integrity sha512-Czz/59VefU+kKDy+ZfDwtOIYIkFjExOKf+HA92aiTZJ6EfWpFzYQWw0l54ji8bVmyhc+mGaLUbSUmXazG7z5OQ== + dependencies: + "@octokit/auth-token" "^3.0.0" + "@octokit/graphql" "^5.0.0" + "@octokit/request" "^6.0.0" + "@octokit/request-error" "^3.0.0" + "@octokit/types" "^8.0.0" + before-after-hook "^2.2.0" + universal-user-agent "^6.0.0" + +"@octokit/endpoint@^7.0.0": + version "7.0.3" + resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-7.0.3.tgz#0b96035673a9e3bedf8bab8f7335de424a2147ed" + integrity sha512-57gRlb28bwTsdNXq+O3JTQ7ERmBTuik9+LelgcLIVfYwf235VHbN9QNo4kXExtp/h8T423cR5iJThKtFYxC7Lw== + dependencies: + "@octokit/types" "^8.0.0" + is-plain-object "^5.0.0" + universal-user-agent "^6.0.0" + +"@octokit/graphql@^5.0.0": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-5.0.4.tgz#519dd5c05123868276f3ae4e50ad565ed7dff8c8" + integrity sha512-amO1M5QUQgYQo09aStR/XO7KAl13xpigcy/kI8/N1PnZYSS69fgte+xA4+c2DISKqUZfsh0wwjc2FaCt99L41A== + dependencies: + "@octokit/request" "^6.0.0" + "@octokit/types" "^8.0.0" + universal-user-agent "^6.0.0" + +"@octokit/openapi-types@^14.0.0": + version "14.0.0" + resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-14.0.0.tgz#949c5019028c93f189abbc2fb42f333290f7134a" + integrity sha512-HNWisMYlR8VCnNurDU6os2ikx0s0VyEjDYHNS/h4cgb8DeOxQ0n72HyinUtdDVxJhFy3FWLGl0DJhfEWk3P5Iw== + +"@octokit/plugin-paginate-rest@^5.0.0": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-5.0.1.tgz#93d7e74f1f69d68ba554fa6b888c2a9cf1f99a83" + integrity sha512-7A+rEkS70pH36Z6JivSlR7Zqepz3KVucEFVDnSrgHXzG7WLAzYwcHZbKdfTXHwuTHbkT1vKvz7dHl1+HNf6Qyw== + dependencies: + "@octokit/types" "^8.0.0" + +"@octokit/plugin-request-log@^1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@octokit/plugin-request-log/-/plugin-request-log-1.0.4.tgz#5e50ed7083a613816b1e4a28aeec5fb7f1462e85" + integrity sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA== + +"@octokit/plugin-rest-endpoint-methods@^6.7.0": + version "6.7.0" + resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-6.7.0.tgz#2f6f17f25b6babbc8b41d2bb0a95a8839672ce7c" + integrity sha512-orxQ0fAHA7IpYhG2flD2AygztPlGYNAdlzYz8yrD8NDgelPfOYoRPROfEyIe035PlxvbYrgkfUZIhSBKju/Cvw== + dependencies: + "@octokit/types" "^8.0.0" + deprecation "^2.3.1" + +"@octokit/request-error@^3.0.0": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-3.0.2.tgz#f74c0f163d19463b87528efe877216c41d6deb0a" + integrity sha512-WMNOFYrSaX8zXWoJg9u/pKgWPo94JXilMLb2VManNOby9EZxrQaBe/QSC4a1TzpAlpxofg2X/jMnCyZgL6y7eg== + dependencies: + "@octokit/types" "^8.0.0" + deprecation "^2.0.0" + once "^1.4.0" + +"@octokit/request@^6.0.0": + version "6.2.2" + resolved "https://registry.yarnpkg.com/@octokit/request/-/request-6.2.2.tgz#a2ba5ac22bddd5dcb3f539b618faa05115c5a255" + integrity sha512-6VDqgj0HMc2FUX2awIs+sM6OwLgwHvAi4KCK3mT2H2IKRt6oH9d0fej5LluF5mck1lRR/rFWN0YIDSYXYSylbw== + dependencies: + "@octokit/endpoint" "^7.0.0" + "@octokit/request-error" "^3.0.0" + "@octokit/types" "^8.0.0" + is-plain-object "^5.0.0" + node-fetch "^2.6.7" + universal-user-agent "^6.0.0" + +"@octokit/rest@^19.0.5": + version "19.0.5" + resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-19.0.5.tgz#4dbde8ae69b27dca04b5f1d8119d282575818f6c" + integrity sha512-+4qdrUFq2lk7Va+Qff3ofREQWGBeoTKNqlJO+FGjFP35ZahP+nBenhZiGdu8USSgmq4Ky3IJ/i4u0xbLqHaeow== + dependencies: + "@octokit/core" "^4.1.0" + "@octokit/plugin-paginate-rest" "^5.0.0" + "@octokit/plugin-request-log" "^1.0.4" + "@octokit/plugin-rest-endpoint-methods" "^6.7.0" + +"@octokit/types@^8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@octokit/types/-/types-8.0.0.tgz#93f0b865786c4153f0f6924da067fe0bb7426a9f" + integrity sha512-65/TPpOJP1i3K4lBJMnWqPUJ6zuOtzhtagDvydAWbEXpbFYA0oMKKyLb95NFZZP0lSh/4b6K+DQlzvYQJQQePg== + dependencies: + "@octokit/openapi-types" "^14.0.0" + "@tsconfig/node10@^1.0.7": version "1.0.9" resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.9.tgz#df4907fc07a886922637b15e02d4cebc4c0021b2" @@ -126,6 +227,20 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + +axios@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.2.2.tgz#72681724c6e6a43a9fea860fc558127dbe32f9f1" + integrity sha512-bz/J4gS2S3I7mpN/YZfGFTqhXTYzRho8Ay38w2otuuDR322KzFIWm/4W2K6gIwvWaws5n+mnb7D1lN9uD+QH6Q== + dependencies: + follow-redirects "^1.15.0" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" @@ -136,6 +251,11 @@ base64-js@^1.3.1: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== +before-after-hook@^2.2.0: + version "2.2.3" + resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.2.3.tgz#c51e809c81a4e354084422b9b26bad88249c517c" + integrity sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ== + bl@^4.0.3: version "4.1.0" resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" @@ -197,6 +317,13 @@ color-name@1.1.3: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -231,6 +358,16 @@ debug@4, debug@4.3.4, debug@^4.1.1, debug@^4.3.4: dependencies: ms "2.1.2" +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + +deprecation@^2.0.0, deprecation@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919" + integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ== + devtools-protocol@0.0.1068969: version "0.0.1068969" resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1068969.tgz#8b9a4bc48aed1453bed08d62b07481f9abf4d6d8" @@ -260,7 +397,7 @@ escape-string-regexp@^1.0.5: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== -extract-zip@2.0.1: +extract-zip@2.0.1, extract-zip@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a" integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg== @@ -283,6 +420,20 @@ filesize@^10.0.6: resolved "https://registry.yarnpkg.com/filesize/-/filesize-10.0.6.tgz#5f4cd2721664cd925db3a7a5a87bbfd6ab5ebb1a" integrity sha512-rzpOZ4C9vMFDqOa6dNpog92CoLYjD79dnjLk2TYDDtImRIyLTOzqojCb05Opd1WuiWjs+fshhCgTd8cl7y5t+g== +follow-redirects@^1.15.0: + version "1.15.2" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13" + integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== + +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + fs-constants@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" @@ -356,6 +507,11 @@ is-arrayish@^0.2.1: resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== +is-plain-object@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" + integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -383,6 +539,18 @@ make-error@^1.1.1: resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.12: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + minimatch@^3.1.1: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -400,7 +568,7 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -node-fetch@2.6.7: +node-fetch@2.6.7, node-fetch@^2.6.7: version "2.6.7" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== @@ -451,7 +619,7 @@ progress@2.0.3: resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== -proxy-from-env@1.1.0: +proxy-from-env@1.1.0, proxy-from-env@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== @@ -608,6 +776,11 @@ unbzip2-stream@1.4.3: buffer "^5.2.1" through "^2.3.8" +universal-user-agent@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-6.0.0.tgz#3381f8503b251c0d9cd21bc1de939ec9df5480ee" + integrity sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w== + util-deprecate@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" From f7873b38c487f4041a102db4f89a57bd7be7c06a Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Mon, 2 Jan 2023 19:03:38 +0100 Subject: [PATCH 22/55] metrics collection timeout --- packages/replay/metrics/configs/ci/collect.ts | 2 +- packages/replay/metrics/src/collector.ts | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/replay/metrics/configs/ci/collect.ts b/packages/replay/metrics/configs/ci/collect.ts index bec5af33ec8f..7d9577e6a9d1 100644 --- a/packages/replay/metrics/configs/ci/collect.ts +++ b/packages/replay/metrics/configs/ci/collect.ts @@ -2,7 +2,7 @@ import { Metrics, MetricsCollector } from '../../src/collector.js'; import { JankTestScenario } from '../../src/scenarios.js'; import { latestResultFile } from './env.js'; -const collector = new MetricsCollector(); +const collector = new MetricsCollector({ headless: true }); const result = await collector.execute({ name: 'dummy', a: new JankTestScenario(false), diff --git a/packages/replay/metrics/src/collector.ts b/packages/replay/metrics/src/collector.ts index 994a8fb2bfd2..a33b0ca9bb93 100644 --- a/packages/replay/metrics/src/collector.ts +++ b/packages/replay/metrics/src/collector.ts @@ -23,8 +23,20 @@ export class Metrics { } } +export interface MetricsCollectorOptions { + headless: boolean; +} export class MetricsCollector { + private options: MetricsCollectorOptions; + + constructor(options: Partial) { + this.options = { + headless: false, + ...options + }; + } + public async execute(testCase: TestCase): Promise { console.log(`Executing test case ${testCase.name}`); console.group(); @@ -64,7 +76,7 @@ export class MetricsCollector { const disposeCallbacks: (() => Promise)[] = []; try { const browser = await puppeteer.launch({ - headless: false, + headless: this.options.headless, }); disposeCallbacks.push(async () => browser.close()); const page = await browser.newPage(); From 4df8bcea71b0fb4b352fd6f6eff73d8cf2c4eb34 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Mon, 2 Jan 2023 19:47:00 +0100 Subject: [PATCH 23/55] fix metrics collection on linux --- packages/replay/metrics/configs/ci/env.ts | 2 +- packages/replay/metrics/src/scenarios.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/replay/metrics/configs/ci/env.ts b/packages/replay/metrics/configs/ci/env.ts index 3f8e48af6701..e3492ced1056 100644 --- a/packages/replay/metrics/configs/ci/env.ts +++ b/packages/replay/metrics/configs/ci/env.ts @@ -1,4 +1,4 @@ export const previousResultsDir = 'out/previous-results'; export const baselineResultsDir = 'out/baseline-results'; export const latestResultFile = 'out/latest-result.json'; -export const artifactName = "sdk-metrics-replay" +export const artifactName = "replay-sdk-metrics" diff --git a/packages/replay/metrics/src/scenarios.ts b/packages/replay/metrics/src/scenarios.ts index a87c5acebef3..1a9a31e74eb7 100644 --- a/packages/replay/metrics/src/scenarios.ts +++ b/packages/replay/metrics/src/scenarios.ts @@ -36,8 +36,10 @@ export class JankTestScenario implements Scenario { public constructor(private withSentry: boolean) { } public async run(_: puppeteer.Browser, page: puppeteer.Page): Promise { - const url = path.resolve('./test-apps/jank/' + (this.withSentry ? 'with-sentry' : 'index') + '.html'); + let url = path.resolve('./test-apps/jank/' + (this.withSentry ? 'with-sentry' : 'index') + '.html'); assert(fs.existsSync(url)); + url = 'file:///' + url.replace('\\', '/'); + console.log('Navigating to ', url); await page.goto(url, { waitUntil: 'load', timeout: 60000 }); await new Promise(resolve => setTimeout(resolve, 5000)); } From e38bbcc8e9a82af9307227da7f9f7aa19feddb8c Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Mon, 2 Jan 2023 21:27:19 +0100 Subject: [PATCH 24/55] await artifacts download --- packages/replay/metrics/configs/ci/process.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/replay/metrics/configs/ci/process.ts b/packages/replay/metrics/configs/ci/process.ts index feb27eed70b0..26575ed622c1 100644 --- a/packages/replay/metrics/configs/ci/process.ts +++ b/packages/replay/metrics/configs/ci/process.ts @@ -9,8 +9,8 @@ import { latestResultFile, previousResultsDir, baselineResultsDir, artifactName const latestResult = Result.readFromFile(latestResultFile); console.debug(latestResult); -GitHub.downloadPreviousArtifact(await Git.baseBranch, baselineResultsDir, artifactName); -GitHub.downloadPreviousArtifact(await Git.branch, previousResultsDir, artifactName); +await GitHub.downloadPreviousArtifact(await Git.baseBranch, baselineResultsDir, artifactName); +await GitHub.downloadPreviousArtifact(await Git.branch, previousResultsDir, artifactName); GitHub.writeOutput("artifactName", artifactName) GitHub.writeOutput("artifactPath", path.resolve(previousResultsDir)); From 49b4131061a92d9d696131f7c29fe23845c26472 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Mon, 2 Jan 2023 23:24:36 +0100 Subject: [PATCH 25/55] fix gh authentication --- packages/replay/metrics/src/util/github.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/replay/metrics/src/util/github.ts b/packages/replay/metrics/src/util/github.ts index afe95e479c06..f5ac856bbad8 100644 --- a/packages/replay/metrics/src/util/github.ts +++ b/packages/replay/metrics/src/util/github.ts @@ -13,12 +13,15 @@ const octokit = new Octokit({ const [_, owner, repo] = (await Git.repository).split('/'); const defaultArgs = { owner: owner, repo: repo } -export function downloadFile(url: string, path: string) { +export function downloadArtifact(url: string, path: string) { const writer = fs.createWriteStream(path); return Axios({ method: 'get', url: url, responseType: 'stream', + headers: { + 'Authorization': `Bearer ${process.env.GITHUB_TOKEN}` + } }).then(response => { return new Promise((resolve, reject) => { response.data.pipe(writer); @@ -50,7 +53,10 @@ export const GitHub = { const workflow = (await octokit.actions.listRepoWorkflows(defaultArgs)) .data.workflows.find((w) => w.name == process.env.GITHUB_WORKFLOW); if (workflow == undefined) { - console.log(`Skipping previous artifact '${artifactName}' download for branch '${branch}' - not running in CI`); + console.log( + `Skipping previous artifact '${artifactName}' download for branch '${branch}' - not running in CI?`, + "Environment variable GITHUB_WORKFLOW isn't set." + ); return; } console.log(`Trying to download previous artifact '${artifactName}' for branch '${branch}'`); @@ -77,7 +83,7 @@ export const GitHub = { return; } - console.log(`Downloading artifact ${artifact.archive_download_url} and extracting to $targetDir`); + console.log(`Downloading artifact ${artifact.archive_download_url} and extracting to ${targetDir}`); const tempFilePath = path.resolve(targetDir, '../tmp-artifacts.zip'); if (fs.existsSync(tempFilePath)) { @@ -85,10 +91,12 @@ export const GitHub = { } try { - await downloadFile(artifact.archive_download_url, tempFilePath); - await extract(tempFilePath, { dir: targetDir }); + await downloadArtifact(artifact.archive_download_url, tempFilePath); + await extract(tempFilePath, { dir: path.resolve(targetDir) }); } finally { - fs.unlinkSync(tempFilePath); + if (fs.existsSync(tempFilePath)) { + fs.unlinkSync(tempFilePath); + } } })(); }, From 940c072ddc01c1aac836addcee79e20e6eef5a80 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Tue, 3 Jan 2023 16:42:24 +0100 Subject: [PATCH 26/55] metrics: add PR comment --- packages/replay/metrics/configs/ci/process.ts | 39 +++--- .../replay/metrics/configs/dev/process.ts | 6 +- .../replay/metrics/src/results/analyzer.ts | 25 +++- .../replay/metrics/src/results/pr-comment.ts | 83 ++++++++++++ .../replay/metrics/src/results/results-set.ts | 4 - packages/replay/metrics/src/util/console.ts | 10 ++ packages/replay/metrics/src/util/github.ts | 118 +++++++++++++----- 7 files changed, 222 insertions(+), 63 deletions(-) create mode 100644 packages/replay/metrics/src/results/pr-comment.ts create mode 100644 packages/replay/metrics/src/util/console.ts diff --git a/packages/replay/metrics/configs/ci/process.ts b/packages/replay/metrics/configs/ci/process.ts index 26575ed622c1..b137577f2a6b 100644 --- a/packages/replay/metrics/configs/ci/process.ts +++ b/packages/replay/metrics/configs/ci/process.ts @@ -1,5 +1,6 @@ import path from 'path'; -// import { AnalyzerItemMetric, ResultsAnalyzer } from '../../src/results/analyzer.js'; +import { ResultsAnalyzer } from '../../src/results/analyzer.js'; +import { PrCommentBuilder } from '../../src/results/pr-comment.js'; import { Result } from '../../src/results/result.js'; import { ResultsSet } from '../../src/results/results-set.js'; import { Git } from '../../src/util/git.js'; @@ -15,23 +16,27 @@ await GitHub.downloadPreviousArtifact(await Git.branch, previousResultsDir, arti GitHub.writeOutput("artifactName", artifactName) GitHub.writeOutput("artifactPath", path.resolve(previousResultsDir)); -const resultsSet = new ResultsSet(previousResultsDir); -// const analysis = ResultsAnalyzer.analyze(latestResult, resultsSet); +const previousResults = new ResultsSet(previousResultsDir); -// val prComment = PrCommentBuilder() -// prComment.addCurrentResult(latestResults) -// if (Git.baseBranch != Git.branch) { -// prComment.addAdditionalResultsSet( -// "Baseline results on branch: ${Git.baseBranch}", -// ResultsSet(baselineResultsDir) -// ) -// } -// prComment.addAdditionalResultsSet( -// "Previous results on branch: ${Git.branch}", -// ResultsSet(previousResultsDir) -// ) +const prComment = new PrCommentBuilder(); +if (Git.baseBranch != Git.branch) { + const baseResults = new ResultsSet(baselineResultsDir); + prComment.addCurrentResult(await ResultsAnalyzer.analyze(latestResult, baseResults), "Baseline"); + await prComment.addAdditionalResultsSet( + `Baseline results on branch: ${Git.baseBranch}`, + // We skip the first one here because it's already included as `Baseline` column above in addCurrentResult(). + baseResults.items().slice(1, 10) + ); +} else { + prComment.addCurrentResult(await ResultsAnalyzer.analyze(latestResult, previousResults), "Previous"); +} -// GitHub.addOrUpdateComment(prComment); +await prComment.addAdditionalResultsSet( + `Previous results on branch: ${Git.branch}`, + previousResults.items().slice(0, 10) +); + +GitHub.addOrUpdateComment(prComment); // Copy the latest test run results to the archived result dir. -await resultsSet.add(latestResultFile, true); +await previousResults.add(latestResultFile, true); diff --git a/packages/replay/metrics/configs/dev/process.ts b/packages/replay/metrics/configs/dev/process.ts index 5a4aaca0dbf6..9672243cf804 100644 --- a/packages/replay/metrics/configs/dev/process.ts +++ b/packages/replay/metrics/configs/dev/process.ts @@ -6,14 +6,14 @@ import { latestResultFile, outDir } from './env.js'; const resultsSet = new ResultsSet(outDir); const latestResult = Result.readFromFile(latestResultFile); -const analysis = ResultsAnalyzer.analyze(latestResult, resultsSet); +const analysis = await ResultsAnalyzer.analyze(latestResult, resultsSet); const table: { [k: string]: any } = {}; -for (const item of analysis) { +for (const item of analysis.items) { const printable: { [k: string]: any } = {}; printable.value = item.value.asString(); if (item.other != undefined) { - printable.baseline = item.other.asString(); + printable.previous = item.other.asString(); } table[AnalyzerItemMetric[item.metric]] = printable; } diff --git a/packages/replay/metrics/src/results/analyzer.ts b/packages/replay/metrics/src/results/analyzer.ts index d1147627fea5..abe29cda6b63 100644 --- a/packages/replay/metrics/src/results/analyzer.ts +++ b/packages/replay/metrics/src/results/analyzer.ts @@ -4,17 +4,17 @@ import { ResultsSet } from './results-set.js'; import { MetricsStats } from './metrics-stats.js'; import { filesize } from "filesize"; -// Compares latest result to previous/baseline results and produces the needed -// info. +// Compares latest result to previous/baseline results and produces the needed info. export class ResultsAnalyzer { - public static analyze(currentResult: Result, baselineResults: ResultsSet): AnalyzerItem[] { + public static async analyze(currentResult: Result, baselineResults?: ResultsSet): Promise { const items = new ResultsAnalyzer(currentResult).collect(); - const baseline = baselineResults.find( + const baseline = baselineResults?.find( (other) => other.cpuThrottling == currentResult.cpuThrottling && other.name == currentResult.name && other.networkConditions == currentResult.networkConditions); + let otherHash: GitHash | undefined if (baseline != undefined) { const baseItems = new ResultsAnalyzer(baseline[1]).collect(); // update items with baseline results @@ -22,13 +22,16 @@ export class ResultsAnalyzer { for (const item of items) { if (item.metric == base.metric) { item.other = base.value; - item.otherHash = baseline[0]; + otherHash = baseline[0]; } } } } - return items; + return { + items: items, + otherHash: otherHash, + }; } private constructor(private result: Result) { } @@ -98,7 +101,17 @@ export enum AnalyzerItemMetric { export interface AnalyzerItem { metric: AnalyzerItemMetric; + + // Current (latest) result. value: AnalyzerItemValue; + + // Previous or baseline results, depending on the context. other?: AnalyzerItemValue; +} + +export interface Analysis { + items: AnalyzerItem[]; + + // Commit hash that the the previous or baseline (depending on the context) result was collected for. otherHash?: GitHash; } diff --git a/packages/replay/metrics/src/results/pr-comment.ts b/packages/replay/metrics/src/results/pr-comment.ts new file mode 100644 index 000000000000..8eff29891fd8 --- /dev/null +++ b/packages/replay/metrics/src/results/pr-comment.ts @@ -0,0 +1,83 @@ +import { Git } from "../util/git.js"; +import { Analysis, AnalyzerItemMetric, ResultsAnalyzer } from "./analyzer.js"; +import { Result } from "./result.js"; +import { ResultSetItem } from "./results-set.js"; + +export class PrCommentBuilder { + private buffer = ''; + + public get title(): string { + return '## Replay SDK metrics :rocket:'; + } + + public get body(): string { + return this.buffer; + } + + public addCurrentResult(analysis: Analysis, otherName: string): void { + // Decides whether to print the "Other" depending on it being set in the input data. + const maybeOther = function (content: () => string): string { + if (analysis.otherHash == undefined) { + return ''; + } + return content(); + } + + this.buffer += ` + ${this.title} + + + + + ${maybeOther(() => '')} + ` + + for (const item of analysis.items) { + this.buffer += ` + + + + ${maybeOther(() => '')} + ` + } + + this.buffer += ` +
 Latest diff (${Git.hash})' + otherName + ' diff (' + analysis.otherHash + ')
${AnalyzerItemMetric[item.metric]}${item.value.asString()}' + item.other!.asString() + '
`; + } + + public async addAdditionalResultsSet(name: String, resultFiles: ResultSetItem[]): Promise { + if (resultFiles.length == 0) return; + + this.buffer += ` +
+

${name}

+ `; + + // Each `resultFile` will be printed as a single row - with metrics as table columns. + for (let i = 0; i < resultFiles.length; i++) { + const resultFile = resultFiles[i]; + // Load the file and "analyse" - collect stats we want to print. + const analysis = await ResultsAnalyzer.analyze(Result.readFromFile(resultFile.path)); + + if (i == 0) { + // Add table header + this.buffer += ''; + for (const item of analysis.items) { + this.buffer += ``; + } + this.buffer += ''; + } + + // Add table row + this.buffer += ``; + for (const item of analysis.items) { + this.buffer += ``; + } + this.buffer += ''; + } + + this.buffer += ` +
Revision${AnalyzerItemMetric[item.metric]}
${resultFile.hash}${item.value.asString()}
+
`; + } +} diff --git a/packages/replay/metrics/src/results/results-set.ts b/packages/replay/metrics/src/results/results-set.ts index 5597d423810d..d19a2ca121e7 100644 --- a/packages/replay/metrics/src/results/results-set.ts +++ b/packages/replay/metrics/src/results/results-set.ts @@ -35,10 +35,6 @@ export class ResultsSet { } } - public count(): number { - return this.items().length; - } - public find(predicate: (value: Result) => boolean): [GitHash, Result] | undefined { const items = this.items(); for (let i = 0; i < items.length; i++) { diff --git a/packages/replay/metrics/src/util/console.ts b/packages/replay/metrics/src/util/console.ts new file mode 100644 index 000000000000..a1cef832db72 --- /dev/null +++ b/packages/replay/metrics/src/util/console.ts @@ -0,0 +1,10 @@ + +export async function consoleGroup(code: () => Promise): Promise { + console.group(); + try { + + return await code(); + } finally { + console.groupEnd(); + } +} diff --git a/packages/replay/metrics/src/util/github.ts b/packages/replay/metrics/src/util/github.ts index f5ac856bbad8..9a393a5819af 100644 --- a/packages/replay/metrics/src/util/github.ts +++ b/packages/replay/metrics/src/util/github.ts @@ -4,6 +4,8 @@ import { Git } from './git.js'; import path from 'path'; import Axios from 'axios'; import extract from 'extract-zip'; +import { consoleGroup } from './console.js'; +import { PrCommentBuilder } from '../results/pr-comment.js'; const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN, @@ -11,9 +13,9 @@ const octokit = new Octokit({ }); const [_, owner, repo] = (await Git.repository).split('/'); -const defaultArgs = { owner: owner, repo: repo } +const defaultArgs = { owner, repo } -export function downloadArtifact(url: string, path: string) { +async function downloadArtifact(url: string, path: string): Promise { const writer = fs.createWriteStream(path); return Axios({ method: 'get', @@ -32,7 +34,7 @@ export function downloadArtifact(url: string, path: string) { reject(err); }); writer.on('close', () => { - if (!error) resolve(true); + if (!error) resolve(); }); }); }); @@ -47,11 +49,17 @@ export const GitHub = { }, downloadPreviousArtifact(branch: string, targetDir: string, artifactName: string): Promise { - return (async () => { + console.log(`Trying to download previous artifact '${artifactName}' for branch '${branch}'`); + return consoleGroup(async () => { fs.mkdirSync(targetDir, { recursive: true }); - const workflow = (await octokit.actions.listRepoWorkflows(defaultArgs)) - .data.workflows.find((w) => w.name == process.env.GITHUB_WORKFLOW); + var workflow = await (async () => { + for await (const workflows of octokit.paginate.iterator(octokit.rest.actions.listRepoWorkflows, defaultArgs)) { + let found = workflows.data.find((w) => w.name == process.env.GITHUB_WORKFLOW); + if (found) return found; + } + return undefined; + })(); if (workflow == undefined) { console.log( `Skipping previous artifact '${artifactName}' download for branch '${branch}' - not running in CI?`, @@ -59,7 +67,6 @@ export const GitHub = { ); return; } - console.log(`Trying to download previous artifact '${artifactName}' for branch '${branch}'`); const workflowRuns = await octokit.actions.listWorkflowRuns({ ...defaultArgs, @@ -98,32 +105,77 @@ export const GitHub = { fs.unlinkSync(tempFilePath); } } - })(); + }); }, - // fun addOrUpdateComment(commentBuilder: PrCommentBuilder) { - // if (pullRequest == null) { - // val file = File("out/comment.html") - // println("No PR available (not running in CI?): writing built comment to ${file.absolutePath}") - // file.writeText(commentBuilder.body) - // } else { - // val comments = pullRequest!!.comments - // // Trying to fetch `github!!.myself` throws (in CI only): Exception in thread "main" org.kohsuke.github.HttpException: - // // {"message":"Resource not accessible by integration","documentation_url":"https://docs.github.com/rest/reference/users#get-the-authenticated-user"} - // // Let's make this conditional on some env variable that's unlikely to be set. - // // Do not use "CI" because that's commonly set during local development and testing. - // val author = if (env.containsKey("GITHUB_ACTION")) "github-actions[bot]" else github!!.myself.login - // val comment = comments.firstOrNull { - // it.user.login.equals(author) && - // it.body.startsWith(commentBuilder.title, ignoreCase = true) - // } - // if (comment != null) { - // println("Updating PR comment ${comment.htmlUrl} body") - // comment.update(commentBuilder.body) - // } else { - // println("Adding new PR comment to ${pullRequest!!.htmlUrl}") - // pullRequest!!.comment(commentBuilder.body) - // } - // } - // } + async addOrUpdateComment(commentBuilder: PrCommentBuilder): Promise { + console.log('Adding/updating PR comment'); + return consoleGroup(async () => { + /* Env var GITHUB_REF is only set if a branch or tag is available for the current CI event trigger type. + The ref given is fully-formed, meaning that + * for branches the format is refs/heads/, + * for pull requests it is refs/pull//merge, + * and for tags it is refs/tags/. + For example, refs/heads/feature-branch-1. + */ + let prNumber: number | undefined; + if (typeof process.env.GITHUB_REF == 'string' && process.env.GITHUB_REF.length > 0) { + if (process.env.GITHUB_REF.startsWith("refs/pull/")) { + prNumber = parseInt(process.env.GITHUB_REF.split('/')[2]); + } else { + const pr = await octokit.rest.pulls.list({ + ...defaultArgs, + base: await Git.baseBranch, + head: await Git.branch + }); + prNumber = pr.data.at(0)?.id; + } + } + + if (prNumber == undefined) { + console.log("No PR available (not running in CI?). Printing the PR comment instead:"); + console.log(commentBuilder.body); + return; + } + + // Determine the PR comment author: + // Trying to fetch `octokit.users.getAuthenticated()` throws (in CI only): + // {"message":"Resource not accessible by integration","documentation_url":"https://docs.github.com/rest/reference/users#get-the-authenticated-user"} + // Let's make this conditional on some env variable that's unlikely to be set locally but will be set in GH Actions. + // Do not use "CI" because that's commonly set during local development and testing. + const author = typeof process.env.GITHUB_ACTION == 'string' ? 'github-actions[bot]' : (await octokit.users.getAuthenticated()).data.login; + + // Try to find an existing comment by the author and title. + var comment = await (async () => { + for await (const comments of octokit.paginate.iterator(octokit.rest.issues.listComments, { + ...defaultArgs, + issue_number: prNumber, + })) { + const found = comments.data.find((comment) => { + return comment.user?.login == author + && comment.body != undefined + && comment.body.indexOf(commentBuilder.title) >= 0; + }); + if (found) return found; + } + return undefined; + })(); + + if (comment != undefined) { + console.log(`Updating PR comment ${comment.html_url} body`) + await octokit.rest.issues.updateComment({ + ...defaultArgs, + comment_id: comment.id, + body: commentBuilder.body, + }); + } else { + console.log(`Adding new PR comment to PR ${prNumber}`) + await octokit.rest.issues.createComment({ + ...defaultArgs, + issue_number: prNumber, + body: commentBuilder.body, + }); + } + }); + } } From c8a71b767caa5bfc156c4b5f11dc0b087dd78654 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Tue, 3 Jan 2023 16:58:49 +0100 Subject: [PATCH 27/55] improve pr comment --- packages/replay/metrics/configs/ci/process.ts | 7 ++-- .../replay/metrics/src/results/pr-comment.ts | 35 +++++++++++++++---- packages/replay/metrics/src/util/github.ts | 5 +-- 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/packages/replay/metrics/configs/ci/process.ts b/packages/replay/metrics/configs/ci/process.ts index b137577f2a6b..29afda5e5294 100644 --- a/packages/replay/metrics/configs/ci/process.ts +++ b/packages/replay/metrics/configs/ci/process.ts @@ -8,7 +8,6 @@ import { GitHub } from '../../src/util/github.js'; import { latestResultFile, previousResultsDir, baselineResultsDir, artifactName } from './env.js'; const latestResult = Result.readFromFile(latestResultFile); -console.debug(latestResult); await GitHub.downloadPreviousArtifact(await Git.baseBranch, baselineResultsDir, artifactName); await GitHub.downloadPreviousArtifact(await Git.branch, previousResultsDir, artifactName); @@ -21,14 +20,14 @@ const previousResults = new ResultsSet(previousResultsDir); const prComment = new PrCommentBuilder(); if (Git.baseBranch != Git.branch) { const baseResults = new ResultsSet(baselineResultsDir); - prComment.addCurrentResult(await ResultsAnalyzer.analyze(latestResult, baseResults), "Baseline"); + await prComment.addCurrentResult(await ResultsAnalyzer.analyze(latestResult, baseResults), "Baseline"); await prComment.addAdditionalResultsSet( `Baseline results on branch: ${Git.baseBranch}`, // We skip the first one here because it's already included as `Baseline` column above in addCurrentResult(). baseResults.items().slice(1, 10) ); } else { - prComment.addCurrentResult(await ResultsAnalyzer.analyze(latestResult, previousResults), "Previous"); + await prComment.addCurrentResult(await ResultsAnalyzer.analyze(latestResult, previousResults), "Previous"); } await prComment.addAdditionalResultsSet( @@ -36,7 +35,7 @@ await prComment.addAdditionalResultsSet( previousResults.items().slice(0, 10) ); -GitHub.addOrUpdateComment(prComment); +await GitHub.addOrUpdateComment(prComment); // Copy the latest test run results to the archived result dir. await previousResults.add(latestResultFile, true); diff --git a/packages/replay/metrics/src/results/pr-comment.ts b/packages/replay/metrics/src/results/pr-comment.ts index 8eff29891fd8..462a9a4e6288 100644 --- a/packages/replay/metrics/src/results/pr-comment.ts +++ b/packages/replay/metrics/src/results/pr-comment.ts @@ -3,18 +3,39 @@ import { Analysis, AnalyzerItemMetric, ResultsAnalyzer } from "./analyzer.js"; import { Result } from "./result.js"; import { ResultSetItem } from "./results-set.js"; +function trimIndent(str: string): string { + return str.split('\n').map(s => s.trim()).join('\n'); +} + +function printableMetricName(metric: AnalyzerItemMetric): string { + switch (metric) { + case AnalyzerItemMetric.lcp: + return 'LCP'; + case AnalyzerItemMetric.cls: + return 'CLS'; + case AnalyzerItemMetric.cpu: + return 'CPU'; + case AnalyzerItemMetric.memoryAvg: + return 'JS heap avg'; + case AnalyzerItemMetric.memoryMax: + return 'JS heap max'; + default: + return AnalyzerItemMetric[metric]; + } +} + export class PrCommentBuilder { private buffer = ''; public get title(): string { - return '## Replay SDK metrics :rocket:'; + return 'Replay SDK metrics :rocket:'; } public get body(): string { - return this.buffer; + return trimIndent(this.buffer); } - public addCurrentResult(analysis: Analysis, otherName: string): void { + public async addCurrentResult(analysis: Analysis, otherName: string): Promise { // Decides whether to print the "Other" depending on it being set in the input data. const maybeOther = function (content: () => string): string { if (analysis.otherHash == undefined) { @@ -24,18 +45,18 @@ export class PrCommentBuilder { } this.buffer += ` - ${this.title} +

${this.title}

- + ${maybeOther(() => '')} ` for (const item of analysis.items) { this.buffer += ` - + ${maybeOther(() => '')} ` @@ -63,7 +84,7 @@ export class PrCommentBuilder { // Add table header this.buffer += ''; for (const item of analysis.items) { - this.buffer += ``; + this.buffer += ``; } this.buffer += ''; } diff --git a/packages/replay/metrics/src/util/github.ts b/packages/replay/metrics/src/util/github.ts index 9a393a5819af..0c292513c72b 100644 --- a/packages/replay/metrics/src/util/github.ts +++ b/packages/replay/metrics/src/util/github.ts @@ -133,8 +133,9 @@ export const GitHub = { } if (prNumber == undefined) { - console.log("No PR available (not running in CI?). Printing the PR comment instead:"); - console.log(commentBuilder.body); + const file = 'out/comment.html'; + console.log(`No PR available (not running in CI?): writing built comment to ${path.resolve(file)}`); + fs.writeFileSync(file, commentBuilder.body); return; } From 78c6a949d695165b34245ab7a0ab5754cf0e22a9 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Tue, 3 Jan 2023 19:34:08 +0100 Subject: [PATCH 28/55] metrics: switch from puppeteer to playwright --- packages/replay/metrics/package.json | 2 +- packages/replay/metrics/src/collector.ts | 36 +- packages/replay/metrics/src/perf/cpu.ts | 8 +- packages/replay/metrics/src/perf/memory.ts | 8 +- packages/replay/metrics/src/perf/sampler.ts | 34 +- packages/replay/metrics/src/scenarios.ts | 8 +- packages/replay/metrics/src/vitals/cls.ts | 6 +- packages/replay/metrics/src/vitals/fid.ts | 6 +- packages/replay/metrics/src/vitals/index.ts | 4 +- packages/replay/metrics/src/vitals/lcp.ts | 6 +- packages/replay/metrics/yarn.lock | 410 +------------------- 11 files changed, 95 insertions(+), 433 deletions(-) diff --git a/packages/replay/metrics/package.json b/packages/replay/metrics/package.json index 29fb646a12cf..79fab58811ad 100644 --- a/packages/replay/metrics/package.json +++ b/packages/replay/metrics/package.json @@ -19,7 +19,7 @@ "axios": "^1.2.2", "extract-zip": "^2.0.1", "filesize": "^10.0.6", - "puppeteer": "^19.4.1", + "playwright": "^1.29.1", "simple-git": "^3.15.1", "simple-statistics": "^7.8.0", "typescript": "^4.9.4" diff --git a/packages/replay/metrics/src/collector.ts b/packages/replay/metrics/src/collector.ts index a33b0ca9bb93..432217ce085a 100644 --- a/packages/replay/metrics/src/collector.ts +++ b/packages/replay/metrics/src/collector.ts @@ -1,5 +1,5 @@ import assert from 'assert'; -import * as puppeteer from 'puppeteer'; +import * as playwright from 'playwright'; import { CpuUsage, CpuUsageSampler } from './perf/cpu.js'; import { JsHeapUsage, JsHeapUsageSampler } from './perf/memory.js'; @@ -11,6 +11,22 @@ import { WebVitals, WebVitalsCollector } from './vitals/index.js'; const cpuThrottling = 4; const networkConditions = 'Fast 3G'; +// Same as puppeteer-core PredefinedNetworkConditions +const PredefinedNetworkConditions = Object.freeze({ + 'Slow 3G': { + download: ((500 * 1000) / 8) * 0.8, + upload: ((500 * 1000) / 8) * 0.8, + latency: 400 * 5, + connectionType: 'cellular3g', + }, + 'Fast 3G': { + download: ((1.6 * 1000 * 1000) / 8) * 0.9, + upload: ((750 * 1000) / 8) * 0.9, + latency: 150 * 3.75, + connectionType: 'cellular3g', + }, +}); + export class Metrics { constructor(public readonly vitals: WebVitals, public readonly cpu: CpuUsage, public readonly memory: JsHeapUsage) { } @@ -30,7 +46,7 @@ export interface MetricsCollectorOptions { export class MetricsCollector { private options: MetricsCollectorOptions; - constructor(options: Partial) { + constructor(options?: Partial) { this.options = { headless: false, ...options @@ -75,18 +91,26 @@ export class MetricsCollector { private async run(scenario: Scenario): Promise { const disposeCallbacks: (() => Promise)[] = []; try { - const browser = await puppeteer.launch({ + const browser = await playwright.chromium.launch({ headless: this.options.headless, + }); disposeCallbacks.push(async () => browser.close()); const page = await browser.newPage(); + const cdp = await page.context().newCDPSession(page); + // Simulate throttling. - await page.emulateNetworkConditions(puppeteer.PredefinedNetworkConditions[networkConditions]); - await page.emulateCPUThrottling(cpuThrottling); + await cdp.send('Network.emulateNetworkConditions', { + offline: false, + latency: PredefinedNetworkConditions[networkConditions].latency, + uploadThroughput: PredefinedNetworkConditions[networkConditions].upload, + downloadThroughput: PredefinedNetworkConditions[networkConditions].download, + }); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: cpuThrottling }); // Collect CPU and memory info 10 times per second. - const perfSampler = await PerfMetricsSampler.create(page, 100); + const perfSampler = await PerfMetricsSampler.create(cdp, 100); disposeCallbacks.push(async () => perfSampler.stop()); const cpuSampler = new CpuUsageSampler(perfSampler); const memSampler = new JsHeapUsageSampler(perfSampler); diff --git a/packages/replay/metrics/src/perf/cpu.ts b/packages/replay/metrics/src/perf/cpu.ts index 152d488a592b..29179c9d2a46 100644 --- a/packages/replay/metrics/src/perf/cpu.ts +++ b/packages/replay/metrics/src/perf/cpu.ts @@ -1,6 +1,4 @@ -import * as puppeteer from 'puppeteer'; - -import { PerfMetricsSampler, TimeBasedMap } from './sampler.js'; +import { PerfMetrics, PerfMetricsSampler, TimeBasedMap } from './sampler.js'; export { CpuUsageSampler, CpuUsage } @@ -35,8 +33,8 @@ class CpuUsageSampler { return new CpuUsage(this._snapshots, this._average); } - private async _collect(metrics: puppeteer.Metrics): Promise { - const data = new MetricsDataPoint(metrics.Timestamp!, metrics.TaskDuration! + metrics.TaskDuration! + metrics.LayoutDuration! + metrics.ScriptDuration!); + private async _collect(metrics: PerfMetrics): Promise { + const data = new MetricsDataPoint(metrics.Timestamp, metrics.Duration); if (this._initial == undefined) { this._initial = data; this._startTime = data.timestamp; diff --git a/packages/replay/metrics/src/perf/memory.ts b/packages/replay/metrics/src/perf/memory.ts index 6c6e907c5e75..3566622ccb0e 100644 --- a/packages/replay/metrics/src/perf/memory.ts +++ b/packages/replay/metrics/src/perf/memory.ts @@ -1,6 +1,4 @@ -import * as puppeteer from 'puppeteer'; - -import { PerfMetricsSampler, TimeBasedMap } from './sampler.js'; +import { PerfMetrics, PerfMetricsSampler, TimeBasedMap } from './sampler.js'; export { JsHeapUsageSampler, JsHeapUsage } @@ -23,7 +21,7 @@ class JsHeapUsageSampler { return new JsHeapUsage(this._snapshots); } - private async _collect(metrics: puppeteer.Metrics): Promise { - this._snapshots.set(metrics.Timestamp!, metrics.JSHeapUsedSize!); + private async _collect(metrics: PerfMetrics): Promise { + this._snapshots.set(metrics.Timestamp, metrics.JSHeapUsedSize!); } } diff --git a/packages/replay/metrics/src/perf/sampler.ts b/packages/replay/metrics/src/perf/sampler.ts index 80ce931045b2..58dac3516646 100644 --- a/packages/replay/metrics/src/perf/sampler.ts +++ b/packages/replay/metrics/src/perf/sampler.ts @@ -1,6 +1,7 @@ -import * as puppeteer from 'puppeteer'; +import * as playwright from 'playwright'; +import { Protocol } from 'playwright-core/types/protocol'; -export type PerfMetricsConsumer = (metrics: puppeteer.Metrics) => Promise; +export type PerfMetricsConsumer = (metrics: PerfMetrics) => Promise; export type TimestampSeconds = number; export class TimeBasedMap extends Map { @@ -18,22 +19,39 @@ export class TimeBasedMap extends Map { } } +export class PerfMetrics { + constructor(private metrics: Protocol.Performance.Metric[]) { } + + private find(name: string): number { + return this.metrics.find((metric) => metric.name == name)!.value; + } + + public get Timestamp(): number { + return this.find('Timestamp'); + } + + public get Duration(): number { + return this.metrics.reduce((sum, metric) => metric.name.endsWith('Duration') ? sum + metric.value : sum, 0); + } + + public get JSHeapUsedSize(): number { + return this.find('JSHeapUsedSize'); + } +} + export class PerfMetricsSampler { private _consumers: PerfMetricsConsumer[] = []; private _timer!: NodeJS.Timer; - public static async create(page: puppeteer.Page, interval: number): Promise { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const cdp = await page.target().createCDPSession(); - + public static async create(cdp: playwright.CDPSession, interval: number): Promise { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access await cdp.send('Performance.enable', { timeDomain: 'timeTicks' }) const self = new PerfMetricsSampler(); self._timer = setInterval(async () => { - const metrics = await page.metrics(); - self._consumers.forEach((cb) => cb(metrics).catch(console.log)); + const metrics = await cdp.send("Performance.getMetrics").then((v) => v.metrics); + self._consumers.forEach((cb) => cb(new PerfMetrics(metrics)).catch(console.log)); }, interval); return self; diff --git a/packages/replay/metrics/src/scenarios.ts b/packages/replay/metrics/src/scenarios.ts index 1a9a31e74eb7..844f85fb573f 100644 --- a/packages/replay/metrics/src/scenarios.ts +++ b/packages/replay/metrics/src/scenarios.ts @@ -1,12 +1,12 @@ import path from 'path'; -import * as puppeteer from 'puppeteer'; +import * as playwright from 'playwright'; import * as fs from 'fs'; import { Metrics } from './collector'; import assert from 'assert'; // A testing scenario we want to collect metrics for. export interface Scenario { - run(browser: puppeteer.Browser, page: puppeteer.Page): Promise; + run(browser: playwright.Browser, page: playwright.Page): Promise; } // Two scenarios that are compared to each other. @@ -26,7 +26,7 @@ export interface TestCase { export class LoadPageScenario implements Scenario { public constructor(public url: string) { } - public async run(_: puppeteer.Browser, page: puppeteer.Page): Promise { + public async run(_: playwright.Browser, page: playwright.Page): Promise { await page.goto(this.url, { waitUntil: 'load', timeout: 60000 }); } } @@ -35,7 +35,7 @@ export class LoadPageScenario implements Scenario { export class JankTestScenario implements Scenario { public constructor(private withSentry: boolean) { } - public async run(_: puppeteer.Browser, page: puppeteer.Page): Promise { + public async run(_: playwright.Browser, page: playwright.Page): Promise { let url = path.resolve('./test-apps/jank/' + (this.withSentry ? 'with-sentry' : 'index') + '.html'); assert(fs.existsSync(url)); url = 'file:///' + url.replace('\\', '/'); diff --git a/packages/replay/metrics/src/vitals/cls.ts b/packages/replay/metrics/src/vitals/cls.ts index 57175011d52b..213c5ddef0e5 100644 --- a/packages/replay/metrics/src/vitals/cls.ts +++ b/packages/replay/metrics/src/vitals/cls.ts @@ -1,14 +1,14 @@ -import * as puppeteer from 'puppeteer'; +import * as playwright from 'playwright'; export { CLS }; // https://web.dev/cls/ class CLS { constructor( - private _page: puppeteer.Page) { } + private _page: playwright.Page) { } public async setup(): Promise { - await this._page.evaluateOnNewDocument(`{ + await this._page.context().addInitScript(`{ window.cumulativeLayoutShiftScore = undefined; const observer = new PerformanceObserver((list) => { diff --git a/packages/replay/metrics/src/vitals/fid.ts b/packages/replay/metrics/src/vitals/fid.ts index dac573010585..550a1d40fbee 100644 --- a/packages/replay/metrics/src/vitals/fid.ts +++ b/packages/replay/metrics/src/vitals/fid.ts @@ -1,14 +1,14 @@ -import * as puppeteer from 'puppeteer'; +import * as playwright from 'playwright'; export { FID }; // https://web.dev/fid/ class FID { constructor( - private _page: puppeteer.Page) { } + private _page: playwright.Page) { } public async setup(): Promise { - await this._page.evaluateOnNewDocument(`{ + await this._page.context().addInitScript(`{ window.firstInputDelay = undefined; const observer = new PerformanceObserver((entryList) => { diff --git a/packages/replay/metrics/src/vitals/index.ts b/packages/replay/metrics/src/vitals/index.ts index 68e08ba8877a..119af6622c83 100644 --- a/packages/replay/metrics/src/vitals/index.ts +++ b/packages/replay/metrics/src/vitals/index.ts @@ -1,4 +1,4 @@ -import * as puppeteer from 'puppeteer'; +import * as playwright from 'playwright'; import { CLS } from './cls.js'; import { FID } from './fid.js'; @@ -18,7 +18,7 @@ class WebVitalsCollector { private constructor(private _lcp: LCP, private _cls: CLS, private _fid: FID) { } - public static async create(page: puppeteer.Page): + public static async create(page: playwright.Page): Promise { const result = new WebVitalsCollector(new LCP(page), new CLS(page), new FID(page)); diff --git a/packages/replay/metrics/src/vitals/lcp.ts b/packages/replay/metrics/src/vitals/lcp.ts index 247ce0fcd3b6..4bc05143ddce 100644 --- a/packages/replay/metrics/src/vitals/lcp.ts +++ b/packages/replay/metrics/src/vitals/lcp.ts @@ -1,14 +1,14 @@ -import * as puppeteer from 'puppeteer'; +import * as playwright from 'playwright'; export { LCP }; // https://web.dev/lcp/ class LCP { constructor( - private _page: puppeteer.Page) { } + private _page: playwright.Page) { } public async setup(): Promise { - await this._page.evaluateOnNewDocument(`{ + await this._page.context().addInitScript(`{ window.largestContentfulPaint = undefined; const observer = new PerformanceObserver((list) => { diff --git a/packages/replay/metrics/yarn.lock b/packages/replay/metrics/yarn.lock index 976135fc15de..754a7ff63816 100644 --- a/packages/replay/metrics/yarn.lock +++ b/packages/replay/metrics/yarn.lock @@ -2,27 +2,6 @@ # yarn lockfile v1 -"@babel/code-frame@^7.0.0": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a" - integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q== - dependencies: - "@babel/highlight" "^7.18.6" - -"@babel/helper-validator-identifier@^7.18.6": - version "7.19.1" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz#7eea834cf32901ffdc1a7ee555e2f9c27e249ca2" - integrity sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w== - -"@babel/highlight@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.18.6.tgz#81158601e93e2563795adcbfbdf5d64be3f2ecdf" - integrity sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g== - dependencies: - "@babel/helper-validator-identifier" "^7.18.6" - chalk "^2.0.0" - js-tokens "^4.0.0" - "@cspotcode/source-map-support@^0.8.0": version "0.8.1" resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" @@ -203,30 +182,11 @@ acorn@^8.4.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.1.tgz#0a3f9cbecc4ec3bea6f0a80b66ae8dd2da250b73" integrity sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA== -agent-base@6: - version "6.0.2" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" - integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== - dependencies: - debug "4" - -ansi-styles@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" - integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== - dependencies: - color-convert "^1.9.0" - arg@^4.1.0: version "4.1.3" resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== -argparse@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" - integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== - asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -241,82 +201,16 @@ axios@^1.2.2: form-data "^4.0.0" proxy-from-env "^1.1.0" -balanced-match@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" - integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== - -base64-js@^1.3.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" - integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== - before-after-hook@^2.2.0: version "2.2.3" resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.2.3.tgz#c51e809c81a4e354084422b9b26bad88249c517c" integrity sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ== -bl@^4.0.3: - version "4.1.0" - resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" - integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== - dependencies: - buffer "^5.5.0" - inherits "^2.0.4" - readable-stream "^3.4.0" - -brace-expansion@^1.1.7: - version "1.1.11" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" - integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== - dependencies: - balanced-match "^1.0.0" - concat-map "0.0.1" - buffer-crc32@~0.2.3: version "0.2.13" resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ== -buffer@^5.2.1, buffer@^5.5.0: - version "5.7.1" - resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" - integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== - dependencies: - base64-js "^1.3.1" - ieee754 "^1.1.13" - -callsites@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" - integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== - -chalk@^2.0.0: - version "2.4.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" - integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== - dependencies: - ansi-styles "^3.2.1" - escape-string-regexp "^1.0.5" - supports-color "^5.3.0" - -chownr@^1.1.1: - version "1.1.4" - resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" - integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== - -color-convert@^1.9.0: - version "1.9.3" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" - integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== - dependencies: - color-name "1.1.3" - -color-name@1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" - integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== - combined-stream@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" @@ -324,34 +218,12 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" -concat-map@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== - -cosmiconfig@8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-8.0.0.tgz#e9feae014eab580f858f8a0288f38997a7bebe97" - integrity sha512-da1EafcpH6b/TD8vDRaWV7xFINlHlF6zKsGwS1TsuVJTZRkquaS5HTMq7uq6h31619QjbsYl21gVDOm32KM1vQ== - dependencies: - import-fresh "^3.2.1" - js-yaml "^4.1.0" - parse-json "^5.0.0" - path-type "^4.0.0" - create-require@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== -cross-fetch@3.1.5: - version "3.1.5" - resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" - integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw== - dependencies: - node-fetch "2.6.7" - -debug@4, debug@4.3.4, debug@^4.1.1, debug@^4.3.4: +debug@^4.1.1, debug@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -368,36 +240,19 @@ deprecation@^2.0.0, deprecation@^2.3.1: resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919" integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ== -devtools-protocol@0.0.1068969: - version "0.0.1068969" - resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1068969.tgz#8b9a4bc48aed1453bed08d62b07481f9abf4d6d8" - integrity sha512-ATFTrPbY1dKYhPPvpjtwWKSK2mIwGmRwX54UASn9THEuIZCe2n9k3vVuMmt6jWeL+e5QaaguEv/pMyR+JQB7VQ== - diff@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== -end-of-stream@^1.1.0, end-of-stream@^1.4.1: +end-of-stream@^1.1.0: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== dependencies: once "^1.4.0" -error-ex@^1.3.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" - integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== - dependencies: - is-arrayish "^0.2.1" - -escape-string-regexp@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" - integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== - -extract-zip@2.0.1, extract-zip@^2.0.1: +extract-zip@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a" integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg== @@ -434,16 +289,6 @@ form-data@^4.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" -fs-constants@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" - integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== - -fs.realpath@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== - get-stream@^5.1.0: version "5.2.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" @@ -451,89 +296,11 @@ get-stream@^5.1.0: dependencies: pump "^3.0.0" -glob@^7.1.3: - version "7.2.3" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" - integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.1.1" - once "^1.3.0" - path-is-absolute "^1.0.0" - -has-flag@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" - integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== - -https-proxy-agent@5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" - integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== - dependencies: - agent-base "6" - debug "4" - -ieee754@^1.1.13: - version "1.2.1" - resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" - integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== - -import-fresh@^3.2.1: - version "3.3.0" - resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" - integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== - dependencies: - parent-module "^1.0.0" - resolve-from "^4.0.0" - -inflight@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== - dependencies: - once "^1.3.0" - wrappy "1" - -inherits@2, inherits@^2.0.3, inherits@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" - integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== - -is-arrayish@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" - integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== - is-plain-object@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== -js-tokens@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" - integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== - -js-yaml@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" - integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== - dependencies: - argparse "^2.0.1" - -json-parse-even-better-errors@^2.3.0: - version "2.3.1" - resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" - integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== - -lines-and-columns@^1.1.6: - version "1.2.4" - resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" - integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== - make-error@^1.1.1: version "1.3.6" resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" @@ -551,75 +318,43 @@ mime-types@^2.1.12: dependencies: mime-db "1.52.0" -minimatch@^3.1.1: - version "3.1.2" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" - integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== - dependencies: - brace-expansion "^1.1.7" - -mkdirp-classic@^0.5.2: - version "0.5.3" - resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" - integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== - ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -node-fetch@2.6.7, node-fetch@^2.6.7: +node-fetch@^2.6.7: version "2.6.7" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== dependencies: whatwg-url "^5.0.0" -once@^1.3.0, once@^1.3.1, once@^1.4.0: +once@^1.3.1, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== dependencies: wrappy "1" -parent-module@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" - integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== - dependencies: - callsites "^3.0.0" - -parse-json@^5.0.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" - integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== - dependencies: - "@babel/code-frame" "^7.0.0" - error-ex "^1.3.1" - json-parse-even-better-errors "^2.3.0" - lines-and-columns "^1.1.6" - -path-is-absolute@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== - -path-type@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" - integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== - pend@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg== -progress@2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" - integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== +playwright-core@1.29.1: + version "1.29.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.29.1.tgz#9ec15d61c4bd2f386ddf6ce010db53a030345a47" + integrity sha512-20Ai3d+lMkWpI9YZYlxk8gxatfgax5STW8GaMozAHwigLiyiKQrdkt7gaoT9UQR8FIVDg6qVXs9IoZUQrDjIIg== -proxy-from-env@1.1.0, proxy-from-env@^1.1.0: +playwright@^1.29.1: + version "1.29.1" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.29.1.tgz#fc04b34f42e3bfc0edadb1c45ef9bffd53c21f70" + integrity sha512-lasC+pMqsQ2uWhNurt3YK3xo0gWlMjslYUylKbHcqF/NTjwp9KStRGO7S6wwz2f52GcSnop8XUK/GymJjdzrxw== + dependencies: + playwright-core "1.29.1" + +proxy-from-env@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== @@ -632,59 +367,6 @@ pump@^3.0.0: end-of-stream "^1.1.0" once "^1.3.1" -puppeteer-core@19.4.1: - version "19.4.1" - resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-19.4.1.tgz#f4875943841ebdb6fc2ad7a475add958692b0237" - integrity sha512-JHIuqtqrUAx4jGOTxXu4ilapV2jabxtVMA/e4wwFUMvtSsqK4nVBSI+Z1SKDoz7gRy/JUIc8WzmfocCa6SIZ1w== - dependencies: - cross-fetch "3.1.5" - debug "4.3.4" - devtools-protocol "0.0.1068969" - extract-zip "2.0.1" - https-proxy-agent "5.0.1" - proxy-from-env "1.1.0" - rimraf "3.0.2" - tar-fs "2.1.1" - unbzip2-stream "1.4.3" - ws "8.11.0" - -puppeteer@^19.4.1: - version "19.4.1" - resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-19.4.1.tgz#cac7d3f0084badebb8ebacbe6f4d7262e7f21818" - integrity sha512-PCnrR13B8A+VSEDXRmrNXRZbrkF1tfsI1hKSC7vs13eNS6CUD3Y4FA8SF8/VZy+Pm1kg5AggJT2Nu3HLAtGkFg== - dependencies: - cosmiconfig "8.0.0" - https-proxy-agent "5.0.1" - progress "2.0.3" - proxy-from-env "1.1.0" - puppeteer-core "19.4.1" - -readable-stream@^3.1.1, readable-stream@^3.4.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" - integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== - dependencies: - inherits "^2.0.3" - string_decoder "^1.1.1" - util-deprecate "^1.0.1" - -resolve-from@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" - integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== - -rimraf@3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" - integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== - dependencies: - glob "^7.1.3" - -safe-buffer@~5.2.0: - version "5.2.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - simple-git@^3.15.1: version "3.15.1" resolved "https://registry.yarnpkg.com/simple-git/-/simple-git-3.15.1.tgz#57f595682cb0c2475d5056da078a05c8715a25ef" @@ -699,46 +381,6 @@ simple-statistics@^7.8.0: resolved "https://registry.yarnpkg.com/simple-statistics/-/simple-statistics-7.8.0.tgz#1033d2d613656c7bd34f0e134fd7e69c803e6836" integrity sha512-lTWbfJc0u6GZhBojLOrlHJMTHu6PdUjSsYLrpiH902dVBiYJyWlN/LdSoG8b5VvfG1D30gIBgarqMNeNmU5nAA== -string_decoder@^1.1.1: - version "1.3.0" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== - dependencies: - safe-buffer "~5.2.0" - -supports-color@^5.3.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" - integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== - dependencies: - has-flag "^3.0.0" - -tar-fs@2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784" - integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng== - dependencies: - chownr "^1.1.1" - mkdirp-classic "^0.5.2" - pump "^3.0.0" - tar-stream "^2.1.4" - -tar-stream@^2.1.4: - version "2.2.0" - resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" - integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== - dependencies: - bl "^4.0.3" - end-of-stream "^1.4.1" - fs-constants "^1.0.0" - inherits "^2.0.3" - readable-stream "^3.1.1" - -through@^2.3.8: - version "2.3.8" - resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" - integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== - tr46@~0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" @@ -768,24 +410,11 @@ typescript@^4.9.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.4.tgz#a2a3d2756c079abda241d75f149df9d561091e78" integrity sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg== -unbzip2-stream@1.4.3: - version "1.4.3" - resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7" - integrity sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg== - dependencies: - buffer "^5.2.1" - through "^2.3.8" - universal-user-agent@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-6.0.0.tgz#3381f8503b251c0d9cd21bc1de939ec9df5480ee" integrity sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w== -util-deprecate@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" - integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== - v8-compile-cache-lib@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" @@ -809,11 +438,6 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== -ws@8.11.0: - version "8.11.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.11.0.tgz#6a0d36b8edfd9f96d8b25683db2f8d7de6e8e143" - integrity sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg== - yauzl@^2.10.0: version "2.10.0" resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" From cef616c662d0ec510cd13c78691caefe795dd423 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Tue, 3 Jan 2023 20:54:29 +0100 Subject: [PATCH 29/55] fix PR commenting --- packages/replay/metrics/src/util/github.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/replay/metrics/src/util/github.ts b/packages/replay/metrics/src/util/github.ts index 0c292513c72b..d00a8d8d41bf 100644 --- a/packages/replay/metrics/src/util/github.ts +++ b/packages/replay/metrics/src/util/github.ts @@ -119,16 +119,18 @@ export const GitHub = { For example, refs/heads/feature-branch-1. */ let prNumber: number | undefined; - if (typeof process.env.GITHUB_REF == 'string' && process.env.GITHUB_REF.length > 0) { - if (process.env.GITHUB_REF.startsWith("refs/pull/")) { - prNumber = parseInt(process.env.GITHUB_REF.split('/')[2]); - } else { - const pr = await octokit.rest.pulls.list({ - ...defaultArgs, - base: await Git.baseBranch, - head: await Git.branch - }); - prNumber = pr.data.at(0)?.id; + if (typeof process.env.GITHUB_REF == 'string' && process.env.GITHUB_REF.length > 0 && process.env.GITHUB_REF.startsWith("refs/pull/")) { + prNumber = parseInt(process.env.GITHUB_REF.split('/')[2]); + console.log(`Determined PR number ${prNumber} based on GITHUB_REF environment variable: '${process.env.GITHUB_REF}'`); + } else { + const pr = await octokit.rest.pulls.list({ + ...defaultArgs, + base: await Git.baseBranch, + head: await Git.branch + }); + prNumber = pr.data.at(0)?.number; + if (prNumber != undefined) { + console.log(`Found PR number ${prNumber} based on base and head branches`); } } From 9ee39437fbd4800f52985dd5f66069e1f00be73a Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Tue, 3 Jan 2023 21:10:03 +0100 Subject: [PATCH 30/55] first self-review --- packages/replay/metrics/README.md | 2 +- packages/replay/metrics/src/perf/sampler.ts | 1 + packages/replay/metrics/src/results/metrics-stats.ts | 11 ++--------- packages/replay/metrics/src/util/console.ts | 7 +------ 4 files changed, 5 insertions(+), 16 deletions(-) diff --git a/packages/replay/metrics/README.md b/packages/replay/metrics/README.md index bbf8d8cddcf5..dcf78671fba6 100644 --- a/packages/replay/metrics/README.md +++ b/packages/replay/metrics/README.md @@ -1,6 +1,6 @@ # Replay performance metrics -Evaluates Replay impact on website performance by running a web app in Chromium via Puppeteer and collecting various metrics. +Evaluates Replay impact on website performance by running a web app in Chromium via Playwright and collecting various metrics. ## Resources diff --git a/packages/replay/metrics/src/perf/sampler.ts b/packages/replay/metrics/src/perf/sampler.ts index 58dac3516646..ed585da9a0b3 100644 --- a/packages/replay/metrics/src/perf/sampler.ts +++ b/packages/replay/metrics/src/perf/sampler.ts @@ -31,6 +31,7 @@ export class PerfMetrics { } public get Duration(): number { + // TODO check if any of `Duration` fields is maybe a sum of the others. E.g. verify the measured CPU usage manually. return this.metrics.reduce((sum, metric) => metric.name.endsWith('Duration') ? sum + metric.value : sum, 0); } diff --git a/packages/replay/metrics/src/results/metrics-stats.ts b/packages/replay/metrics/src/results/metrics-stats.ts index ec4071caab88..acec86c6973c 100644 --- a/packages/replay/metrics/src/results/metrics-stats.ts +++ b/packages/replay/metrics/src/results/metrics-stats.ts @@ -6,15 +6,8 @@ export type NumberProvider = (metrics: Metrics) => number; export class MetricsStats { constructor(private items: Metrics[]) { } - // See https://en.wikipedia.org/wiki/Interquartile_range#Outliers for details - public filterOutliers(dataProvider: NumberProvider): number[] { - let numbers = this.items.map(dataProvider); - // TODO implement, see https://github.com/getsentry/action-app-sdk-overhead-metrics/blob/9ce7d562ff79b317688d22bd5c0bb725cbdfdb81/src/test/kotlin/StartupTimeTest.kt#L27-L37 - return numbers; - } - public filteredMean(dataProvider: NumberProvider): number | undefined { - const numbers = this.filterOutliers(dataProvider); + const numbers = this.items.map(dataProvider); return numbers.length > 0 ? ss.mean(numbers) : undefined; } @@ -35,7 +28,7 @@ export class MetricsStats { } public get memoryMax(): number | undefined { - const numbers = this.filterOutliers((metrics) => ss.max(Array.from(metrics.memory.snapshots.values()))); + const numbers = this.items.map((metrics) => ss.max(Array.from(metrics.memory.snapshots.values()))); return numbers.length > 0 ? ss.max(numbers) : undefined; } } diff --git a/packages/replay/metrics/src/util/console.ts b/packages/replay/metrics/src/util/console.ts index a1cef832db72..3ceaac1c862b 100644 --- a/packages/replay/metrics/src/util/console.ts +++ b/packages/replay/metrics/src/util/console.ts @@ -1,10 +1,5 @@ export async function consoleGroup(code: () => Promise): Promise { console.group(); - try { - - return await code(); - } finally { - console.groupEnd(); - } + return code().finally(console.groupEnd); } From 6133883c049b44880852d8163e3b2bcfd7c6396e Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Tue, 3 Jan 2023 21:46:32 +0100 Subject: [PATCH 31/55] fixes --- packages/replay/metrics/configs/ci/process.ts | 12 +++++++----- packages/replay/metrics/src/results/pr-comment.ts | 4 ++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/replay/metrics/configs/ci/process.ts b/packages/replay/metrics/configs/ci/process.ts index 29afda5e5294..def9dd2491b4 100644 --- a/packages/replay/metrics/configs/ci/process.ts +++ b/packages/replay/metrics/configs/ci/process.ts @@ -8,9 +8,11 @@ import { GitHub } from '../../src/util/github.js'; import { latestResultFile, previousResultsDir, baselineResultsDir, artifactName } from './env.js'; const latestResult = Result.readFromFile(latestResultFile); +const branch = await Git.branch; +const baseBranch = await Git.baseBranch; -await GitHub.downloadPreviousArtifact(await Git.baseBranch, baselineResultsDir, artifactName); -await GitHub.downloadPreviousArtifact(await Git.branch, previousResultsDir, artifactName); +await GitHub.downloadPreviousArtifact(baseBranch, baselineResultsDir, artifactName); +await GitHub.downloadPreviousArtifact(branch, previousResultsDir, artifactName); GitHub.writeOutput("artifactName", artifactName) GitHub.writeOutput("artifactPath", path.resolve(previousResultsDir)); @@ -18,11 +20,11 @@ GitHub.writeOutput("artifactPath", path.resolve(previousResultsDir)); const previousResults = new ResultsSet(previousResultsDir); const prComment = new PrCommentBuilder(); -if (Git.baseBranch != Git.branch) { +if (baseBranch != branch) { const baseResults = new ResultsSet(baselineResultsDir); await prComment.addCurrentResult(await ResultsAnalyzer.analyze(latestResult, baseResults), "Baseline"); await prComment.addAdditionalResultsSet( - `Baseline results on branch: ${Git.baseBranch}`, + `Baseline results on branch: ${baseBranch}`, // We skip the first one here because it's already included as `Baseline` column above in addCurrentResult(). baseResults.items().slice(1, 10) ); @@ -31,7 +33,7 @@ if (Git.baseBranch != Git.branch) { } await prComment.addAdditionalResultsSet( - `Previous results on branch: ${Git.branch}`, + `Previous results on branch: ${branch}`, previousResults.items().slice(0, 10) ); diff --git a/packages/replay/metrics/src/results/pr-comment.ts b/packages/replay/metrics/src/results/pr-comment.ts index 462a9a4e6288..704992583306 100644 --- a/packages/replay/metrics/src/results/pr-comment.ts +++ b/packages/replay/metrics/src/results/pr-comment.ts @@ -49,8 +49,8 @@ export class PrCommentBuilder {
 Latest diff (${Git.hash})Latest diff (${await Git.hash})' + otherName + ' diff (' + analysis.otherHash + ')
${AnalyzerItemMetric[item.metric]}${printableMetricName(item.metric)} ${item.value.asString()}' + item.other!.asString() + '
Revision${AnalyzerItemMetric[item.metric]}${printableMetricName(item.metric)}
- - ${maybeOther(() => '')} + + ${maybeOther(() => '')} ` for (const item of analysis.items) { From d4d7f3be5879090962f63993c724ed1127db11e5 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Wed, 4 Jan 2023 11:20:29 +0100 Subject: [PATCH 32/55] chore - metrics linter issues --- .../metrics/{.eslintrc.js => .eslintrc.cjs} | 2 + packages/replay/metrics/configs/ci/env.ts | 2 +- packages/replay/metrics/configs/ci/process.ts | 11 ++--- .../replay/metrics/configs/dev/process.ts | 13 +++--- packages/replay/metrics/package.json | 1 + packages/replay/metrics/src/collector.ts | 26 ++++++------ packages/replay/metrics/src/perf/cpu.ts | 11 +++-- packages/replay/metrics/src/perf/memory.ts | 9 +++-- packages/replay/metrics/src/perf/sampler.ts | 30 +++++++------- .../replay/metrics/src/results/analyzer.ts | 25 ++++++------ .../metrics/src/results/metrics-stats.ts | 9 +++-- .../replay/metrics/src/results/pr-comment.ts | 40 +++++++++---------- packages/replay/metrics/src/results/result.ts | 37 +++++++---------- .../replay/metrics/src/results/results-set.ts | 34 ++++++++-------- packages/replay/metrics/src/scenarios.ts | 11 ++--- packages/replay/metrics/src/util/git.ts | 2 +- packages/replay/metrics/src/util/github.ts | 31 +++++++------- packages/replay/metrics/src/util/json.ts | 14 +++++++ packages/replay/metrics/yarn.lock | 2 +- 19 files changed, 166 insertions(+), 144 deletions(-) rename packages/replay/metrics/{.eslintrc.js => .eslintrc.cjs} (75%) create mode 100644 packages/replay/metrics/src/util/json.ts diff --git a/packages/replay/metrics/.eslintrc.js b/packages/replay/metrics/.eslintrc.cjs similarity index 75% rename from packages/replay/metrics/.eslintrc.js rename to packages/replay/metrics/.eslintrc.cjs index 3c4c14a69860..9f90433a8fa8 100644 --- a/packages/replay/metrics/.eslintrc.js +++ b/packages/replay/metrics/.eslintrc.cjs @@ -1,11 +1,13 @@ module.exports = { extends: ['../.eslintrc.js'], + ignorePatterns: ['test-apps'], overrides: [ { files: ['*.ts'], rules: { 'no-console': 'off', '@typescript-eslint/no-non-null-assertion': 'off', + 'import/no-unresolved': 'off', }, }, ], diff --git a/packages/replay/metrics/configs/ci/env.ts b/packages/replay/metrics/configs/ci/env.ts index e3492ced1056..c41e4bcdf6c3 100644 --- a/packages/replay/metrics/configs/ci/env.ts +++ b/packages/replay/metrics/configs/ci/env.ts @@ -1,4 +1,4 @@ export const previousResultsDir = 'out/previous-results'; export const baselineResultsDir = 'out/baseline-results'; export const latestResultFile = 'out/latest-result.json'; -export const artifactName = "replay-sdk-metrics" +export const artifactName = 'replay-sdk-metrics' diff --git a/packages/replay/metrics/configs/ci/process.ts b/packages/replay/metrics/configs/ci/process.ts index def9dd2491b4..aa6686a98237 100644 --- a/packages/replay/metrics/configs/ci/process.ts +++ b/packages/replay/metrics/configs/ci/process.ts @@ -1,11 +1,12 @@ import path from 'path'; + import { ResultsAnalyzer } from '../../src/results/analyzer.js'; import { PrCommentBuilder } from '../../src/results/pr-comment.js'; import { Result } from '../../src/results/result.js'; import { ResultsSet } from '../../src/results/results-set.js'; import { Git } from '../../src/util/git.js'; import { GitHub } from '../../src/util/github.js'; -import { latestResultFile, previousResultsDir, baselineResultsDir, artifactName } from './env.js'; +import { artifactName,baselineResultsDir, latestResultFile, previousResultsDir } from './env.js'; const latestResult = Result.readFromFile(latestResultFile); const branch = await Git.branch; @@ -14,22 +15,22 @@ const baseBranch = await Git.baseBranch; await GitHub.downloadPreviousArtifact(baseBranch, baselineResultsDir, artifactName); await GitHub.downloadPreviousArtifact(branch, previousResultsDir, artifactName); -GitHub.writeOutput("artifactName", artifactName) -GitHub.writeOutput("artifactPath", path.resolve(previousResultsDir)); +GitHub.writeOutput('artifactName', artifactName) +GitHub.writeOutput('artifactPath', path.resolve(previousResultsDir)); const previousResults = new ResultsSet(previousResultsDir); const prComment = new PrCommentBuilder(); if (baseBranch != branch) { const baseResults = new ResultsSet(baselineResultsDir); - await prComment.addCurrentResult(await ResultsAnalyzer.analyze(latestResult, baseResults), "Baseline"); + await prComment.addCurrentResult(await ResultsAnalyzer.analyze(latestResult, baseResults), 'Baseline'); await prComment.addAdditionalResultsSet( `Baseline results on branch: ${baseBranch}`, // We skip the first one here because it's already included as `Baseline` column above in addCurrentResult(). baseResults.items().slice(1, 10) ); } else { - await prComment.addCurrentResult(await ResultsAnalyzer.analyze(latestResult, previousResults), "Previous"); + await prComment.addCurrentResult(await ResultsAnalyzer.analyze(latestResult, previousResults), 'Previous'); } await prComment.addAdditionalResultsSet( diff --git a/packages/replay/metrics/configs/dev/process.ts b/packages/replay/metrics/configs/dev/process.ts index 9672243cf804..ac19291d1e48 100644 --- a/packages/replay/metrics/configs/dev/process.ts +++ b/packages/replay/metrics/configs/dev/process.ts @@ -8,14 +8,15 @@ const latestResult = Result.readFromFile(latestResultFile); const analysis = await ResultsAnalyzer.analyze(latestResult, resultsSet); +// eslint-disable-next-line @typescript-eslint/no-explicit-any const table: { [k: string]: any } = {}; for (const item of analysis.items) { - const printable: { [k: string]: any } = {}; - printable.value = item.value.asString(); - if (item.other != undefined) { - printable.previous = item.other.asString(); - } - table[AnalyzerItemMetric[item.metric]] = printable; + table[AnalyzerItemMetric[item.metric]] = { + value: item.value.asString(), + ...((item.other == undefined) ? {} : { + previous: item.other.asString() + }) + }; } console.table(table); diff --git a/packages/replay/metrics/package.json b/packages/replay/metrics/package.json index 79fab58811ad..e4163257018b 100644 --- a/packages/replay/metrics/package.json +++ b/packages/replay/metrics/package.json @@ -20,6 +20,7 @@ "extract-zip": "^2.0.1", "filesize": "^10.0.6", "playwright": "^1.29.1", + "playwright-core": "^1.29.1", "simple-git": "^3.15.1", "simple-statistics": "^7.8.0", "typescript": "^4.9.4" diff --git a/packages/replay/metrics/src/collector.ts b/packages/replay/metrics/src/collector.ts index 432217ce085a..394dd2b8c956 100644 --- a/packages/replay/metrics/src/collector.ts +++ b/packages/replay/metrics/src/collector.ts @@ -1,8 +1,8 @@ import assert from 'assert'; import * as playwright from 'playwright'; -import { CpuUsage, CpuUsageSampler } from './perf/cpu.js'; -import { JsHeapUsage, JsHeapUsageSampler } from './perf/memory.js'; +import { CpuUsage, CpuUsageSampler, CpuUsageSerialized } from './perf/cpu.js'; +import { JsHeapUsage, JsHeapUsageSampler, JsHeapUsageSerialized } from './perf/memory.js'; import { PerfMetricsSampler } from './perf/sampler.js'; import { Result } from './results/result.js'; import { Scenario, TestCase } from './scenarios.js'; @@ -30,7 +30,7 @@ const PredefinedNetworkConditions = Object.freeze({ export class Metrics { constructor(public readonly vitals: WebVitals, public readonly cpu: CpuUsage, public readonly memory: JsHeapUsage) { } - public static fromJSON(data: Partial): Metrics { + public static fromJSON(data: Partial<{ vitals: Partial, cpu: CpuUsageSerialized, memory: JsHeapUsageSerialized }>): Metrics { return new Metrics( WebVitals.fromJSON(data.vitals || {}), CpuUsage.fromJSON(data.cpu || {}), @@ -44,10 +44,10 @@ export interface MetricsCollectorOptions { } export class MetricsCollector { - private options: MetricsCollectorOptions; + private _options: MetricsCollectorOptions; constructor(options?: Partial) { - this.options = { + this._options = { headless: false, ...options }; @@ -57,8 +57,8 @@ export class MetricsCollector { console.log(`Executing test case ${testCase.name}`); console.group(); for (let i = 1; i <= testCase.tries; i++) { - let aResults = await this.collect('A', testCase.a, testCase.runs); - let bResults = await this.collect('B', testCase.b, testCase.runs); + const aResults = await this._collect('A', testCase.a, testCase.runs); + const bResults = await this._collect('B', testCase.b, testCase.runs); if (await testCase.test(aResults, bResults)) { console.groupEnd(); console.log(`Test case ${testCase.name} passed on try ${i}/${testCase.tries}`); @@ -73,26 +73,26 @@ export class MetricsCollector { throw `Test case execution ${testCase.name} failed after ${testCase.tries} tries.`; } - private async collect(name: string, scenario: Scenario, runs: number): Promise { + private async _collect(name: string, scenario: Scenario, runs: number): Promise { const label = `Scenario ${name} data collection (total ${runs} runs)`; console.time(label); const results: Metrics[] = []; for (let run = 0; run < runs; run++) { - let innerLabel = `Scenario ${name} data collection, run ${run}/${runs}`; + const innerLabel = `Scenario ${name} data collection, run ${run}/${runs}`; console.time(innerLabel); - results.push(await this.run(scenario)); + results.push(await this._run(scenario)); console.timeEnd(innerLabel); } console.timeEnd(label); - assert.equal(results.length, runs); + assert.strictEqual(results.length, runs); return results; } - private async run(scenario: Scenario): Promise { + private async _run(scenario: Scenario): Promise { const disposeCallbacks: (() => Promise)[] = []; try { const browser = await playwright.chromium.launch({ - headless: this.options.headless, + headless: this._options.headless, }); disposeCallbacks.push(async () => browser.close()); diff --git a/packages/replay/metrics/src/perf/cpu.ts b/packages/replay/metrics/src/perf/cpu.ts index 29179c9d2a46..cd64fd6038f5 100644 --- a/packages/replay/metrics/src/perf/cpu.ts +++ b/packages/replay/metrics/src/perf/cpu.ts @@ -1,13 +1,16 @@ +import { JsonObject } from '../util/json.js'; import { PerfMetrics, PerfMetricsSampler, TimeBasedMap } from './sampler.js'; export { CpuUsageSampler, CpuUsage } +export type CpuUsageSerialized = Partial<{ snapshots: JsonObject, average: number }>; + class CpuUsage { constructor(public snapshots: TimeBasedMap, public average: number) { }; - public static fromJSON(data: Partial): CpuUsage { + public static fromJSON(data: CpuUsageSerialized): CpuUsage { return new CpuUsage( - TimeBasedMap.fromJSON(data.snapshots || []), + TimeBasedMap.fromJSON(data.snapshots || {}), data.average as number, ); } @@ -18,7 +21,7 @@ class MetricsDataPoint { } class CpuUsageSampler { - private _snapshots = new TimeBasedMap(); + private _snapshots: TimeBasedMap = new TimeBasedMap(); private _average: number = 0; private _initial?: MetricsDataPoint = undefined; private _startTime!: number; @@ -40,7 +43,7 @@ class CpuUsageSampler { this._startTime = data.timestamp; } else { const frameDuration = data.timestamp - this._lastTimestamp; - let usage = frameDuration == 0 ? 0 : (data.activeTime - this._cumulativeActiveTime) / frameDuration; + const usage = frameDuration == 0 ? 0 : (data.activeTime - this._cumulativeActiveTime) / frameDuration; this._snapshots.set(data.timestamp, usage); this._average = data.activeTime / (data.timestamp - this._startTime); diff --git a/packages/replay/metrics/src/perf/memory.ts b/packages/replay/metrics/src/perf/memory.ts index 3566622ccb0e..97ad3a490e04 100644 --- a/packages/replay/metrics/src/perf/memory.ts +++ b/packages/replay/metrics/src/perf/memory.ts @@ -1,17 +1,20 @@ +import { JsonObject } from '../util/json.js'; import { PerfMetrics, PerfMetricsSampler, TimeBasedMap } from './sampler.js'; export { JsHeapUsageSampler, JsHeapUsage } +export type JsHeapUsageSerialized = Partial<{ snapshots: JsonObject }>; + class JsHeapUsage { public constructor(public snapshots: TimeBasedMap) { } - public static fromJSON(data: Partial): JsHeapUsage { - return new JsHeapUsage(TimeBasedMap.fromJSON(data.snapshots || [])); + public static fromJSON(data: JsHeapUsageSerialized): JsHeapUsage { + return new JsHeapUsage(TimeBasedMap.fromJSON(data.snapshots || {})); } } class JsHeapUsageSampler { - private _snapshots = new TimeBasedMap(); + private _snapshots: TimeBasedMap = new TimeBasedMap(); public constructor(sampler: PerfMetricsSampler) { sampler.subscribe(this._collect.bind(this)); diff --git a/packages/replay/metrics/src/perf/sampler.ts b/packages/replay/metrics/src/perf/sampler.ts index ed585da9a0b3..e50ce3dd02b4 100644 --- a/packages/replay/metrics/src/perf/sampler.ts +++ b/packages/replay/metrics/src/perf/sampler.ts @@ -1,42 +1,44 @@ import * as playwright from 'playwright'; import { Protocol } from 'playwright-core/types/protocol'; +import { JsonObject } from '../util/json'; + export type PerfMetricsConsumer = (metrics: PerfMetrics) => Promise; export type TimestampSeconds = number; export class TimeBasedMap extends Map { - public toJSON(): any { - return Object.fromEntries(this.entries()); - } - - public static fromJSON(entries: Object): TimeBasedMap { + public static fromJSON(entries: JsonObject): TimeBasedMap { const result = new TimeBasedMap(); + // eslint-disable-next-line guard-for-in for (const key in entries) { - const value = entries[key as keyof Object]; - result.set(parseFloat(key), value as T); + result.set(parseFloat(key), entries[key]); } return result; } + + public toJSON(): JsonObject { + return Object.fromEntries(this.entries()); + } } export class PerfMetrics { - constructor(private metrics: Protocol.Performance.Metric[]) { } + constructor(private _metrics: Protocol.Performance.Metric[]) { } - private find(name: string): number { - return this.metrics.find((metric) => metric.name == name)!.value; + private _find(name: string): number { + return this._metrics.find((metric) => metric.name == name)!.value; } public get Timestamp(): number { - return this.find('Timestamp'); + return this._find('Timestamp'); } public get Duration(): number { // TODO check if any of `Duration` fields is maybe a sum of the others. E.g. verify the measured CPU usage manually. - return this.metrics.reduce((sum, metric) => metric.name.endsWith('Duration') ? sum + metric.value : sum, 0); + return this._metrics.reduce((sum, metric) => metric.name.endsWith('Duration') ? sum + metric.value : sum, 0); } public get JSHeapUsedSize(): number { - return this.find('JSHeapUsedSize'); + return this._find('JSHeapUsedSize'); } } @@ -51,7 +53,7 @@ export class PerfMetricsSampler { const self = new PerfMetricsSampler(); self._timer = setInterval(async () => { - const metrics = await cdp.send("Performance.getMetrics").then((v) => v.metrics); + const metrics = await cdp.send('Performance.getMetrics').then((v) => v.metrics); self._consumers.forEach((cb) => cb(new PerfMetrics(metrics)).catch(console.log)); }, interval); diff --git a/packages/replay/metrics/src/results/analyzer.ts b/packages/replay/metrics/src/results/analyzer.ts index abe29cda6b63..edd99fa69398 100644 --- a/packages/replay/metrics/src/results/analyzer.ts +++ b/packages/replay/metrics/src/results/analyzer.ts @@ -1,13 +1,16 @@ +import { filesize } from 'filesize'; + import { GitHash } from '../util/git.js'; +import { MetricsStats } from './metrics-stats.js'; import { Result } from './result.js'; import { ResultsSet } from './results-set.js'; -import { MetricsStats } from './metrics-stats.js'; -import { filesize } from "filesize"; // Compares latest result to previous/baseline results and produces the needed info. export class ResultsAnalyzer { + private constructor(private _result: Result) { } + public static async analyze(currentResult: Result, baselineResults?: ResultsSet): Promise { - const items = new ResultsAnalyzer(currentResult).collect(); + const items = new ResultsAnalyzer(currentResult)._collect(); const baseline = baselineResults?.find( (other) => other.cpuThrottling == currentResult.cpuThrottling && @@ -16,7 +19,7 @@ export class ResultsAnalyzer { let otherHash: GitHash | undefined if (baseline != undefined) { - const baseItems = new ResultsAnalyzer(baseline[1]).collect(); + const baseItems = new ResultsAnalyzer(baseline[1])._collect(); // update items with baseline results for (const base of baseItems) { for (const item of items) { @@ -34,15 +37,13 @@ export class ResultsAnalyzer { }; } - private constructor(private result: Result) { } - - private collect(): AnalyzerItem[] { + private _collect(): AnalyzerItem[] { const items = new Array(); - const aStats = new MetricsStats(this.result.aResults); - const bStats = new MetricsStats(this.result.bResults); + const aStats = new MetricsStats(this._result.aResults); + const bStats = new MetricsStats(this._result.bResults); - const pushIfDefined = function (metric: AnalyzerItemMetric, unit: AnalyzerItemUnit, valueA?: number, valueB?: number) { + const pushIfDefined = function (metric: AnalyzerItemMetric, unit: AnalyzerItemUnit, valueA?: number, valueB?: number): void { if (valueA == undefined || valueB == undefined) return; items.push({ @@ -59,9 +60,9 @@ export class ResultsAnalyzer { case AnalyzerItemUnit.bytes: return prefix + filesize(diff); case AnalyzerItemUnit.ratio: - return prefix + (diff * 100).toFixed(2) + ' %'; + return `${prefix + (diff * 100).toFixed(2)} %`; default: - return prefix + diff.toFixed(2) + ' ' + AnalyzerItemUnit[unit]; + return `${prefix + diff.toFixed(2)} ${AnalyzerItemUnit[unit]}`; } } } diff --git a/packages/replay/metrics/src/results/metrics-stats.ts b/packages/replay/metrics/src/results/metrics-stats.ts index acec86c6973c..37bc05c26fb6 100644 --- a/packages/replay/metrics/src/results/metrics-stats.ts +++ b/packages/replay/metrics/src/results/metrics-stats.ts @@ -1,13 +1,14 @@ -import { Metrics } from '../collector'; import * as ss from 'simple-statistics' +import { Metrics } from '../collector'; + export type NumberProvider = (metrics: Metrics) => number; export class MetricsStats { - constructor(private items: Metrics[]) { } + constructor(private _items: Metrics[]) { } public filteredMean(dataProvider: NumberProvider): number | undefined { - const numbers = this.items.map(dataProvider); + const numbers = this._items.map(dataProvider); return numbers.length > 0 ? ss.mean(numbers) : undefined; } @@ -28,7 +29,7 @@ export class MetricsStats { } public get memoryMax(): number | undefined { - const numbers = this.items.map((metrics) => ss.max(Array.from(metrics.memory.snapshots.values()))); + const numbers = this._items.map((metrics) => ss.max(Array.from(metrics.memory.snapshots.values()))); return numbers.length > 0 ? ss.max(numbers) : undefined; } } diff --git a/packages/replay/metrics/src/results/pr-comment.ts b/packages/replay/metrics/src/results/pr-comment.ts index 704992583306..c9fa79e3a500 100644 --- a/packages/replay/metrics/src/results/pr-comment.ts +++ b/packages/replay/metrics/src/results/pr-comment.ts @@ -1,7 +1,7 @@ -import { Git } from "../util/git.js"; -import { Analysis, AnalyzerItemMetric, ResultsAnalyzer } from "./analyzer.js"; -import { Result } from "./result.js"; -import { ResultSetItem } from "./results-set.js"; +import { Git } from '../util/git.js'; +import { Analysis, AnalyzerItemMetric, ResultsAnalyzer } from './analyzer.js'; +import { Result } from './result.js'; +import { ResultSetItem } from './results-set.js'; function trimIndent(str: string): string { return str.split('\n').map(s => s.trim()).join('\n'); @@ -25,14 +25,14 @@ function printableMetricName(metric: AnalyzerItemMetric): string { } export class PrCommentBuilder { - private buffer = ''; + private _buffer: string = ''; public get title(): string { return 'Replay SDK metrics :rocket:'; } public get body(): string { - return trimIndent(this.buffer); + return trimIndent(this._buffer); } public async addCurrentResult(analysis: Analysis, otherName: string): Promise { @@ -44,32 +44,32 @@ export class PrCommentBuilder { return content(); } - this.buffer += ` + this._buffer += `

${this.title}

 Latest diff (${await Git.hash})' + otherName + ' diff (' + analysis.otherHash + ')This PR (${await Git.hash})' + otherName + ' (' + analysis.otherHash + ')
- ${maybeOther(() => '')} + ${maybeOther(() => ``)} ` for (const item of analysis.items) { - this.buffer += ` + this._buffer += ` - ${maybeOther(() => '')} + ${maybeOther(() => ``)} ` } - this.buffer += ` + this._buffer += `
  This PR (${await Git.hash})' + otherName + ' (' + analysis.otherHash + ')${ otherName } (${ analysis.otherHash })
${printableMetricName(item.metric)} ${item.value.asString()}' + item.other!.asString() + '${ item.other!.asString() }
`; } - public async addAdditionalResultsSet(name: String, resultFiles: ResultSetItem[]): Promise { + public async addAdditionalResultsSet(name: string, resultFiles: ResultSetItem[]): Promise { if (resultFiles.length == 0) return; - this.buffer += ` + this._buffer += `

${name}

`; @@ -82,22 +82,22 @@ export class PrCommentBuilder { if (i == 0) { // Add table header - this.buffer += ''; + this._buffer += ''; for (const item of analysis.items) { - this.buffer += ``; + this._buffer += ``; } - this.buffer += ''; + this._buffer += ''; } // Add table row - this.buffer += ``; + this._buffer += ``; for (const item of analysis.items) { - this.buffer += ``; + this._buffer += ``; } - this.buffer += ''; + this._buffer += ''; } - this.buffer += ` + this._buffer += `
Revision
Revision${printableMetricName(item.metric)}${printableMetricName(item.metric)}
${resultFile.hash}
${resultFile.hash}${item.value.asString()}${item.value.asString()}
`; } diff --git a/packages/replay/metrics/src/results/result.ts b/packages/replay/metrics/src/results/result.ts index eac564dc882b..8229642b6fea 100644 --- a/packages/replay/metrics/src/results/result.ts +++ b/packages/replay/metrics/src/results/result.ts @@ -2,6 +2,7 @@ import * as fs from 'fs'; import path from 'path'; import { Metrics } from '../collector.js'; +import { JsonObject, JsonStringify } from '../util/json.js'; export class Result { constructor( @@ -10,34 +11,24 @@ export class Result { public readonly aResults: Metrics[], public readonly bResults: Metrics[]) { } + public static readFromFile(filePath: string): Result { + const json = fs.readFileSync(filePath, { encoding: 'utf-8' }); + const data = JSON.parse(json) as JsonObject; + return new Result( + data.name as string, + data.cpuThrottling as number, + data.networkConditions as string, + (data.aResults as Partial[] || []).map(Metrics.fromJSON.bind(Metrics)), + (data.bResults as Partial[] || []).map(Metrics.fromJSON.bind(Metrics)), + ); + } + public writeToFile(filePath: string): void { const dir = path.dirname(filePath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir); } - const json = this.serialize(); + const json = JsonStringify(this); fs.writeFileSync(filePath, json); } - - serialize(): string { - return JSON.stringify(this, (_: any, value: any): any => { - if (typeof value != 'undefined' && typeof value.toJSON == 'function') { - return value.toJSON(); - } else { - return value; - } - }, 2); - } - - public static readFromFile(filePath: string): Result { - const json = fs.readFileSync(filePath, { encoding: 'utf-8' }); - const data = JSON.parse(json); - return new Result( - data.name || '', - data.cpuThrottling as number, - data.networkConditions || '', - (data.aResults || []).map(Metrics.fromJSON), - (data.bResults || []).map(Metrics.fromJSON), - ); - } } diff --git a/packages/replay/metrics/src/results/results-set.ts b/packages/replay/metrics/src/results/results-set.ts index d19a2ca121e7..f3432441deb7 100644 --- a/packages/replay/metrics/src/results/results-set.ts +++ b/packages/replay/metrics/src/results/results-set.ts @@ -1,6 +1,7 @@ import assert from 'assert'; import * as fs from 'fs'; import path from 'path'; + import { Git, GitHash } from '../util/git.js'; import { Result } from './result.js'; @@ -29,35 +30,30 @@ export class ResultSetItem { /// Wraps a directory containing multiple (N--result.json) files. /// The files are numbered from the most recently added one, to the oldest one. export class ResultsSet { - public constructor(private directory: string) { - if (!fs.existsSync(directory)) { - fs.mkdirSync(directory, { recursive: true }); + public constructor(private _directory: string) { + if (!fs.existsSync(_directory)) { + fs.mkdirSync(_directory, { recursive: true }); } } public find(predicate: (value: Result) => boolean): [GitHash, Result] | undefined { - const items = this.items(); - for (let i = 0; i < items.length; i++) { - const result = Result.readFromFile(items[i].path); + for (const item of this.items()) { + const result = Result.readFromFile(item.path); if (predicate(result)) { - return [items[i].hash, result]; + return [item.hash, result]; } } return undefined; } public items(): ResultSetItem[] { - return this.files().map((file) => { - return new ResultSetItem(path.join(this.directory, file.name)); + return this._files().map((file) => { + return new ResultSetItem(path.join(this._directory, file.name)); }).filter((item) => !isNaN(item.number)); } - private files(): fs.Dirent[] { - return fs.readdirSync(this.directory, { withFileTypes: true }).filter((v) => v.isFile()) - } - public async add(newFile: string, onlyIfDifferent: boolean = false): Promise { - console.log(`Preparing to add ${newFile} to ${this.directory}`); + console.log(`Preparing to add ${newFile} to ${this._directory}`); assert(fs.existsSync(newFile)); // Get the list of file sorted by the prefix number in the descending order (starting with the oldest files). @@ -75,13 +71,17 @@ export class ResultsSet { for (const file of files) { const parts = file.name.split(delimiter); parts[0] = (file.number + 1).toString(); - const newPath = path.join(this.directory, parts.join(delimiter)); + const newPath = path.join(this._directory, parts.join(delimiter)); console.log(`Renaming ${file.path} to ${newPath}`); fs.renameSync(file.path, newPath); } const newName = `1${delimiter}${await Git.hash}${delimiter}result.json`; - console.log(`Adding ${newFile} to ${this.directory} as ${newName}`); - fs.copyFileSync(newFile, path.join(this.directory, newName)); + console.log(`Adding ${newFile} to ${this._directory} as ${newName}`); + fs.copyFileSync(newFile, path.join(this._directory, newName)); + } + + private _files(): fs.Dirent[] { + return fs.readdirSync(this._directory, { withFileTypes: true }).filter((v) => v.isFile()) } } diff --git a/packages/replay/metrics/src/scenarios.ts b/packages/replay/metrics/src/scenarios.ts index 844f85fb573f..2141b74ec460 100644 --- a/packages/replay/metrics/src/scenarios.ts +++ b/packages/replay/metrics/src/scenarios.ts @@ -1,8 +1,9 @@ +import assert from 'assert'; +import * as fs from 'fs'; import path from 'path'; import * as playwright from 'playwright'; -import * as fs from 'fs'; + import { Metrics } from './collector'; -import assert from 'assert'; // A testing scenario we want to collect metrics for. export interface Scenario { @@ -33,12 +34,12 @@ export class LoadPageScenario implements Scenario { // Loads test-apps/jank/ as a page source & waits for a short time before quitting. export class JankTestScenario implements Scenario { - public constructor(private withSentry: boolean) { } + public constructor(private _withSentry: boolean) { } public async run(_: playwright.Browser, page: playwright.Page): Promise { - let url = path.resolve('./test-apps/jank/' + (this.withSentry ? 'with-sentry' : 'index') + '.html'); + let url = path.resolve(`./test-apps/jank/${ this._withSentry ? 'with-sentry' : 'index' }.html`); assert(fs.existsSync(url)); - url = 'file:///' + url.replace('\\', '/'); + url = `file:///${ url.replace('\\', '/')}`; console.log('Navigating to ', url); await page.goto(url, { waitUntil: 'load', timeout: 60000 }); await new Promise(resolve => setTimeout(resolve, 5000)); diff --git a/packages/replay/metrics/src/util/git.ts b/packages/replay/metrics/src/util/git.ts index f5799904fb04..9ec6acae0600 100644 --- a/packages/replay/metrics/src/util/git.ts +++ b/packages/replay/metrics/src/util/git.ts @@ -56,7 +56,7 @@ export const Git = { get hash(): Promise { return (async () => { let gitHash = await git.revparse('HEAD'); - let diff = await git.diff(); + const diff = await git.diff(); if (diff.trim().length > 0) { gitHash += '+dirty'; } diff --git a/packages/replay/metrics/src/util/github.ts b/packages/replay/metrics/src/util/github.ts index d00a8d8d41bf..3dbc3ee66692 100644 --- a/packages/replay/metrics/src/util/github.ts +++ b/packages/replay/metrics/src/util/github.ts @@ -1,23 +1,24 @@ +import { Octokit } from '@octokit/rest'; +import axios from 'axios'; +import extract from 'extract-zip'; import * as fs from 'fs'; -import { Octokit } from "@octokit/rest"; -import { Git } from './git.js'; import path from 'path'; -import Axios from 'axios'; -import extract from 'extract-zip'; -import { consoleGroup } from './console.js'; + import { PrCommentBuilder } from '../results/pr-comment.js'; +import { consoleGroup } from './console.js'; +import { Git } from './git.js'; const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN, // log: console, }); -const [_, owner, repo] = (await Git.repository).split('/'); +const [, owner, repo] = (await Git.repository).split('/'); const defaultArgs = { owner, repo } async function downloadArtifact(url: string, path: string): Promise { const writer = fs.createWriteStream(path); - return Axios({ + return axios({ method: 'get', url: url, responseType: 'stream', @@ -26,6 +27,7 @@ async function downloadArtifact(url: string, path: string): Promise { } }).then(response => { return new Promise((resolve, reject) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access response.data.pipe(writer); let error: Error; writer.on('error', err => { @@ -41,7 +43,7 @@ async function downloadArtifact(url: string, path: string): Promise { } export const GitHub = { - writeOutput(name: string, value: any): void { + writeOutput(name: string, value: string): void { if (typeof process.env.GITHUB_OUTPUT == 'string' && process.env.GITHUB_OUTPUT.length > 0) { fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`); } @@ -53,9 +55,9 @@ export const GitHub = { return consoleGroup(async () => { fs.mkdirSync(targetDir, { recursive: true }); - var workflow = await (async () => { + const workflow = await (async () => { for await (const workflows of octokit.paginate.iterator(octokit.rest.actions.listRepoWorkflows, defaultArgs)) { - let found = workflows.data.find((w) => w.name == process.env.GITHUB_WORKFLOW); + const found = workflows.data.find((w) => w.name == process.env.GITHUB_WORKFLOW); if (found) return found; } return undefined; @@ -119,16 +121,15 @@ export const GitHub = { For example, refs/heads/feature-branch-1. */ let prNumber: number | undefined; - if (typeof process.env.GITHUB_REF == 'string' && process.env.GITHUB_REF.length > 0 && process.env.GITHUB_REF.startsWith("refs/pull/")) { + if (typeof process.env.GITHUB_REF == 'string' && process.env.GITHUB_REF.length > 0 && process.env.GITHUB_REF.startsWith('refs/pull/')) { prNumber = parseInt(process.env.GITHUB_REF.split('/')[2]); console.log(`Determined PR number ${prNumber} based on GITHUB_REF environment variable: '${process.env.GITHUB_REF}'`); } else { - const pr = await octokit.rest.pulls.list({ + prNumber = (await octokit.rest.pulls.list({ ...defaultArgs, base: await Git.baseBranch, head: await Git.branch - }); - prNumber = pr.data.at(0)?.number; + })).data[0].number; if (prNumber != undefined) { console.log(`Found PR number ${prNumber} based on base and head branches`); } @@ -149,7 +150,7 @@ export const GitHub = { const author = typeof process.env.GITHUB_ACTION == 'string' ? 'github-actions[bot]' : (await octokit.users.getAuthenticated()).data.login; // Try to find an existing comment by the author and title. - var comment = await (async () => { + const comment = await (async () => { for await (const comments of octokit.paginate.iterator(octokit.rest.issues.listComments, { ...defaultArgs, issue_number: prNumber, diff --git a/packages/replay/metrics/src/util/json.ts b/packages/replay/metrics/src/util/json.ts new file mode 100644 index 000000000000..095614fd115e --- /dev/null +++ b/packages/replay/metrics/src/util/json.ts @@ -0,0 +1,14 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ + +export type JsonObject = { [k: string]: T }; + +export function JsonStringify(object: T): string { + return JSON.stringify(object, (_: unknown, value: any): unknown => { + if (typeof value != 'undefined' && typeof value.toJSON == 'function') { + return value.toJSON(); + } else { + return value; + } + }, 2); +} diff --git a/packages/replay/metrics/yarn.lock b/packages/replay/metrics/yarn.lock index 754a7ff63816..3db63790ad27 100644 --- a/packages/replay/metrics/yarn.lock +++ b/packages/replay/metrics/yarn.lock @@ -342,7 +342,7 @@ pend@~1.2.0: resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg== -playwright-core@1.29.1: +playwright-core@1.29.1, playwright-core@^1.29.1: version "1.29.1" resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.29.1.tgz#9ec15d61c4bd2f386ddf6ce010db53a030345a47" integrity sha512-20Ai3d+lMkWpI9YZYlxk8gxatfgax5STW8GaMozAHwigLiyiKQrdkt7gaoT9UQR8FIVDg6qVXs9IoZUQrDjIIg== From 416ac54846d443ff78e20bc23fe3be5407dfe4cf Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Wed, 4 Jan 2023 14:55:24 +0100 Subject: [PATCH 33/55] more linter fixes --- .gitignore | 2 +- packages/replay/.eslintignore | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 17ef110da73a..d822f532c8cc 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,4 @@ tmp.js # eslint .eslintcache -eslintcache/* +**/eslintcache/* diff --git a/packages/replay/.eslintignore b/packages/replay/.eslintignore index 0a749745f94c..c76c6c2d64d1 100644 --- a/packages/replay/.eslintignore +++ b/packages/replay/.eslintignore @@ -3,3 +3,4 @@ build/ demo/build/ # TODO: Check if we can re-introduce linting in demo demo +metrics From d8297ec9fa633ed2c2f42a2d66bf2ef56f69d4f6 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Wed, 4 Jan 2023 16:44:24 +0100 Subject: [PATCH 34/55] metrics: improve PR comment contents --- .../replay/metrics/configs/dev/process.ts | 4 +- .../replay/metrics/src/results/analyzer.ts | 61 ++++---- .../metrics/src/results/metrics-stats.ts | 12 +- .../replay/metrics/src/results/pr-comment.ts | 34 +++-- packages/replay/metrics/src/util/github.ts | 140 ++++++++++-------- 5 files changed, 142 insertions(+), 109 deletions(-) diff --git a/packages/replay/metrics/configs/dev/process.ts b/packages/replay/metrics/configs/dev/process.ts index ac19291d1e48..872bf03cb0c8 100644 --- a/packages/replay/metrics/configs/dev/process.ts +++ b/packages/replay/metrics/configs/dev/process.ts @@ -12,9 +12,9 @@ const analysis = await ResultsAnalyzer.analyze(latestResult, resultsSet); const table: { [k: string]: any } = {}; for (const item of analysis.items) { table[AnalyzerItemMetric[item.metric]] = { - value: item.value.asString(), + value: item.value.diff, ...((item.other == undefined) ? {} : { - previous: item.other.asString() + previous: item.other.diff }) }; } diff --git a/packages/replay/metrics/src/results/analyzer.ts b/packages/replay/metrics/src/results/analyzer.ts index edd99fa69398..6158561404b8 100644 --- a/packages/replay/metrics/src/results/analyzer.ts +++ b/packages/replay/metrics/src/results/analyzer.ts @@ -45,34 +45,13 @@ export class ResultsAnalyzer { const pushIfDefined = function (metric: AnalyzerItemMetric, unit: AnalyzerItemUnit, valueA?: number, valueB?: number): void { if (valueA == undefined || valueB == undefined) return; - - items.push({ - metric: metric, - value: { - unit: unit, - asDiff: () => valueB - valueA, - asRatio: () => valueB / valueA, - asString: () => { - const diff = valueB - valueA; - const prefix = diff >= 0 ? '+' : ''; - - switch (unit) { - case AnalyzerItemUnit.bytes: - return prefix + filesize(diff); - case AnalyzerItemUnit.ratio: - return `${prefix + (diff * 100).toFixed(2)} %`; - default: - return `${prefix + diff.toFixed(2)} ${AnalyzerItemUnit[unit]}`; - } - } - } - }) + items.push({ metric: metric, value: new AnalyzerItemNumberValue(unit, valueA, valueB) }) } pushIfDefined(AnalyzerItemMetric.lcp, AnalyzerItemUnit.ms, aStats.lcp, bStats.lcp); pushIfDefined(AnalyzerItemMetric.cls, AnalyzerItemUnit.ms, aStats.cls, bStats.cls); pushIfDefined(AnalyzerItemMetric.cpu, AnalyzerItemUnit.ratio, aStats.cpu, bStats.cpu); - pushIfDefined(AnalyzerItemMetric.memoryAvg, AnalyzerItemUnit.bytes, aStats.memoryAvg, bStats.memoryAvg); + pushIfDefined(AnalyzerItemMetric.memoryAvg, AnalyzerItemUnit.bytes, aStats.memoryMean, bStats.memoryMean); pushIfDefined(AnalyzerItemMetric.memoryMax, AnalyzerItemUnit.bytes, aStats.memoryMax, bStats.memoryMax); return items.filter((item) => item.value != undefined); @@ -86,10 +65,38 @@ export enum AnalyzerItemUnit { } export interface AnalyzerItemValue { - unit: AnalyzerItemUnit; - asString(): string; - asDiff(): number; - asRatio(): number; // 1.0 == 100 % + readonly a: string; + readonly b: string; + readonly diff: string; +} + +class AnalyzerItemNumberValue implements AnalyzerItemValue { + constructor(private _unit: AnalyzerItemUnit, private _a: number, private _b: number) { } + + public get a(): string { + return this._withUnit(this._a); + } + + public get b(): string { + return this._withUnit(this._b); + } + + public get diff(): string { + const diff = this._b - this._a; + const str = this._withUnit(diff); + return diff > 0 ? `+${str}` : str; + } + + private _withUnit(value: number): string { + switch (this._unit) { + case AnalyzerItemUnit.bytes: + return filesize(value) as string; + case AnalyzerItemUnit.ratio: + return `${(value * 100).toFixed(2)} %`; + default: + return `${value.toFixed(2)} ${AnalyzerItemUnit[this._unit]}`; + } + } } export enum AnalyzerItemMetric { diff --git a/packages/replay/metrics/src/results/metrics-stats.ts b/packages/replay/metrics/src/results/metrics-stats.ts index 37bc05c26fb6..a75154e68de8 100644 --- a/packages/replay/metrics/src/results/metrics-stats.ts +++ b/packages/replay/metrics/src/results/metrics-stats.ts @@ -7,25 +7,25 @@ export type NumberProvider = (metrics: Metrics) => number; export class MetricsStats { constructor(private _items: Metrics[]) { } - public filteredMean(dataProvider: NumberProvider): number | undefined { + public mean(dataProvider: NumberProvider): number | undefined { const numbers = this._items.map(dataProvider); return numbers.length > 0 ? ss.mean(numbers) : undefined; } public get lcp(): number | undefined { - return this.filteredMean((metrics) => metrics.vitals.lcp); + return this.mean((metrics) => metrics.vitals.lcp); } public get cls(): number | undefined { - return this.filteredMean((metrics) => metrics.vitals.cls); + return this.mean((metrics) => metrics.vitals.cls); } public get cpu(): number | undefined { - return this.filteredMean((metrics) => metrics.cpu.average); + return this.mean((metrics) => metrics.cpu.average); } - public get memoryAvg(): number | undefined { - return this.filteredMean((metrics) => ss.mean(Array.from(metrics.memory.snapshots.values()))); + public get memoryMean(): number | undefined { + return this.mean((metrics) => ss.mean(Array.from(metrics.memory.snapshots.values()))); } public get memoryMax(): number | undefined { diff --git a/packages/replay/metrics/src/results/pr-comment.ts b/packages/replay/metrics/src/results/pr-comment.ts index c9fa79e3a500..12bb7723004b 100644 --- a/packages/replay/metrics/src/results/pr-comment.ts +++ b/packages/replay/metrics/src/results/pr-comment.ts @@ -47,18 +47,34 @@ export class PrCommentBuilder { this._buffer += `

${this.title}

- - - - ${maybeOther(() => ``)} - ` + + + + + ${maybeOther(() => ``)} + + + + + + ${maybeOther(() => ` + + + `)} + + ` for (const item of analysis.items) { this._buffer += ` - - - ${maybeOther(() => ``)} + + + + + ${maybeOther(() => ` + + + `)} ` } @@ -92,7 +108,7 @@ export class PrCommentBuilder { // Add table row this._buffer += ``; for (const item of analysis.items) { - this._buffer += ``; + this._buffer += ``; } this._buffer += ''; } diff --git a/packages/replay/metrics/src/util/github.ts b/packages/replay/metrics/src/util/github.ts index 3dbc3ee66692..0345c10c9ef4 100644 --- a/packages/replay/metrics/src/util/github.ts +++ b/packages/replay/metrics/src/util/github.ts @@ -42,6 +42,73 @@ async function downloadArtifact(url: string, path: string): Promise { }); } +async function tryAddOrUpdateComment(commentBuilder: PrCommentBuilder): Promise { + /* Env var GITHUB_REF is only set if a branch or tag is available for the current CI event trigger type. + The ref given is fully-formed, meaning that + * for branches the format is refs/heads/, + * for pull requests it is refs/pull//merge, + * and for tags it is refs/tags/. + For example, refs/heads/feature-branch-1. + */ + let prNumber: number | undefined; + if (typeof process.env.GITHUB_REF == 'string' && process.env.GITHUB_REF.length > 0 && process.env.GITHUB_REF.startsWith('refs/pull/')) { + prNumber = parseInt(process.env.GITHUB_REF.split('/')[2]); + console.log(`Determined PR number ${prNumber} based on GITHUB_REF environment variable: '${process.env.GITHUB_REF}'`); + } else { + prNumber = (await octokit.rest.pulls.list({ + ...defaultArgs, + base: await Git.baseBranch, + head: await Git.branch + })).data[0].number; + if (prNumber != undefined) { + console.log(`Found PR number ${prNumber} based on base and head branches`); + } + } + + if (prNumber == undefined) return false; + + // Determine the PR comment author: + // Trying to fetch `octokit.users.getAuthenticated()` throws (in CI only): + // {"message":"Resource not accessible by integration","documentation_url":"https://docs.github.com/rest/reference/users#get-the-authenticated-user"} + // Let's make this conditional on some env variable that's unlikely to be set locally but will be set in GH Actions. + // Do not use "CI" because that's commonly set during local development and testing. + const author = typeof process.env.GITHUB_ACTION == 'string' ? 'github-actions[bot]' : (await octokit.users.getAuthenticated()).data.login; + + // Try to find an existing comment by the author and title. + const comment = await (async () => { + for await (const comments of octokit.paginate.iterator(octokit.rest.issues.listComments, { + ...defaultArgs, + issue_number: prNumber, + })) { + const found = comments.data.find((comment) => { + return comment.user?.login == author + && comment.body != undefined + && comment.body.indexOf(commentBuilder.title) >= 0; + }); + if (found) return found; + } + return undefined; + })(); + + if (comment != undefined) { + console.log(`Updating PR comment ${comment.html_url} body`) + await octokit.rest.issues.updateComment({ + ...defaultArgs, + comment_id: comment.id, + body: commentBuilder.body, + }); + } else { + console.log(`Adding new PR comment to PR ${prNumber}`) + await octokit.rest.issues.createComment({ + ...defaultArgs, + issue_number: prNumber, + body: commentBuilder.body, + }); + } + + return true; +} + export const GitHub = { writeOutput(name: string, value: string): void { if (typeof process.env.GITHUB_OUTPUT == 'string' && process.env.GITHUB_OUTPUT.length > 0) { @@ -113,72 +180,15 @@ export const GitHub = { async addOrUpdateComment(commentBuilder: PrCommentBuilder): Promise { console.log('Adding/updating PR comment'); return consoleGroup(async () => { - /* Env var GITHUB_REF is only set if a branch or tag is available for the current CI event trigger type. - The ref given is fully-formed, meaning that - * for branches the format is refs/heads/, - * for pull requests it is refs/pull//merge, - * and for tags it is refs/tags/. - For example, refs/heads/feature-branch-1. - */ - let prNumber: number | undefined; - if (typeof process.env.GITHUB_REF == 'string' && process.env.GITHUB_REF.length > 0 && process.env.GITHUB_REF.startsWith('refs/pull/')) { - prNumber = parseInt(process.env.GITHUB_REF.split('/')[2]); - console.log(`Determined PR number ${prNumber} based on GITHUB_REF environment variable: '${process.env.GITHUB_REF}'`); - } else { - prNumber = (await octokit.rest.pulls.list({ - ...defaultArgs, - base: await Git.baseBranch, - head: await Git.branch - })).data[0].number; - if (prNumber != undefined) { - console.log(`Found PR number ${prNumber} based on base and head branches`); - } - } - - if (prNumber == undefined) { - const file = 'out/comment.html'; - console.log(`No PR available (not running in CI?): writing built comment to ${path.resolve(file)}`); - fs.writeFileSync(file, commentBuilder.body); - return; - } - - // Determine the PR comment author: - // Trying to fetch `octokit.users.getAuthenticated()` throws (in CI only): - // {"message":"Resource not accessible by integration","documentation_url":"https://docs.github.com/rest/reference/users#get-the-authenticated-user"} - // Let's make this conditional on some env variable that's unlikely to be set locally but will be set in GH Actions. - // Do not use "CI" because that's commonly set during local development and testing. - const author = typeof process.env.GITHUB_ACTION == 'string' ? 'github-actions[bot]' : (await octokit.users.getAuthenticated()).data.login; - - // Try to find an existing comment by the author and title. - const comment = await (async () => { - for await (const comments of octokit.paginate.iterator(octokit.rest.issues.listComments, { - ...defaultArgs, - issue_number: prNumber, - })) { - const found = comments.data.find((comment) => { - return comment.user?.login == author - && comment.body != undefined - && comment.body.indexOf(commentBuilder.title) >= 0; - }); - if (found) return found; + let successful = false; + try { + successful = await tryAddOrUpdateComment(commentBuilder); + } finally { + if (!successful) { + const file = 'out/comment.html'; + console.log(`Writing built comment to ${path.resolve(file)}`); + fs.writeFileSync(file, commentBuilder.body); } - return undefined; - })(); - - if (comment != undefined) { - console.log(`Updating PR comment ${comment.html_url} body`) - await octokit.rest.issues.updateComment({ - ...defaultArgs, - comment_id: comment.id, - body: commentBuilder.body, - }); - } else { - console.log(`Adding new PR comment to PR ${prNumber}`) - await octokit.rest.issues.createComment({ - ...defaultArgs, - issue_number: prNumber, - body: commentBuilder.body, - }); } }); } From 639722526fded7e8a9e8363808936881ec20dae2 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Wed, 4 Jan 2023 17:15:17 +0100 Subject: [PATCH 35/55] metrics: improve comment --- packages/replay/metrics/configs/ci/process.ts | 6 +-- .../replay/metrics/src/results/pr-comment.ts | 42 +++++++++++-------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/packages/replay/metrics/configs/ci/process.ts b/packages/replay/metrics/configs/ci/process.ts index aa6686a98237..cf23744e4eab 100644 --- a/packages/replay/metrics/configs/ci/process.ts +++ b/packages/replay/metrics/configs/ci/process.ts @@ -6,7 +6,7 @@ import { Result } from '../../src/results/result.js'; import { ResultsSet } from '../../src/results/results-set.js'; import { Git } from '../../src/util/git.js'; import { GitHub } from '../../src/util/github.js'; -import { artifactName,baselineResultsDir, latestResultFile, previousResultsDir } from './env.js'; +import { artifactName, baselineResultsDir, latestResultFile, previousResultsDir } from './env.js'; const latestResult = Result.readFromFile(latestResultFile); const branch = await Git.branch; @@ -25,7 +25,7 @@ if (baseBranch != branch) { const baseResults = new ResultsSet(baselineResultsDir); await prComment.addCurrentResult(await ResultsAnalyzer.analyze(latestResult, baseResults), 'Baseline'); await prComment.addAdditionalResultsSet( - `Baseline results on branch: ${baseBranch}`, + `Baseline results on branch: ${baseBranch}`, // We skip the first one here because it's already included as `Baseline` column above in addCurrentResult(). baseResults.items().slice(1, 10) ); @@ -34,7 +34,7 @@ if (baseBranch != branch) { } await prComment.addAdditionalResultsSet( - `Previous results on branch: ${branch}`, + `Previous results on branch: ${branch}`, previousResults.items().slice(0, 10) ); diff --git a/packages/replay/metrics/src/results/pr-comment.ts b/packages/replay/metrics/src/results/pr-comment.ts index 12bb7723004b..2f01637d07b5 100644 --- a/packages/replay/metrics/src/results/pr-comment.ts +++ b/packages/replay/metrics/src/results/pr-comment.ts @@ -47,22 +47,28 @@ export class PrCommentBuilder { this._buffer += `

${this.title}

 This PR (${await Git.hash})${ otherName } (${ analysis.otherHash })
 This PR (${await Git.hash})${otherName} (${analysis.otherHash})
Plain+ReplayDiffPlain+ReplayDiff
${printableMetricName(item.metric)}${item.value.asString()}${ item.other!.asString() }${printableMetricName(item.metric)}${item.value.a}${item.value.b}${item.value.diff}${item.other!.a}${item.other!.b}${item.other!.diff}
${resultFile.hash}${item.value.asString()}${item.value.diff}
- - - - - ${maybeOther(() => ``)} - - - - - - ${maybeOther(() => ` - - - `)} - - ` + `; + + const headerCols = ''; + if (analysis.otherHash != undefined) { + // If "other" is defined, add an aditional row of headers. + this._buffer += ` + + + + + + + ${headerCols} + ${headerCols} + `; + } else { + this._buffer += ` + + + ${headerCols} + `; + } for (const item of analysis.items) { this._buffer += ` @@ -70,11 +76,11 @@ export class PrCommentBuilder { - + ${maybeOther(() => ` - `)} + `)} ` } From 2bfaa589e24ab7b73caf5dd30af8348ad40ee98f Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Wed, 4 Jan 2023 18:51:27 +0100 Subject: [PATCH 36/55] metrics: check stddev when collecting results --- packages/replay/metrics/configs/ci/collect.ts | 32 ++++++++-- .../replay/metrics/configs/dev/collect.ts | 2 +- packages/replay/metrics/src/collector.ts | 58 ++++++++++--------- .../replay/metrics/src/results/analyzer.ts | 10 ++-- .../metrics/src/results/metrics-stats.ts | 29 ++++------ packages/replay/metrics/src/scenarios.ts | 11 ++-- 6 files changed, 81 insertions(+), 61 deletions(-) diff --git a/packages/replay/metrics/configs/ci/collect.ts b/packages/replay/metrics/configs/ci/collect.ts index 7d9577e6a9d1..543b4f026a0b 100644 --- a/packages/replay/metrics/configs/ci/collect.ts +++ b/packages/replay/metrics/configs/ci/collect.ts @@ -1,16 +1,38 @@ import { Metrics, MetricsCollector } from '../../src/collector.js'; +import { MetricsStats, NumberProvider } from '../../src/results/metrics-stats.js'; import { JankTestScenario } from '../../src/scenarios.js'; import { latestResultFile } from './env.js'; +function checkStdDev(stats: MetricsStats, name: string, provider: NumberProvider, max: number): boolean { + const value = stats.stddev(provider); + if (value == undefined) { + console.warn(`✗ | Discarding results because StandardDeviation(${name}) is undefined`); + return false; + } else if (value > max) { + console.warn(`✗ | Discarding results because StandardDeviation(${name}) is larger than ${max}. Actual value: ${value}`); + return false; + } else { + console.log(`✓ | StandardDeviation(${name}) is ${value} (<= ${max})`) + } + return true; +} + const collector = new MetricsCollector({ headless: true }); const result = await collector.execute({ - name: 'dummy', + name: 'jank', a: new JankTestScenario(false), b: new JankTestScenario(true), - runs: 1, - tries: 1, - async test(_aResults: Metrics[], _bResults: Metrics[]) { - return true; + runs: 10, + tries: 10, + async shouldAccept(results: Metrics[]): Promise { + const stats = new MetricsStats(results); + return true + && checkStdDev(stats, 'lcp', MetricsStats.lcp, 10) + && checkStdDev(stats, 'cls', MetricsStats.cls, 10) + && checkStdDev(stats, 'cpu', MetricsStats.cpu, 10) + && checkStdDev(stats, 'memory-mean', MetricsStats.memoryMean, 10000) + && checkStdDev(stats, 'memory-max', MetricsStats.memoryMax, 10000); + ; }, }); diff --git a/packages/replay/metrics/configs/dev/collect.ts b/packages/replay/metrics/configs/dev/collect.ts index bec5af33ec8f..24e7b87a7db8 100644 --- a/packages/replay/metrics/configs/dev/collect.ts +++ b/packages/replay/metrics/configs/dev/collect.ts @@ -9,7 +9,7 @@ const result = await collector.execute({ b: new JankTestScenario(true), runs: 1, tries: 1, - async test(_aResults: Metrics[], _bResults: Metrics[]) { + async shouldAccept(_results: Metrics[]): Promise { return true; }, }); diff --git a/packages/replay/metrics/src/collector.ts b/packages/replay/metrics/src/collector.ts index 394dd2b8c956..de37851e0864 100644 --- a/packages/replay/metrics/src/collector.ts +++ b/packages/replay/metrics/src/collector.ts @@ -6,6 +6,7 @@ import { JsHeapUsage, JsHeapUsageSampler, JsHeapUsageSerialized } from './perf/m import { PerfMetricsSampler } from './perf/sampler.js'; import { Result } from './results/result.js'; import { Scenario, TestCase } from './scenarios.js'; +import { consoleGroup } from './util/console.js'; import { WebVitals, WebVitalsCollector } from './vitals/index.js'; const cpuThrottling = 4; @@ -55,37 +56,38 @@ export class MetricsCollector { public async execute(testCase: TestCase): Promise { console.log(`Executing test case ${testCase.name}`); - console.group(); - for (let i = 1; i <= testCase.tries; i++) { - const aResults = await this._collect('A', testCase.a, testCase.runs); - const bResults = await this._collect('B', testCase.b, testCase.runs); - if (await testCase.test(aResults, bResults)) { - console.groupEnd(); - console.log(`Test case ${testCase.name} passed on try ${i}/${testCase.tries}`); - return new Result(testCase.name, cpuThrottling, networkConditions, aResults, bResults); - } else if (i != testCase.tries) { - console.log(`Test case ${testCase.name} failed on try ${i}/${testCase.tries}`); - } else { - console.groupEnd(); - console.error(`Test case ${testCase.name} failed`); - } - } - throw `Test case execution ${testCase.name} failed after ${testCase.tries} tries.`; + return consoleGroup(async () => { + const aResults = await this._collect(testCase, 'A', testCase.a); + const bResults = await this._collect(testCase, 'B', testCase.b); + return new Result(testCase.name, cpuThrottling, networkConditions, aResults, bResults); + }); } - private async _collect(name: string, scenario: Scenario, runs: number): Promise { - const label = `Scenario ${name} data collection (total ${runs} runs)`; - console.time(label); - const results: Metrics[] = []; - for (let run = 0; run < runs; run++) { - const innerLabel = `Scenario ${name} data collection, run ${run}/${runs}`; - console.time(innerLabel); - results.push(await this._run(scenario)); - console.timeEnd(innerLabel); + private async _collect(testCase: TestCase, name: string, scenario: Scenario): Promise { + const label = `Scenario ${name} data collection (total ${testCase.runs} runs)`; + for (let try_ = 1; try_ <= testCase.tries; try_++) { + console.time(label); + const results: Metrics[] = []; + for (let run = 1; run <= testCase.runs; run++) { + const innerLabel = `Scenario ${name} data collection, run ${run}/${testCase.runs}`; + console.time(innerLabel); + results.push(await this._run(scenario)); + console.timeEnd(innerLabel); + } + console.timeEnd(label); + assert.strictEqual(results.length, testCase.runs); + if (await testCase.shouldAccept(results)) { + console.log(`Test case ${testCase.name}, scenario ${name} passed on try ${try_}/${testCase.tries}`); + return results; + } else if (try_ != testCase.tries) { + console.log(`Test case ${testCase.name} failed on try ${try_}/${testCase.tries}, retrying`); + } else { + throw `Test case ${testCase.name}, scenario ${name} failed after ${testCase.tries} tries.`; + } } - console.timeEnd(label); - assert.strictEqual(results.length, runs); - return results; + // Unreachable code, if configured properly: + console.assert(testCase.tries >= 1); + return []; } private async _run(scenario: Scenario): Promise { diff --git a/packages/replay/metrics/src/results/analyzer.ts b/packages/replay/metrics/src/results/analyzer.ts index 6158561404b8..d010ca0f2204 100644 --- a/packages/replay/metrics/src/results/analyzer.ts +++ b/packages/replay/metrics/src/results/analyzer.ts @@ -48,11 +48,11 @@ export class ResultsAnalyzer { items.push({ metric: metric, value: new AnalyzerItemNumberValue(unit, valueA, valueB) }) } - pushIfDefined(AnalyzerItemMetric.lcp, AnalyzerItemUnit.ms, aStats.lcp, bStats.lcp); - pushIfDefined(AnalyzerItemMetric.cls, AnalyzerItemUnit.ms, aStats.cls, bStats.cls); - pushIfDefined(AnalyzerItemMetric.cpu, AnalyzerItemUnit.ratio, aStats.cpu, bStats.cpu); - pushIfDefined(AnalyzerItemMetric.memoryAvg, AnalyzerItemUnit.bytes, aStats.memoryMean, bStats.memoryMean); - pushIfDefined(AnalyzerItemMetric.memoryMax, AnalyzerItemUnit.bytes, aStats.memoryMax, bStats.memoryMax); + pushIfDefined(AnalyzerItemMetric.lcp, AnalyzerItemUnit.ms, aStats.mean(MetricsStats.lcp), bStats.mean(MetricsStats.lcp)); + pushIfDefined(AnalyzerItemMetric.cls, AnalyzerItemUnit.ms, aStats.mean(MetricsStats.cls), bStats.mean(MetricsStats.cls)); + pushIfDefined(AnalyzerItemMetric.cpu, AnalyzerItemUnit.ratio, aStats.mean(MetricsStats.cpu), bStats.mean(MetricsStats.cpu)); + pushIfDefined(AnalyzerItemMetric.memoryAvg, AnalyzerItemUnit.bytes, aStats.mean(MetricsStats.memoryMean), bStats.mean(MetricsStats.memoryMean)); + pushIfDefined(AnalyzerItemMetric.memoryMax, AnalyzerItemUnit.bytes, aStats.max(MetricsStats.memoryMax), bStats.max(MetricsStats.memoryMax)); return items.filter((item) => item.value != undefined); } diff --git a/packages/replay/metrics/src/results/metrics-stats.ts b/packages/replay/metrics/src/results/metrics-stats.ts index a75154e68de8..6ac6db22bb7a 100644 --- a/packages/replay/metrics/src/results/metrics-stats.ts +++ b/packages/replay/metrics/src/results/metrics-stats.ts @@ -7,29 +7,24 @@ export type NumberProvider = (metrics: Metrics) => number; export class MetricsStats { constructor(private _items: Metrics[]) { } + static lcp: NumberProvider = metrics => metrics.vitals.lcp; + static cls: NumberProvider = metrics => metrics.vitals.cls; + static cpu: NumberProvider = metrics => metrics.cpu.average; + static memoryMean: NumberProvider = metrics => ss.mean(Array.from(metrics.memory.snapshots.values())); + static memoryMax: NumberProvider = metrics => ss.max(Array.from(metrics.memory.snapshots.values())); + public mean(dataProvider: NumberProvider): number | undefined { const numbers = this._items.map(dataProvider); return numbers.length > 0 ? ss.mean(numbers) : undefined; } - public get lcp(): number | undefined { - return this.mean((metrics) => metrics.vitals.lcp); - } - - public get cls(): number | undefined { - return this.mean((metrics) => metrics.vitals.cls); - } - - public get cpu(): number | undefined { - return this.mean((metrics) => metrics.cpu.average); - } - - public get memoryMean(): number | undefined { - return this.mean((metrics) => ss.mean(Array.from(metrics.memory.snapshots.values()))); + public max(dataProvider: NumberProvider): number | undefined { + const numbers = this._items.map(dataProvider); + return numbers.length > 0 ? ss.max(numbers) : undefined; } - public get memoryMax(): number | undefined { - const numbers = this._items.map((metrics) => ss.max(Array.from(metrics.memory.snapshots.values()))); - return numbers.length > 0 ? ss.max(numbers) : undefined; + public stddev(dataProvider: NumberProvider): number | undefined { + const numbers = this._items.map(dataProvider); + return numbers.length > 0 ? ss.standardDeviation(numbers) : undefined; } } diff --git a/packages/replay/metrics/src/scenarios.ts b/packages/replay/metrics/src/scenarios.ts index 2141b74ec460..47bf27294012 100644 --- a/packages/replay/metrics/src/scenarios.ts +++ b/packages/replay/metrics/src/scenarios.ts @@ -18,9 +18,10 @@ export interface TestCase { runs: number; tries: number; - // Test function that will be executed and given scenarios A and B result sets. - // Each has exactly `runs` number of items. - test(aResults: Metrics[], bResults: Metrics[]): Promise; + // Test function that will be executed and given a scenarios result set with exactly `runs` number of items. + // Should returns true if this "try" should be accepted and collected. + // If false is returned, `Collector` will retry up to `tries` number of times. + shouldAccept(results: Metrics[]): Promise; } // A simple scenario that just loads the given URL. @@ -37,9 +38,9 @@ export class JankTestScenario implements Scenario { public constructor(private _withSentry: boolean) { } public async run(_: playwright.Browser, page: playwright.Page): Promise { - let url = path.resolve(`./test-apps/jank/${ this._withSentry ? 'with-sentry' : 'index' }.html`); + let url = path.resolve(`./test-apps/jank/${this._withSentry ? 'with-sentry' : 'index'}.html`); assert(fs.existsSync(url)); - url = `file:///${ url.replace('\\', '/')}`; + url = `file:///${url.replace('\\', '/')}`; console.log('Navigating to ', url); await page.goto(url, { waitUntil: 'load', timeout: 60000 }); await new Promise(resolve => setTimeout(resolve, 5000)); From 432591e7114cf5fd24728c58f3704d1ba8dc680b Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Wed, 4 Jan 2023 20:56:26 +0100 Subject: [PATCH 37/55] metrics: filter outliers --- packages/replay/metrics/configs/ci/collect.ts | 9 ++++----- .../metrics/src/results/metrics-stats.ts | 18 +++++++++++++++--- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/replay/metrics/configs/ci/collect.ts b/packages/replay/metrics/configs/ci/collect.ts index 543b4f026a0b..ce34aafcd2a9 100644 --- a/packages/replay/metrics/configs/ci/collect.ts +++ b/packages/replay/metrics/configs/ci/collect.ts @@ -27,12 +27,11 @@ const result = await collector.execute({ async shouldAccept(results: Metrics[]): Promise { const stats = new MetricsStats(results); return true - && checkStdDev(stats, 'lcp', MetricsStats.lcp, 10) - && checkStdDev(stats, 'cls', MetricsStats.cls, 10) + && checkStdDev(stats, 'lcp', MetricsStats.lcp, 30) + && checkStdDev(stats, 'cls', MetricsStats.cls, 0.1) && checkStdDev(stats, 'cpu', MetricsStats.cpu, 10) - && checkStdDev(stats, 'memory-mean', MetricsStats.memoryMean, 10000) - && checkStdDev(stats, 'memory-max', MetricsStats.memoryMax, 10000); - ; + && checkStdDev(stats, 'memory-mean', MetricsStats.memoryMean, 30 * 1024) + && checkStdDev(stats, 'memory-max', MetricsStats.memoryMax, 100 * 1024); }, }); diff --git a/packages/replay/metrics/src/results/metrics-stats.ts b/packages/replay/metrics/src/results/metrics-stats.ts index 6ac6db22bb7a..f15cf2731dfb 100644 --- a/packages/replay/metrics/src/results/metrics-stats.ts +++ b/packages/replay/metrics/src/results/metrics-stats.ts @@ -14,17 +14,29 @@ export class MetricsStats { static memoryMax: NumberProvider = metrics => ss.max(Array.from(metrics.memory.snapshots.values())); public mean(dataProvider: NumberProvider): number | undefined { - const numbers = this._items.map(dataProvider); + const numbers = this._filteredValues(dataProvider); return numbers.length > 0 ? ss.mean(numbers) : undefined; } public max(dataProvider: NumberProvider): number | undefined { - const numbers = this._items.map(dataProvider); + const numbers = this._filteredValues(dataProvider); return numbers.length > 0 ? ss.max(numbers) : undefined; } public stddev(dataProvider: NumberProvider): number | undefined { - const numbers = this._items.map(dataProvider); + const numbers = this._filteredValues(dataProvider); return numbers.length > 0 ? ss.standardDeviation(numbers) : undefined; } + + // See https://en.wikipedia.org/wiki/Interquartile_range#Outliers for details on filtering. + private _filteredValues(dataProvider: NumberProvider): number[] { + const numbers = this._items.map(dataProvider); + numbers.sort((a, b) => a - b) + + const q1 = ss.quantileSorted(numbers, 0.25); + const q3 = ss.quantileSorted(numbers, 0.75); + const iqr = q3 - q1 + + return numbers.filter(num => num >= (q1 - 1.5 * iqr) && num <= (q3 + 1.5 * iqr)) + } } From 686a313d9185b089695ba3035473ee208f3beb4c Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Thu, 5 Jan 2023 14:31:03 +0100 Subject: [PATCH 38/55] metrics: fix CPU usage collection --- packages/replay/metrics/configs/ci/collect.ts | 22 ++++++++++++++----- .../replay/metrics/configs/dev/collect.ts | 10 ++++++++- packages/replay/metrics/src/perf/sampler.ts | 22 ++++++++++++------- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/packages/replay/metrics/configs/ci/collect.ts b/packages/replay/metrics/configs/ci/collect.ts index ce34aafcd2a9..de65e5166985 100644 --- a/packages/replay/metrics/configs/ci/collect.ts +++ b/packages/replay/metrics/configs/ci/collect.ts @@ -26,12 +26,22 @@ const result = await collector.execute({ tries: 10, async shouldAccept(results: Metrics[]): Promise { const stats = new MetricsStats(results); - return true - && checkStdDev(stats, 'lcp', MetricsStats.lcp, 30) - && checkStdDev(stats, 'cls', MetricsStats.cls, 0.1) - && checkStdDev(stats, 'cpu', MetricsStats.cpu, 10) - && checkStdDev(stats, 'memory-mean', MetricsStats.memoryMean, 30 * 1024) - && checkStdDev(stats, 'memory-max', MetricsStats.memoryMax, 100 * 1024); + if (!checkStdDev(stats, 'lcp', MetricsStats.lcp, 30) + || !checkStdDev(stats, 'cls', MetricsStats.cls, 0.1) + || !checkStdDev(stats, 'cpu', MetricsStats.cpu, 10) + || !checkStdDev(stats, 'memory-mean', MetricsStats.memoryMean, 30 * 1024) + || !checkStdDev(stats, 'memory-max', MetricsStats.memoryMax, 100 * 1024)) { + return false; + } + + const cpuUsage = stats.mean(MetricsStats.cpu)!; + if (cpuUsage > 0.9) { + console.error(`CPU usage too high to be accurate: ${(cpuUsage * 100).toFixed(2)} %.`, + 'Consider simplifying the scenario or changing the CPU throttling factor.'); + return false; + } + + return true; }, }); diff --git a/packages/replay/metrics/configs/dev/collect.ts b/packages/replay/metrics/configs/dev/collect.ts index 24e7b87a7db8..79f127b0fe27 100644 --- a/packages/replay/metrics/configs/dev/collect.ts +++ b/packages/replay/metrics/configs/dev/collect.ts @@ -1,4 +1,5 @@ import { Metrics, MetricsCollector } from '../../src/collector.js'; +import { MetricsStats } from '../../src/results/metrics-stats.js'; import { JankTestScenario } from '../../src/scenarios.js'; import { latestResultFile } from './env.js'; @@ -9,7 +10,14 @@ const result = await collector.execute({ b: new JankTestScenario(true), runs: 1, tries: 1, - async shouldAccept(_results: Metrics[]): Promise { + async shouldAccept(results: Metrics[]): Promise { + const stats = new MetricsStats(results); + const cpuUsage = stats.mean(MetricsStats.cpu)!; + if (cpuUsage > 0.9) { + console.error(`CPU usage too high to be accurate: ${(cpuUsage * 100).toFixed(2)} %.`, + 'Consider simplifying the scenario or changing the CPU throttling factor.'); + return false; + } return true; }, }); diff --git a/packages/replay/metrics/src/perf/sampler.ts b/packages/replay/metrics/src/perf/sampler.ts index e50ce3dd02b4..7b34d61293e7 100644 --- a/packages/replay/metrics/src/perf/sampler.ts +++ b/packages/replay/metrics/src/perf/sampler.ts @@ -33,8 +33,7 @@ export class PerfMetrics { } public get Duration(): number { - // TODO check if any of `Duration` fields is maybe a sum of the others. E.g. verify the measured CPU usage manually. - return this._metrics.reduce((sum, metric) => metric.name.endsWith('Duration') ? sum + metric.value : sum, 0); + return this._find('TaskDuration'); } public get JSHeapUsedSize(): number { @@ -46,16 +45,17 @@ export class PerfMetricsSampler { private _consumers: PerfMetricsConsumer[] = []; private _timer!: NodeJS.Timer; + private constructor(private _cdp: playwright.CDPSession) { } + public static async create(cdp: playwright.CDPSession, interval: number): Promise { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const self = new PerfMetricsSampler(cdp); await cdp.send('Performance.enable', { timeDomain: 'timeTicks' }) - const self = new PerfMetricsSampler(); + // collect first sample immediately + void self._collectSample(); - self._timer = setInterval(async () => { - const metrics = await cdp.send('Performance.getMetrics').then((v) => v.metrics); - self._consumers.forEach((cb) => cb(new PerfMetrics(metrics)).catch(console.log)); - }, interval); + // and set up automatic collection in the given interval + self._timer = setInterval(self._collectSample.bind(self), interval); return self; } @@ -67,4 +67,10 @@ export class PerfMetricsSampler { public stop(): void { clearInterval(this._timer); } + + private async _collectSample(): Promise { + const response = await this._cdp.send('Performance.getMetrics'); + const metrics = new PerfMetrics(response.metrics); + this._consumers.forEach(cb => cb(metrics).catch(console.error)); + } } From 743221a28886b7c329d733a0eb2ac683967e0e7f Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Thu, 5 Jan 2023 14:38:16 +0100 Subject: [PATCH 39/55] tune cpu usage --- packages/replay/metrics/configs/ci/collect.ts | 4 ++-- packages/replay/metrics/test-apps/jank/app.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/replay/metrics/configs/ci/collect.ts b/packages/replay/metrics/configs/ci/collect.ts index de65e5166985..50a60c48a59b 100644 --- a/packages/replay/metrics/configs/ci/collect.ts +++ b/packages/replay/metrics/configs/ci/collect.ts @@ -35,8 +35,8 @@ const result = await collector.execute({ } const cpuUsage = stats.mean(MetricsStats.cpu)!; - if (cpuUsage > 0.9) { - console.error(`CPU usage too high to be accurate: ${(cpuUsage * 100).toFixed(2)} %.`, + if (cpuUsage > 0.75) { + console.error(`CPU usage too high and may be inaccurate: ${(cpuUsage * 100).toFixed(2)} %.`, 'Consider simplifying the scenario or changing the CPU throttling factor.'); return false; } diff --git a/packages/replay/metrics/test-apps/jank/app.js b/packages/replay/metrics/test-apps/jank/app.js index 23660eecfd9b..390160a21f54 100644 --- a/packages/replay/metrics/test-apps/jank/app.js +++ b/packages/replay/metrics/test-apps/jank/app.js @@ -25,7 +25,7 @@ document.addEventListener("DOMContentLoaded", function() { incrementor = 10, distance = 3, frame, - minimum = 100, + minimum = 30, subtract = document.querySelector('.subtract'), add = document.querySelector('.add'); From cedef009a94797909e58109697d5962553567731 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Thu, 5 Jan 2023 15:37:29 +0100 Subject: [PATCH 40/55] perf sampler error handling --- packages/replay/metrics/src/perf/sampler.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/replay/metrics/src/perf/sampler.ts b/packages/replay/metrics/src/perf/sampler.ts index 7b34d61293e7..7929b10822d6 100644 --- a/packages/replay/metrics/src/perf/sampler.ts +++ b/packages/replay/metrics/src/perf/sampler.ts @@ -52,7 +52,7 @@ export class PerfMetricsSampler { await cdp.send('Performance.enable', { timeDomain: 'timeTicks' }) // collect first sample immediately - void self._collectSample(); + self._collectSample(); // and set up automatic collection in the given interval self._timer = setInterval(self._collectSample.bind(self), interval); @@ -68,9 +68,10 @@ export class PerfMetricsSampler { clearInterval(this._timer); } - private async _collectSample(): Promise { - const response = await this._cdp.send('Performance.getMetrics'); - const metrics = new PerfMetrics(response.metrics); - this._consumers.forEach(cb => cb(metrics).catch(console.error)); + private _collectSample(): void { + this._cdp.send('Performance.getMetrics').then(response => { + const metrics = new PerfMetrics(response.metrics); + this._consumers.forEach(cb => cb(metrics).catch(console.error)); + }, console.error); } } From 7d1b2b45a2fd0f1d6c589db0e3b285399aa2b4b1 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Thu, 5 Jan 2023 18:07:36 +0100 Subject: [PATCH 41/55] metrics collection timeout issues --- packages/replay/metrics/configs/ci/collect.ts | 11 ++- .../replay/metrics/configs/dev/process.ts | 16 +--- packages/replay/metrics/package.json | 1 + packages/replay/metrics/src/collector.ts | 80 ++++++++++--------- packages/replay/metrics/src/perf/sampler.ts | 10 ++- packages/replay/metrics/src/util/console.ts | 30 +++++++ packages/replay/metrics/test-apps/jank/app.js | 2 +- packages/replay/metrics/yarn.lock | 5 ++ 8 files changed, 100 insertions(+), 55 deletions(-) diff --git a/packages/replay/metrics/configs/ci/collect.ts b/packages/replay/metrics/configs/ci/collect.ts index 50a60c48a59b..4e64e157f56b 100644 --- a/packages/replay/metrics/configs/ci/collect.ts +++ b/packages/replay/metrics/configs/ci/collect.ts @@ -1,6 +1,7 @@ import { Metrics, MetricsCollector } from '../../src/collector.js'; import { MetricsStats, NumberProvider } from '../../src/results/metrics-stats.js'; import { JankTestScenario } from '../../src/scenarios.js'; +import { printStats } from '../../src/util/console.js'; import { latestResultFile } from './env.js'; function checkStdDev(stats: MetricsStats, name: string, provider: NumberProvider, max: number): boolean { @@ -26,17 +27,19 @@ const result = await collector.execute({ tries: 10, async shouldAccept(results: Metrics[]): Promise { const stats = new MetricsStats(results); + printStats(stats); + if (!checkStdDev(stats, 'lcp', MetricsStats.lcp, 30) || !checkStdDev(stats, 'cls', MetricsStats.cls, 0.1) || !checkStdDev(stats, 'cpu', MetricsStats.cpu, 10) - || !checkStdDev(stats, 'memory-mean', MetricsStats.memoryMean, 30 * 1024) - || !checkStdDev(stats, 'memory-max', MetricsStats.memoryMax, 100 * 1024)) { + || !checkStdDev(stats, 'memory-mean', MetricsStats.memoryMean, 1000 * 1024) + || !checkStdDev(stats, 'memory-max', MetricsStats.memoryMax, 1000 * 1024)) { return false; } const cpuUsage = stats.mean(MetricsStats.cpu)!; - if (cpuUsage > 0.75) { - console.error(`CPU usage too high and may be inaccurate: ${(cpuUsage * 100).toFixed(2)} %.`, + if (cpuUsage > 0.85) { + console.warn(`✗ | Discarding results because CPU usage is too high and may be inaccurate: ${(cpuUsage * 100).toFixed(2)} %.`, 'Consider simplifying the scenario or changing the CPU throttling factor.'); return false; } diff --git a/packages/replay/metrics/configs/dev/process.ts b/packages/replay/metrics/configs/dev/process.ts index 872bf03cb0c8..096244b5c750 100644 --- a/packages/replay/metrics/configs/dev/process.ts +++ b/packages/replay/metrics/configs/dev/process.ts @@ -1,23 +1,13 @@ -import { AnalyzerItemMetric, ResultsAnalyzer } from '../../src/results/analyzer.js'; +import { ResultsAnalyzer } from '../../src/results/analyzer.js'; import { Result } from '../../src/results/result.js'; import { ResultsSet } from '../../src/results/results-set.js'; +import { printAnalysis } from '../../src/util/console.js'; import { latestResultFile, outDir } from './env.js'; const resultsSet = new ResultsSet(outDir); const latestResult = Result.readFromFile(latestResultFile); const analysis = await ResultsAnalyzer.analyze(latestResult, resultsSet); - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const table: { [k: string]: any } = {}; -for (const item of analysis.items) { - table[AnalyzerItemMetric[item.metric]] = { - value: item.value.diff, - ...((item.other == undefined) ? {} : { - previous: item.other.diff - }) - }; -} -console.table(table); +printAnalysis(analysis); await resultsSet.add(latestResultFile, true); diff --git a/packages/replay/metrics/package.json b/packages/replay/metrics/package.json index e4163257018b..c119f1003c8f 100644 --- a/packages/replay/metrics/package.json +++ b/packages/replay/metrics/package.json @@ -19,6 +19,7 @@ "axios": "^1.2.2", "extract-zip": "^2.0.1", "filesize": "^10.0.6", + "p-timeout": "^6.0.0", "playwright": "^1.29.1", "playwright-core": "^1.29.1", "simple-git": "^3.15.1", diff --git a/packages/replay/metrics/src/collector.ts b/packages/replay/metrics/src/collector.ts index de37851e0864..9d1745f5aa79 100644 --- a/packages/replay/metrics/src/collector.ts +++ b/packages/replay/metrics/src/collector.ts @@ -1,4 +1,4 @@ -import assert from 'assert'; +import pTimeout from 'p-timeout'; import * as playwright from 'playwright'; import { CpuUsage, CpuUsageSampler, CpuUsageSerialized } from './perf/cpu.js'; @@ -71,12 +71,16 @@ export class MetricsCollector { for (let run = 1; run <= testCase.runs; run++) { const innerLabel = `Scenario ${name} data collection, run ${run}/${testCase.runs}`; console.time(innerLabel); - results.push(await this._run(scenario)); + try { + results.push(await this._run(scenario)); + } catch (e) { + console.warn(`${innerLabel} failed with ${e}`); + break; + } console.timeEnd(innerLabel); } console.timeEnd(label); - assert.strictEqual(results.length, testCase.runs); - if (await testCase.shouldAccept(results)) { + if ((results.length == testCase.runs) && await testCase.shouldAccept(results)) { console.log(`Test case ${testCase.name}, scenario ${name} passed on try ${try_}/${testCase.tries}`); return results; } else if (try_ != testCase.tries) { @@ -93,40 +97,44 @@ export class MetricsCollector { private async _run(scenario: Scenario): Promise { const disposeCallbacks: (() => Promise)[] = []; try { - const browser = await playwright.chromium.launch({ - headless: this._options.headless, - + return await pTimeout((async () => { + const browser = await playwright.chromium.launch({ + headless: this._options.headless, + }); + disposeCallbacks.push(() => browser.close()); + const page = await browser.newPage(); + disposeCallbacks.push(() => page.close()); + + const cdp = await page.context().newCDPSession(page); + + // Simulate throttling. + await cdp.send('Network.emulateNetworkConditions', { + offline: false, + latency: PredefinedNetworkConditions[networkConditions].latency, + uploadThroughput: PredefinedNetworkConditions[networkConditions].upload, + downloadThroughput: PredefinedNetworkConditions[networkConditions].download, + }); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: cpuThrottling }); + + // Collect CPU and memory info 10 times per second. + const perfSampler = await PerfMetricsSampler.create(cdp, 100); + disposeCallbacks.push(async () => perfSampler.stop()); + const cpuSampler = new CpuUsageSampler(perfSampler); + const memSampler = new JsHeapUsageSampler(perfSampler); + + const vitalsCollector = await WebVitalsCollector.create(page); + await scenario.run(browser, page); + + // NOTE: FID needs some interaction to actually show a value + const vitals = await vitalsCollector.collect(); + + return new Metrics(vitals, cpuSampler.getData(), memSampler.getData()); + })(), { + milliseconds: 60 * 1000, }); - disposeCallbacks.push(async () => browser.close()); - const page = await browser.newPage(); - - const cdp = await page.context().newCDPSession(page); - - // Simulate throttling. - await cdp.send('Network.emulateNetworkConditions', { - offline: false, - latency: PredefinedNetworkConditions[networkConditions].latency, - uploadThroughput: PredefinedNetworkConditions[networkConditions].upload, - downloadThroughput: PredefinedNetworkConditions[networkConditions].download, - }); - await cdp.send('Emulation.setCPUThrottlingRate', { rate: cpuThrottling }); - - // Collect CPU and memory info 10 times per second. - const perfSampler = await PerfMetricsSampler.create(cdp, 100); - disposeCallbacks.push(async () => perfSampler.stop()); - const cpuSampler = new CpuUsageSampler(perfSampler); - const memSampler = new JsHeapUsageSampler(perfSampler); - - const vitalsCollector = await WebVitalsCollector.create(page); - - await scenario.run(browser, page); - - // NOTE: FID needs some interaction to actually show a value - const vitals = await vitalsCollector.collect(); - - return new Metrics(vitals, cpuSampler.getData(), memSampler.getData()); } finally { - disposeCallbacks.reverse().forEach((cb) => cb().catch(console.log)); + console.log('Disposing of browser and resources'); + disposeCallbacks.reverse().forEach((cb) => cb().catch(() => { /* silent */ })); } } } diff --git a/packages/replay/metrics/src/perf/sampler.ts b/packages/replay/metrics/src/perf/sampler.ts index 7929b10822d6..b4dd26013cd5 100644 --- a/packages/replay/metrics/src/perf/sampler.ts +++ b/packages/replay/metrics/src/perf/sampler.ts @@ -44,6 +44,7 @@ export class PerfMetrics { export class PerfMetricsSampler { private _consumers: PerfMetricsConsumer[] = []; private _timer!: NodeJS.Timer; + private _errorPrinted: boolean = false; private constructor(private _cdp: playwright.CDPSession) { } @@ -72,6 +73,13 @@ export class PerfMetricsSampler { this._cdp.send('Performance.getMetrics').then(response => { const metrics = new PerfMetrics(response.metrics); this._consumers.forEach(cb => cb(metrics).catch(console.error)); - }, console.error); + }, (e) => { + // This happens if the browser closed unexpectedly. No reason to try again. + if (!this._errorPrinted) { + this._errorPrinted = true; + console.log(e); + this.stop(); + } + }); } } diff --git a/packages/replay/metrics/src/util/console.ts b/packages/replay/metrics/src/util/console.ts index 3ceaac1c862b..9ebf57c54bb2 100644 --- a/packages/replay/metrics/src/util/console.ts +++ b/packages/replay/metrics/src/util/console.ts @@ -1,5 +1,35 @@ +import { filesize } from 'filesize'; + +import { Analysis, AnalyzerItemMetric } from '../results/analyzer.js'; +import { MetricsStats } from '../results/metrics-stats.js'; export async function consoleGroup(code: () => Promise): Promise { console.group(); return code().finally(console.groupEnd); } + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type PrintableTable = { [k: string]: any }; + +export function printStats(stats: MetricsStats): void { + console.table({ + lcp: `${stats.mean(MetricsStats.lcp)?.toFixed(2)} %`, + cls: `${stats.mean(MetricsStats.cls)?.toFixed(2)} %`, + cpu: `${((stats.mean(MetricsStats.cpu) || 0) * 100).toFixed(2)} %`, + memoryMean: filesize(stats.mean(MetricsStats.memoryMean)), + memoryMax: filesize(stats.max(MetricsStats.memoryMax)), + }); +} + +export function printAnalysis(analysis: Analysis): void { + const table: PrintableTable = {}; + for (const item of analysis.items) { + table[AnalyzerItemMetric[item.metric]] = { + value: item.value.diff, + ...((item.other == undefined) ? {} : { + previous: item.other.diff + }) + }; + } + console.table(table); +} diff --git a/packages/replay/metrics/test-apps/jank/app.js b/packages/replay/metrics/test-apps/jank/app.js index 390160a21f54..aa482a228bb6 100644 --- a/packages/replay/metrics/test-apps/jank/app.js +++ b/packages/replay/metrics/test-apps/jank/app.js @@ -25,7 +25,7 @@ document.addEventListener("DOMContentLoaded", function() { incrementor = 10, distance = 3, frame, - minimum = 30, + minimum = 50, subtract = document.querySelector('.subtract'), add = document.querySelector('.add'); diff --git a/packages/replay/metrics/yarn.lock b/packages/replay/metrics/yarn.lock index 3db63790ad27..b8837974c2a1 100644 --- a/packages/replay/metrics/yarn.lock +++ b/packages/replay/metrics/yarn.lock @@ -337,6 +337,11 @@ once@^1.3.1, once@^1.4.0: dependencies: wrappy "1" +p-timeout@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-6.0.0.tgz#84c210f5500da1af4c31ab2768d794e5e081dd91" + integrity sha512-5iS61MOdUMemWH9CORQRxVXTp9g5K8rPnI9uQpo97aWgsH3vVXKjkIhDi+OgIDmN3Ly9+AZ2fZV01Wut1yzfKA== + pend@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" From ec925abb8809a75c902545cbb7ac6b0b647cb7d6 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Thu, 5 Jan 2023 21:03:50 +0100 Subject: [PATCH 42/55] metrics: fail on error logs --- packages/replay/metrics/src/collector.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/replay/metrics/src/collector.ts b/packages/replay/metrics/src/collector.ts index 9d1745f5aa79..3ec628596533 100644 --- a/packages/replay/metrics/src/collector.ts +++ b/packages/replay/metrics/src/collector.ts @@ -105,6 +105,11 @@ export class MetricsCollector { const page = await browser.newPage(); disposeCallbacks.push(() => page.close()); + const errorLogs: Array = []; + await page.on('console', message => { if (message.type() === 'error') errorLogs.push(message.text()) }); + await page.on('crash', _ => { errorLogs.push('Page crashed') }); + await page.on('pageerror', error => { errorLogs.push(`${error.name}: ${error.message}`) }); + const cdp = await page.context().newCDPSession(page); // Simulate throttling. @@ -128,6 +133,10 @@ export class MetricsCollector { // NOTE: FID needs some interaction to actually show a value const vitals = await vitalsCollector.collect(); + if (errorLogs.length > 0) { + throw `Error logs in browser console:\n\t\t${errorLogs.join('\n\t\t')}`; + } + return new Metrics(vitals, cpuSampler.getData(), memSampler.getData()); })(), { milliseconds: 60 * 1000, From be830afceff503d15fd07e14e153a6e94752be94 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Thu, 5 Jan 2023 21:27:24 +0100 Subject: [PATCH 43/55] move metrics CI job to build.yaml to get the full build cache --- .github/workflows/build.yml | 44 +++++++++++++++++++ .github/workflows/metrics.yml | 81 ----------------------------------- 2 files changed, 44 insertions(+), 81 deletions(-) delete mode 100644 .github/workflows/metrics.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e2468933eeef..dc55e5ffd3bd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -815,3 +815,47 @@ jobs: if: contains(needs.*.result, 'failure') run: | echo "One of the dependent jobs have failed. You may need to re-run it." && exit 1 + + replay_metrics: + name: Replay Metrics + needs: [job_get_metadata, job_build] + runs-on: ubuntu-20.04 + timeout-minutes: 30 + steps: + - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) + uses: actions/checkout@v3 + with: + ref: ${{ env.HEAD_COMMIT }} + - name: Set up Node + uses: volta-cli/action@v4 + - name: Check dependency cache + uses: actions/cache@v3 + with: + path: ${{ env.CACHED_DEPENDENCY_PATHS }} + key: ${{ needs.job_build.outputs.dependency_cache_key }} + - name: Check build cache + uses: actions/cache@v3 + with: + path: ${{ env.CACHED_BUILD_PATHS }} + key: ${{ env.BUILD_CACHE_KEY }} + + - name: Setup + run: yarn install + working-directory: packages/replay/metrics + + - name: Collect + run: yarn ci:collect + working-directory: packages/replay/metrics + + - name: Process + id: process + run: yarn ci:process + working-directory: packages/replay/metrics + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: Upload results + uses: actions/upload-artifact@v3 + with: + name: ${{ steps.process.outputs.artifactName }} + path: ${{ steps.process.outputs.artifactPath }} diff --git a/.github/workflows/metrics.yml b/.github/workflows/metrics.yml deleted file mode 100644 index ebd57236a53e..000000000000 --- a/.github/workflows/metrics.yml +++ /dev/null @@ -1,81 +0,0 @@ -name: Collect SDK metrics -on: - push: - paths: - - .github/workflows/metrics.yml - - packages/** - - patches/** - - lerna.json - - package.json - - tsconfig.json - - yarn.lock - branches-ignore: - - deps/** - - dependabot/** - tags-ignore: ['**'] - -env: - CACHED_DEPENDENCY_PATHS: | - ${{ github.workspace }}/node_modules - ${{ github.workspace }}/packages/*/node_modules - ~/.cache/ms-playwright/ - ~/.cache/mongodb-binaries/ - -jobs: - cancel-previous-workflow: - runs-on: ubuntu-latest - steps: - - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@b173b6ec0100793626c2d9e6b90435061f4fc3e5 # pin@0.11.0 - with: - access_token: ${{ github.token }} - - replay: - name: Replay SDK metrics - runs-on: ubuntu-20.04 - - steps: - - uses: actions/checkout@v3 - - - name: Set up Node - uses: volta-cli/action@v4 - - - name: Compute dependency cache key - id: compute_lockfile_hash - # we use a hash of yarn.lock as our cache key, because if it hasn't changed, our dependencies haven't changed, - # so no need to reinstall them - run: echo "hash=${{ hashFiles('yarn.lock') }}" >> "$GITHUB_OUTPUT" - - - name: Check dependency cache - uses: actions/cache@v3 - id: cache_dependencies - with: - path: ${{ env.CACHED_DEPENDENCY_PATHS }} - key: ${{ steps.compute_lockfile_hash.outputs.hash }} - - - name: Install dependencies - if: steps.cache_dependencies.outputs.cache-hit == '' - run: yarn install --ignore-engines --frozen-lockfile - - - name: Build - run: | - yarn install --ignore-engines --frozen-lockfile - yarn deps - working-directory: packages/replay/metrics - - - name: Collect - run: yarn ci:collect - working-directory: packages/replay/metrics - - - name: Process - id: process - run: yarn ci:process - working-directory: packages/replay/metrics - env: - GITHUB_TOKEN: ${{ github.token }} - - - name: Upload results - uses: actions/upload-artifact@v3 - with: - name: ${{ steps.process.outputs.artifactName }} - path: ${{ steps.process.outputs.artifactPath }} From 4d0a36c63902f5979271e897c595c71bc20f1d6a Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Thu, 5 Jan 2023 22:54:46 +0100 Subject: [PATCH 44/55] tune metrics collection --- packages/replay/metrics/configs/ci/collect.ts | 2 +- packages/replay/metrics/test-apps/jank/app.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/replay/metrics/configs/ci/collect.ts b/packages/replay/metrics/configs/ci/collect.ts index 4e64e157f56b..f2a62764f479 100644 --- a/packages/replay/metrics/configs/ci/collect.ts +++ b/packages/replay/metrics/configs/ci/collect.ts @@ -31,7 +31,7 @@ const result = await collector.execute({ if (!checkStdDev(stats, 'lcp', MetricsStats.lcp, 30) || !checkStdDev(stats, 'cls', MetricsStats.cls, 0.1) - || !checkStdDev(stats, 'cpu', MetricsStats.cpu, 10) + || !checkStdDev(stats, 'cpu', MetricsStats.cpu, 1) || !checkStdDev(stats, 'memory-mean', MetricsStats.memoryMean, 1000 * 1024) || !checkStdDev(stats, 'memory-max', MetricsStats.memoryMax, 1000 * 1024)) { return false; diff --git a/packages/replay/metrics/test-apps/jank/app.js b/packages/replay/metrics/test-apps/jank/app.js index aa482a228bb6..390160a21f54 100644 --- a/packages/replay/metrics/test-apps/jank/app.js +++ b/packages/replay/metrics/test-apps/jank/app.js @@ -25,7 +25,7 @@ document.addEventListener("DOMContentLoaded", function() { incrementor = 10, distance = 3, frame, - minimum = 50, + minimum = 30, subtract = document.querySelector('.subtract'), add = document.querySelector('.add'); From 3afa90bb06d6dfd301672c577d51e2da64dd507f Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Fri, 6 Jan 2023 08:43:28 +0100 Subject: [PATCH 45/55] ci metrics collection issues --- packages/replay/metrics/configs/ci/collect.ts | 2 +- packages/replay/metrics/configs/dev/collect.ts | 3 +++ packages/replay/metrics/src/collector.ts | 7 ++++--- packages/replay/metrics/src/util/console.ts | 4 ++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/replay/metrics/configs/ci/collect.ts b/packages/replay/metrics/configs/ci/collect.ts index f2a62764f479..5972ae3e0032 100644 --- a/packages/replay/metrics/configs/ci/collect.ts +++ b/packages/replay/metrics/configs/ci/collect.ts @@ -18,7 +18,7 @@ function checkStdDev(stats: MetricsStats, name: string, provider: NumberProvider return true; } -const collector = new MetricsCollector({ headless: true }); +const collector = new MetricsCollector({ headless: true, cpuThrottling: 2 }); const result = await collector.execute({ name: 'jank', a: new JankTestScenario(false), diff --git a/packages/replay/metrics/configs/dev/collect.ts b/packages/replay/metrics/configs/dev/collect.ts index 79f127b0fe27..a370fdf594f8 100644 --- a/packages/replay/metrics/configs/dev/collect.ts +++ b/packages/replay/metrics/configs/dev/collect.ts @@ -1,6 +1,7 @@ import { Metrics, MetricsCollector } from '../../src/collector.js'; import { MetricsStats } from '../../src/results/metrics-stats.js'; import { JankTestScenario } from '../../src/scenarios.js'; +import { printStats } from '../../src/util/console.js'; import { latestResultFile } from './env.js'; const collector = new MetricsCollector(); @@ -12,6 +13,8 @@ const result = await collector.execute({ tries: 1, async shouldAccept(results: Metrics[]): Promise { const stats = new MetricsStats(results); + printStats(stats); + const cpuUsage = stats.mean(MetricsStats.cpu)!; if (cpuUsage > 0.9) { console.error(`CPU usage too high to be accurate: ${(cpuUsage * 100).toFixed(2)} %.`, diff --git a/packages/replay/metrics/src/collector.ts b/packages/replay/metrics/src/collector.ts index 3ec628596533..36b0fdfb9b19 100644 --- a/packages/replay/metrics/src/collector.ts +++ b/packages/replay/metrics/src/collector.ts @@ -9,7 +9,6 @@ import { Scenario, TestCase } from './scenarios.js'; import { consoleGroup } from './util/console.js'; import { WebVitals, WebVitalsCollector } from './vitals/index.js'; -const cpuThrottling = 4; const networkConditions = 'Fast 3G'; // Same as puppeteer-core PredefinedNetworkConditions @@ -42,6 +41,7 @@ export class Metrics { export interface MetricsCollectorOptions { headless: boolean; + cpuThrottling: number; } export class MetricsCollector { @@ -50,6 +50,7 @@ export class MetricsCollector { constructor(options?: Partial) { this._options = { headless: false, + cpuThrottling: 4, ...options }; } @@ -59,7 +60,7 @@ export class MetricsCollector { return consoleGroup(async () => { const aResults = await this._collect(testCase, 'A', testCase.a); const bResults = await this._collect(testCase, 'B', testCase.b); - return new Result(testCase.name, cpuThrottling, networkConditions, aResults, bResults); + return new Result(testCase.name, this._options.cpuThrottling, networkConditions, aResults, bResults); }); } @@ -119,7 +120,7 @@ export class MetricsCollector { uploadThroughput: PredefinedNetworkConditions[networkConditions].upload, downloadThroughput: PredefinedNetworkConditions[networkConditions].download, }); - await cdp.send('Emulation.setCPUThrottlingRate', { rate: cpuThrottling }); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: this._options.cpuThrottling }); // Collect CPU and memory info 10 times per second. const perfSampler = await PerfMetricsSampler.create(cdp, 100); diff --git a/packages/replay/metrics/src/util/console.ts b/packages/replay/metrics/src/util/console.ts index 9ebf57c54bb2..991216fb45ec 100644 --- a/packages/replay/metrics/src/util/console.ts +++ b/packages/replay/metrics/src/util/console.ts @@ -13,8 +13,8 @@ type PrintableTable = { [k: string]: any }; export function printStats(stats: MetricsStats): void { console.table({ - lcp: `${stats.mean(MetricsStats.lcp)?.toFixed(2)} %`, - cls: `${stats.mean(MetricsStats.cls)?.toFixed(2)} %`, + lcp: `${stats.mean(MetricsStats.lcp)?.toFixed(2)} ms`, + cls: `${stats.mean(MetricsStats.cls)?.toFixed(2)} ms`, cpu: `${((stats.mean(MetricsStats.cpu) || 0) * 100).toFixed(2)} %`, memoryMean: filesize(stats.mean(MetricsStats.memoryMean)), memoryMax: filesize(stats.max(MetricsStats.memoryMax)), From e713fab073eb36ad2035d7d039e18f3a7fd34b72 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Fri, 6 Jan 2023 10:44:37 +0100 Subject: [PATCH 46/55] tune CI metrics configs --- packages/replay/metrics/configs/ci/collect.ts | 1 + packages/replay/metrics/src/results/analyzer.ts | 6 +++--- packages/replay/metrics/src/results/pr-comment.ts | 12 ++++++++++-- packages/replay/metrics/test-apps/jank/app.js | 2 +- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/replay/metrics/configs/ci/collect.ts b/packages/replay/metrics/configs/ci/collect.ts index 5972ae3e0032..cbed78974a3e 100644 --- a/packages/replay/metrics/configs/ci/collect.ts +++ b/packages/replay/metrics/configs/ci/collect.ts @@ -39,6 +39,7 @@ const result = await collector.execute({ const cpuUsage = stats.mean(MetricsStats.cpu)!; if (cpuUsage > 0.85) { + // Note: complexity on the "JankTest" is defined by the `minimum = ...,` setting in app.js - specifying the number of animated elements. console.warn(`✗ | Discarding results because CPU usage is too high and may be inaccurate: ${(cpuUsage * 100).toFixed(2)} %.`, 'Consider simplifying the scenario or changing the CPU throttling factor.'); return false; diff --git a/packages/replay/metrics/src/results/analyzer.ts b/packages/replay/metrics/src/results/analyzer.ts index d010ca0f2204..d7cddf707ec4 100644 --- a/packages/replay/metrics/src/results/analyzer.ts +++ b/packages/replay/metrics/src/results/analyzer.ts @@ -83,16 +83,16 @@ class AnalyzerItemNumberValue implements AnalyzerItemValue { public get diff(): string { const diff = this._b - this._a; - const str = this._withUnit(diff); + const str = this._withUnit(diff, true); return diff > 0 ? `+${str}` : str; } - private _withUnit(value: number): string { + private _withUnit(value: number, isDiff: boolean = false): string { switch (this._unit) { case AnalyzerItemUnit.bytes: return filesize(value) as string; case AnalyzerItemUnit.ratio: - return `${(value * 100).toFixed(2)} %`; + return `${(value * 100).toFixed(2)} ${isDiff ? 'pp' : '%'}`; default: return `${value.toFixed(2)} ${AnalyzerItemUnit[this._unit]}`; } diff --git a/packages/replay/metrics/src/results/pr-comment.ts b/packages/replay/metrics/src/results/pr-comment.ts index 2f01637d07b5..178cd7401b0b 100644 --- a/packages/replay/metrics/src/results/pr-comment.ts +++ b/packages/replay/metrics/src/results/pr-comment.ts @@ -4,7 +4,7 @@ import { Result } from './result.js'; import { ResultSetItem } from './results-set.js'; function trimIndent(str: string): string { - return str.split('\n').map(s => s.trim()).join('\n'); + return str.trim().split('\n').map(s => s.trim()).join('\n'); } function printableMetricName(metric: AnalyzerItemMetric): string { @@ -32,7 +32,15 @@ export class PrCommentBuilder { } public get body(): string { - return trimIndent(this._buffer); + const now = new Date(); + return trimIndent(` + ${this._buffer} +
+
+ CPU usage difference is shown as percentage points.
+ Last updated: +
+ `); } public async addCurrentResult(analysis: Analysis, otherName: string): Promise { diff --git a/packages/replay/metrics/test-apps/jank/app.js b/packages/replay/metrics/test-apps/jank/app.js index 390160a21f54..546644596756 100644 --- a/packages/replay/metrics/test-apps/jank/app.js +++ b/packages/replay/metrics/test-apps/jank/app.js @@ -25,7 +25,7 @@ document.addEventListener("DOMContentLoaded", function() { incrementor = 10, distance = 3, frame, - minimum = 30, + minimum = 70, subtract = document.querySelector('.subtract'), add = document.querySelector('.add'); From 2078d631404c0fe4f59f4926acc52ad57a97d6f2 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Fri, 6 Jan 2023 12:04:53 +0100 Subject: [PATCH 47/55] more CI tuning --- packages/replay/metrics/configs/ci/collect.ts | 4 ++-- packages/replay/metrics/src/collector.ts | 3 ++- packages/replay/metrics/test-apps/jank/app.js | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/replay/metrics/configs/ci/collect.ts b/packages/replay/metrics/configs/ci/collect.ts index cbed78974a3e..49fcbb910944 100644 --- a/packages/replay/metrics/configs/ci/collect.ts +++ b/packages/replay/metrics/configs/ci/collect.ts @@ -27,9 +27,9 @@ const result = await collector.execute({ tries: 10, async shouldAccept(results: Metrics[]): Promise { const stats = new MetricsStats(results); - printStats(stats); + await printStats(stats); - if (!checkStdDev(stats, 'lcp', MetricsStats.lcp, 30) + if (!checkStdDev(stats, 'lcp', MetricsStats.lcp, 50) || !checkStdDev(stats, 'cls', MetricsStats.cls, 0.1) || !checkStdDev(stats, 'cpu', MetricsStats.cpu, 1) || !checkStdDev(stats, 'memory-mean', MetricsStats.memoryMean, 1000 * 1024) diff --git a/packages/replay/metrics/src/collector.ts b/packages/replay/metrics/src/collector.ts index 36b0fdfb9b19..1efd4afcd0be 100644 --- a/packages/replay/metrics/src/collector.ts +++ b/packages/replay/metrics/src/collector.ts @@ -77,8 +77,9 @@ export class MetricsCollector { } catch (e) { console.warn(`${innerLabel} failed with ${e}`); break; + } finally { + console.timeEnd(innerLabel); } - console.timeEnd(innerLabel); } console.timeEnd(label); if ((results.length == testCase.runs) && await testCase.shouldAccept(results)) { diff --git a/packages/replay/metrics/test-apps/jank/app.js b/packages/replay/metrics/test-apps/jank/app.js index 546644596756..a854fd00d187 100644 --- a/packages/replay/metrics/test-apps/jank/app.js +++ b/packages/replay/metrics/test-apps/jank/app.js @@ -25,7 +25,7 @@ document.addEventListener("DOMContentLoaded", function() { incrementor = 10, distance = 3, frame, - minimum = 70, + minimum = 20, subtract = document.querySelector('.subtract'), add = document.querySelector('.add'); From 0f2d1ac9d51e50a8052a864ca3451d46b3d9b504 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Fri, 6 Jan 2023 13:38:52 +0100 Subject: [PATCH 48/55] docs --- packages/replay/metrics/README.md | 4 ++++ packages/replay/metrics/configs/ci/collect.ts | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/replay/metrics/README.md b/packages/replay/metrics/README.md index dcf78671fba6..11764a62009e 100644 --- a/packages/replay/metrics/README.md +++ b/packages/replay/metrics/README.md @@ -2,6 +2,10 @@ Evaluates Replay impact on website performance by running a web app in Chromium via Playwright and collecting various metrics. +The general idea is to run a web app without Sentry Replay and then run the same app again with Replay included. +For both scenarios, we collect some metrics (CPU, memory, vitals) and later compare them and post as a comment in a PR. +Changes in the collected, compared to previous runs from the main branch, should be evaluated on case-by-case basis when preparing and reviewing the PR. + ## Resources * https://github.com/addyosmani/puppeteer-webperf diff --git a/packages/replay/metrics/configs/ci/collect.ts b/packages/replay/metrics/configs/ci/collect.ts index 49fcbb910944..aafb8d149ba6 100644 --- a/packages/replay/metrics/configs/ci/collect.ts +++ b/packages/replay/metrics/configs/ci/collect.ts @@ -21,8 +21,8 @@ function checkStdDev(stats: MetricsStats, name: string, provider: NumberProvider const collector = new MetricsCollector({ headless: true, cpuThrottling: 2 }); const result = await collector.execute({ name: 'jank', - a: new JankTestScenario(false), - b: new JankTestScenario(true), + a: new JankTestScenario(false), // No sentry + b: new JankTestScenario(true), // Sentry + Replay runs: 10, tries: 10, async shouldAccept(results: Metrics[]): Promise { From d9b8244af65cb37eb84fd08d6b264f953695bbb6 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Fri, 6 Jan 2023 13:51:53 +0100 Subject: [PATCH 49/55] show increase as ratio too --- packages/replay/metrics/src/results/analyzer.ts | 8 ++++++++ packages/replay/metrics/src/results/pr-comment.ts | 12 +++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/replay/metrics/src/results/analyzer.ts b/packages/replay/metrics/src/results/analyzer.ts index d7cddf707ec4..e6b8ce7a68dc 100644 --- a/packages/replay/metrics/src/results/analyzer.ts +++ b/packages/replay/metrics/src/results/analyzer.ts @@ -68,6 +68,7 @@ export interface AnalyzerItemValue { readonly a: string; readonly b: string; readonly diff: string; + readonly percent: string; } class AnalyzerItemNumberValue implements AnalyzerItemValue { @@ -87,6 +88,13 @@ class AnalyzerItemNumberValue implements AnalyzerItemValue { return diff > 0 ? `+${str}` : str; } + public get percent(): string { + if (this._a == 0) return 'n/a'; + const diff = this._b / this._a * 100 - 100; + const str = `${diff.toFixed(2)} %`; + return diff > 0 ? `+${str}` : str; + } + private _withUnit(value: number, isDiff: boolean = false): string { switch (this._unit) { case AnalyzerItemUnit.bytes: diff --git a/packages/replay/metrics/src/results/pr-comment.ts b/packages/replay/metrics/src/results/pr-comment.ts index 178cd7401b0b..5406d9041650 100644 --- a/packages/replay/metrics/src/results/pr-comment.ts +++ b/packages/replay/metrics/src/results/pr-comment.ts @@ -37,7 +37,7 @@ export class PrCommentBuilder { ${this._buffer}
- CPU usage difference is shown as percentage points.
+ *) pp - percentage points - an absolute difference between two percentages.
Last updated:
`); @@ -57,14 +57,14 @@ export class PrCommentBuilder {
 This PR (${await Git.hash})${otherName} (${analysis.otherHash})
Plain+ReplayDiffPlain+ReplayDiff
Plain+ReplayDiff
 This PR (${await Git.hash})${otherName} (${analysis.otherHash})
 
${printableMetricName(item.metric)} ${item.value.a} ${item.value.b}${item.value.diff}${item.value.diff}${item.other!.a} ${item.other!.b}${item.other!.diff}${item.other!.diff}
`; - const headerCols = ''; + const headerCols = ''; if (analysis.otherHash != undefined) { // If "other" is defined, add an aditional row of headers. this._buffer += ` - - + + ${headerCols} @@ -85,10 +85,12 @@ export class PrCommentBuilder { + ${maybeOther(() => ` - `)} + + `)} ` } From b772b3e13076235b5081d94824eb601a301c1b2c Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Mon, 9 Jan 2023 14:31:37 +0100 Subject: [PATCH 50/55] fix: don't post PR comments on forks --- .github/workflows/build.yml | 3 +++ packages/replay/metrics/src/util/github.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dc55e5ffd3bd..73ea96d24c1e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -851,11 +851,14 @@ jobs: id: process run: yarn ci:process working-directory: packages/replay/metrics + # Don't run on forks - the PR comment cannot be added. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository env: GITHUB_TOKEN: ${{ github.token }} - name: Upload results uses: actions/upload-artifact@v3 + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository with: name: ${{ steps.process.outputs.artifactName }} path: ${{ steps.process.outputs.artifactPath }} diff --git a/packages/replay/metrics/src/util/github.ts b/packages/replay/metrics/src/util/github.ts index 0345c10c9ef4..01b0e9fbe991 100644 --- a/packages/replay/metrics/src/util/github.ts +++ b/packages/replay/metrics/src/util/github.ts @@ -98,7 +98,7 @@ async function tryAddOrUpdateComment(commentBuilder: PrCommentBuilder): Promise< body: commentBuilder.body, }); } else { - console.log(`Adding new PR comment to PR ${prNumber}`) + console.log(`Adding a new comment to PR ${prNumber}`) await octokit.rest.issues.createComment({ ...defaultArgs, issue_number: prNumber, From c3fcad96808bd3f827faef843e43201af1cc8c04 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Wed, 11 Jan 2023 21:06:52 +0100 Subject: [PATCH 51/55] feat: separate metrics measurements for sentry and sentry+replay --- packages/replay/metrics/configs/ci/collect.ts | 26 +++--- .../replay/metrics/configs/dev/collect.ts | 12 ++- packages/replay/metrics/src/collector.ts | 8 +- .../replay/metrics/src/results/analyzer.ts | 78 +++++++++------- .../metrics/src/results/metrics-stats.ts | 18 ++-- .../replay/metrics/src/results/pr-comment.ts | 91 +++++++++++-------- packages/replay/metrics/src/results/result.ts | 6 +- packages/replay/metrics/src/scenarios.ts | 7 +- packages/replay/metrics/src/util/console.ts | 19 ++-- .../metrics/test-apps/jank/with-replay.html | 56 ++++++++++++ .../metrics/test-apps/jank/with-sentry.html | 6 -- 11 files changed, 204 insertions(+), 123 deletions(-) create mode 100644 packages/replay/metrics/test-apps/jank/with-replay.html diff --git a/packages/replay/metrics/configs/ci/collect.ts b/packages/replay/metrics/configs/ci/collect.ts index aafb8d149ba6..67add4feb6f2 100644 --- a/packages/replay/metrics/configs/ci/collect.ts +++ b/packages/replay/metrics/configs/ci/collect.ts @@ -4,8 +4,8 @@ import { JankTestScenario } from '../../src/scenarios.js'; import { printStats } from '../../src/util/console.js'; import { latestResultFile } from './env.js'; -function checkStdDev(stats: MetricsStats, name: string, provider: NumberProvider, max: number): boolean { - const value = stats.stddev(provider); +function checkStdDev(results: Metrics[], name: string, provider: NumberProvider, max: number): boolean { + const value = MetricsStats.stddev(results, provider); if (value == undefined) { console.warn(`✗ | Discarding results because StandardDeviation(${name}) is undefined`); return false; @@ -21,23 +21,25 @@ function checkStdDev(stats: MetricsStats, name: string, provider: NumberProvider const collector = new MetricsCollector({ headless: true, cpuThrottling: 2 }); const result = await collector.execute({ name: 'jank', - a: new JankTestScenario(false), // No sentry - b: new JankTestScenario(true), // Sentry + Replay + scenarios: [ + new JankTestScenario('index.html'), + new JankTestScenario('with-sentry.html'), + new JankTestScenario('with-replay.html'), + ], runs: 10, tries: 10, async shouldAccept(results: Metrics[]): Promise { - const stats = new MetricsStats(results); - await printStats(stats); + await printStats(results); - if (!checkStdDev(stats, 'lcp', MetricsStats.lcp, 50) - || !checkStdDev(stats, 'cls', MetricsStats.cls, 0.1) - || !checkStdDev(stats, 'cpu', MetricsStats.cpu, 1) - || !checkStdDev(stats, 'memory-mean', MetricsStats.memoryMean, 1000 * 1024) - || !checkStdDev(stats, 'memory-max', MetricsStats.memoryMax, 1000 * 1024)) { + if (!checkStdDev(results, 'lcp', MetricsStats.lcp, 50) + || !checkStdDev(results, 'cls', MetricsStats.cls, 0.1) + || !checkStdDev(results, 'cpu', MetricsStats.cpu, 1) + || !checkStdDev(results, 'memory-mean', MetricsStats.memoryMean, 1000 * 1024) + || !checkStdDev(results, 'memory-max', MetricsStats.memoryMax, 1000 * 1024)) { return false; } - const cpuUsage = stats.mean(MetricsStats.cpu)!; + const cpuUsage = MetricsStats.mean(results, MetricsStats.cpu)!; if (cpuUsage > 0.85) { // Note: complexity on the "JankTest" is defined by the `minimum = ...,` setting in app.js - specifying the number of animated elements. console.warn(`✗ | Discarding results because CPU usage is too high and may be inaccurate: ${(cpuUsage * 100).toFixed(2)} %.`, diff --git a/packages/replay/metrics/configs/dev/collect.ts b/packages/replay/metrics/configs/dev/collect.ts index a370fdf594f8..a159d7a4f7b1 100644 --- a/packages/replay/metrics/configs/dev/collect.ts +++ b/packages/replay/metrics/configs/dev/collect.ts @@ -7,15 +7,17 @@ import { latestResultFile } from './env.js'; const collector = new MetricsCollector(); const result = await collector.execute({ name: 'dummy', - a: new JankTestScenario(false), - b: new JankTestScenario(true), + scenarios: [ + new JankTestScenario('index.html'), + new JankTestScenario('with-sentry.html'), + new JankTestScenario('with-replay.html'), + ], runs: 1, tries: 1, async shouldAccept(results: Metrics[]): Promise { - const stats = new MetricsStats(results); - printStats(stats); + printStats(results); - const cpuUsage = stats.mean(MetricsStats.cpu)!; + const cpuUsage = MetricsStats.mean(results, MetricsStats.cpu)!; if (cpuUsage > 0.9) { console.error(`CPU usage too high to be accurate: ${(cpuUsage * 100).toFixed(2)} %.`, 'Consider simplifying the scenario or changing the CPU throttling factor.'); diff --git a/packages/replay/metrics/src/collector.ts b/packages/replay/metrics/src/collector.ts index 1efd4afcd0be..d00fa1caf131 100644 --- a/packages/replay/metrics/src/collector.ts +++ b/packages/replay/metrics/src/collector.ts @@ -58,9 +58,11 @@ export class MetricsCollector { public async execute(testCase: TestCase): Promise { console.log(`Executing test case ${testCase.name}`); return consoleGroup(async () => { - const aResults = await this._collect(testCase, 'A', testCase.a); - const bResults = await this._collect(testCase, 'B', testCase.b); - return new Result(testCase.name, this._options.cpuThrottling, networkConditions, aResults, bResults); + const scenarioResults: Metrics[][] = []; + for (let s = 0; s < testCase.scenarios.length; s++) { + scenarioResults.push(await this._collect(testCase, s.toString(), testCase.scenarios[s])); + } + return new Result(testCase.name, this._options.cpuThrottling, networkConditions, scenarioResults); }); } diff --git a/packages/replay/metrics/src/results/analyzer.ts b/packages/replay/metrics/src/results/analyzer.ts index e6b8ce7a68dc..82da37bf3d44 100644 --- a/packages/replay/metrics/src/results/analyzer.ts +++ b/packages/replay/metrics/src/results/analyzer.ts @@ -1,7 +1,7 @@ import { filesize } from 'filesize'; import { GitHash } from '../util/git.js'; -import { MetricsStats } from './metrics-stats.js'; +import { AnalyticsFunction, MetricsStats, NumberProvider } from './metrics-stats.js'; import { Result } from './result.js'; import { ResultsSet } from './results-set.js'; @@ -24,7 +24,7 @@ export class ResultsAnalyzer { for (const base of baseItems) { for (const item of items) { if (item.metric == base.metric) { - item.other = base.value; + item.others = base.values; otherHash = baseline[0]; } } @@ -40,21 +40,26 @@ export class ResultsAnalyzer { private _collect(): AnalyzerItem[] { const items = new Array(); - const aStats = new MetricsStats(this._result.aResults); - const bStats = new MetricsStats(this._result.bResults); + const scenarioResults = this._result.scenarioResults; - const pushIfDefined = function (metric: AnalyzerItemMetric, unit: AnalyzerItemUnit, valueA?: number, valueB?: number): void { - if (valueA == undefined || valueB == undefined) return; - items.push({ metric: metric, value: new AnalyzerItemNumberValue(unit, valueA, valueB) }) + const pushIfDefined = function (metric: AnalyzerItemMetric, unit: AnalyzerItemUnit, source: NumberProvider, fn: AnalyticsFunction): void { + const values = scenarioResults.map(items => fn(items, source)); + // only push if at least one value is defined + if (values.findIndex(v => v != undefined) >= 0) { + items.push({ + metric: metric, + values: new AnalyzerItemNumberValues(unit, values) + }); + } } - pushIfDefined(AnalyzerItemMetric.lcp, AnalyzerItemUnit.ms, aStats.mean(MetricsStats.lcp), bStats.mean(MetricsStats.lcp)); - pushIfDefined(AnalyzerItemMetric.cls, AnalyzerItemUnit.ms, aStats.mean(MetricsStats.cls), bStats.mean(MetricsStats.cls)); - pushIfDefined(AnalyzerItemMetric.cpu, AnalyzerItemUnit.ratio, aStats.mean(MetricsStats.cpu), bStats.mean(MetricsStats.cpu)); - pushIfDefined(AnalyzerItemMetric.memoryAvg, AnalyzerItemUnit.bytes, aStats.mean(MetricsStats.memoryMean), bStats.mean(MetricsStats.memoryMean)); - pushIfDefined(AnalyzerItemMetric.memoryMax, AnalyzerItemUnit.bytes, aStats.max(MetricsStats.memoryMax), bStats.max(MetricsStats.memoryMax)); + pushIfDefined(AnalyzerItemMetric.lcp, AnalyzerItemUnit.ms, MetricsStats.lcp, MetricsStats.mean); + pushIfDefined(AnalyzerItemMetric.cls, AnalyzerItemUnit.ms, MetricsStats.cls, MetricsStats.mean); + pushIfDefined(AnalyzerItemMetric.cpu, AnalyzerItemUnit.ratio, MetricsStats.cpu, MetricsStats.mean); + pushIfDefined(AnalyzerItemMetric.memoryAvg, AnalyzerItemUnit.bytes, MetricsStats.memoryMean, MetricsStats.mean); + pushIfDefined(AnalyzerItemMetric.memoryMax, AnalyzerItemUnit.bytes, MetricsStats.memoryMax, MetricsStats.max); - return items.filter((item) => item.value != undefined); + return items; } } @@ -64,35 +69,42 @@ export enum AnalyzerItemUnit { bytes, } -export interface AnalyzerItemValue { - readonly a: string; - readonly b: string; - readonly diff: string; - readonly percent: string; +export interface AnalyzerItemValues { + value(index: number): string; + diff(aIndex: number, bIndex: number): string; + percent(aIndex: number, bIndex: number): string; } -class AnalyzerItemNumberValue implements AnalyzerItemValue { - constructor(private _unit: AnalyzerItemUnit, private _a: number, private _b: number) { } +const AnalyzerItemValueNotAvailable = 'n/a'; + +class AnalyzerItemNumberValues implements AnalyzerItemValues { + constructor(private _unit: AnalyzerItemUnit, private _values: (number | undefined)[]) { } - public get a(): string { - return this._withUnit(this._a); + private _has(index: number): boolean { + return index >= 0 && index < this._values.length && this._values[index] != undefined; } - public get b(): string { - return this._withUnit(this._b); + private _get(index: number): number { + return this._values[index]!; } - public get diff(): string { - const diff = this._b - this._a; + public value(index: number): string { + if (!this._has(index)) return AnalyzerItemValueNotAvailable; + return this._withUnit(this._get(index)); + } + + public diff(aIndex: number, bIndex: number): string { + if (!this._has(aIndex) || !this._has(bIndex)) return AnalyzerItemValueNotAvailable; + const diff = this._get(bIndex) - this._get(aIndex); const str = this._withUnit(diff, true); return diff > 0 ? `+${str}` : str; } - public get percent(): string { - if (this._a == 0) return 'n/a'; - const diff = this._b / this._a * 100 - 100; - const str = `${diff.toFixed(2)} %`; - return diff > 0 ? `+${str}` : str; + public percent(aIndex: number, bIndex: number): string { + if (!this._has(aIndex) || !this._has(bIndex) || this._get(aIndex) == 0.0) return AnalyzerItemValueNotAvailable; + const percent = this._get(bIndex) / this._get(aIndex) * 100 - 100; + const str = `${percent.toFixed(2)} %`; + return percent > 0 ? `+${str}` : str; } private _withUnit(value: number, isDiff: boolean = false): string { @@ -119,10 +131,10 @@ export interface AnalyzerItem { metric: AnalyzerItemMetric; // Current (latest) result. - value: AnalyzerItemValue; + values: AnalyzerItemValues; // Previous or baseline results, depending on the context. - other?: AnalyzerItemValue; + others?: AnalyzerItemValues; } export interface Analysis { diff --git a/packages/replay/metrics/src/results/metrics-stats.ts b/packages/replay/metrics/src/results/metrics-stats.ts index f15cf2731dfb..6989a4645315 100644 --- a/packages/replay/metrics/src/results/metrics-stats.ts +++ b/packages/replay/metrics/src/results/metrics-stats.ts @@ -3,34 +3,32 @@ import * as ss from 'simple-statistics' import { Metrics } from '../collector'; export type NumberProvider = (metrics: Metrics) => number; +export type AnalyticsFunction = (items: Metrics[], dataProvider: NumberProvider) => number | undefined; export class MetricsStats { - constructor(private _items: Metrics[]) { } - static lcp: NumberProvider = metrics => metrics.vitals.lcp; static cls: NumberProvider = metrics => metrics.vitals.cls; static cpu: NumberProvider = metrics => metrics.cpu.average; static memoryMean: NumberProvider = metrics => ss.mean(Array.from(metrics.memory.snapshots.values())); static memoryMax: NumberProvider = metrics => ss.max(Array.from(metrics.memory.snapshots.values())); - public mean(dataProvider: NumberProvider): number | undefined { - const numbers = this._filteredValues(dataProvider); + static mean: AnalyticsFunction = (items: Metrics[], dataProvider: NumberProvider) => { + const numbers = MetricsStats._filteredValues(items.map(dataProvider)); return numbers.length > 0 ? ss.mean(numbers) : undefined; } - public max(dataProvider: NumberProvider): number | undefined { - const numbers = this._filteredValues(dataProvider); + static max: AnalyticsFunction = (items: Metrics[], dataProvider: NumberProvider) => { + const numbers = MetricsStats._filteredValues(items.map(dataProvider)); return numbers.length > 0 ? ss.max(numbers) : undefined; } - public stddev(dataProvider: NumberProvider): number | undefined { - const numbers = this._filteredValues(dataProvider); + static stddev: AnalyticsFunction = (items: Metrics[], dataProvider: NumberProvider) => { + const numbers = MetricsStats._filteredValues(items.map(dataProvider)); return numbers.length > 0 ? ss.standardDeviation(numbers) : undefined; } // See https://en.wikipedia.org/wiki/Interquartile_range#Outliers for details on filtering. - private _filteredValues(dataProvider: NumberProvider): number[] { - const numbers = this._items.map(dataProvider); + private static _filteredValues(numbers: number[]): number[] { numbers.sort((a, b) => a - b) const q1 = ss.quantileSorted(numbers, 0.25); diff --git a/packages/replay/metrics/src/results/pr-comment.ts b/packages/replay/metrics/src/results/pr-comment.ts index 5406d9041650..618c1aa8d76e 100644 --- a/packages/replay/metrics/src/results/pr-comment.ts +++ b/packages/replay/metrics/src/results/pr-comment.ts @@ -1,5 +1,5 @@ import { Git } from '../util/git.js'; -import { Analysis, AnalyzerItemMetric, ResultsAnalyzer } from './analyzer.js'; +import { Analysis, AnalyzerItemMetric, AnalyzerItemValues, ResultsAnalyzer } from './analyzer.js'; import { Result } from './result.js'; import { ResultSetItem } from './results-set.js'; @@ -44,54 +44,70 @@ export class PrCommentBuilder { } public async addCurrentResult(analysis: Analysis, otherName: string): Promise { - // Decides whether to print the "Other" depending on it being set in the input data. + // Decides whether to print the "Other" for comparison depending on it being set in the input data. + const hasOther = analysis.otherHash != undefined; const maybeOther = function (content: () => string): string { - if (analysis.otherHash == undefined) { - return ''; - } - return content(); + return hasOther ? content() : ''; } + const currentHash = await Git.hash + + this._buffer += `

${this.title}

`; + if (!hasOther) { + this._buffer += `Latest data for: ${currentHash}`; + } this._buffer += ` -

${this.title}

-
Plain+ReplayDiffPlain+ReplayDiffRatio
 This PR (${await Git.hash})${otherName} (${analysis.otherHash})This PR (${await Git.hash})${otherName} (${analysis.otherHash})
${item.value.a} ${item.value.b} ${item.value.diff}${item.value.percent}${item.other!.a} ${item.other!.b}${item.other!.diff}${item.other!.diff}${item.other!.percent}
- `; - - const headerCols = ''; - if (analysis.otherHash != undefined) { - // If "other" is defined, add an aditional row of headers. - this._buffer += ` +
Plain+ReplayDiffRatio
+ - - + ${maybeOther(() => ``)} + + + - ${headerCols} - ${headerCols} - `; - } else { - this._buffer += ` - - - ${headerCols} + ${maybeOther(() => ``)} + + + + + + + `; + + const valueColumns = function (values: AnalyzerItemValues): string { + return ` + + + + + + + + `; } for (const item of analysis.items) { - this._buffer += ` + if (hasOther) { + this._buffer += ` - - - - - - ${maybeOther(() => ` - - - - `)} + + + + ` + } else { + this._buffer += ` + + + ${valueColumns(item.values)} + ` + } } this._buffer += ` @@ -104,7 +120,7 @@ export class PrCommentBuilder { this._buffer += `

${name}

-
 This PR (${await Git.hash})${otherName} (${analysis.otherHash}) Plain+Sentry+Replay
 RevisionValueValueDiffRatioValueDiffRatio
${values.value(0)}${values.value(1)}${values.diff(0, 1)}${values.percent(0, 1)}${values.value(2)}${values.diff(1, 2)}${values.percent(1, 2)}
${printableMetricName(item.metric)}${item.value.a}${item.value.b}${item.value.diff}${item.value.percent}${item.other!.a}${item.other!.b}${item.other!.diff}${item.other!.percent}${printableMetricName(item.metric)}This PR ${currentHash} + ${valueColumns(item.values)} +
${otherName} ${analysis.otherHash} + ${valueColumns(item.others!)}
${printableMetricName(item.metric)}
`; +
`; // Each `resultFile` will be printed as a single row - with metrics as table columns. for (let i = 0; i < resultFiles.length; i++) { @@ -124,7 +140,8 @@ export class PrCommentBuilder { // Add table row this._buffer += ``; for (const item of analysis.items) { - this._buffer += ``; + // TODO maybe find a better way of showing this. After the change to multiple scenarios, this shows diff between "With Sentry" and "With Sentry + Replay" + this._buffer += ``; } this._buffer += ''; } diff --git a/packages/replay/metrics/src/results/result.ts b/packages/replay/metrics/src/results/result.ts index 8229642b6fea..ed4ca476b039 100644 --- a/packages/replay/metrics/src/results/result.ts +++ b/packages/replay/metrics/src/results/result.ts @@ -8,8 +8,7 @@ export class Result { constructor( public readonly name: string, public readonly cpuThrottling: number, public readonly networkConditions: string, - public readonly aResults: Metrics[], - public readonly bResults: Metrics[]) { } + public readonly scenarioResults: Metrics[][]) { } public static readFromFile(filePath: string): Result { const json = fs.readFileSync(filePath, { encoding: 'utf-8' }); @@ -18,8 +17,7 @@ export class Result { data.name as string, data.cpuThrottling as number, data.networkConditions as string, - (data.aResults as Partial[] || []).map(Metrics.fromJSON.bind(Metrics)), - (data.bResults as Partial[] || []).map(Metrics.fromJSON.bind(Metrics)), + (data.scenarioResults as Partial[][] || []).map(list => list.map(Metrics.fromJSON.bind(Metrics))) ); } diff --git a/packages/replay/metrics/src/scenarios.ts b/packages/replay/metrics/src/scenarios.ts index 47bf27294012..86974272394d 100644 --- a/packages/replay/metrics/src/scenarios.ts +++ b/packages/replay/metrics/src/scenarios.ts @@ -13,8 +13,7 @@ export interface Scenario { // Two scenarios that are compared to each other. export interface TestCase { name: string; - a: Scenario; - b: Scenario; + scenarios: Scenario[]; runs: number; tries: number; @@ -35,10 +34,10 @@ export class LoadPageScenario implements Scenario { // Loads test-apps/jank/ as a page source & waits for a short time before quitting. export class JankTestScenario implements Scenario { - public constructor(private _withSentry: boolean) { } + public constructor(private _indexFile: string) { } public async run(_: playwright.Browser, page: playwright.Page): Promise { - let url = path.resolve(`./test-apps/jank/${this._withSentry ? 'with-sentry' : 'index'}.html`); + let url = path.resolve(`./test-apps/jank/${this._indexFile}`); assert(fs.existsSync(url)); url = `file:///${url.replace('\\', '/')}`; console.log('Navigating to ', url); diff --git a/packages/replay/metrics/src/util/console.ts b/packages/replay/metrics/src/util/console.ts index 991216fb45ec..098953054e87 100644 --- a/packages/replay/metrics/src/util/console.ts +++ b/packages/replay/metrics/src/util/console.ts @@ -1,4 +1,5 @@ import { filesize } from 'filesize'; +import { Metrics } from '../collector.js'; import { Analysis, AnalyzerItemMetric } from '../results/analyzer.js'; import { MetricsStats } from '../results/metrics-stats.js'; @@ -11,13 +12,13 @@ export async function consoleGroup(code: () => Promise): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any type PrintableTable = { [k: string]: any }; -export function printStats(stats: MetricsStats): void { +export function printStats(items: Metrics[]): void { console.table({ - lcp: `${stats.mean(MetricsStats.lcp)?.toFixed(2)} ms`, - cls: `${stats.mean(MetricsStats.cls)?.toFixed(2)} ms`, - cpu: `${((stats.mean(MetricsStats.cpu) || 0) * 100).toFixed(2)} %`, - memoryMean: filesize(stats.mean(MetricsStats.memoryMean)), - memoryMax: filesize(stats.max(MetricsStats.memoryMax)), + lcp: `${MetricsStats.mean(items, MetricsStats.lcp)?.toFixed(2)} ms`, + cls: `${MetricsStats.mean(items, MetricsStats.cls)?.toFixed(2)} ms`, + cpu: `${((MetricsStats.mean(items, MetricsStats.cpu) || 0) * 100).toFixed(2)} %`, + memoryMean: filesize(MetricsStats.mean(items, MetricsStats.memoryMean)), + memoryMax: filesize(MetricsStats.max(items, MetricsStats.memoryMax)), }); } @@ -25,9 +26,9 @@ export function printAnalysis(analysis: Analysis): void { const table: PrintableTable = {}; for (const item of analysis.items) { table[AnalyzerItemMetric[item.metric]] = { - value: item.value.diff, - ...((item.other == undefined) ? {} : { - previous: item.other.diff + value: item.values.diff(0, 1), + ...((item.others == undefined) ? {} : { + previous: item.others.diff(0, 1) }) }; } diff --git a/packages/replay/metrics/test-apps/jank/with-replay.html b/packages/replay/metrics/test-apps/jank/with-replay.html new file mode 100644 index 000000000000..7331eacfdd7f --- /dev/null +++ b/packages/replay/metrics/test-apps/jank/with-replay.html @@ -0,0 +1,56 @@ + + + + + + + + + + Janky Animation + + + + + + + + + + + +
+ + + + + + + +
+ + + diff --git a/packages/replay/metrics/test-apps/jank/with-sentry.html b/packages/replay/metrics/test-apps/jank/with-sentry.html index 7331eacfdd7f..3d43051eaf5a 100644 --- a/packages/replay/metrics/test-apps/jank/with-sentry.html +++ b/packages/replay/metrics/test-apps/jank/with-sentry.html @@ -27,15 +27,9 @@ - From bedeecfaa6dd5ec150eee21576799c9707c969cf Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Thu, 12 Jan 2023 09:36:34 +0100 Subject: [PATCH 52/55] fix and improve metrics analyzer console output --- packages/replay/metrics/src/results/analyzer.ts | 10 ++++++---- packages/replay/metrics/src/results/results-set.ts | 2 +- packages/replay/metrics/src/util/console.ts | 8 ++++++-- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/replay/metrics/src/results/analyzer.ts b/packages/replay/metrics/src/results/analyzer.ts index 82da37bf3d44..bdc2ee77864e 100644 --- a/packages/replay/metrics/src/results/analyzer.ts +++ b/packages/replay/metrics/src/results/analyzer.ts @@ -1,6 +1,7 @@ import { filesize } from 'filesize'; import { GitHash } from '../util/git.js'; +import { JsonStringify } from '../util/json.js'; import { AnalyticsFunction, MetricsStats, NumberProvider } from './metrics-stats.js'; import { Result } from './result.js'; import { ResultsSet } from './results-set.js'; @@ -13,19 +14,20 @@ export class ResultsAnalyzer { const items = new ResultsAnalyzer(currentResult)._collect(); const baseline = baselineResults?.find( - (other) => other.cpuThrottling == currentResult.cpuThrottling && - other.name == currentResult.name && - other.networkConditions == currentResult.networkConditions); + (other) => other.cpuThrottling == currentResult.cpuThrottling + && other.name == currentResult.name + && other.networkConditions == currentResult.networkConditions + && JsonStringify(other) != JsonStringify(currentResult)); let otherHash: GitHash | undefined if (baseline != undefined) { + otherHash = baseline[0]; const baseItems = new ResultsAnalyzer(baseline[1])._collect(); // update items with baseline results for (const base of baseItems) { for (const item of items) { if (item.metric == base.metric) { item.others = base.values; - otherHash = baseline[0]; } } } diff --git a/packages/replay/metrics/src/results/results-set.ts b/packages/replay/metrics/src/results/results-set.ts index f3432441deb7..ee38efd8a9f9 100644 --- a/packages/replay/metrics/src/results/results-set.ts +++ b/packages/replay/metrics/src/results/results-set.ts @@ -49,7 +49,7 @@ export class ResultsSet { public items(): ResultSetItem[] { return this._files().map((file) => { return new ResultSetItem(path.join(this._directory, file.name)); - }).filter((item) => !isNaN(item.number)); + }).filter((item) => !isNaN(item.number)).sort((a, b) => a.number - b.number); } public async add(newFile: string, onlyIfDifferent: boolean = false): Promise { diff --git a/packages/replay/metrics/src/util/console.ts b/packages/replay/metrics/src/util/console.ts index 098953054e87..9af66f36eb90 100644 --- a/packages/replay/metrics/src/util/console.ts +++ b/packages/replay/metrics/src/util/console.ts @@ -26,9 +26,13 @@ export function printAnalysis(analysis: Analysis): void { const table: PrintableTable = {}; for (const item of analysis.items) { table[AnalyzerItemMetric[item.metric]] = { - value: item.values.diff(0, 1), + value: item.values.value(0), + withSentry: item.values.diff(0, 1), + withReplay: item.values.diff(1, 2), ...((item.others == undefined) ? {} : { - previous: item.others.diff(0, 1) + previous: item.others.value(0), + previousWithSentry: item.others.diff(0, 1), + previousWithReplay: item.others.diff(1, 2) }) }; } From 77fa8868408f4a0f997ec75ccab4383924bd9cef Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Fri, 13 Jan 2023 12:13:51 +0100 Subject: [PATCH 53/55] review changes --- packages/replay/metrics/README.md | 6 +++--- packages/replay/metrics/src/results/metrics-stats.ts | 12 ++++++++---- packages/replay/metrics/src/vitals/cls.ts | 2 +- packages/replay/metrics/src/vitals/fid.ts | 2 +- packages/replay/metrics/src/vitals/index.ts | 2 +- packages/replay/metrics/src/vitals/lcp.ts | 2 +- 6 files changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/replay/metrics/README.md b/packages/replay/metrics/README.md index 11764a62009e..6877d491c1b2 100644 --- a/packages/replay/metrics/README.md +++ b/packages/replay/metrics/README.md @@ -2,9 +2,9 @@ Evaluates Replay impact on website performance by running a web app in Chromium via Playwright and collecting various metrics. -The general idea is to run a web app without Sentry Replay and then run the same app again with Replay included. -For both scenarios, we collect some metrics (CPU, memory, vitals) and later compare them and post as a comment in a PR. -Changes in the collected, compared to previous runs from the main branch, should be evaluated on case-by-case basis when preparing and reviewing the PR. +The general idea is to run a web app without Sentry Replay and then run the same app again with Sentry and another one with Sentry+Replay included. +For the three scenarios, we collect some metrics (CPU, memory, vitals) and later compare them and post as a comment in a PR. +Changes in the metrics, compared to previous runs from the main branch, should be evaluated on case-by-case basis when preparing and reviewing the PR. ## Resources diff --git a/packages/replay/metrics/src/results/metrics-stats.ts b/packages/replay/metrics/src/results/metrics-stats.ts index 6989a4645315..fd23d032c11d 100644 --- a/packages/replay/metrics/src/results/metrics-stats.ts +++ b/packages/replay/metrics/src/results/metrics-stats.ts @@ -2,7 +2,7 @@ import * as ss from 'simple-statistics' import { Metrics } from '../collector'; -export type NumberProvider = (metrics: Metrics) => number; +export type NumberProvider = (metrics: Metrics) => number | undefined; export type AnalyticsFunction = (items: Metrics[], dataProvider: NumberProvider) => number | undefined; export class MetricsStats { @@ -13,20 +13,24 @@ export class MetricsStats { static memoryMax: NumberProvider = metrics => ss.max(Array.from(metrics.memory.snapshots.values())); static mean: AnalyticsFunction = (items: Metrics[], dataProvider: NumberProvider) => { - const numbers = MetricsStats._filteredValues(items.map(dataProvider)); + const numbers = MetricsStats._filteredValues(MetricsStats._collect(items, dataProvider)); return numbers.length > 0 ? ss.mean(numbers) : undefined; } static max: AnalyticsFunction = (items: Metrics[], dataProvider: NumberProvider) => { - const numbers = MetricsStats._filteredValues(items.map(dataProvider)); + const numbers = MetricsStats._filteredValues(MetricsStats._collect(items, dataProvider)); return numbers.length > 0 ? ss.max(numbers) : undefined; } static stddev: AnalyticsFunction = (items: Metrics[], dataProvider: NumberProvider) => { - const numbers = MetricsStats._filteredValues(items.map(dataProvider)); + const numbers = MetricsStats._filteredValues(MetricsStats._collect(items, dataProvider)); return numbers.length > 0 ? ss.standardDeviation(numbers) : undefined; } + private static _collect(items: Metrics[], dataProvider: NumberProvider): number[] { + return items.map(dataProvider).filter(v => v != undefined && !Number.isNaN(v)) as number[]; + } + // See https://en.wikipedia.org/wiki/Interquartile_range#Outliers for details on filtering. private static _filteredValues(numbers: number[]): number[] { numbers.sort((a, b) => a - b) diff --git a/packages/replay/metrics/src/vitals/cls.ts b/packages/replay/metrics/src/vitals/cls.ts index 213c5ddef0e5..abe6d63fa58b 100644 --- a/packages/replay/metrics/src/vitals/cls.ts +++ b/packages/replay/metrics/src/vitals/cls.ts @@ -34,7 +34,7 @@ class CLS { }`); } - public async collect(): Promise { + public async collect(): Promise { const result = await this._page.evaluate('window.cumulativeLayoutShiftScore'); return result as number; } diff --git a/packages/replay/metrics/src/vitals/fid.ts b/packages/replay/metrics/src/vitals/fid.ts index 550a1d40fbee..fb6baa41537a 100644 --- a/packages/replay/metrics/src/vitals/fid.ts +++ b/packages/replay/metrics/src/vitals/fid.ts @@ -28,7 +28,7 @@ class FID { }`); } - public async collect(): Promise { + public async collect(): Promise { const result = await this._page.evaluate('window.firstInputDelay'); return result as number; } diff --git a/packages/replay/metrics/src/vitals/index.ts b/packages/replay/metrics/src/vitals/index.ts index 119af6622c83..3170a6c73cb2 100644 --- a/packages/replay/metrics/src/vitals/index.ts +++ b/packages/replay/metrics/src/vitals/index.ts @@ -7,7 +7,7 @@ import { LCP } from './lcp.js'; export { WebVitals, WebVitalsCollector }; class WebVitals { - constructor(public lcp: number, public cls: number, public fid: number) { } + constructor(public lcp: number | undefined, public cls: number | undefined, public fid: number | undefined) { } public static fromJSON(data: Partial): WebVitals { return new WebVitals(data.lcp as number, data.cls as number, data.fid as number); diff --git a/packages/replay/metrics/src/vitals/lcp.ts b/packages/replay/metrics/src/vitals/lcp.ts index 4bc05143ddce..2f817ba97297 100644 --- a/packages/replay/metrics/src/vitals/lcp.ts +++ b/packages/replay/metrics/src/vitals/lcp.ts @@ -28,7 +28,7 @@ class LCP { }`); } - public async collect(): Promise { + public async collect(): Promise { const result = await this._page.evaluate('window.largestContentfulPaint'); return result as number; } From ef05d06cc5eca91e65d0af34bda6d0c6d572f88b Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Fri, 13 Jan 2023 17:02:47 +0100 Subject: [PATCH 54/55] metrics collector: improve close/dispose error handling --- packages/replay/metrics/src/collector.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/replay/metrics/src/collector.ts b/packages/replay/metrics/src/collector.ts index d00fa1caf131..d8673a8c4021 100644 --- a/packages/replay/metrics/src/collector.ts +++ b/packages/replay/metrics/src/collector.ts @@ -147,7 +147,20 @@ export class MetricsCollector { }); } finally { console.log('Disposing of browser and resources'); - disposeCallbacks.reverse().forEach((cb) => cb().catch(() => { /* silent */ })); + disposeCallbacks.reverse(); + const errors = []; + for (const cb of disposeCallbacks) { + try { + await cb(); + } catch (e) { + errors.push(e instanceof Error ? `${e.name}: ${e.message}` : `${e}`); + } + } + if (errors.length > 0) { + console.warn(`All disposose callbacks have finished. Errors: ${errors}`); + } else { + console.warn(`All disposose callbacks have finished.`); + } } } } From 355c4dcc1b39f7feb9e1e712938938bd6ed27c52 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Fri, 13 Jan 2023 17:19:56 +0100 Subject: [PATCH 55/55] collect metrics only if `ci-overhead-measurements` label is set on a PR --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 73ea96d24c1e..c331823234af 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -821,6 +821,7 @@ jobs: needs: [job_get_metadata, job_build] runs-on: ubuntu-20.04 timeout-minutes: 30 + if: contains(github.event.pull_request.labels.*.name, 'ci-overhead-measurements') steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v3
${resultFile.hash}${item.value.diff}${item.values.diff(1, 2)}