From 0fe5b23b0ee1097e7e138a95c82046ea1c626b24 Mon Sep 17 00:00:00 2001 From: Joe Haddad Date: Fri, 16 Oct 2020 16:31:09 -0400 Subject: [PATCH] Add perf data experiment (#17956) --- packages/next/build/index.ts | 9 +++ packages/next/build/webpack-config.ts | 3 + packages/next/client/performance-relayer.ts | 40 +++++++++++ packages/next/next-server/server/config.ts | 1 + .../relay-analytics-disabled/pages/_app.js | 15 ++++ .../relay-analytics-disabled/pages/index.js | 25 +++++++ .../test/index.test.js | 72 +++++++++++++++++++ .../relay-analytics/pages/index.js | 17 +++++ .../relay-analytics/test/index.test.js | 35 ++++++++- 9 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 test/integration/relay-analytics-disabled/pages/_app.js create mode 100644 test/integration/relay-analytics-disabled/pages/index.js create mode 100644 test/integration/relay-analytics-disabled/test/index.test.js diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 046ad6a69aa74f4..9462c1e5dabcc39 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -1159,6 +1159,15 @@ export default async function build( printCustomRoutes({ redirects, rewrites, headers }) } + if (config.experimental.analyticsId) { + console.log( + chalk.bold.green('Next.js Analytics') + + ' is enabled for this production build. ' + + "You'll receive a Real Experience Score computed by all of your visitors." + ) + console.log('') + } + if (tracer) { const parsedResults = await tracer.profiler.stopProfiling() await new Promise((resolve) => { diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index fdfd07ffe49c297..0df798ee8034364 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -1015,6 +1015,9 @@ export default async function getBaseWebpackConfig( 'process.env.__NEXT_i18n_SUPPORT': JSON.stringify( !!config.experimental.i18n ), + 'process.env.__NEXT_ANALYTICS_ID': JSON.stringify( + config.experimental.analyticsId + ), ...(isServer ? { // Fix bad-actors in the npm ecosystem (e.g. `node-formidable`) diff --git a/packages/next/client/performance-relayer.ts b/packages/next/client/performance-relayer.ts index 1a2dfe1cb899c6d..434e4b8dc77d79d 100644 --- a/packages/next/client/performance-relayer.ts +++ b/packages/next/client/performance-relayer.ts @@ -8,6 +8,7 @@ import { ReportHandler, } from 'web-vitals' +const initialHref = location.href let isRegistered = false let userReportHandler: ReportHandler | undefined @@ -15,6 +16,45 @@ function onReport(metric: Metric) { if (userReportHandler) { userReportHandler(metric) } + + // This code is not shipped, executed, or present in the client-side + // JavaScript bundle unless explicitly enabled in your application. + // + // When this feature is enabled, we'll make it very clear by printing a + // message during the build (`next build`). + if ( + process.env.NODE_ENV === 'production' && + // This field is empty unless you explicitly configure it: + process.env.__NEXT_ANALYTICS_ID + ) { + const body: Record = { + dsn: process.env.__NEXT_ANALYTICS_ID, + id: metric.id, + page: window.__NEXT_DATA__.page, + href: initialHref, + event_name: metric.name, + value: metric.value.toString(), + speed: + 'connection' in navigator && + navigator['connection'] && + 'effectiveType' in navigator['connection'] + ? (navigator['connection']['effectiveType'] as string) + : '', + } + + const blob = new Blob([new URLSearchParams(body).toString()], { + // This content type is necessary for `sendBeacon`: + type: 'application/x-www-form-urlencoded', + }) + const vitalsUrl = 'https://vitals.vercel-analytics.com/v1/vitals' + ;(navigator.sendBeacon && navigator.sendBeacon(vitalsUrl, blob)) || + fetch(vitalsUrl, { + body: blob, + method: 'POST', + credentials: 'omit', + keepalive: true, + }) + } } export default (onPerfEntry?: ReportHandler) => { diff --git a/packages/next/next-server/server/config.ts b/packages/next/next-server/server/config.ts index dabf639b15cf5b0..753b0ea0242c8be 100644 --- a/packages/next/next-server/server/config.ts +++ b/packages/next/next-server/server/config.ts @@ -60,6 +60,7 @@ const defaultConfig: { [key: string]: any } = { optimizeImages: false, scrollRestoration: false, i18n: false, + analyticsId: process.env.VERCEL_ANALYTICS_ID || '', }, future: { excludeDefaultMomentLocales: false, diff --git a/test/integration/relay-analytics-disabled/pages/_app.js b/test/integration/relay-analytics-disabled/pages/_app.js new file mode 100644 index 000000000000000..5cc9a4d48488850 --- /dev/null +++ b/test/integration/relay-analytics-disabled/pages/_app.js @@ -0,0 +1,15 @@ +/* global localStorage */ +/* eslint-disable camelcase */ +import App from 'next/app' + +export default class MyApp extends App {} + +/* + Method is experimental and will eventually be handled in a Next.js plugin +*/ +export function reportWebVitals(data) { + localStorage.setItem( + data.name || data.entryType, + data.value !== undefined ? data.value : data.startTime + ) +} diff --git a/test/integration/relay-analytics-disabled/pages/index.js b/test/integration/relay-analytics-disabled/pages/index.js new file mode 100644 index 000000000000000..46c828a80fd0d44 --- /dev/null +++ b/test/integration/relay-analytics-disabled/pages/index.js @@ -0,0 +1,25 @@ +if (typeof navigator !== 'undefined') { + window.__BEACONS = window.__BEACONS || [] + + navigator.sendBeacon = async function () { + const args = await Promise.all( + [...arguments].map((v) => { + if (v instanceof Blob) { + return v.text() + } + return v + }) + ) + + window.__BEACONS.push(args) + } +} + +export default () => { + return ( +
+

Foo!

+

bar!

+
+ ) +} diff --git a/test/integration/relay-analytics-disabled/test/index.test.js b/test/integration/relay-analytics-disabled/test/index.test.js new file mode 100644 index 000000000000000..9a5edbaa9080472 --- /dev/null +++ b/test/integration/relay-analytics-disabled/test/index.test.js @@ -0,0 +1,72 @@ +/* eslint-env jest */ + +import fs from 'fs-extra' +import { findPort, killApp, nextBuild, nextStart } from 'next-test-utils' +import webdriver from 'next-webdriver' +import path, { join } from 'path' + +const appDir = join(__dirname, '../') +let appPort +let server +jest.setTimeout(1000 * 60 * 2) + +let buildManifest + +describe('Analytics relayer (disabled)', () => { + let stdout + beforeAll(async () => { + appPort = await findPort() + ;({ stdout } = await nextBuild(appDir, [], { + stdout: true, + })) + buildManifest = require(path.join( + appDir, + '.next/build-manifest.json' + ), 'utf8') + server = await nextStart(appDir, appPort) + }) + afterAll(() => killApp(server)) + + it('Does not relay any data', async () => { + const browser = await webdriver(appPort, '/') + await browser.waitForElementByCss('h1') + const h1Text = await browser.elementByCss('h1').text() + const firstContentfulPaint = parseFloat( + await browser.eval('localStorage.getItem("FCP")') + ) + + expect(h1Text).toMatch(/Foo!/) + + expect(firstContentfulPaint).not.toBeNaN() + expect(firstContentfulPaint).toBeGreaterThan(0) + + const beacons = (await browser.eval('window.__BEACONS')).map(([, value]) => + Object.fromEntries(new URLSearchParams(value)) + ) + + expect(beacons.length).toBe(0) + + expect(stdout).not.toMatch('Next.js Analytics') + + await browser.close() + }) + + it('Does not include the code', async () => { + const pageFiles = [ + ...new Set([ + ...buildManifest.pages['/'].filter((file) => file.endsWith('.js')), + ...buildManifest.pages['/_app'].filter((file) => file.endsWith('.js')), + ]), + ] + + expect(pageFiles.length).toBeGreaterThan(1) + + for (const pageFile of pageFiles) { + const content = await fs.readFile( + path.join(appDir, '.next', pageFile), + 'utf8' + ) + expect(content).not.toMatch('vercel-analytics') + } + }) +}) diff --git a/test/integration/relay-analytics/pages/index.js b/test/integration/relay-analytics/pages/index.js index 5730772dc4b9bb4..46c828a80fd0d44 100644 --- a/test/integration/relay-analytics/pages/index.js +++ b/test/integration/relay-analytics/pages/index.js @@ -1,3 +1,20 @@ +if (typeof navigator !== 'undefined') { + window.__BEACONS = window.__BEACONS || [] + + navigator.sendBeacon = async function () { + const args = await Promise.all( + [...arguments].map((v) => { + if (v instanceof Blob) { + return v.text() + } + return v + }) + ) + + window.__BEACONS.push(args) + } +} + export default () => { return (
diff --git a/test/integration/relay-analytics/test/index.test.js b/test/integration/relay-analytics/test/index.test.js index 18adc1501ca9c6f..38c87d816ea5a51 100644 --- a/test/integration/relay-analytics/test/index.test.js +++ b/test/integration/relay-analytics/test/index.test.js @@ -10,9 +10,13 @@ let server jest.setTimeout(1000 * 60 * 2) describe('Analytics relayer', () => { + let stdout beforeAll(async () => { appPort = await findPort() - await nextBuild(appDir) + ;({ stdout } = await nextBuild(appDir, [], { + env: { VERCEL_ANALYTICS_ID: 'test' }, + stdout: true, + })) server = await nextStart(appDir, appPort) }) afterAll(() => killApp(server)) @@ -54,6 +58,35 @@ describe('Analytics relayer', () => { expect(cls).not.toBeNull() expect(largestContentfulPaint).not.toBeNaN() expect(largestContentfulPaint).toBeGreaterThan(0) + + const beacons = (await browser.eval('window.__BEACONS')).map(([, value]) => + Object.fromEntries(new URLSearchParams(value)) + ) + + beacons.sort((a, b) => a.event_name.localeCompare(b.event_name)) + + expect(beacons.length).toBe(2) + expect(beacons[0]).toMatchObject({ + dsn: 'test', + event_name: 'FCP', + href: expect.stringMatching('http://'), + id: expect.stringContaining('-'), + page: '/', + speed: '4g', + value: expect.stringContaining('.'), + }) + expect(beacons[1]).toMatchObject({ + dsn: 'test', + event_name: 'TTFB', + href: expect.stringMatching('http://'), + id: expect.stringContaining('-'), + page: '/', + speed: '4g', + value: expect.stringContaining('.'), + }) + + expect(stdout).toMatch('Next.js Analytics') + await browser.close() }) })