Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add perf data experiment #17956

Merged
merged 4 commits into from Oct 16, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/next/build/index.ts
Expand Up @@ -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) => {
Expand Down
3 changes: 3 additions & 0 deletions packages/next/build/webpack-config.ts
Expand Up @@ -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`)
Expand Down
40 changes: 40 additions & 0 deletions packages/next/client/performance-relayer.ts
Expand Up @@ -8,13 +8,53 @@ import {
ReportHandler,
} from 'web-vitals'

const initialHref = location.href
let isRegistered = false
let userReportHandler: ReportHandler | undefined

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<string, string> = {
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) => {
Expand Down
1 change: 1 addition & 0 deletions packages/next/next-server/server/config.ts
Expand Up @@ -60,6 +60,7 @@ const defaultConfig: { [key: string]: any } = {
optimizeImages: false,
scrollRestoration: false,
i18n: false,
analyticsId: process.env.VERCEL_ANALYTICS_ID || '',
},
future: {
excludeDefaultMomentLocales: false,
Expand Down
15 changes: 15 additions & 0 deletions 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
)
}
25 changes: 25 additions & 0 deletions 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 (
<div>
<h1>Foo!</h1>
<h2>bar!</h2>
</div>
)
}
72 changes: 72 additions & 0 deletions 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')
}
})
})
17 changes: 17 additions & 0 deletions 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 (
<div>
Expand Down
35 changes: 34 additions & 1 deletion test/integration/relay-analytics/test/index.test.js
Expand Up @@ -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))
Expand Down Expand Up @@ -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()
})
})