Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/nice-balloons-peel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'create-hydrogen-app': minor
'@shopify/hydrogen': minor
---

Make performance data available with ClientAnalytics and optional for developers to include
48 changes: 48 additions & 0 deletions docs/framework/analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<PerformanceMetrics />`. |

> Note:
> The event name constants are available in `ClientAnalytics.eventNames`.
Expand Down Expand Up @@ -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 `<PerformanceMetrics />` and `PerformanceMetricsServerAnalyticsConnector` in `App.server.js`.

If you want to see performance debug metrics displayed in your browser console log, then include `<PerformanceMetricsDebug />` 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 (
<Suspense fallback={<LoadingFallback />}>
<ShopifyProvider shopifyConfig={shopifyConfig}>
...
<PerformanceMetrics />
{process.env.LOCAL_DEV && <PerformanceMetricsDebug />}
</ShopifyProvider>
</Suspense>
);
}

...

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):
Expand Down
2 changes: 2 additions & 0 deletions packages/hydrogen/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
11 changes: 10 additions & 1 deletion packages/hydrogen/src/foundation/Analytics/ClientAnalytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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?
});
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
);
}
});
Copy link
Contributor

Choose a reason for hiding this comment

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

Could maybe add an empty dep array [] and also return a function that cleans up the subscribe?

Copy link
Author

Choose a reason for hiding this comment

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

I can't - it still double executes. It's either this or that useRef trick documented

Copy link
Contributor

Choose a reason for hiding this comment

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

That's due to StrictMode, correct? If so, it won't double execute in a real build; only in dev mode.

Copy link
Author

Choose a reason for hiding this comment

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

I don't want this to double execute, even in dev. Double firing of analytics should never happen no matter where you are.


return null;
}

function logMetricIf(lable: string, data: any | undefined) {
data && console.log(`${lable.padEnd(PAD)}${Math.round(data)} ms`);
}
1 change: 1 addition & 0 deletions packages/hydrogen/src/foundation/Analytics/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
Original file line number Diff line number Diff line change
Expand Up @@ -132,5 +132,6 @@ describe('Analytics - ClientAnalytics', () => {
expect(ClientAnalytics.eventNames.DISCOUNT_CODE_UPDATED).toEqual(
'discount-code-updated'
);
expect(ClientAnalytics.eventNames.PERFORMANCE).toEqual('performance');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
83 changes: 0 additions & 83 deletions packages/hydrogen/src/foundation/Boomerang/Boomerang.client.tsx

This file was deleted.

8 changes: 0 additions & 8 deletions packages/hydrogen/src/foundation/Route/Route.server.tsx
Original file line number Diff line number Diff line change
@@ -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`. */
Expand Down Expand Up @@ -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 (
<RouteParamsProvider routeParams={match.params}>
{cloneElement(page, {params: match.params || {}, ...serverProps})}
{name ? <Boomerang pageTemplate={name} /> : null}
</RouteParamsProvider>
);
}
Expand Down
1 change: 1 addition & 0 deletions packages/hydrogen/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading