Skip to content

Commit 426427c

Browse files
feat: [UIE-9805] - Extract EUUID from /profile header and send to Adobe analytics (#13229)
* feat: [UIE-9805] - Extract EUUID from /profile header and send to Adobe analytics * Added mock data for validating EUUID extraction in local. * Added changeset: Extract EUUID from authenticated API calls at the interceptor level and share it with Adobe analytics through the page view event * Address review comments * Address review comments by Alban. * Use Extended Profile type for euuid alongwith a custom hook for type assertion.
1 parent a3af683 commit 426427c

File tree

8 files changed

+166
-3
lines changed

8 files changed

+166
-3
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@linode/manager": Added
3+
---
4+
5+
Extract EUUID from authenticated API calls at the interceptor level and share it with Adobe analytics through the page view event ([#13229](https://github.com/linode/manager/pull/13229))

packages/manager/src/hooks/useAdobeAnalytics.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@ import React from 'react';
55
import { ADOBE_ANALYTICS_URL } from 'src/constants';
66
import { reportException } from 'src/exceptionReporting';
77

8+
import { useEuuidFromHttpHeader } from './useEuuidFromHttpHeader';
9+
810
/**
911
* Initializes our Adobe Analytics script on mount and subscribes to page view events.
12+
* The EUUID is read from the profile data (injected by the injectEuuidToProfile interceptor).
1013
*/
1114
export const useAdobeAnalytics = () => {
1215
const location = useLocation();
16+
const { euuid } = useEuuidFromHttpHeader();
1317

1418
React.useEffect(() => {
1519
// Load Adobe Analytics Launch Script
@@ -26,6 +30,7 @@ export const useAdobeAnalytics = () => {
2630
// Fire the first page view for the landing page
2731
window._satellite.track('page view', {
2832
url: window.location.pathname,
33+
...(euuid && { euuid }),
2934
});
3035
})
3136
.catch(() => {
@@ -36,11 +41,13 @@ export const useAdobeAnalytics = () => {
3641

3742
React.useEffect(() => {
3843
/**
39-
* Send pageviews when location changes
44+
* Send pageviews when location changes.
45+
* Includes EUUID (Enterprise UUID) if available from the profile response.
4046
*/
4147
if (window._satellite) {
4248
window._satellite.track('page view', {
4349
url: location.pathname,
50+
...(euuid && { euuid }),
4451
});
4552
}
4653
}, [location.pathname]); // Listen to location changes
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { renderHook, waitFor } from '@testing-library/react';
2+
3+
import { http, HttpResponse, server } from 'src/mocks/testServer';
4+
import { wrapWithTheme } from 'src/utilities/testHelpers';
5+
6+
import { useEuuidFromHttpHeader } from './useEuuidFromHttpHeader';
7+
8+
describe('useEuuidFromHttpHeader', () => {
9+
it('returns EUUID when the header is included', async () => {
10+
const mockEuuid = 'test-euuid-12345';
11+
12+
server.use(
13+
http.get('*/profile', () => {
14+
return new HttpResponse(null, {
15+
headers: { 'X-Customer-Uuid': mockEuuid },
16+
});
17+
})
18+
);
19+
20+
const { result } = renderHook(() => useEuuidFromHttpHeader(), {
21+
wrapper: (ui) => wrapWithTheme(ui),
22+
});
23+
24+
await waitFor(() => {
25+
expect(result.current.euuid).toBe(mockEuuid);
26+
});
27+
});
28+
29+
it('returns undefined when the header is not included', async () => {
30+
server.use(
31+
http.get('*/profile', () => {
32+
return new HttpResponse(null, {
33+
headers: {},
34+
});
35+
})
36+
);
37+
38+
const { result } = renderHook(() => useEuuidFromHttpHeader(), {
39+
wrapper: (ui) => wrapWithTheme(ui),
40+
});
41+
42+
await waitFor(() => {
43+
expect(result.current.euuid).toBeUndefined();
44+
});
45+
});
46+
47+
it('returns undefined when profile is loading', () => {
48+
const { result } = renderHook(() => useEuuidFromHttpHeader(), {
49+
wrapper: (ui) => wrapWithTheme(ui),
50+
});
51+
52+
// Before the profile loads, euuid should be undefined
53+
expect(result.current.euuid).toBeUndefined();
54+
});
55+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { useProfile } from '@linode/queries';
2+
3+
import type { UseQueryResult } from '@tanstack/react-query';
4+
import type { ProfileWithEuuid } from 'src/request';
5+
6+
/**
7+
* Hook to get the customer EUUID (Enterprise UUID) from the profile data.
8+
* The EUUID is injected by the injectEuuidToProfile interceptor from the
9+
* X-Customer-Uuid header.
10+
*
11+
* NOTE: this won't work locally (only staging and prod return this header)
12+
*/
13+
export const useEuuidFromHttpHeader = () => ({
14+
euuid: (useProfile() as UseQueryResult<ProfileWithEuuid>).data
15+
?._euuidFromHttpHeader,
16+
});

packages/manager/src/mocks/serverHandlers.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -716,7 +716,11 @@ export const handlers = [
716716
// restricted: true,
717717
// user_type: 'default',
718718
});
719-
return HttpResponse.json(profile);
719+
return HttpResponse.json(profile, {
720+
headers: {
721+
'X-Customer-UUID': '51C68049-266E-451B-80ABFC92B5B9D576',
722+
},
723+
});
720724
}),
721725

722726
http.put('*/profile', async ({ request }) => {

packages/manager/src/request.test.tsx

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import { profileFactory } from '@linode/utilities';
22
import { AxiosHeaders } from 'axios';
33

44
import { setAuthDataInLocalStorage } from './OAuth/oauth';
5-
import { getURL, handleError, injectAkamaiAccountHeader } from './request';
5+
import {
6+
getURL,
7+
handleError,
8+
injectAkamaiAccountHeader,
9+
injectEuuidToProfile,
10+
} from './request';
611
import { storeFactory } from './store';
712
import { storage } from './utilities/storage';
813

@@ -106,3 +111,34 @@ describe('injectAkamaiAccountHeader', () => {
106111
);
107112
});
108113
});
114+
115+
describe('injectEuuidToProfile', () => {
116+
const profile = profileFactory.build();
117+
const response: AxiosResponse = {
118+
data: profile,
119+
status: 200,
120+
statusText: 'OK',
121+
config: { headers: new AxiosHeaders(), url: '/profile', method: 'get' },
122+
headers: { 'x-customer-uuid': '1234' },
123+
};
124+
125+
it('injects the euuid on successful GET profile response ', () => {
126+
const results = injectEuuidToProfile(response);
127+
expect(results.data).toHaveProperty('_euuidFromHttpHeader', '1234');
128+
const { _euuidFromHttpHeader, ...originalData } = results.data;
129+
expect(originalData).toEqual(profile);
130+
});
131+
132+
it('returns the original profile data if no header is present', () => {
133+
const responseWithNoHeaders: AxiosResponse = { ...response, headers: {} };
134+
expect(injectEuuidToProfile(responseWithNoHeaders).data).toEqual(profile);
135+
});
136+
137+
it("doesn't inject the euuid on other endpoints", () => {
138+
const accountResponse: AxiosResponse = {
139+
...response,
140+
config: { ...response.config, url: '/account' },
141+
};
142+
expect(injectEuuidToProfile(accountResponse).data).toEqual(profile);
143+
});
144+
});

packages/manager/src/request.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,14 @@ export type ProfileWithAkamaiAccountHeader = Profile & {
102102
_akamaiAccount: boolean;
103103
};
104104

105+
// A user's external UUID can be found on the response to /account.
106+
// Since that endpoint is not available to restricted users, the API also
107+
// returns it as an HTTP header ("X-Customer-Uuid"). This header is injected
108+
// in the response to `/profile` so that it's available in Redux.
109+
export type ProfileWithEuuid = Profile & {
110+
_euuidFromHttpHeader?: string;
111+
};
112+
105113
export const injectAkamaiAccountHeader = (
106114
response: AxiosResponse
107115
): AxiosResponse => {
@@ -133,6 +141,34 @@ export const isSuccessfulGETProfileResponse = (
133141
);
134142
};
135143

144+
/**
145+
* A user's external UUID can be found on the response to /account.
146+
* Since that endpoint is not available to restricted users, the API also
147+
* returns it as an HTTP header ("X-Customer-Uuid"). This middleware injects
148+
* the value of the header to the GET /profile response so it can be added to
149+
* the Redux store and used throughout the app.
150+
*/
151+
export const injectEuuidToProfile = (
152+
response: AxiosResponse
153+
): AxiosResponse => {
154+
if (isSuccessfulGETProfileResponse(response)) {
155+
const xCustomerUuidHeader = response.headers['x-customer-uuid'];
156+
// NOTE: this won't work locally (only staging and prod allow this header)
157+
if (xCustomerUuidHeader) {
158+
const profileWithEuuid: ProfileWithEuuid = {
159+
...response.data,
160+
_euuidFromHttpHeader: xCustomerUuidHeader,
161+
};
162+
163+
return {
164+
...response,
165+
data: profileWithEuuid,
166+
};
167+
}
168+
}
169+
return response;
170+
};
171+
136172
export const setupInterceptors = (store: ApplicationStore) => {
137173
baseRequest.interceptors.request.use(async (config) => {
138174
if (
@@ -176,4 +212,7 @@ export const setupInterceptors = (store: ApplicationStore) => {
176212
);
177213

178214
baseRequest.interceptors.response.use(injectAkamaiAccountHeader);
215+
216+
// Inject the EUUID from the X-Customer-Uuid header into the profile response
217+
baseRequest.interceptors.response.use(injectEuuidToProfile);
179218
};

packages/manager/src/utilities/analytics/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type DTMSatellite = {
1515
};
1616

1717
interface PageViewPayload {
18+
euuid?: string;
1819
url: string;
1920
}
2021

0 commit comments

Comments
 (0)