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); +});