diff --git a/.changeset/nice-balloons-peel.md b/.changeset/nice-balloons-peel.md
new file mode 100644
index 0000000000..fdd55d7305
--- /dev/null
+++ b/.changeset/nice-balloons-peel.md
@@ -0,0 +1,6 @@
+---
+'create-hydrogen-app': minor
+'@shopify/hydrogen': minor
+---
+
+Make performance data available with ClientAnalytics and optional for developers to include
diff --git a/docs/framework/analytics.md b/docs/framework/analytics.md
index c3bc24644d..72573c62ad 100644
--- a/docs/framework/analytics.md
+++ b/docs/framework/analytics.md
@@ -35,6 +35,7 @@ By default, Hydrogen publishes the following events to subscribers (`ClientAnaly
| `REMOVE_FROM_CART` | A customer removes an item from their cart |
| `DISCOUNT_CODE_UPDATED` | A discount code that a customer applies to a cart is updated |
| `VIEWED_PRODUCT` | A customer views a product details page. This is set with `publishEventsOnNavigate` on product pages. |
+| `PERFORMANCE` | The performance metrics for page loads in a Hydrogen app. This is available when you opt in to ``. |
> Note:
> The event name constants are available in `ClientAnalytics.eventNames`.
@@ -311,6 +312,53 @@ useEffect(() => {
{% endcodeblock %}
+## Performance metrics
+
+Performance metrics provide insight into how fast pages are loading in your Hydrogen app. For example, you might want to gather the following metrics for full and sub page loads:
+
+- **Time to First Byte (TTFB)**: The time between a browser requesting a page and receiving the first byte of information from the server
+- **First Contentful Paint (FCP)**: The time it takes for a browser to render content on a page
+- **Largest Contentful Paint (LCP)**: The time it takes to render and interact with the largest content element on the page
+- **Duration**: The total amount of time it takes for a page to finish streaming
+
+You can opt in to receive performance metrics for page loads in your Hydrogen app by including `` and `PerformanceMetricsServerAnalyticsConnector` in `App.server.js`.
+
+If you want to see performance debug metrics displayed in your browser console log, then include `` in your client component:
+
+{% codeblock file, filename: 'components/SomeComponent.client.jsx' %}
+
+```jsx
+import {
+ PerformanceMetricsServerAnalyticsConnector,
+ ...
+} from '@shopify/hydrogen';
+import {
+ PerformanceMetrics,
+ PerformanceMetricsDebug,
+} from '@shopify/hydrogen/client';
+
+function App({routes}) {
+ return (
+ }>
+
+ ...
+
+ {process.env.LOCAL_DEV && }
+
+
+ );
+}
+
+...
+
+export default renderHydrogen(App, {
+ ...
+ serverAnalyticsConnectors: [PerformanceMetricsServerAnalyticsConnector],
+});
+```
+
+{% endcodeblock %}
+
## Example analytics connectors
The following example shows an implementation of a client analytics connector with [Google Analytics 4](https://developers.google.com/analytics/devguides/collection/ga4):
diff --git a/packages/hydrogen/src/client.ts b/packages/hydrogen/src/client.ts
index 1826099321..ac6e1d6635 100644
--- a/packages/hydrogen/src/client.ts
+++ b/packages/hydrogen/src/client.ts
@@ -11,3 +11,5 @@ export {useRouteParams} from './foundation/useRouteParams/useRouteParams';
export {useNavigate} from './foundation/useNavigate/useNavigate';
export {fetchSync} from './foundation/fetchSync/client/fetchSync';
export {suspendFunction, preloadFunction} from './utilities/suspense';
+export {PerformanceMetrics} from './foundation/Analytics/connectors/PerformanceMetrics/PerformanceMetrics.client';
+export {PerformanceMetricsDebug} from './foundation/Analytics/connectors/PerformanceMetrics/PerformanceMetricsDebug.client';
diff --git a/packages/hydrogen/src/foundation/Analytics/ClientAnalytics.tsx b/packages/hydrogen/src/foundation/Analytics/ClientAnalytics.tsx
index e0e5b46cd3..5d9fd0b778 100644
--- a/packages/hydrogen/src/foundation/Analytics/ClientAnalytics.tsx
+++ b/packages/hydrogen/src/foundation/Analytics/ClientAnalytics.tsx
@@ -105,7 +105,16 @@ function subscribe(
function pushToServer(init?: RequestInit, searchParam?: string) {
return fetch(
`${EVENT_PATHNAME}${searchParam ? `?${searchParam}` : ''}`,
- init
+ Object.assign(
+ {
+ method: 'post',
+ headers: {
+ 'cache-control': 'no-cache',
+ 'Content-Type': 'application/json',
+ },
+ },
+ init
+ )
);
}
diff --git a/packages/hydrogen/src/foundation/Analytics/connectors/PerformanceMetrics/PerformanceMetrics.client.tsx b/packages/hydrogen/src/foundation/Analytics/connectors/PerformanceMetrics/PerformanceMetrics.client.tsx
new file mode 100644
index 0000000000..60774fd2d2
--- /dev/null
+++ b/packages/hydrogen/src/foundation/Analytics/connectors/PerformanceMetrics/PerformanceMetrics.client.tsx
@@ -0,0 +1,84 @@
+import {useEffect} from 'react';
+import {loadScript} from '../../../../utilities';
+import {ClientAnalytics} from '../../index';
+import {useShop} from '../../../useShop';
+
+declare global {
+ interface Window {
+ BOOMR: any;
+ BOOMR_onload: any;
+ }
+}
+
+const URL =
+ 'https://cdn.shopify.com/shopifycloud/boomerang/shopify-boomerang-hydrogen.min.js';
+
+export function PerformanceMetrics() {
+ const {storeDomain} = useShop();
+
+ useEffect(() => {
+ try {
+ (function () {
+ if (
+ window.BOOMR &&
+ (window.BOOMR.version || window.BOOMR.snippetExecuted)
+ ) {
+ return;
+ }
+
+ // Executes only on first mount
+ window.BOOMR = window.BOOMR || {};
+ window.BOOMR.hydrogenPerformanceEvent = (data: any) => {
+ ClientAnalytics.publish(
+ ClientAnalytics.eventNames.PERFORMANCE,
+ true,
+ data
+ );
+ ClientAnalytics.pushToServer(
+ {
+ body: JSON.stringify(data),
+ },
+ ClientAnalytics.eventNames.PERFORMANCE
+ );
+ };
+ window.BOOMR.storeDomain = storeDomain;
+
+ function boomerangSaveLoadTime(e: Event) {
+ window.BOOMR_onload = (e && e.timeStamp) || Date.now();
+ }
+
+ // @ts-ignore
+ function boomerangInit(e) {
+ e.detail.BOOMR.init();
+ e.detail.BOOMR.t_end = Date.now();
+ }
+
+ if (window.addEventListener) {
+ window.addEventListener('load', boomerangSaveLoadTime, false);
+ // @ts-ignore
+ } else if (window.attachEvent) {
+ // @ts-ignore
+ window.attachEvent('onload', boomerangSaveLoadTime);
+ }
+ if (document.addEventListener) {
+ document.addEventListener('onBoomerangLoaded', boomerangInit);
+ // @ts-ignore
+ } else if (document.attachEvent) {
+ // @ts-ignore
+ document.attachEvent('onpropertychange', function (e) {
+ if (!e) e = event;
+ if (e.propertyName === 'onBoomerangLoaded') boomerangInit(e);
+ });
+ }
+ })();
+ loadScript(URL).catch(() => {
+ // ignore if boomerang doesn't load
+ // most likely because of an ad blocker
+ });
+ } catch (err) {
+ // Do nothing
+ }
+ }, [storeDomain]);
+
+ return null;
+}
diff --git a/packages/hydrogen/src/foundation/Analytics/connectors/PerformanceMetrics/PerformanceMetrics.server.tsx b/packages/hydrogen/src/foundation/Analytics/connectors/PerformanceMetrics/PerformanceMetrics.server.tsx
new file mode 100644
index 0000000000..f279fd033d
--- /dev/null
+++ b/packages/hydrogen/src/foundation/Analytics/connectors/PerformanceMetrics/PerformanceMetrics.server.tsx
@@ -0,0 +1,29 @@
+export function request(
+ request: Request,
+ data?: any,
+ contentType?: string
+): void {
+ const url = new URL(request.url);
+ if (url.search === '?performance' && contentType === 'json') {
+ const initTime = new Date().getTime();
+
+ fetch('https://monorail-edge.shopifysvc.com/v1/produce', {
+ method: 'post',
+ headers: {
+ 'content-type': 'text/plain',
+ 'x-forwarded-for': request.headers.get('x-forwarded-for') || '',
+ 'user-agent': request.headers.get('user-agent') || '',
+ },
+ body: JSON.stringify({
+ schema_id: 'hydrogen_buyer_performance/2.0',
+ payload: data,
+ metadata: {
+ event_created_at_ms: initTime,
+ event_sent_at_ms: new Date().getTime(),
+ },
+ }),
+ }).catch((error) => {
+ // send to bugsnag? oxygen?
+ });
+ }
+}
diff --git a/packages/hydrogen/src/foundation/Analytics/connectors/PerformanceMetrics/PerformanceMetricsDebug.client.tsx b/packages/hydrogen/src/foundation/Analytics/connectors/PerformanceMetrics/PerformanceMetricsDebug.client.tsx
new file mode 100644
index 0000000000..f88cc6d35a
--- /dev/null
+++ b/packages/hydrogen/src/foundation/Analytics/connectors/PerformanceMetrics/PerformanceMetricsDebug.client.tsx
@@ -0,0 +1,29 @@
+import {useEffect} from 'react';
+import {ClientAnalytics} from '../../index';
+
+const PAD = 10;
+let isInit = false;
+export function PerformanceMetricsDebug() {
+ useEffect(() => {
+ if (!isInit) {
+ isInit = true;
+ ClientAnalytics.subscribe(
+ ClientAnalytics.eventNames.PERFORMANCE,
+ (data: any) => {
+ console.group(`Performance - ${data.page_load_type} load`);
+ logMetricIf('TTFB:', data.response_start - data.navigation_start);
+ logMetricIf('FCP:', data.first_contentful_paint);
+ logMetricIf('LCP:', data.largest_contentful_paint);
+ logMetricIf('Duration:', data.response_end - data.navigation_start);
+ console.groupEnd();
+ }
+ );
+ }
+ });
+
+ return null;
+}
+
+function logMetricIf(lable: string, data: any | undefined) {
+ data && console.log(`${lable.padEnd(PAD)}${Math.round(data)} ms`);
+}
diff --git a/packages/hydrogen/src/foundation/Analytics/const.ts b/packages/hydrogen/src/foundation/Analytics/const.ts
index d806ed9036..694d786f37 100644
--- a/packages/hydrogen/src/foundation/Analytics/const.ts
+++ b/packages/hydrogen/src/foundation/Analytics/const.ts
@@ -5,4 +5,5 @@ export const eventNames = {
REMOVE_FROM_CART: 'remove-from-cart',
UPDATE_CART: 'update-cart',
DISCOUNT_CODE_UPDATED: 'discount-code-updated',
+ PERFORMANCE: 'performance',
};
diff --git a/packages/hydrogen/src/foundation/Analytics/tests/ClientAnalytics.test.tsx b/packages/hydrogen/src/foundation/Analytics/tests/ClientAnalytics.test.tsx
index 792a5278ee..8ac7a971da 100644
--- a/packages/hydrogen/src/foundation/Analytics/tests/ClientAnalytics.test.tsx
+++ b/packages/hydrogen/src/foundation/Analytics/tests/ClientAnalytics.test.tsx
@@ -132,5 +132,6 @@ describe('Analytics - ClientAnalytics', () => {
expect(ClientAnalytics.eventNames.DISCOUNT_CODE_UPDATED).toEqual(
'discount-code-updated'
);
+ expect(ClientAnalytics.eventNames.PERFORMANCE).toEqual('performance');
});
});
diff --git a/packages/hydrogen/src/foundation/Analytics/tests/utils.test.tsx b/packages/hydrogen/src/foundation/Analytics/tests/utils.test.tsx
index 0396ed15dc..45bf787388 100644
--- a/packages/hydrogen/src/foundation/Analytics/tests/utils.test.tsx
+++ b/packages/hydrogen/src/foundation/Analytics/tests/utils.test.tsx
@@ -12,6 +12,7 @@ describe('Analytics - utils', () => {
expect(getNamedspacedEventname('discount-code-updated')).toEqual(
'discount-code-updated'
);
+ expect(getNamedspacedEventname('performance')).toEqual('performance');
});
it('should return namespaced event name when it is not part of reserve name list', () => {
diff --git a/packages/hydrogen/src/foundation/Boomerang/Boomerang.client.tsx b/packages/hydrogen/src/foundation/Boomerang/Boomerang.client.tsx
deleted file mode 100644
index 0cf45d244b..0000000000
--- a/packages/hydrogen/src/foundation/Boomerang/Boomerang.client.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import {useEffect} from 'react';
-import {loadScript} from '../../utilities';
-import {useShop} from '../useShop';
-
-declare global {
- interface Window {
- BOOMR: any;
- BOOMR_onload: any;
- }
-}
-
-const URL =
- 'https://cdn.shopify.com/shopifycloud/boomerang/shopify-boomerang-hydrogen.min.js';
-
-export function Boomerang({pageTemplate}: {pageTemplate: string | null}) {
- const {storeDomain} = useShop();
- const templateName =
- pageTemplate && pageTemplate !== null
- ? pageTemplate.toLowerCase()
- : 'not-set';
-
- useEffect(() => {
- (function () {
- function boomerangAddVar() {
- if (window.BOOMR && window.BOOMR.addVar) {
- window.BOOMR.addVar('page_template', templateName);
- }
- }
-
- // Executes on every mount
- boomerangAddVar();
-
- if (
- window.BOOMR &&
- (window.BOOMR.version || window.BOOMR.snippetExecuted)
- ) {
- return;
- }
-
- // Executes only on first mount
- window.BOOMR = window.BOOMR || {};
- window.BOOMR.storeDomain = storeDomain;
- window.BOOMR.pageTemplate = templateName;
-
- function boomerangSaveLoadTime(e: Event) {
- window.BOOMR_onload = (e && e.timeStamp) || Date.now();
- }
-
- // @ts-ignore
- function boomerangInit(e) {
- e.detail.BOOMR.init({
- producer_url: 'https://monorail-edge.shopifysvc.com/v1/produce',
- });
- e.detail.BOOMR.t_end = Date.now();
- boomerangAddVar();
- }
-
- if (window.addEventListener) {
- window.addEventListener('load', boomerangSaveLoadTime, false);
- // @ts-ignore
- } else if (window.attachEvent) {
- // @ts-ignore
- window.attachEvent('onload', boomerangSaveLoadTime);
- }
- if (document.addEventListener) {
- document.addEventListener('onBoomerangLoaded', boomerangInit);
- // @ts-ignore
- } else if (document.attachEvent) {
- // @ts-ignore
- document.attachEvent('onpropertychange', function (e) {
- if (!e) e = event;
- if (e.propertyName === 'onBoomerangLoaded') boomerangInit(e);
- });
- }
- })();
- loadScript(URL).catch(() => {
- // ignore if boomerang doesn't load
- // most likely because of a ad blocker
- });
- }, [storeDomain, pageTemplate]);
-
- return null;
-}
diff --git a/packages/hydrogen/src/foundation/Route/Route.server.tsx b/packages/hydrogen/src/foundation/Route/Route.server.tsx
index b42d681bc2..a0d57ab89d 100644
--- a/packages/hydrogen/src/foundation/Route/Route.server.tsx
+++ b/packages/hydrogen/src/foundation/Route/Route.server.tsx
@@ -1,9 +1,7 @@
import React, {cloneElement, ReactElement} from 'react';
import {useServerRequest} from '../ServerRequestProvider';
import {matchPath} from '../../utilities/matchPath';
-import {Boomerang} from '../Boomerang/Boomerang.client';
import {RouteParamsProvider} from '../useRouteParams/RouteParamsProvider.client';
-import {useServerAnalytics} from '../Analytics';
export type RouteProps = {
/** The URL path where the route exists. The path can contain variables. For example, `/products/:handle`. */
@@ -35,16 +33,10 @@ export function Route({path, page}: RouteProps): ReactElement | null {
if (match) {
request.ctx.router.routeRendered = true;
request.ctx.router.routeParams = match.params;
- const name = (page?.type as any)?.name;
-
- useServerAnalytics({
- templateName: name,
- });
return (
{cloneElement(page, {params: match.params || {}, ...serverProps})}
- {name ? : null}
);
}
diff --git a/packages/hydrogen/src/index.ts b/packages/hydrogen/src/index.ts
index 1fbc955982..d51a93aad7 100644
--- a/packages/hydrogen/src/index.ts
+++ b/packages/hydrogen/src/index.ts
@@ -43,6 +43,7 @@ export {
export {fetchSync} from './foundation/fetchSync/server/fetchSync';
export {useServerAnalytics} from './foundation/Analytics';
+export * as PerformanceMetricsServerAnalyticsConnector from './foundation/Analytics/connectors/PerformanceMetrics/PerformanceMetrics.server';
export {useSession} from './foundation/useSession/useSession';
export {CookieSessionStorage} from './foundation/CookieSessionStorage/CookieSessionStorage';
diff --git a/templates/template-hydrogen-default/src/App.server.jsx b/templates/template-hydrogen-default/src/App.server.jsx
index 6e8136677b..da952deb28 100644
--- a/templates/template-hydrogen-default/src/App.server.jsx
+++ b/templates/template-hydrogen-default/src/App.server.jsx
@@ -4,6 +4,7 @@ import {
Route,
FileRoutes,
ShopifyProvider,
+ PerformanceMetricsServerAnalyticsConnector,
CookieSessionStorage,
} from '@shopify/hydrogen';
import {Suspense} from 'react';
@@ -12,6 +13,10 @@ import DefaultSeo from './components/DefaultSeo.server';
import NotFound from './components/NotFound.server';
import LoadingFallback from './components/LoadingFallback';
import CartProvider from './components/CartProvider.client';
+import {
+ PerformanceMetrics,
+ PerformanceMetricsDebug,
+} from '@shopify/hydrogen/client';
function App({routes}) {
return (
@@ -24,6 +29,8 @@ function App({routes}) {
} />
+
+ {process.env.LOCAL_DEV && }
);
@@ -41,4 +48,5 @@ export default renderHydrogen(App, {
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 30,
}),
+ serverAnalyticsConnectors: [PerformanceMetricsServerAnalyticsConnector],
});
diff --git a/templates/template-hydrogen-default/tests/e2e/performance-metrics.test.js b/templates/template-hydrogen-default/tests/e2e/performance-metrics.test.js
new file mode 100644
index 0000000000..e97734342d
--- /dev/null
+++ b/templates/template-hydrogen-default/tests/e2e/performance-metrics.test.js
@@ -0,0 +1,57 @@
+import {startHydrogenServer} from '../utils';
+
+const SHOPIFY_EVENTS_ENDPOINT = '/__event?performance';
+
+describe('Performance metrics', () => {
+ let hydrogen;
+ let eventsEndpoint;
+ let session;
+
+ beforeAll(async () => {
+ hydrogen = await startHydrogenServer();
+ eventsEndpoint = hydrogen.url(SHOPIFY_EVENTS_ENDPOINT);
+ });
+
+ beforeEach(async () => {
+ session = await hydrogen.newPage();
+ });
+
+ afterAll(async () => {
+ await hydrogen.cleanUp();
+ });
+
+ it('should emit performance event', async () => {
+ const [request] = await Promise.all([
+ session.page.waitForRequest(eventsEndpoint),
+ session.visit('/'),
+ ]);
+
+ const performanceEvent = request.postDataJSON();
+ expect(request.url()).toEqual(eventsEndpoint);
+ expect(performanceEvent.page_load_type).toEqual('full');
+ expect(performanceEvent.store_domain).toEqual(
+ 'hydrogen-preview.myshopify.com',
+ );
+ expect(performanceEvent.url).toEqual(hydrogen.url('/'));
+ }, 60000);
+
+ it('should emit performance on sub load', async () => {
+ const collectionPath = '/collections/freestyle-collection';
+ // Full load
+ await Promise.all([
+ session.page.waitForRequest(eventsEndpoint),
+ session.visit('/'),
+ ]);
+
+ // Sub load
+ const [request] = await Promise.all([
+ session.page.waitForRequest(eventsEndpoint),
+ session.page.click(`a[href="${collectionPath}"]`),
+ ]);
+
+ const performanceEvent = request.postDataJSON();
+ expect(request.url()).toEqual(eventsEndpoint);
+ expect(performanceEvent.page_load_type).toEqual('sub');
+ expect(performanceEvent.url).toEqual(hydrogen.url(collectionPath));
+ }, 60000);
+});