diff --git a/.changeset/great-swans-punch.md b/.changeset/great-swans-punch.md new file mode 100644 index 000000000000..60725a224ce0 --- /dev/null +++ b/.changeset/great-swans-punch.md @@ -0,0 +1,5 @@ +--- +"@astrojs/web-vitals": minor +--- + +Adds a new web-vitals integration powered by Astro DB diff --git a/packages/integrations/web-vitals/README.md b/packages/integrations/web-vitals/README.md new file mode 100644 index 000000000000..3a4c5fb0141d --- /dev/null +++ b/packages/integrations/web-vitals/README.md @@ -0,0 +1,59 @@ +# @astrojs/web-vitals (experimental) ⏱️ + +This **[Astro integration][astro-integration]** enables tracking real-world website performance and storing the data in [Astro DB][db]. + +## Pre-requisites + +- [Astro DB](https://astro.build/db) — `@astrojs/web-vitals` will store performance data in Astro DB in production +- [An SSR adapter](https://docs.astro.build/en/guides/server-side-rendering/) — `@astrojs/web-vitals` injects a server endpoint to manage saving data to Astro DB + +## Installation + +1. Install and configure the Web Vitals integration using `astro add`: + + ```sh + npx astro add web-vitals + ``` + +2. Push the tables added by the Web Vitals integration to Astro Studio: + + ```sh + npx astro db push + ``` + +3. Redeploy your site. + +4. Visit your project dashboard at https://studio.astro.build to see the data collected. + +Learn more about [Astro DB](https://docs.astro.build/en/guides/astro-db/) and [deploying with Astro Studio](https://docs.astro.build/en/guides/astro-db/#astro-studio) in the Astro docs. + +## Support + +- Get help in the [Astro Discord][discord]. Post questions in our `#support` forum, or visit our dedicated `#dev` channel to discuss current development and more! + +- Check our [Astro Integration Documentation][astro-integration] for more on integrations. + +- Submit bug reports and feature requests as [GitHub issues][issues]. + +## Contributing + +This package is maintained by Astro's Core team. You're welcome to submit an issue or PR! These links will help you get started: + +- [Contributor Manual][contributing] +- [Code of Conduct][coc] +- [Community Guide][community] + +## License + +MIT + +Copyright (c) 2023–present [Astro][astro] + +[astro]: https://astro.build/ +[db]: https://astro.build/db/ +[contributing]: https://github.com/withastro/astro/blob/main/CONTRIBUTING.md +[coc]: https://github.com/withastro/.github/blob/main/CODE_OF_CONDUCT.md +[community]: https://github.com/withastro/.github/blob/main/COMMUNITY_GUIDE.md +[discord]: https://astro.build/chat/ +[issues]: https://github.com/withastro/astro/issues +[astro-integration]: https://docs.astro.build/en/guides/integrations-guide/ diff --git a/packages/integrations/web-vitals/package.json b/packages/integrations/web-vitals/package.json new file mode 100644 index 000000000000..1719f423ee0f --- /dev/null +++ b/packages/integrations/web-vitals/package.json @@ -0,0 +1,49 @@ +{ + "name": "@astrojs/web-vitals", + "description": "Track your website’s performance with Astro DB", + "version": "0.0.0", + "type": "module", + "author": "withastro", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/withastro/astro.git", + "directory": "packages/integrations/web-vitals" + }, + "keywords": [ + "withastro", + "astro-integration" + ], + "bugs": "https://github.com/withastro/astro/issues", + "exports": { + ".": "./dist/index.js", + "./middleware": "./dist/middleware.js", + "./endpoint": "./dist/endpoint.js", + "./client-script": "./dist/client-script.js", + "./db-config": "./dist/db-config.js" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "astro-scripts build \"src/**/*.ts\" && tsc", + "build:ci": "astro-scripts build \"src/**/*.ts\"", + "dev": "astro-scripts dev \"src/**/*.ts\"", + "test": "astro-scripts test --timeout 50000 \"test/**/*.test.js\"" + }, + "dependencies": { + "web-vitals": "^3.5.2" + }, + "peerDependencies": { + "@astrojs/db": "^0.11.0" + }, + "devDependencies": { + "@astrojs/db": "workspace:*", + "astro": "workspace:*", + "astro-scripts": "workspace:*", + "linkedom": "^0.16.11" + }, + "publishConfig": { + "provenance": true + } +} diff --git a/packages/integrations/web-vitals/src/client-script.ts b/packages/integrations/web-vitals/src/client-script.ts new file mode 100644 index 000000000000..b69fa6772eb9 --- /dev/null +++ b/packages/integrations/web-vitals/src/client-script.ts @@ -0,0 +1,36 @@ +import { type Metric, onCLS, onFCP, onFID, onINP, onLCP, onTTFB } from 'web-vitals'; +import { WEB_VITALS_ENDPOINT_PATH } from './constants.js'; +import type { ClientMetric } from './schemas.js'; + +const pathname = location.pathname.replace(/(?<=.)\/$/, ''); +const route = + document + .querySelector('meta[name="x-astro-vitals-route"]') + ?.getAttribute('content') || pathname; + +const queue = new Set(); +const addToQueue = (metric: Metric) => queue.add(metric); +function flushQueue() { + if (!queue.size) return; + const rawBody: ClientMetric[] = [...queue].map(({ name, id, value, rating }) => ({ + pathname, + route, + name, + id, + value, + rating, + })); + const body = JSON.stringify(rawBody); + if (navigator.sendBeacon) navigator.sendBeacon(WEB_VITALS_ENDPOINT_PATH, body); + else fetch(WEB_VITALS_ENDPOINT_PATH, { body, method: 'POST', keepalive: true }); + queue.clear(); +} + +for (const listener of [onCLS, onLCP, onINP, onFID, onFCP, onTTFB]) { + listener(addToQueue); +} + +addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') flushQueue(); +}); +addEventListener('pagehide', flushQueue); diff --git a/packages/integrations/web-vitals/src/constants.ts b/packages/integrations/web-vitals/src/constants.ts new file mode 100644 index 000000000000..7df5bb8b6572 --- /dev/null +++ b/packages/integrations/web-vitals/src/constants.ts @@ -0,0 +1 @@ +export const WEB_VITALS_ENDPOINT_PATH = '/_web-vitals' diff --git a/packages/integrations/web-vitals/src/db-config.ts b/packages/integrations/web-vitals/src/db-config.ts new file mode 100644 index 000000000000..918850f63580 --- /dev/null +++ b/packages/integrations/web-vitals/src/db-config.ts @@ -0,0 +1,22 @@ +import { column, defineDb, defineTable } from 'astro:db'; +import { asDrizzleTable } from '@astrojs/db/utils'; + +const Metric = defineTable({ + columns: { + pathname: column.text(), + route: column.text(), + name: column.text(), + id: column.text({ primaryKey: true }), + value: column.number(), + rating: column.text(), + timestamp: column.date(), + }, +}); + +export const AstrojsWebVitals_Metric = asDrizzleTable('AstrojsWebVitals_Metric', Metric); + +export default defineDb({ + tables: { + AstrojsWebVitals_Metric: Metric, + }, +}); diff --git a/packages/integrations/web-vitals/src/endpoint.ts b/packages/integrations/web-vitals/src/endpoint.ts new file mode 100644 index 000000000000..10dea1ca88aa --- /dev/null +++ b/packages/integrations/web-vitals/src/endpoint.ts @@ -0,0 +1,23 @@ +import { db, sql } from 'astro:db'; +import type { APIRoute } from 'astro'; +import { AstrojsWebVitals_Metric } from './db-config.js'; +import { ServerMetricSchema } from './schemas.js'; + +export const prerender = false; + +export const ALL: APIRoute = async ({ request }) => { + try { + const rawBody = await request.json(); + const body = ServerMetricSchema.array().parse(rawBody); + await db + .insert(AstrojsWebVitals_Metric) + .values(body) + .onConflictDoUpdate({ + target: AstrojsWebVitals_Metric.id, + set: { value: sql`excluded.value` }, + }); + } catch (error) { + console.error(error); + } + return new Response(); +}; diff --git a/packages/integrations/web-vitals/src/env.d.ts b/packages/integrations/web-vitals/src/env.d.ts new file mode 100644 index 000000000000..18ef3554e128 --- /dev/null +++ b/packages/integrations/web-vitals/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/integrations/web-vitals/src/index.ts b/packages/integrations/web-vitals/src/index.ts new file mode 100644 index 000000000000..f8a5ad433496 --- /dev/null +++ b/packages/integrations/web-vitals/src/index.ts @@ -0,0 +1,42 @@ +import { defineDbIntegration } from '@astrojs/db/utils'; +import { AstroError } from 'astro/errors'; +import { WEB_VITALS_ENDPOINT_PATH } from './constants.js'; + +export default function webVitals() { + return defineDbIntegration({ + name: '@astrojs/web-vitals', + hooks: { + 'astro:db:setup'({ extendDb }) { + extendDb({ configEntrypoint: '@astrojs/web-vitals/db-config' }); + }, + + 'astro:config:setup'({ addMiddleware, config, injectRoute, injectScript }) { + if (!config.integrations.find(({ name }) => name === 'astro:db')) { + throw new AstroError( + 'Astro DB integration not found.', + 'Run `npx astro add db` to install `@astrojs/db` and add it to your Astro config.' + ); + } + + if (config.output !== 'hybrid' && config.output !== 'server') { + throw new AstroError( + 'No SSR adapter found.', + '`@astrojs/web-vitals` requires your site to be built with `hybrid` or `server` output.\n' + + 'Please add an SSR adapter: https://docs.astro.build/en/guides/server-side-rendering/' + ); + } + + // Middleware that adds a `` tag to each page. + addMiddleware({ entrypoint: '@astrojs/web-vitals/middleware', order: 'post' }); + // Endpoint that collects metrics and inserts them in Astro DB. + injectRoute({ + entrypoint: '@astrojs/web-vitals/endpoint', + pattern: WEB_VITALS_ENDPOINT_PATH, + prerender: false, + }); + // Client-side performance measurement script. + injectScript('page', `import '@astrojs/web-vitals/client-script';`); + }, + }, + }); +} diff --git a/packages/integrations/web-vitals/src/middleware.ts b/packages/integrations/web-vitals/src/middleware.ts new file mode 100644 index 000000000000..b4994c902c1c --- /dev/null +++ b/packages/integrations/web-vitals/src/middleware.ts @@ -0,0 +1,60 @@ +import type { MiddlewareHandler } from 'astro'; + +/** + * Middleware which adds the web vitals `` tag to each page’s ``. + * + * @example + * + */ +export const onRequest: MiddlewareHandler = async ({ params, url }, next) => { + const response = await next(); + const contentType = response.headers.get('Content-Type'); + if (contentType !== 'text/html') return response; + const webVitalsMetaTag = getMetaTag(url, params); + return new Response( + response.body + ?.pipeThrough(new TextDecoderStream()) + .pipeThrough(HeadInjectionTransformStream(webVitalsMetaTag)) + .pipeThrough(new TextEncoderStream()), + response + ); +}; + +/** TransformStream which injects the passed HTML just before the closing tag. */ +function HeadInjectionTransformStream(htmlToInject: string) { + let hasInjected = false; + return new TransformStream({ + transform: (chunk, controller) => { + if (!hasInjected) { + const headCloseIndex = chunk.indexOf(''); + if (headCloseIndex > -1) { + chunk = chunk.slice(0, headCloseIndex) + htmlToInject + chunk.slice(headCloseIndex); + hasInjected = true; + } + } + controller.enqueue(chunk); + }, + }); +} + +/** Get a `` tag to identify the current Astro route. */ +function getMetaTag(url: URL, params: Record) { + let route = url.pathname; + for (const [key, value] of Object.entries(params)) { + if (value) route = route.replace(value, `[${key}]`); + } + route = miniEncodeAttribute(stripTrailingSlash(route)); + return ``; +} + +function stripTrailingSlash(str: string) { + return str.length > 1 && str.at(-1) === '/' ? str.slice(0, -1) : str; +} + +function miniEncodeAttribute(str: string) { + return str + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} diff --git a/packages/integrations/web-vitals/src/schemas.ts b/packages/integrations/web-vitals/src/schemas.ts new file mode 100644 index 000000000000..7a2050bd53ae --- /dev/null +++ b/packages/integrations/web-vitals/src/schemas.ts @@ -0,0 +1,32 @@ +import { z } from 'astro/zod'; + +export const RatingSchema = z.enum(['good', 'needs-improvement', 'poor']); +const MetricTypeSchema = z.enum(['CLS', 'INP', 'LCP', 'FCP', 'FID', 'TTFB']); + +/** `web-vitals` generated ID, transformed to reduce data resolution. */ +const MetricIdSchema = z + .string() + // Match https://github.com/GoogleChrome/web-vitals/blob/main/src/lib/generateUniqueID.ts + .regex(/^v3-\d{13}-\d{13}$/) + // Avoid collecting higher resolution timestamp in ID. + // Transforms `'v3-1711484350895-3748043125387'` to `'v3-17114843-3748043125387'` + .transform((id) => id.replace(/^(v3-\d{8})\d{5}(-\d{13})$/, '$1$2')); + +/** Shape of the data submitted from clients to the collection API. */ +const ClientMetricSchema = z.object({ + pathname: z.string(), + route: z.string(), + name: MetricTypeSchema, + id: MetricIdSchema, + value: z.number().gte(0), + rating: RatingSchema, +}); + +/** Transformed client data with added timestamp. */ +export const ServerMetricSchema = ClientMetricSchema.transform((metric) => { + const timestamp = new Date(); + timestamp.setMinutes(0, 0, 0); + return { ...metric, timestamp }; +}); + +export type ClientMetric = z.input; diff --git a/packages/integrations/web-vitals/test/basics.test.js b/packages/integrations/web-vitals/test/basics.test.js new file mode 100644 index 000000000000..937619b483d4 --- /dev/null +++ b/packages/integrations/web-vitals/test/basics.test.js @@ -0,0 +1,118 @@ +// @ts-check + +import * as assert from 'node:assert/strict'; +import { after, before, beforeEach, describe, it } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { loadFixture } from './test-utils.js'; + +/** + * @template {Record void>} T + * @template {keyof T} K + */ +class MockFunction { + /** @type {Parameters[]} */ + calls = []; + + /** + * @param {T} object + * @param {K} property + */ + constructor(object, property) { + this.object = object; + this.property = property; + this.original = object[property]; + object[property] = /** @param {Parameters} args */ (...args) => { + this.calls.push(args); + }; + } + restore() { + this.object[this.property] = this.original; + } + reset() { + this.calls = []; + } +} + +describe('Web Vitals integration basics', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + /** @type {import('./test-utils').DevServer} */ + let devServer; + /** @type {MockFunction} */ + let consoleErrorMock; + + before(async () => { + consoleErrorMock = new MockFunction(console, 'error'); + fixture = await loadFixture({ root: './fixtures/basics/' }); + devServer = await fixture.startDevServer({}); + }); + + after(async () => { + consoleErrorMock.restore(); + await devServer.stop(); + }); + + beforeEach(() => { + consoleErrorMock.reset(); + }); + + it('adds a meta tag to the page', async () => { + const html = await fixture.fetch('/', {}).then((res) => res.text()); + const { document } = parseHTML(html); + const meta = document.querySelector('head > meta[name="x-astro-vitals-route"]'); + assert.ok(meta); + assert.equal(meta.getAttribute('content'), '/'); + }); + + it('adds a meta tag using the route pattern to the page', async () => { + const html = await fixture.fetch('/test', {}).then((res) => res.text()); + const { document } = parseHTML(html); + const meta = document.querySelector('head > meta[name="x-astro-vitals-route"]'); + assert.ok(meta); + assert.equal(meta.getAttribute('content'), '/[dynamic]'); + }); + + it('returns a 200 response even when bad data is sent to the injected endpoint', async () => { + { + // bad data + const res = await fixture.fetch('/_web-vitals', { method: 'POST', body: 'garbage' }); + assert.equal(res.status, 200); + } + { + // no data + const res = await fixture.fetch('/_web-vitals', { method: 'POST', body: '[]' }); + assert.equal(res.status, 200); + } + assert.equal(consoleErrorMock.calls.length, 2); + }); + + it('validates data sent to the injected endpoint with Zod', async () => { + const res = await fixture.fetch('/_web-vitals', { method: 'POST', body: '[{}]' }); + assert.equal(res.status, 200); + const call = consoleErrorMock.calls[0][0]; + assert.ok(call instanceof Error); + assert.equal(call.name, 'ZodError'); + }); + + it('inserts data via the injected endpoint', async () => { + const res = await fixture.fetch('/_web-vitals', { + method: 'POST', + body: JSON.stringify([ + { + pathname: '/', + route: '/', + name: 'CLS', + id: 'v3-1711484350895-3748043125387', + value: 0, + rating: 'good', + }, + ]), + }); + assert.equal(res.status, 200); + assert.equal( + consoleErrorMock.calls.length, + 0, + 'Endpoint logged errors:\n' + consoleErrorMock.calls[0]?.join(' ') + ); + }); +}); diff --git a/packages/integrations/web-vitals/test/fixtures/basics/astro.config.mjs b/packages/integrations/web-vitals/test/fixtures/basics/astro.config.mjs new file mode 100644 index 000000000000..42bfa6f6693f --- /dev/null +++ b/packages/integrations/web-vitals/test/fixtures/basics/astro.config.mjs @@ -0,0 +1,14 @@ +import db from '@astrojs/db'; +import node from '@astrojs/node'; +import webVitals from '@astrojs/web-vitals'; +import { defineConfig } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({ + integrations: [db(), webVitals()], + output: 'hybrid', + adapter: node({ mode: 'standalone' }), + devToolbar: { + enabled: false, + }, +}); diff --git a/packages/integrations/web-vitals/test/fixtures/basics/package.json b/packages/integrations/web-vitals/test/fixtures/basics/package.json new file mode 100644 index 000000000000..25ab0abc1b75 --- /dev/null +++ b/packages/integrations/web-vitals/test/fixtures/basics/package.json @@ -0,0 +1,16 @@ +{ + "name": "@test/web-vitals", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview" + }, + "dependencies": { + "@astrojs/db": "workspace:*", + "@astrojs/node": "workspace:*", + "@astrojs/web-vitals": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/web-vitals/test/fixtures/basics/src/pages/[dynamic].astro b/packages/integrations/web-vitals/test/fixtures/basics/src/pages/[dynamic].astro new file mode 100644 index 000000000000..36c7c50e6276 --- /dev/null +++ b/packages/integrations/web-vitals/test/fixtures/basics/src/pages/[dynamic].astro @@ -0,0 +1,19 @@ +--- +import type { GetStaticPaths } from "astro"; +export const getStaticPaths = (() => { + return [{ params: { dynamic: 'test' } }]; +}) satisfies GetStaticPaths; +--- + + + + + + + Web Vitals basics — dynamic route test + + +

Web Vitals basics

+

Dynamic route test

+ + diff --git a/packages/integrations/web-vitals/test/fixtures/basics/src/pages/index.astro b/packages/integrations/web-vitals/test/fixtures/basics/src/pages/index.astro new file mode 100644 index 000000000000..06ddd6565db9 --- /dev/null +++ b/packages/integrations/web-vitals/test/fixtures/basics/src/pages/index.astro @@ -0,0 +1,11 @@ + + + + + + Web Vitals basics test + + +

Web Vitals basics test

+ + diff --git a/packages/integrations/web-vitals/test/test-utils.js b/packages/integrations/web-vitals/test/test-utils.js new file mode 100644 index 000000000000..8dd4d970bdd5 --- /dev/null +++ b/packages/integrations/web-vitals/test/test-utils.js @@ -0,0 +1,16 @@ +import { loadFixture as baseLoadFixture } from '../../../astro/test/test-utils.js'; + +/** @typedef {import('../../../astro/test/test-utils').Fixture} Fixture */ +/** @typedef {import('../../../astro/test/test-utils').DevServer} DevServer */ + +/** @type {typeof import('../../../astro/test/test-utils.js')['loadFixture']} */ +export function loadFixture(inlineConfig) { + if (!inlineConfig?.root) throw new Error("Must provide { root: './fixtures/...' }"); + + // resolve the relative root (i.e. "./fixtures/tailwindcss") to a full filepath + // without this, the main `loadFixture` helper will resolve relative to `packages/astro/test` + return baseLoadFixture({ + ...inlineConfig, + root: new URL(inlineConfig.root, import.meta.url).toString(), + }); +} diff --git a/packages/integrations/web-vitals/tsconfig.json b/packages/integrations/web-vitals/tsconfig.json new file mode 100644 index 000000000000..1504b4b6dfa4 --- /dev/null +++ b/packages/integrations/web-vitals/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src"], + "compilerOptions": { + "outDir": "./dist" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb460a49c147..9e7fd73d2047 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5327,6 +5327,40 @@ importers: specifier: workspace:* version: link:../../../../../astro + packages/integrations/web-vitals: + dependencies: + web-vitals: + specifier: ^3.5.2 + version: 3.5.2 + devDependencies: + '@astrojs/db': + specifier: workspace:* + version: link:../../db + astro: + specifier: workspace:* + version: link:../../astro + astro-scripts: + specifier: workspace:* + version: link:../../../scripts + linkedom: + specifier: ^0.16.11 + version: 0.16.11 + + packages/integrations/web-vitals/test/fixtures/basics: + dependencies: + '@astrojs/db': + specifier: workspace:* + version: link:../../../../../db + '@astrojs/node': + specifier: workspace:* + version: link:../../../../node + '@astrojs/web-vitals': + specifier: workspace:* + version: link:../../.. + astro: + specifier: workspace:* + version: link:../../../../../astro + packages/internal-helpers: devDependencies: astro-scripts: