Skip to content

Commit b889c8e

Browse files
authored
Merge pull request #1315 from PADAS/ERA-11776
Measuring ER Web Vitals
2 parents 8da263a + 2373251 commit b889c8e

File tree

9 files changed

+481
-2
lines changed

9 files changed

+481
-2
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
"terser-webpack-plugin": "^5.2.5",
104104
"use-sound": "^5.0.0",
105105
"uuid": "^11.0.3",
106+
"web-vitals": "^5.0.3",
106107
"webpack": "^5.98.0",
107108
"webpack-dev-server": "^5.2.1",
108109
"webpack-manifest-plugin": "^5.0.1",

src/hooks/useWebVitals/index.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { useEffect, useRef } from 'react';
2+
import { useSelector } from 'react-redux';
3+
import { initializeWebVitals, createUserAnalyticsData } from '../../utils/webVitals';
4+
5+
const useWebVitals = () => {
6+
const user = useSelector((state) => state.data?.user);
7+
const selectedUserProfile = useSelector((state) => state.data?.selectedUserProfile);
8+
const serverVersion = useSelector((state) => state.data?.systemStatus?.server?.version);
9+
const isInitialized = useRef(false);
10+
11+
useEffect(() => {
12+
if (!isInitialized.current && user?.id) {
13+
const userData = createUserAnalyticsData(user, selectedUserProfile, serverVersion);
14+
initializeWebVitals(userData);
15+
isInitialized.current = true;
16+
17+
if (process.env.NODE_ENV === 'development') {
18+
console.log('Web vitals initialized with user context:', userData);
19+
}
20+
}
21+
}, [user, selectedUserProfile, serverVersion]);
22+
};
23+
24+
export default useWebVitals;
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import React from 'react';
2+
import { Provider } from 'react-redux';
3+
import { renderHook } from '@testing-library/react';
4+
import { mockStore } from '../../__test-helpers/MockStore';
5+
import useWebVitals from './index';
6+
import { initializeWebVitals, createUserAnalyticsData } from '../../utils/webVitals';
7+
8+
jest.mock('../../utils/webVitals', () => ({
9+
initializeWebVitals: jest.fn(),
10+
createUserAnalyticsData: jest.fn(),
11+
}));
12+
13+
describe('useWebVitals hook', () => {
14+
beforeEach(() => {
15+
jest.clearAllMocks();
16+
17+
jest.spyOn(console, 'log').mockImplementation(() => { }); // Mock console.log to avoid noise in tests
18+
});
19+
20+
afterEach(() => {
21+
jest.restoreAllMocks();
22+
});
23+
24+
const createWrapper = (storeState = {}) => {
25+
const testStore = mockStore({
26+
data: {
27+
user: null,
28+
selectedUserProfile: {},
29+
...storeState.data,
30+
},
31+
...storeState,
32+
});
33+
34+
const Wrapper = ({ children }) => (
35+
<Provider store={testStore}>
36+
{children}
37+
</Provider>
38+
);
39+
40+
return Wrapper;
41+
};
42+
43+
it('should not initialize web vitals when user is not available', () => {
44+
const wrapper = createWrapper({
45+
data: {
46+
user: null,
47+
selectedUserProfile: {},
48+
},
49+
});
50+
51+
renderHook(() => useWebVitals(), { wrapper });
52+
53+
expect(initializeWebVitals).not.toHaveBeenCalled();
54+
expect(createUserAnalyticsData).not.toHaveBeenCalled();
55+
});
56+
57+
it('should initialize web vitals when user with ID is available', () => {
58+
const mockUser = {
59+
id: 'user123',
60+
username: 'testuser',
61+
role: 'ranger',
62+
is_staff: false,
63+
is_superuser: false,
64+
};
65+
66+
const mockSelectedProfile = {
67+
id: 'profile456',
68+
role: 'admin',
69+
};
70+
71+
const mockServerVersion = '1.2.3';
72+
73+
const mockUserData = {
74+
user_role: 'admin',
75+
organization: 'test.earthranger.com',
76+
user_id_hash: 'abc123',
77+
is_staff: false,
78+
is_superuser: false,
79+
};
80+
81+
createUserAnalyticsData.mockReturnValue(mockUserData);
82+
83+
const wrapper = createWrapper({
84+
data: {
85+
user: mockUser,
86+
selectedUserProfile: mockSelectedProfile,
87+
systemStatus: {
88+
server: {
89+
version: mockServerVersion,
90+
},
91+
},
92+
},
93+
});
94+
95+
renderHook(() => useWebVitals(), { wrapper });
96+
97+
expect(createUserAnalyticsData).toHaveBeenCalledWith(mockUser, mockSelectedProfile, mockServerVersion);
98+
expect(initializeWebVitals).toHaveBeenCalledWith(mockUserData);
99+
});
100+
101+
it('should only initialize once even with multiple renders', () => {
102+
const mockUser = {
103+
id: 'user123',
104+
username: 'testuser',
105+
role: 'ranger',
106+
};
107+
108+
const mockUserData = {
109+
user_role: 'ranger',
110+
organization: 'test.earthranger.com',
111+
user_id_hash: 'abc123',
112+
};
113+
114+
createUserAnalyticsData.mockReturnValue(mockUserData);
115+
116+
const wrapper = createWrapper({
117+
data: {
118+
user: mockUser,
119+
selectedUserProfile: {},
120+
systemStatus: {
121+
server: {
122+
version: '1.0.0',
123+
},
124+
},
125+
},
126+
});
127+
128+
const { rerender } = renderHook(() => useWebVitals(), { wrapper });
129+
130+
expect(initializeWebVitals).toHaveBeenCalledTimes(1);
131+
132+
// Rerender the hook
133+
rerender();
134+
135+
expect(initializeWebVitals).toHaveBeenCalledTimes(1); // Should still be 1
136+
});
137+
});

src/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import NavigationContextProvider from './NavigationContextProvider';
2727
import RequestConfigManager from './RequestConfigManager';
2828
import RequireAccessToken from './RequireAccessToken';
2929
import RequireEulaConfirmation from './RequireEulaConfirmation';
30+
import useWebVitals from './hooks/useWebVitals';
3031

3132
const App = lazy(() => import('./App'));
3233
const EulaPage = lazy(() => import('./views/EULA'));
@@ -61,6 +62,8 @@ const PathNormalizationRouteComponent = ({ location }) => {
6162
const RootApp = () => {
6263
const { i18n } = useTranslation();
6364

65+
useWebVitals();
66+
6467
useEffect(() => {
6568
if (window?.OneTrust) {
6669
document.documentElement.lang = i18n.language;

src/utils/string.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,22 @@ export const hashCode = (string) => {
88
if (string.length === 0) return 0;
99

1010
const hash = string.split('').reduce((hash, char) => {
11-
hash = ((hash << 5) - hash) + char.charCodeAt(0);
11+
hash = ((hash << 5) - hash) + char.charCodeAt(0);
1212
hash |= 0;
1313

1414
return hash;
1515
}, 0);
1616

1717
return hash;
1818
};
19+
20+
export const hashString = (str) => { // String hashing function for anonymization. Differs from hashCode above as hashCode is a numeric hash for map feature idenitification.
21+
if (!str) return 'unknown';
22+
let hash = 0;
23+
for (let i = 0; i < str.length; i++) {
24+
const char = str.charCodeAt(i);
25+
hash = ((hash << 5) - hash) + char;
26+
hash |= 0; // Coerce to 32-bit integer
27+
}
28+
return Math.abs(hash).toString(36);
29+
};

src/utils/string.test.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { hashCode } from './string';
1+
import { hashCode, hashString } from './string';
22

33
describe('String utils', () => {
44
describe('hashCode', () => {
@@ -12,4 +12,30 @@ describe('String utils', () => {
1212
expect(hashCode('Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.')).toBe(512895612);
1313
});
1414
});
15+
16+
describe('hashString', () => {
17+
test('returns "unknown" for falsy values', () => {
18+
expect(hashString(null)).toBe('unknown');
19+
expect(hashString(undefined)).toBe('unknown');
20+
expect(hashString('')).toBe('unknown');
21+
expect(hashString(0)).toBe('unknown');
22+
expect(hashString(false)).toBe('unknown');
23+
});
24+
25+
test('returns consistent hash for same string', () => {
26+
const hash1 = hashString('test-user-123');
27+
const hash2 = hashString('test-user-123');
28+
expect(hash1).toBe(hash2);
29+
expect(hash1).not.toBe('unknown');
30+
expect(typeof hash1).toBe('string');
31+
});
32+
33+
test('returns different hashes for different strings', () => {
34+
const hash1 = hashString('user-123');
35+
const hash2 = hashString('user-456');
36+
expect(hash1).not.toBe(hash2);
37+
expect(hash1).not.toBe('unknown');
38+
expect(hash2).not.toBe('unknown');
39+
});
40+
});
1541
});

src/utils/webVitals.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals';
2+
import ReactGA4 from 'react-ga4';
3+
import { hashString } from './string';
4+
import { CLIENT_BUILD_VERSION } from '../constants';
5+
6+
export const createUserAnalyticsData = (user = {}, selectedUserProfile = {}, serverVersion = 'unknown') => {
7+
const activeUser = selectedUserProfile.id ? selectedUserProfile : user;
8+
9+
return {
10+
user_role: activeUser.role || 'unknown',
11+
organization: window.location.hostname,
12+
user_id_hash: hashString(activeUser.id),
13+
is_staff: activeUser.is_staff || false,
14+
is_superuser: activeUser.is_superuser || false,
15+
client_version: CLIENT_BUILD_VERSION,
16+
server_version: serverVersion,
17+
};
18+
};
19+
20+
const isLocalhost = () => {
21+
return window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
22+
};
23+
24+
export const initializeWebVitals = (userData = {}) => {
25+
const sendToGA4 = (metric) => {
26+
if (isLocalhost()) {
27+
if (process.env.NODE_ENV === 'development') {
28+
console.log('localhost web vitals:', {
29+
name: metric.name,
30+
value: metric.value,
31+
rating: metric.rating,
32+
delta: metric.delta,
33+
id: metric.id,
34+
path: window.location.pathname,
35+
});
36+
}
37+
return;
38+
}
39+
40+
ReactGA4.event(`web_vital_${metric.name.toLowerCase()}`, {
41+
event_category: 'Web Vitals',
42+
metric_name: metric.name,
43+
metric_value: Math.round(metric.value),
44+
metric_delta: metric.delta,
45+
metric_rating: metric.rating,
46+
metric_id: metric.id,
47+
page_path: window.location.pathname,
48+
page_title: document.title,
49+
hostname: window.location.hostname,
50+
...userData,
51+
});
52+
53+
if (process.env.NODE_ENV === 'development') {
54+
console.log('Web Vital sent to GA4:', {
55+
name: metric.name,
56+
value: metric.value,
57+
rating: metric.rating,
58+
delta: metric.delta,
59+
id: metric.id,
60+
path: window.location.pathname,
61+
});
62+
}
63+
};
64+
65+
onCLS(sendToGA4);
66+
onLCP(sendToGA4);
67+
onFCP(sendToGA4);
68+
onINP(sendToGA4);
69+
onTTFB(sendToGA4);
70+
};
71+
72+
export default initializeWebVitals;

0 commit comments

Comments
 (0)