diff --git a/packages/manager/.changeset/pr-13229-added-1767110711119.md b/packages/manager/.changeset/pr-13229-added-1767110711119.md new file mode 100644 index 00000000000..1a250a7954f --- /dev/null +++ b/packages/manager/.changeset/pr-13229-added-1767110711119.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +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)) diff --git a/packages/manager/src/hooks/useAdobeAnalytics.ts b/packages/manager/src/hooks/useAdobeAnalytics.ts index 30cbe4fc320..424e2adebd3 100644 --- a/packages/manager/src/hooks/useAdobeAnalytics.ts +++ b/packages/manager/src/hooks/useAdobeAnalytics.ts @@ -1,3 +1,4 @@ +import { useProfile } from '@linode/queries'; import { loadScript } from '@linode/utilities'; import { useLocation } from '@tanstack/react-router'; import React from 'react'; @@ -5,14 +6,23 @@ import React from 'react'; import { ADOBE_ANALYTICS_URL } from 'src/constants'; import { reportException } from 'src/exceptionReporting'; +import type { ExtendedProfile } from 'src/request'; + /** * Initializes our Adobe Analytics script on mount and subscribes to page view events. + * The EUUID is read from the profile data (injected by the injectEuuidToProfile interceptor). */ export const useAdobeAnalytics = () => { const location = useLocation(); + const { data: profile } = useProfile() as { + data: ExtendedProfile | undefined; + }; + const euuid = profile?._euuidFromHttpHeader; React.useEffect(() => { // Load Adobe Analytics Launch Script + // Note: The first page view may not include EUUID since the profile + // may not have loaded yet. Subsequent page views will include it. if (ADOBE_ANALYTICS_URL) { loadScript(ADOBE_ANALYTICS_URL, { location: 'head' }) .then((data) => { @@ -26,6 +36,7 @@ export const useAdobeAnalytics = () => { // Fire the first page view for the landing page window._satellite.track('page view', { url: window.location.pathname, + ...(euuid && { euuid }), }); }) .catch(() => { @@ -36,11 +47,13 @@ export const useAdobeAnalytics = () => { React.useEffect(() => { /** - * Send pageviews when location changes + * Send pageviews when location changes. + * Includes EUUID (Enterprise UUID) if available from the profile response. */ if (window._satellite) { window._satellite.track('page view', { url: location.pathname, + ...(euuid && { euuid }), }); } }, [location.pathname]); // Listen to location changes diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 79aa9ae194c..a365f8975e6 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -682,7 +682,11 @@ export const handlers = [ // restricted: true, // user_type: 'default', }); - return HttpResponse.json(profile); + return HttpResponse.json(profile, { + headers: { + 'X-Customer-UUID': '51C68049-266E-451B-80ABFC92B5B9D576', + }, + }); }), http.put('*/profile', async ({ request }) => { diff --git a/packages/manager/src/request.test.tsx b/packages/manager/src/request.test.tsx index c92d8a0df8c..648bae878ea 100644 --- a/packages/manager/src/request.test.tsx +++ b/packages/manager/src/request.test.tsx @@ -2,7 +2,12 @@ import { profileFactory } from '@linode/utilities'; import { AxiosHeaders } from 'axios'; import { setAuthDataInLocalStorage } from './OAuth/oauth'; -import { getURL, handleError, injectAkamaiAccountHeader } from './request'; +import { + getURL, + handleError, + injectAkamaiAccountHeader, + injectEuuidToProfile, +} from './request'; import { storeFactory } from './store'; import { storage } from './utilities/storage'; @@ -106,3 +111,33 @@ describe('injectAkamaiAccountHeader', () => { ); }); }); + +describe('injectEuuidToProfile', () => { + const profile = profileFactory.build(); + const response: Partial = { + data: profile, + status: 200, + config: { headers: new AxiosHeaders(), url: '/profile', method: 'get' }, + headers: { 'x-customer-uuid': '1234' }, + }; + + it('injects the euuid on successful GET profile response ', () => { + const results = injectEuuidToProfile(response as any); + expect(results.data).toHaveProperty('_euuidFromHttpHeader', '1234'); + // eslint-disable-next-line + const { _euuidFromHttpHeader, ...originalData } = results.data; + expect(originalData).toEqual(profile); + }); + + it('returns the original profile data if no header is present', () => { + const responseWithNoHeaders = { ...response, headers: {} }; + expect(injectEuuidToProfile(responseWithNoHeaders as any).data).toEqual( + profile + ); + }); + + it("doesn't inject the euuid on other endpoints", () => { + const accountResponse = { ...response, config: { url: '/account' } }; + expect(injectEuuidToProfile(accountResponse as any).data).toEqual(profile); + }); +}); diff --git a/packages/manager/src/request.tsx b/packages/manager/src/request.tsx index 54e65a3280d..a1834f62ae1 100644 --- a/packages/manager/src/request.tsx +++ b/packages/manager/src/request.tsx @@ -102,6 +102,18 @@ export type ProfileWithAkamaiAccountHeader = Profile & { _akamaiAccount: boolean; }; +// A user's external UUID can be found on the response to /account. +// Since that endpoint is not available to restricted users, the API also +// returns it as an HTTP header ("X-Customer-Uuid"). This header is injected +// in the response to `/profile` so that it's available in Redux. +export type ProfileWithEuuid = Profile & { + _euuidFromHttpHeader?: string; +}; + +export type ExtendedProfile = Profile & + ProfileWithAkamaiAccountHeader & + ProfileWithEuuid; + export const injectAkamaiAccountHeader = ( response: AxiosResponse ): AxiosResponse => { @@ -133,6 +145,34 @@ export const isSuccessfulGETProfileResponse = ( ); }; +/** + * A user's external UUID can be found on the response to /account. + * Since that endpoint is not available to restricted users, the API also + * returns it as an HTTP header ("X-Customer-Uuid"). This middleware injects + * the value of the header to the GET /profile response so it can be added to + * the Redux store and used throughout the app. + */ +export const injectEuuidToProfile = ( + response: AxiosResponse +): AxiosResponse => { + if (isSuccessfulGETProfileResponse(response)) { + const xCustomerUuidHeader = response.headers['x-customer-uuid']; + // NOTE: this won't work locally (only staging and prod allow this header) + if (xCustomerUuidHeader) { + const profileWithEuuid: ProfileWithEuuid = { + ...response.data, + _euuidFromHttpHeader: xCustomerUuidHeader, + }; + + return { + ...response, + data: profileWithEuuid, + }; + } + } + return response; +}; + export const setupInterceptors = (store: ApplicationStore) => { baseRequest.interceptors.request.use(async (config) => { if ( @@ -176,4 +216,7 @@ export const setupInterceptors = (store: ApplicationStore) => { ); baseRequest.interceptors.response.use(injectAkamaiAccountHeader); + + // Inject the EUUID from the X-Customer-Uuid header into the profile response + baseRequest.interceptors.response.use(injectEuuidToProfile); }; diff --git a/packages/manager/src/utilities/analytics/types.ts b/packages/manager/src/utilities/analytics/types.ts index c4d635f122e..7aa13b56a47 100644 --- a/packages/manager/src/utilities/analytics/types.ts +++ b/packages/manager/src/utilities/analytics/types.ts @@ -15,6 +15,7 @@ type DTMSatellite = { }; interface PageViewPayload { + euuid?: string; url: string; }