-
-
Notifications
You must be signed in to change notification settings - Fork 2.6k
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 web-vitals integration #10883
Add web-vitals integration #10883
Changes from 24 commits
08ba552
dbb5f7b
25edcb7
e93cfe6
715d099
8325926
f1c3fc9
5652b71
a0e3add
5da2721
492c0f3
7d3be6e
3adc646
8ce23d2
a397c63
dceeb1a
ef93901
3e543a1
80c8e06
3ec42de
5bf5dd4
3bda10b
9b7092e
201e6e4
d575a71
b54b582
dd4e5f0
ce1838c
9bb97d8
5dc6a15
f494238
758e96b
23bbe95
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@astrojs/web-vitals": minor | ||
--- | ||
|
||
Adds a new web-vitals integration powered by Astro DB |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@astrojs/db": patch | ||
--- | ||
|
||
Fixes database table creation for Astro DB integrations |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
# @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. Redeploy your site. | ||
|
||
3. Visit your project dashboard at https://studio.astro.build to see the data collected. | ||
|
||
## 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/ |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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.10.6" | ||
}, | ||
"devDependencies": { | ||
"@astrojs/db": "workspace:*", | ||
"astro": "workspace:*", | ||
"astro-scripts": "workspace:*", | ||
"linkedom": "^0.16.11" | ||
}, | ||
"publishConfig": { | ||
"provenance": true | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
import { type Metric, onCLS, onFCP, onFID, onINP, onLCP, onTTFB } from 'web-vitals'; | ||
import type { ClientMetric } from './schemas.js'; | ||
|
||
const pathname = location.pathname.replace(/(?<=.)\/$/, ''); | ||
const route = | ||
document | ||
.querySelector<HTMLMetaElement>('meta[name="x-astro-vitals-route"]') | ||
?.getAttribute('content') || pathname; | ||
|
||
const queue = new Set<Metric>(); | ||
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); | ||
const endpoint = '/_/astro-vitals'; | ||
if (navigator.sendBeacon) navigator.sendBeacon(endpoint, body); | ||
else fetch(endpoint, { 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); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, | ||
}, | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(); | ||
}; |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this file intended to be committed? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes — currently Astro DB types aren’t working great for integrations that don’t have an Astro project environment to refer to, so we’re kind of working around that with this triple-slash reference. Hopefully something @bholmesdev or a TS magician might be able to improve in the future? |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
/// <reference types="@astrojs/db" /> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
import { defineDbIntegration } from '@astrojs/db/utils'; | ||
import { AstroError } from 'astro/errors'; | ||
|
||
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 `<meta>` 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: '/_/astro-vitals', | ||
delucis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
prerender: false, | ||
}); | ||
// Client-side performance measurement script. | ||
injectScript('page', `import '@astrojs/web-vitals/client-script';`); | ||
}, | ||
}, | ||
}); | ||
} |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This middleware preserves streaming and injects a |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
import type { MiddlewareHandler } from 'astro'; | ||
|
||
/** | ||
* Middleware which adds the web vitals `<meta>` tag to each page’s `<head>`. | ||
* | ||
* @example | ||
* <meta name="x-astro-vitals-route" content="/blog/[slug]" /> | ||
*/ | ||
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 </head> tag. */ | ||
function HeadInjectionTransformStream(htmlToInject: string) { | ||
let hasInjected = false; | ||
return new TransformStream({ | ||
transform: (chunk, controller) => { | ||
if (!hasInjected) { | ||
const headCloseIndex = chunk.indexOf('</head>'); | ||
if (headCloseIndex > -1) { | ||
chunk = chunk.slice(0, headCloseIndex) + htmlToInject + chunk.slice(headCloseIndex); | ||
hasInjected = true; | ||
} | ||
} | ||
controller.enqueue(chunk); | ||
}, | ||
}); | ||
} | ||
|
||
/** Get a `<meta>` tag to identify the current Astro route. */ | ||
function getMetaTag(url: URL, params: Record<string, string | undefined>) { | ||
let route = url.pathname; | ||
for (const [key, value] of Object.entries(params)) { | ||
if (value) route = route.replace(value, `[${key}]`); | ||
} | ||
route = miniEncodeAttribute(stripTrailingSlash(route)); | ||
return `<meta name="x-astro-vitals-route" content="${route}" />`; | ||
} | ||
|
||
function stripTrailingSlash(str: string) { | ||
return str.length > 1 && str.at(-1) === '/' ? str.slice(0, -1) : str; | ||
} | ||
|
||
function miniEncodeAttribute(str: string) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We sure this covers all necessary cases? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I’m pretty sure for attributes it does? I think in fact even content={'/blog/&"<>\\\'/'} output to content="/blog/&"<>\'/" So only the |
||
return str | ||
.replaceAll('&', '&') | ||
.replaceAll('<', '<') | ||
.replaceAll('>', '>') | ||
.replaceAll('"', '"'); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<typeof ClientMetricSchema>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TIL about
sendBeacon
💡