Skip to content
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-13229-added-1767110711119.md
Original file line number Diff line number Diff line change
@@ -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))
9 changes: 8 additions & 1 deletion packages/manager/src/hooks/useAdobeAnalytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(() => {
Expand All @@ -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
Expand Down
55 changes: 55 additions & 0 deletions packages/manager/src/hooks/useEuuidFromHttpHeader.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
16 changes: 16 additions & 0 deletions packages/manager/src/hooks/useEuuidFromHttpHeader.ts
Original file line number Diff line number Diff line change
@@ -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<ProfileWithEuuid>).data
?._euuidFromHttpHeader,
});
6 changes: 5 additions & 1 deletion packages/manager/src/mocks/serverHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
38 changes: 37 additions & 1 deletion packages/manager/src/request.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
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';

Expand Down Expand Up @@ -106,3 +111,34 @@
);
});
});

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;

Check warning on line 128 in packages/manager/src/request.test.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 '_euuidFromHttpHeader' is assigned a value but never used. Raw Output: {"ruleId":"no-unused-vars","severity":1,"message":"'_euuidFromHttpHeader' is assigned a value but never used.","line":128,"column":13,"nodeType":"Identifier","messageId":"unusedVar","endLine":128,"endColumn":33,"suggestions":[{"messageId":"removeVar","data":{"varName":"_euuidFromHttpHeader"},"fix":{"range":[3802,3823],"text":""},"desc":"Remove unused variable '_euuidFromHttpHeader'."}]}
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);
});
});
39 changes: 39 additions & 0 deletions packages/manager/src/request.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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);
};
1 change: 1 addition & 0 deletions packages/manager/src/utilities/analytics/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type DTMSatellite = {
};

interface PageViewPayload {
euuid?: string;
url: string;
}

Expand Down