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..2ed49184f36 100644 --- a/packages/manager/src/hooks/useAdobeAnalytics.ts +++ b/packages/manager/src/hooks/useAdobeAnalytics.ts @@ -5,11 +5,15 @@ import React from 'react'; import { ADOBE_ANALYTICS_URL } from 'src/constants'; import { reportException } from 'src/exceptionReporting'; +import { useEuuidFromHttpHeader } from './useEuuidFromHttpHeader'; + /** * 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 { euuid } = useEuuidFromHttpHeader(); React.useEffect(() => { // Load Adobe Analytics Launch Script @@ -26,6 +30,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 +41,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/hooks/useEuuidFromHttpHeader.test.ts b/packages/manager/src/hooks/useEuuidFromHttpHeader.test.ts new file mode 100644 index 00000000000..eff361103e4 --- /dev/null +++ b/packages/manager/src/hooks/useEuuidFromHttpHeader.test.ts @@ -0,0 +1,55 @@ +import { renderHook, waitFor } from '@testing-library/react'; + +import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { wrapWithTheme } from 'src/utilities/testHelpers'; + +import { useEuuidFromHttpHeader } from './useEuuidFromHttpHeader'; + +describe('useEuuidFromHttpHeader', () => { + it('returns EUUID when the header is included', async () => { + const mockEuuid = 'test-euuid-12345'; + + server.use( + http.get('*/profile', () => { + return new HttpResponse(null, { + headers: { 'X-Customer-Uuid': mockEuuid }, + }); + }) + ); + + const { result } = renderHook(() => useEuuidFromHttpHeader(), { + wrapper: (ui) => wrapWithTheme(ui), + }); + + await waitFor(() => { + expect(result.current.euuid).toBe(mockEuuid); + }); + }); + + it('returns undefined when the header is not included', async () => { + server.use( + http.get('*/profile', () => { + return new HttpResponse(null, { + headers: {}, + }); + }) + ); + + const { result } = renderHook(() => useEuuidFromHttpHeader(), { + wrapper: (ui) => wrapWithTheme(ui), + }); + + await waitFor(() => { + expect(result.current.euuid).toBeUndefined(); + }); + }); + + it('returns undefined when profile is loading', () => { + const { result } = renderHook(() => useEuuidFromHttpHeader(), { + wrapper: (ui) => wrapWithTheme(ui), + }); + + // Before the profile loads, euuid should be undefined + expect(result.current.euuid).toBeUndefined(); + }); +}); diff --git a/packages/manager/src/hooks/useEuuidFromHttpHeader.ts b/packages/manager/src/hooks/useEuuidFromHttpHeader.ts new file mode 100644 index 00000000000..5d066dc31dd --- /dev/null +++ b/packages/manager/src/hooks/useEuuidFromHttpHeader.ts @@ -0,0 +1,16 @@ +import { useProfile } from '@linode/queries'; + +import type { UseQueryResult } from '@tanstack/react-query'; +import type { ProfileWithEuuid } from 'src/request'; + +/** + * Hook to get the customer EUUID (Enterprise UUID) from the profile data. + * The EUUID is injected by the injectEuuidToProfile interceptor from the + * X-Customer-Uuid header. + * + * NOTE: this won't work locally (only staging and prod return this header) + */ +export const useEuuidFromHttpHeader = () => ({ + euuid: (useProfile() as UseQueryResult).data + ?._euuidFromHttpHeader, +}); diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 54bce1d7e1b..6710414e142 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -716,7 +716,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..c3f847cbd60 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,34 @@ describe('injectAkamaiAccountHeader', () => { ); }); }); + +describe('injectEuuidToProfile', () => { + const profile = profileFactory.build(); + const response: AxiosResponse = { + data: profile, + status: 200, + statusText: 'OK', + 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); + expect(results.data).toHaveProperty('_euuidFromHttpHeader', '1234'); + const { _euuidFromHttpHeader, ...originalData } = results.data; + expect(originalData).toEqual(profile); + }); + + it('returns the original profile data if no header is present', () => { + const responseWithNoHeaders: AxiosResponse = { ...response, headers: {} }; + expect(injectEuuidToProfile(responseWithNoHeaders).data).toEqual(profile); + }); + + it("doesn't inject the euuid on other endpoints", () => { + const accountResponse: AxiosResponse = { + ...response, + config: { ...response.config, url: '/account' }, + }; + expect(injectEuuidToProfile(accountResponse).data).toEqual(profile); + }); +}); diff --git a/packages/manager/src/request.tsx b/packages/manager/src/request.tsx index 54e65a3280d..247c3fc4f64 100644 --- a/packages/manager/src/request.tsx +++ b/packages/manager/src/request.tsx @@ -102,6 +102,14 @@ 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 const injectAkamaiAccountHeader = ( response: AxiosResponse ): AxiosResponse => { @@ -133,6 +141,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 +212,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; }