From 81890c8cfbdecfcf230b29341f508d2b6bbc29ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20Born=C3=B6?= Date: Tue, 8 Nov 2022 18:02:26 +0100 Subject: [PATCH] Add telemetry for @next/font (#42579) ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have a helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have a helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm build && pnpm lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md) --- .../build/webpack/plugins/telemetry-plugin.ts | 21 ++++- packages/next/telemetry/events/build.ts | 2 + test/integration/telemetry/test/index.test.js | 54 +++++------ test/lib/next-test-utils.js | 15 ++++ .../next-font/google-font-mocked-responses.js | 14 +++ test/production/next-font/telemetry.test.ts | 84 ++++++++++++++++++ .../next-font/telemetry/next.config.js | 13 +++ .../next-font/telemetry/pages-unused/index.js | 3 + .../next-font/telemetry/pages/_app.js | 13 +++ .../next-font/telemetry/pages/index.js | 11 +++ .../next-font/telemetry/pages/my-font.woff2 | Bin 0 -> 17508 bytes 11 files changed, 204 insertions(+), 26 deletions(-) create mode 100644 test/production/next-font/google-font-mocked-responses.js create mode 100644 test/production/next-font/telemetry.test.ts create mode 100644 test/production/next-font/telemetry/next.config.js create mode 100644 test/production/next-font/telemetry/pages-unused/index.js create mode 100644 test/production/next-font/telemetry/pages/_app.js create mode 100644 test/production/next-font/telemetry/pages/index.js create mode 100644 test/production/next-font/telemetry/pages/my-font.woff2 diff --git a/packages/next/build/webpack/plugins/telemetry-plugin.ts b/packages/next/build/webpack/plugins/telemetry-plugin.ts index 20cc027d385296f..7e62625b45814ae 100644 --- a/packages/next/build/webpack/plugins/telemetry-plugin.ts +++ b/packages/next/build/webpack/plugins/telemetry-plugin.ts @@ -24,6 +24,8 @@ export type Feature = | 'next/legacy/image' | 'next/script' | 'next/dynamic' + | '@next/font/google' + | '@next/font/local' | 'swcLoader' | 'swcMinify' | 'swcRelay' @@ -64,6 +66,10 @@ const FEATURE_MODULE_MAP: ReadonlyMap = new Map([ ['next/script', '/next/script.js'], ['next/dynamic', '/next/dynamic.js'], ]) +const FEATURE_MODULE_REGEXP_MAP: ReadonlyMap = new Map([ + ['@next/font/google', /\/@next\/font\/google\/target.css?.+$/], + ['@next/font/local', /\/@next\/font\/local\/target.css?.+$/], +]) // List of build features used in webpack configuration const BUILD_FEATURES: Array = [ @@ -101,8 +107,14 @@ function findFeatureInModule(module: Module): Feature | undefined { if (module.type !== 'javascript/auto') { return } + const normalizedIdentifier = module.identifier().replace(/\\/g, '/') for (const [feature, path] of FEATURE_MODULE_MAP) { - if (module.identifier().replace(/\\/g, '/').endsWith(path)) { + if (normalizedIdentifier.endsWith(path)) { + return feature + } + } + for (const [feature, regexp] of FEATURE_MODULE_REGEXP_MAP) { + if (regexp.test(normalizedIdentifier)) { return feature } } @@ -152,6 +164,13 @@ export class TelemetryPlugin implements webpack.WebpackPluginInstance { invocationCount: 0, }) } + + for (const featureName of FEATURE_MODULE_REGEXP_MAP.keys()) { + this.usageTracker.set(featureName, { + featureName, + invocationCount: 0, + }) + } } apply(compiler: webpack.Compiler): void { diff --git a/packages/next/telemetry/events/build.ts b/packages/next/telemetry/events/build.ts index 4321b76bb5937cf..7d4a7b962c4fbd7 100644 --- a/packages/next/telemetry/events/build.ts +++ b/packages/next/telemetry/events/build.ts @@ -136,6 +136,8 @@ export type EventBuildFeatureUsage = { | 'next/future/image' | 'next/script' | 'next/dynamic' + | '@next/font/google' + | '@next/font/local' | 'experimental/optimizeCss' | 'experimental/nextScriptWorkers' | 'optimizeFonts' diff --git a/test/integration/telemetry/test/index.test.js b/test/integration/telemetry/test/index.test.js index 4d26bcad9f4d93e..5b2b9d1da6499e9 100644 --- a/test/integration/telemetry/test/index.test.js +++ b/test/integration/telemetry/test/index.test.js @@ -11,6 +11,7 @@ import { nextBuild, nextLint, check, + findAllTelemetryEvents, } from 'next-test-utils' const appDir = path.join(__dirname, '..') @@ -672,7 +673,10 @@ describe('Telemetry CLI', () => { expect(event1).toMatch(`"nextRulesEnabled": {`) expect(event1).toMatch(/"@next\/next\/.+?": "(off|warn|error)"/) - const featureUsageEvents = findAllEvents(stderr, 'NEXT_BUILD_FEATURE_USAGE') + const featureUsageEvents = findAllTelemetryEvents( + stderr, + 'NEXT_BUILD_FEATURE_USAGE' + ) expect(featureUsageEvents).toContainEqual({ featureName: 'build-lint', invocationCount: 1, @@ -684,7 +688,7 @@ describe('Telemetry CLI', () => { stderr: true, env: { NEXT_TELEMETRY_DEBUG: 1 }, }) - const events = findAllEvents(stderr, 'NEXT_BUILD_FEATURE_USAGE') + const events = findAllTelemetryEvents(stderr, 'NEXT_BUILD_FEATURE_USAGE') expect(events).toContainEqual({ featureName: 'build-lint', invocationCount: 0, @@ -703,7 +707,7 @@ describe('Telemetry CLI', () => { }) await fs.remove(nextConfig) - const events = findAllEvents(stderr, 'NEXT_BUILD_FEATURE_USAGE') + const events = findAllTelemetryEvents(stderr, 'NEXT_BUILD_FEATURE_USAGE') expect(events).toContainEqual({ featureName: 'build-lint', invocationCount: 0, @@ -742,7 +746,10 @@ describe('Telemetry CLI', () => { stderr: true, env: { NEXT_TELEMETRY_DEBUG: 1 }, }) - const featureUsageEvents = findAllEvents(stderr, 'NEXT_BUILD_FEATURE_USAGE') + const featureUsageEvents = findAllTelemetryEvents( + stderr, + 'NEXT_BUILD_FEATURE_USAGE' + ) expect(featureUsageEvents).toEqual( expect.arrayContaining([ { @@ -782,7 +789,10 @@ describe('Telemetry CLI', () => { }) await fs.remove(path.join(appDir, 'next.config.js')) await fs.remove(path.join(appDir, 'jsconfig.json')) - const featureUsageEvents = findAllEvents(stderr, 'NEXT_BUILD_FEATURE_USAGE') + const featureUsageEvents = findAllTelemetryEvents( + stderr, + 'NEXT_BUILD_FEATURE_USAGE' + ) expect(featureUsageEvents).toEqual( expect.arrayContaining([ { @@ -837,7 +847,7 @@ describe('Telemetry CLI', () => { path.join(appDir, 'next.config.optimize-css') ) - const events = findAllEvents(stderr, 'NEXT_BUILD_FEATURE_USAGE') + const events = findAllTelemetryEvents(stderr, 'NEXT_BUILD_FEATURE_USAGE') expect(events).toContainEqual({ featureName: 'experimental/optimizeCss', invocationCount: 1, @@ -860,7 +870,10 @@ describe('Telemetry CLI', () => { path.join(appDir, 'next.config.next-script-workers') ) - const featureUsageEvents = findAllEvents(stderr, 'NEXT_BUILD_FEATURE_USAGE') + const featureUsageEvents = findAllTelemetryEvents( + stderr, + 'NEXT_BUILD_FEATURE_USAGE' + ) expect(featureUsageEvents).toContainEqual({ featureName: 'experimental/nextScriptWorkers', invocationCount: 1, @@ -880,7 +893,10 @@ describe('Telemetry CLI', () => { await fs.remove(path.join(appDir, 'middleware.js')) - const buildOptimizedEvents = findAllEvents(stderr, 'NEXT_BUILD_OPTIMIZED') + const buildOptimizedEvents = findAllTelemetryEvents( + stderr, + 'NEXT_BUILD_OPTIMIZED' + ) expect(buildOptimizedEvents).toContainEqual( expect.objectContaining({ middlewareCount: 1, @@ -917,7 +933,7 @@ describe('Telemetry CLI', () => { path.join(appDir, 'package.swc-plugins') ) - const pluginDetectedEvents = findAllEvents( + const pluginDetectedEvents = findAllTelemetryEvents( stderr, 'NEXT_SWC_PLUGIN_DETECTED' ) @@ -941,7 +957,10 @@ describe('Telemetry CLI', () => { stderr: true, env: { NEXT_TELEMETRY_DEBUG: 1 }, }) - const featureUsageEvents = findAllEvents(stderr, 'NEXT_BUILD_FEATURE_USAGE') + const featureUsageEvents = findAllTelemetryEvents( + stderr, + 'NEXT_BUILD_FEATURE_USAGE' + ) expect(featureUsageEvents).toContainEqual({ featureName: 'next/legacy/image', invocationCount: 1, @@ -952,18 +971,3 @@ describe('Telemetry CLI', () => { }) }) }) - -/** - * Parse the output and return all entries that match the provided `eventName` - * @param {string} output output of the console - * @param {string} eventName - * @returns {Array<{}>} - */ -function findAllEvents(output, eventName) { - const regex = /\[telemetry\] ({.+?^})/gms - // Pop the last element of each entry to retrieve contents of the capturing group - const events = [...output.matchAll(regex)].map((entry) => - JSON.parse(entry.pop()) - ) - return events.filter((e) => e.eventName === eventName).map((e) => e.payload) -} diff --git a/test/lib/next-test-utils.js b/test/lib/next-test-utils.js index 13727b9ccd807c8..3cdda7813ab5512 100644 --- a/test/lib/next-test-utils.js +++ b/test/lib/next-test-utils.js @@ -852,3 +852,18 @@ export function runDevSuite(suiteName, appDir, options) { export function runProdSuite(suiteName, appDir, options) { return runSuite(suiteName, { appDir, env: 'prod' }, options) } + +/** + * Parse the output and return all entries that match the provided `eventName` + * @param {string} output output of the console + * @param {string} eventName + * @returns {Array<{}>} + */ +export function findAllTelemetryEvents(output, eventName) { + const regex = /\[telemetry\] ({.+?^})/gms + // Pop the last element of each entry to retrieve contents of the capturing group + const events = [...output.matchAll(regex)].map((entry) => + JSON.parse(entry.pop()) + ) + return events.filter((e) => e.eventName === eventName).map((e) => e.payload) +} diff --git a/test/production/next-font/google-font-mocked-responses.js b/test/production/next-font/google-font-mocked-responses.js new file mode 100644 index 000000000000000..cd7b8e56b0b0286 --- /dev/null +++ b/test/production/next-font/google-font-mocked-responses.js @@ -0,0 +1,14 @@ +module.exports = { + 'https://fonts.googleapis.com/css2?family=Open+Sans:wght@300..800&display=optional': ` +/* latin */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 300 800; + font-stretch: 100%; + font-display: optional; + src: url(https://fonts.gstatic.com/s/opensans/v34/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-mu0SC55I.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + `, +} diff --git a/test/production/next-font/telemetry.test.ts b/test/production/next-font/telemetry.test.ts new file mode 100644 index 000000000000000..8867c1ec780f890 --- /dev/null +++ b/test/production/next-font/telemetry.test.ts @@ -0,0 +1,84 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { findAllTelemetryEvents } from 'next-test-utils' +import { join } from 'path' + +const mockedGoogleFontResponses = require.resolve( + './google-font-mocked-responses.js' +) + +describe('@next/font used telemetry', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, 'telemetry/pages')), + 'next.config.js': new FileRef( + join(__dirname, 'telemetry/next.config.js') + ), + }, + dependencies: { + '@next/font': 'canary', + }, + env: { + NEXT_FONT_GOOGLE_MOCKED_RESPONSES: mockedGoogleFontResponses, + NEXT_TELEMETRY_DEBUG: '1', + }, + }) + }) + afterAll(() => next.destroy()) + + it('should send @next/font/google and @next/font/local usage event', async () => { + const events = findAllTelemetryEvents( + next.cliOutput, + 'NEXT_BUILD_FEATURE_USAGE' + ) + expect(events).toContainEqual({ + featureName: '@next/font/google', + invocationCount: 1, + }) + expect(events).toContainEqual({ + featureName: '@next/font/local', + invocationCount: 1, + }) + }) +}) + +describe('@next/font unused telemetry', () => { + let next: NextInstance + + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, 'telemetry/pages-unused')), + 'next.config.js': new FileRef( + join(__dirname, 'telemetry/next.config.js') + ), + }, + dependencies: { + '@next/font': 'canary', + }, + env: { + NEXT_FONT_GOOGLE_MOCKED_RESPONSES: mockedGoogleFontResponses, + NEXT_TELEMETRY_DEBUG: '1', + }, + }) + }) + afterAll(() => next.destroy()) + + it('should not send @next/font/google and @next/font/local usage event', async () => { + const events = findAllTelemetryEvents( + next.cliOutput, + 'NEXT_BUILD_FEATURE_USAGE' + ) + expect(events).toContainEqual({ + featureName: '@next/font/google', + invocationCount: 0, + }) + expect(events).toContainEqual({ + featureName: '@next/font/local', + invocationCount: 0, + }) + }) +}) diff --git a/test/production/next-font/telemetry/next.config.js b/test/production/next-font/telemetry/next.config.js new file mode 100644 index 000000000000000..6a94ea94ad86409 --- /dev/null +++ b/test/production/next-font/telemetry/next.config.js @@ -0,0 +1,13 @@ +module.exports = { + experimental: { + fontLoaders: [ + { + loader: '@next/font/google', + options: { subsets: ['latin'] }, + }, + { + loader: '@next/font/local', + }, + ], + }, +} diff --git a/test/production/next-font/telemetry/pages-unused/index.js b/test/production/next-font/telemetry/pages-unused/index.js new file mode 100644 index 000000000000000..a681aa7ce257cbe --- /dev/null +++ b/test/production/next-font/telemetry/pages-unused/index.js @@ -0,0 +1,3 @@ +export default function Page() { + return

Hello world

+} diff --git a/test/production/next-font/telemetry/pages/_app.js b/test/production/next-font/telemetry/pages/_app.js new file mode 100644 index 000000000000000..05702a3dc483443 --- /dev/null +++ b/test/production/next-font/telemetry/pages/_app.js @@ -0,0 +1,13 @@ +import localFont from '@next/font/local' + +const myFont = localFont({ src: './my-font.woff2' }) + +function MyApp({ Component, pageProps }) { + return ( +
+ +
+ ) +} + +export default MyApp diff --git a/test/production/next-font/telemetry/pages/index.js b/test/production/next-font/telemetry/pages/index.js new file mode 100644 index 000000000000000..26400dc9cebe7fc --- /dev/null +++ b/test/production/next-font/telemetry/pages/index.js @@ -0,0 +1,11 @@ +import { Open_Sans } from '@next/font/google' +const openSans = Open_Sans({ subsets: ['latin'] }) + +export default function Page() { + return ( + <> +

Hello world 1

+

Hello world 2

+ + ) +} diff --git a/test/production/next-font/telemetry/pages/my-font.woff2 b/test/production/next-font/telemetry/pages/my-font.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..a6b3c3a9d69faa7447699b148f39a2205119ac31 GIT binary patch literal 17508 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 literal 0 HcmV?d00001