Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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))
15 changes: 14 additions & 1 deletion packages/manager/src/hooks/useAdobeAnalytics.ts
Original file line number Diff line number Diff line change
@@ -1,18 +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 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;
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we inject a property to the profile object, then it should be typed accordingly. No casting please

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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why?

if (ADOBE_ANALYTICS_URL) {
loadScript(ADOBE_ANALYTICS_URL, { location: 'head' })
.then((data) => {
Expand All @@ -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(() => {
Expand All @@ -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
Expand Down
6 changes: 5 additions & 1 deletion packages/manager/src/mocks/serverHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
37 changes: 36 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,33 @@
);
});
});

describe('injectEuuidToProfile', () => {
const profile = profileFactory.build();
const response: Partial<AxiosResponse> = {
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);

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

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Unexpected any. Specify a different type. Raw Output: {"ruleId":"@typescript-eslint/no-explicit-any","severity":1,"message":"Unexpected any. Specify a different type.","line":125,"column":54,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":125,"endColumn":57,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[3705,3708],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[3705,3708],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please strengthen your types. No 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(

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

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Unexpected any. Specify a different type. Raw Output: {"ruleId":"@typescript-eslint/no-explicit-any","severity":1,"message":"Unexpected any. Specify a different type.","line":134,"column":58,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":134,"endColumn":61,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[4129,4132],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[4129,4132],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]}
profile
);
});

it("doesn't inject the euuid on other endpoints", () => {
const accountResponse = { ...response, config: { url: '/account' } };
expect(injectEuuidToProfile(accountResponse as any).data).toEqual(profile);

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

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Unexpected any. Specify a different type. Raw Output: {"ruleId":"@typescript-eslint/no-explicit-any","severity":1,"message":"Unexpected any. Specify a different type.","line":141,"column":52,"nodeType":"TSAnyKeyword","messageId":"unexpectedAny","endLine":141,"endColumn":55,"suggestions":[{"messageId":"suggestUnknown","fix":{"range":[4362,4365],"text":"unknown"},"desc":"Use `unknown` instead, this will force you to explicitly, and safely assert the type is correct."},{"messageId":"suggestNever","fix":{"range":[4362,4365],"text":"never"},"desc":"Use `never` instead, this is useful when instantiating generic type parameters that you don't need to know the type of."}]}
});
});
43 changes: 43 additions & 0 deletions packages/manager/src/request.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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);
};
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