From f6a73aa7bc96bde227fbc997025873ecb3c0a0af Mon Sep 17 00:00:00 2001 From: Tanushree Bhattacharji Date: Mon, 29 Dec 2025 20:15:03 +0530 Subject: [PATCH 1/6] feat: [UIE-9805] - Extract EUUID from /profile header and send to Adobe analytics --- .../manager/src/hooks/useAdobeAnalytics.ts | 8 +++- packages/manager/src/request.test.tsx | 6 ++- packages/manager/src/request.tsx | 35 ++++++++++++++++ .../manager/src/utilities/analytics/types.ts | 15 ++++++- .../manager/src/utilities/analytics/utils.ts | 42 +++++++++++++++++++ 5 files changed, 103 insertions(+), 3 deletions(-) diff --git a/packages/manager/src/hooks/useAdobeAnalytics.ts b/packages/manager/src/hooks/useAdobeAnalytics.ts index 30cbe4fc320..b4b401ff883 100644 --- a/packages/manager/src/hooks/useAdobeAnalytics.ts +++ b/packages/manager/src/hooks/useAdobeAnalytics.ts @@ -4,6 +4,7 @@ import React from 'react'; import { ADOBE_ANALYTICS_URL } from 'src/constants'; import { reportException } from 'src/exceptionReporting'; +import { getStoredCustomerUuid } from 'src/utilities/analytics/utils'; /** * Initializes our Adobe Analytics script on mount and subscribes to page view events. @@ -24,8 +25,10 @@ export const useAdobeAnalytics = () => { } // Fire the first page view for the landing page + const euuid = getStoredCustomerUuid(); window._satellite.track('page view', { url: window.location.pathname, + ...(euuid && { euuid }), }); }) .catch(() => { @@ -36,11 +39,14 @@ export const useAdobeAnalytics = () => { React.useEffect(() => { /** - * Send pageviews when location changes + * Send pageviews when location changes. + * Includes EUUID (Enterprise UUID) if available from authenticated API responses. */ if (window._satellite) { + const euuid = getStoredCustomerUuid(); window._satellite.track('page view', { url: location.pathname, + ...(euuid && { euuid }), }); } }, [location.pathname]); // Listen to location changes diff --git a/packages/manager/src/request.test.tsx b/packages/manager/src/request.test.tsx index c92d8a0df8c..f63a2dac264 100644 --- a/packages/manager/src/request.test.tsx +++ b/packages/manager/src/request.test.tsx @@ -2,7 +2,11 @@ import { profileFactory } from '@linode/utilities'; import { AxiosHeaders } from 'axios'; import { setAuthDataInLocalStorage } from './OAuth/oauth'; -import { getURL, handleError, injectAkamaiAccountHeader } from './request'; +import { + getURL, + handleError, + injectAkamaiAccountHeader, +} from './request'; import { storeFactory } from './store'; import { storage } from './utilities/storage'; diff --git a/packages/manager/src/request.tsx b/packages/manager/src/request.tsx index 54e65a3280d..e0203764731 100644 --- a/packages/manager/src/request.tsx +++ b/packages/manager/src/request.tsx @@ -3,6 +3,7 @@ import { AxiosHeaders } from 'axios'; import { ACCESS_TOKEN, API_ROOT, DEFAULT_ERROR_MESSAGE } from 'src/constants'; import { setErrors } from 'src/store/globalErrors/globalErrors.actions'; +import { sendCustomerUuidEvent } from 'src/utilities/analytics/utils'; import { clearAuthDataFromLocalStorage, redirectToLogin } from './OAuth/oauth'; import { getEnvLocalStorageOverrides, storage } from './utilities/storage'; @@ -133,6 +134,35 @@ export const isSuccessfulGETProfileResponse = ( ); }; +/** + * Flag to ensure we only send the EUUID to Adobe Analytics once per session. + * The EUUID doesn't change during a session, so we only need to track it once. + */ +let hasTrackedCustomerUuid = false; + +/** + * Extracts the X-Customer-Uuid header from API responses and sends it to Adobe Analytics. + * This header contains the EUUID (Enterprise UUID) which identifies customers, + * including those with restricted billing access who may not have access to account info. + * + * The EUUID is only tracked once per session to avoid duplicate analytics events. + */ +export const extractAndTrackCustomerUuid = ( + response: AxiosResponse +): AxiosResponse => { + const customerUuidHeader = 'x-customer-uuid'; + + if (!hasTrackedCustomerUuid && customerUuidHeader in response.headers) { + const euuid = response.headers[customerUuidHeader]; + if (euuid && typeof euuid === 'string') { + sendCustomerUuidEvent(euuid); + hasTrackedCustomerUuid = true; + } + } + + return response; +}; + export const setupInterceptors = (store: ApplicationStore) => { baseRequest.interceptors.request.use(async (config) => { if ( @@ -176,4 +206,9 @@ export const setupInterceptors = (store: ApplicationStore) => { ); baseRequest.interceptors.response.use(injectAkamaiAccountHeader); + + // Extract the EUUID (Enterprise UUID) from the X-Customer-Uuid header + // and send it to Adobe Analytics. This header is returned on authenticated + // API requests and identifies customers, including those with restricted billing access. + baseRequest.interceptors.response.use(extractAndTrackCustomerUuid); }; diff --git a/packages/manager/src/utilities/analytics/types.ts b/packages/manager/src/utilities/analytics/types.ts index c4d635f122e..909ab3dc97b 100644 --- a/packages/manager/src/utilities/analytics/types.ts +++ b/packages/manager/src/utilities/analytics/types.ts @@ -10,14 +10,27 @@ declare global { type DTMSatellite = { track: ( eventName: string, - eventPayload: AnalyticsPayload | FormPayload | PageViewPayload + eventPayload: + | AnalyticsPayload + | CustomerUuidPayload + | FormPayload + | PageViewPayload ) => void; }; interface PageViewPayload { + euuid?: string; url: string; } +/** + * Payload for the 'setCustomerUUID' event sent to Adobe Analytics. + * Contains the EUUID (Enterprise UUID) extracted from the X-Customer-Uuid HTTP header. + */ +export interface CustomerUuidPayload { + euuid: string; +} + export interface CustomAnalyticsData { /** * Whether the Linode was powered before before being cloned. diff --git a/packages/manager/src/utilities/analytics/utils.ts b/packages/manager/src/utilities/analytics/utils.ts index 14a9ee8dd41..b0d42d9c858 100644 --- a/packages/manager/src/utilities/analytics/utils.ts +++ b/packages/manager/src/utilities/analytics/utils.ts @@ -161,3 +161,45 @@ export const getFormattedStringFromFormEventOptions = ({ subheaderName ? `:${subheaderName}` : '' }|${interaction}:${label}${trackOnce ? ':once' : ''}`; }; + +/** + * Module-level storage for the EUUID. + * Set by the API response interceptor when X-Customer-Uuid header is present. + */ +let storedCustomerUuid: null | string = null; + +/** + * Stores the EUUID for use in page view events. + * Called by the API response interceptor. + */ +export const setStoredCustomerUuid = (euuid: string): void => { + storedCustomerUuid = euuid; +}; + +/** + * Retrieves the stored EUUID for inclusion in page view events. + * Returns null if EUUID hasn't been captured yet. + */ +export const getStoredCustomerUuid = (): null | string => { + return storedCustomerUuid; +}; + +/** + * Sends the EUUID (Enterprise UUID) to Adobe Analytics via a direct call rule. + * This is called from the API response interceptor when the X-Customer-Uuid header is present. + * The EUUID is used to identify customers, including those with restricted billing access. + * + * @param euuid - The Enterprise UUID from the X-Customer-Uuid HTTP header + */ +export const sendCustomerUuidEvent = (euuid: string): void => { + // Store the EUUID for inclusion in page view events + setStoredCustomerUuid(euuid); + + if (!ADOBE_ANALYTICS_URL) { + return; + } + + if (window._satellite) { + window._satellite.track('setCustomerUUID', { euuid }); + } +}; From ce9cfa2fab5f9a95cbfdb87421697cf0e97ed6e7 Mon Sep 17 00:00:00 2001 From: Tanushree Bhattacharji Date: Mon, 29 Dec 2025 22:17:19 +0530 Subject: [PATCH 2/6] Added mock data for validating EUUID extraction in local. --- packages/manager/src/mocks/serverHandlers.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 }) => { From 1f715e96b3dd578b9ca6b5d1fec9218d7f754aa6 Mon Sep 17 00:00:00 2001 From: Tanushree Bhattacharji Date: Tue, 30 Dec 2025 21:35:11 +0530 Subject: [PATCH 3/6] Added changeset: Extract EUUID from authenticated API calls at the interceptor level and share it with Adobe analytics through the page view event --- packages/manager/.changeset/pr-13229-added-1767110711119.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/manager/.changeset/pr-13229-added-1767110711119.md 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)) From 4524e2ad8ff3b8a418e4833bc35d7d7d0fea6c8d Mon Sep 17 00:00:00 2001 From: Tanushree Bhattacharji Date: Wed, 31 Dec 2025 18:17:30 +0530 Subject: [PATCH 4/6] Address review comments --- .../manager/src/hooks/useAdobeAnalytics.ts | 15 +++-- packages/manager/src/request.test.tsx | 31 ++++++++++ packages/manager/src/request.tsx | 58 +++++++++++-------- .../manager/src/utilities/analytics/types.ts | 14 +---- .../manager/src/utilities/analytics/utils.ts | 42 -------------- 5 files changed, 76 insertions(+), 84 deletions(-) diff --git a/packages/manager/src/hooks/useAdobeAnalytics.ts b/packages/manager/src/hooks/useAdobeAnalytics.ts index b4b401ff883..424e2adebd3 100644 --- a/packages/manager/src/hooks/useAdobeAnalytics.ts +++ b/packages/manager/src/hooks/useAdobeAnalytics.ts @@ -1,19 +1,28 @@ +import { useProfile } from '@linode/queries'; import { loadScript } from '@linode/utilities'; import { useLocation } from '@tanstack/react-router'; import React from 'react'; import { ADOBE_ANALYTICS_URL } from 'src/constants'; import { reportException } from 'src/exceptionReporting'; -import { getStoredCustomerUuid } from 'src/utilities/analytics/utils'; + +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) => { @@ -25,7 +34,6 @@ export const useAdobeAnalytics = () => { } // Fire the first page view for the landing page - const euuid = getStoredCustomerUuid(); window._satellite.track('page view', { url: window.location.pathname, ...(euuid && { euuid }), @@ -40,10 +48,9 @@ export const useAdobeAnalytics = () => { React.useEffect(() => { /** * Send pageviews when location changes. - * Includes EUUID (Enterprise UUID) if available from authenticated API responses. + * Includes EUUID (Enterprise UUID) if available from the profile response. */ if (window._satellite) { - const euuid = getStoredCustomerUuid(); window._satellite.track('page view', { url: location.pathname, ...(euuid && { euuid }), diff --git a/packages/manager/src/request.test.tsx b/packages/manager/src/request.test.tsx index f63a2dac264..648bae878ea 100644 --- a/packages/manager/src/request.test.tsx +++ b/packages/manager/src/request.test.tsx @@ -6,6 +6,7 @@ import { getURL, handleError, injectAkamaiAccountHeader, + injectEuuidToProfile, } from './request'; import { storeFactory } from './store'; import { storage } from './utilities/storage'; @@ -110,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 e0203764731..a1834f62ae1 100644 --- a/packages/manager/src/request.tsx +++ b/packages/manager/src/request.tsx @@ -3,7 +3,6 @@ import { AxiosHeaders } from 'axios'; import { ACCESS_TOKEN, API_ROOT, DEFAULT_ERROR_MESSAGE } from 'src/constants'; import { setErrors } from 'src/store/globalErrors/globalErrors.actions'; -import { sendCustomerUuidEvent } from 'src/utilities/analytics/utils'; import { clearAuthDataFromLocalStorage, redirectToLogin } from './OAuth/oauth'; import { getEnvLocalStorageOverrides, storage } from './utilities/storage'; @@ -103,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 => { @@ -135,31 +146,30 @@ export const isSuccessfulGETProfileResponse = ( }; /** - * Flag to ensure we only send the EUUID to Adobe Analytics once per session. - * The EUUID doesn't change during a session, so we only need to track it once. - */ -let hasTrackedCustomerUuid = false; - -/** - * Extracts the X-Customer-Uuid header from API responses and sends it to Adobe Analytics. - * This header contains the EUUID (Enterprise UUID) which identifies customers, - * including those with restricted billing access who may not have access to account info. - * - * The EUUID is only tracked once per session to avoid duplicate analytics events. + * 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 extractAndTrackCustomerUuid = ( +export const injectEuuidToProfile = ( response: AxiosResponse ): AxiosResponse => { - const customerUuidHeader = 'x-customer-uuid'; - - if (!hasTrackedCustomerUuid && customerUuidHeader in response.headers) { - const euuid = response.headers[customerUuidHeader]; - if (euuid && typeof euuid === 'string') { - sendCustomerUuidEvent(euuid); - hasTrackedCustomerUuid = true; + 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; }; @@ -207,8 +217,6 @@ export const setupInterceptors = (store: ApplicationStore) => { baseRequest.interceptors.response.use(injectAkamaiAccountHeader); - // Extract the EUUID (Enterprise UUID) from the X-Customer-Uuid header - // and send it to Adobe Analytics. This header is returned on authenticated - // API requests and identifies customers, including those with restricted billing access. - baseRequest.interceptors.response.use(extractAndTrackCustomerUuid); + // 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 909ab3dc97b..7aa13b56a47 100644 --- a/packages/manager/src/utilities/analytics/types.ts +++ b/packages/manager/src/utilities/analytics/types.ts @@ -10,11 +10,7 @@ declare global { type DTMSatellite = { track: ( eventName: string, - eventPayload: - | AnalyticsPayload - | CustomerUuidPayload - | FormPayload - | PageViewPayload + eventPayload: AnalyticsPayload | FormPayload | PageViewPayload ) => void; }; @@ -23,14 +19,6 @@ interface PageViewPayload { url: string; } -/** - * Payload for the 'setCustomerUUID' event sent to Adobe Analytics. - * Contains the EUUID (Enterprise UUID) extracted from the X-Customer-Uuid HTTP header. - */ -export interface CustomerUuidPayload { - euuid: string; -} - export interface CustomAnalyticsData { /** * Whether the Linode was powered before before being cloned. diff --git a/packages/manager/src/utilities/analytics/utils.ts b/packages/manager/src/utilities/analytics/utils.ts index b0d42d9c858..14a9ee8dd41 100644 --- a/packages/manager/src/utilities/analytics/utils.ts +++ b/packages/manager/src/utilities/analytics/utils.ts @@ -161,45 +161,3 @@ export const getFormattedStringFromFormEventOptions = ({ subheaderName ? `:${subheaderName}` : '' }|${interaction}:${label}${trackOnce ? ':once' : ''}`; }; - -/** - * Module-level storage for the EUUID. - * Set by the API response interceptor when X-Customer-Uuid header is present. - */ -let storedCustomerUuid: null | string = null; - -/** - * Stores the EUUID for use in page view events. - * Called by the API response interceptor. - */ -export const setStoredCustomerUuid = (euuid: string): void => { - storedCustomerUuid = euuid; -}; - -/** - * Retrieves the stored EUUID for inclusion in page view events. - * Returns null if EUUID hasn't been captured yet. - */ -export const getStoredCustomerUuid = (): null | string => { - return storedCustomerUuid; -}; - -/** - * Sends the EUUID (Enterprise UUID) to Adobe Analytics via a direct call rule. - * This is called from the API response interceptor when the X-Customer-Uuid header is present. - * The EUUID is used to identify customers, including those with restricted billing access. - * - * @param euuid - The Enterprise UUID from the X-Customer-Uuid HTTP header - */ -export const sendCustomerUuidEvent = (euuid: string): void => { - // Store the EUUID for inclusion in page view events - setStoredCustomerUuid(euuid); - - if (!ADOBE_ANALYTICS_URL) { - return; - } - - if (window._satellite) { - window._satellite.track('setCustomerUUID', { euuid }); - } -}; From 8f9a6fae1dee249b50a196693efd2ad3f3ad9abd Mon Sep 17 00:00:00 2001 From: Tanushree Bhattacharji Date: Mon, 5 Jan 2026 18:42:08 +0530 Subject: [PATCH 5/6] Address review comments by Alban. --- packages/api-v4/src/profile/types.ts | 1 + .../manager/src/hooks/useAdobeAnalytics.ts | 10 ++------- packages/manager/src/request.test.tsx | 22 ++++++++++--------- packages/manager/src/request.tsx | 16 ++------------ 4 files changed, 17 insertions(+), 32 deletions(-) diff --git a/packages/api-v4/src/profile/types.ts b/packages/api-v4/src/profile/types.ts index ecda9295fb4..52bf5d7b2fa 100644 --- a/packages/api-v4/src/profile/types.ts +++ b/packages/api-v4/src/profile/types.ts @@ -16,6 +16,7 @@ export interface Profile { authorized_keys: null | string[]; email: string; email_notifications: boolean; + euuidFromHttpHeader?: string; ip_whitelist_enabled: boolean; lish_auth_method: 'disabled' | 'keys_only' | 'password_keys'; referrals: Referrals; diff --git a/packages/manager/src/hooks/useAdobeAnalytics.ts b/packages/manager/src/hooks/useAdobeAnalytics.ts index 424e2adebd3..742e2fd85ef 100644 --- a/packages/manager/src/hooks/useAdobeAnalytics.ts +++ b/packages/manager/src/hooks/useAdobeAnalytics.ts @@ -6,23 +6,17 @@ 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; + const { data: profile } = useProfile(); + 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) => { diff --git a/packages/manager/src/request.test.tsx b/packages/manager/src/request.test.tsx index 648bae878ea..fcd613a56d5 100644 --- a/packages/manager/src/request.test.tsx +++ b/packages/manager/src/request.test.tsx @@ -114,30 +114,32 @@ describe('injectAkamaiAccountHeader', () => { describe('injectEuuidToProfile', () => { const profile = profileFactory.build(); - const response: Partial = { + 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 as any); - expect(results.data).toHaveProperty('_euuidFromHttpHeader', '1234'); + const results = injectEuuidToProfile(response); + expect(results.data).toHaveProperty('euuidFromHttpHeader', '1234'); // eslint-disable-next-line - const { _euuidFromHttpHeader, ...originalData } = results.data; + 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 - ); + const responseWithNoHeaders: AxiosResponse = { ...response, headers: {} }; + expect(injectEuuidToProfile(responseWithNoHeaders).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); + 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 a1834f62ae1..fb6dc06b7c5 100644 --- a/packages/manager/src/request.tsx +++ b/packages/manager/src/request.tsx @@ -102,18 +102,6 @@ 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 => { @@ -159,9 +147,9 @@ export const injectEuuidToProfile = ( 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 = { + const profileWithEuuid: Profile = { ...response.data, - _euuidFromHttpHeader: xCustomerUuidHeader, + euuidFromHttpHeader: xCustomerUuidHeader, }; return { From 2d7d93e54920132439f722c23120a6717bffa108 Mon Sep 17 00:00:00 2001 From: Tanushree Bhattacharji Date: Mon, 5 Jan 2026 21:15:33 +0530 Subject: [PATCH 6/6] Use Extended Profile type for euuid alongwith a custom hook for type assertion. --- packages/api-v4/src/profile/types.ts | 1 - .../manager/src/hooks/useAdobeAnalytics.ts | 6 +- .../src/hooks/useEuuidFromHttpHeader.test.ts | 55 +++++++++++++++++++ .../src/hooks/useEuuidFromHttpHeader.ts | 16 ++++++ packages/manager/src/request.test.tsx | 5 +- packages/manager/src/request.tsx | 12 +++- 6 files changed, 86 insertions(+), 9 deletions(-) create mode 100644 packages/manager/src/hooks/useEuuidFromHttpHeader.test.ts create mode 100644 packages/manager/src/hooks/useEuuidFromHttpHeader.ts diff --git a/packages/api-v4/src/profile/types.ts b/packages/api-v4/src/profile/types.ts index 52bf5d7b2fa..ecda9295fb4 100644 --- a/packages/api-v4/src/profile/types.ts +++ b/packages/api-v4/src/profile/types.ts @@ -16,7 +16,6 @@ export interface Profile { authorized_keys: null | string[]; email: string; email_notifications: boolean; - euuidFromHttpHeader?: string; ip_whitelist_enabled: boolean; lish_auth_method: 'disabled' | 'keys_only' | 'password_keys'; referrals: Referrals; diff --git a/packages/manager/src/hooks/useAdobeAnalytics.ts b/packages/manager/src/hooks/useAdobeAnalytics.ts index 742e2fd85ef..2ed49184f36 100644 --- a/packages/manager/src/hooks/useAdobeAnalytics.ts +++ b/packages/manager/src/hooks/useAdobeAnalytics.ts @@ -1,4 +1,3 @@ -import { useProfile } from '@linode/queries'; import { loadScript } from '@linode/utilities'; import { useLocation } from '@tanstack/react-router'; import React from 'react'; @@ -6,14 +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 { data: profile } = useProfile(); - const euuid = profile?.euuidFromHttpHeader; + const { euuid } = useEuuidFromHttpHeader(); React.useEffect(() => { // Load Adobe Analytics Launch Script 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/request.test.tsx b/packages/manager/src/request.test.tsx index fcd613a56d5..c3f847cbd60 100644 --- a/packages/manager/src/request.test.tsx +++ b/packages/manager/src/request.test.tsx @@ -124,9 +124,8 @@ describe('injectEuuidToProfile', () => { it('injects the euuid on successful GET profile response ', () => { const results = injectEuuidToProfile(response); - expect(results.data).toHaveProperty('euuidFromHttpHeader', '1234'); - // eslint-disable-next-line - const { euuidFromHttpHeader, ...originalData } = results.data; + expect(results.data).toHaveProperty('_euuidFromHttpHeader', '1234'); + const { _euuidFromHttpHeader, ...originalData } = results.data; expect(originalData).toEqual(profile); }); diff --git a/packages/manager/src/request.tsx b/packages/manager/src/request.tsx index fb6dc06b7c5..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 => { @@ -147,9 +155,9 @@ export const injectEuuidToProfile = ( const xCustomerUuidHeader = response.headers['x-customer-uuid']; // NOTE: this won't work locally (only staging and prod allow this header) if (xCustomerUuidHeader) { - const profileWithEuuid: Profile = { + const profileWithEuuid: ProfileWithEuuid = { ...response.data, - euuidFromHttpHeader: xCustomerUuidHeader, + _euuidFromHttpHeader: xCustomerUuidHeader, }; return {