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 web-vitals integration #10883

Merged
merged 33 commits into from May 3, 2024
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
08ba552
Init web-vitals integration
delucis Apr 25, 2024
dbb5f7b
Explicitly add DB types to integration env
delucis Apr 25, 2024
25edcb7
Refactor endpoint to use `asDrizzleTable()`
delucis Apr 25, 2024
e93cfe6
Improve `asDrizzleTable()` types
delucis Apr 25, 2024
715d099
Cast table to `any` to avoid error
delucis Apr 25, 2024
8325926
Wrap `asDrizzleTable()` for integration utility
delucis Apr 25, 2024
f1c3fc9
Revert "Improve `asDrizzleTable()` types"
delucis Apr 25, 2024
5652b71
Fix `asDrizzleTable()` import source
delucis Apr 25, 2024
a0e3add
Refactor to only expose processed table config
delucis Apr 26, 2024
5da2721
Add a basic test fixture
delucis Apr 26, 2024
492c0f3
Error if a user doesn’t have SSR output
delucis Apr 26, 2024
7d3be6e
Simplify package.json `exports`
delucis Apr 26, 2024
3adc646
Initial test files
delucis Apr 26, 2024
8ce23d2
Peace of mind
delucis Apr 26, 2024
a397c63
`pnpm format`
delucis Apr 26, 2024
dceeb1a
Mock `console.error` in tests to track endpoint issues
delucis Apr 26, 2024
ef93901
Surface error message in test
delucis Apr 26, 2024
3e543a1
Merge branch 'main' into chris/web-vitals
delucis Apr 26, 2024
80c8e06
Fix lockfile
delucis Apr 26, 2024
3ec42de
Recreate tables when first establishing db client
delucis Apr 26, 2024
5bf5dd4
Another test
delucis Apr 26, 2024
3bda10b
Merge branch 'main' into chris/web-vitals
delucis Apr 29, 2024
9b7092e
Flesh out README
delucis Apr 29, 2024
201e6e4
Add changesets
delucis Apr 29, 2024
d575a71
Improve README
delucis Apr 30, 2024
b54b582
Rename endpoint path
delucis May 3, 2024
dd4e5f0
Merge branch 'main' into chris/web-vitals
delucis May 3, 2024
ce1838c
Delete .changeset/tough-plants-kiss.md
delucis May 3, 2024
9bb97d8
Discard changes to packages/db/src/core/integration/vite-plugin-db.ts
delucis May 3, 2024
5dc6a15
Discard changes to packages/db/src/runtime/index.ts
delucis May 3, 2024
f494238
Discard changes to packages/db/src/runtime/seed-local.ts
delucis May 3, 2024
758e96b
Merge branch 'main' into chris/web-vitals
delucis May 3, 2024
23bbe95
Bump peer dep version of `@astrojs/db`
delucis May 3, 2024
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
7 changes: 5 additions & 2 deletions packages/db/src/core/integration/vite-plugin-db.ts
Expand Up @@ -119,18 +119,21 @@ export function getLocalVirtualModContents({

const dbUrl = new URL(DB_PATH, root);
return `
import { asDrizzleTable, createLocalDatabaseClient, normalizeDatabaseUrl } from ${RUNTIME_IMPORT};
import { asDrizzleTable, createLocalDatabaseClient, normalizeDatabaseUrl, recreateTables } from ${RUNTIME_IMPORT};
${shouldSeed ? `import { seedLocal } from ${RUNTIME_IMPORT};` : ''}
${shouldSeed ? integrationSeedImportStatements.join('\n') : ''}

const tables = ${JSON.stringify(tables)};

const dbUrl = normalizeDatabaseUrl(import.meta.env.ASTRO_DATABASE_FILE, ${JSON.stringify(dbUrl)});
export const db = createLocalDatabaseClient({ dbUrl });
await recreateTables({ db, tables });

${
shouldSeed
? `await seedLocal({
db,
tables: ${JSON.stringify(tables)},
tables,
userSeedGlob: import.meta.glob(${JSON.stringify(userSeedFilePaths)}, { eager: true }),
integrationSeedFunctions: [${integrationSeedImportNames.join(',')}],
});`
Expand Down
2 changes: 1 addition & 1 deletion packages/db/src/runtime/index.ts
Expand Up @@ -14,7 +14,7 @@ import { pathToFileURL } from './utils.js';

export type { Table } from './types.js';
export { createRemoteDatabaseClient, createLocalDatabaseClient } from './db-client.js';
export { seedLocal } from './seed-local.js';
export { seedLocal, recreateTables } from './seed-local.js';

export function hasPrimaryKey(column: DBColumn) {
return 'primaryKey' in column.schema && !!column.schema.primaryKey;
Expand Down
2 changes: 1 addition & 1 deletion packages/db/src/runtime/seed-local.ts
Expand Up @@ -44,7 +44,7 @@ export async function seedLocal({
}
}

async function recreateTables({ db, tables }: { db: LibSQLDatabase; tables: DBTables }) {
export async function recreateTables({ db, tables }: { db: LibSQLDatabase; tables: DBTables }) {
const setupQueries: SQL[] = [];
for (const [name, table] of Object.entries(tables)) {
const dropQuery = sql.raw(`DROP TABLE IF EXISTS ${sqlite.escapeName(name)}`);
Expand Down
39 changes: 39 additions & 0 deletions packages/integrations/web-vitals/README.md
@@ -0,0 +1,39 @@
# @astrojs/web-vitals ⏱️

This **[Astro integration][astro-integration]** enables tracking real-world website performance and storing the data in [Astro DB][db].

<!-- ## Documentation

Read the [`@astrojs/web-vitals` docs][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/
[docs]: https://docs.astro.build/en/guides/integrations-guide/web-vitals/
[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/
49 changes: 49 additions & 0 deletions 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.10.6"
},
"devDependencies": {
"@astrojs/db": "workspace:*",
"astro": "workspace:*",
"astro-scripts": "workspace:*",
"linkedom": "^0.16.11"
},
"publishConfig": {
"provenance": true
}
}
36 changes: 36 additions & 0 deletions 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 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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL about sendBeacon 💡

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);
22 changes: 22 additions & 0 deletions 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,
},
});
23 changes: 23 additions & 0 deletions 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();
};
1 change: 1 addition & 0 deletions packages/integrations/web-vitals/src/env.d.ts
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this file intended to be committed?

Copy link
Member Author

Choose a reason for hiding this comment

The 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?

@@ -0,0 +1 @@
/// <reference types="@astrojs/db" />
41 changes: 41 additions & 0 deletions packages/integrations/web-vitals/src/index.ts
@@ -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';`);
},
},
});
}
60 changes: 60 additions & 0 deletions packages/integrations/web-vitals/src/middleware.ts
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This middleware preserves streaming and injects a <meta> tag to each page’s <head>. Wouldn’t mind someone taking a quick look to make sure there are no footguns here.

@@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We sure this covers all necessary cases?

Copy link
Member Author

Choose a reason for hiding this comment

The 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 < and > would be safe unencoded — just tried in an Astro component and this input:

content={'/blog/&"<>\\\'/'}

output to

content="/blog/&#38;&#34;<>\'/"

So only the & and the " were escaped.

return str
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
32 changes: 32 additions & 0 deletions 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<typeof ClientMetricSchema>;