Skip to content

change: [M3-9347] - Switch to self hosting Pendo agent in Adobe Launch #12203

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

15 changes: 8 additions & 7 deletions docs/tooling/analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,20 @@ Pendo is configured in [`usePendo.js`](https://github.com/linode/manager/blob/de

Important notes:

- Pendo is only loaded if the user has enabled Performance Cookies via OneTrust *and* if a valid `PENDO_API_KEY` is configured as an environment variable. In our development, staging, and production environments, `PENDO_API_KEY` is available at build time. See **Locally Testing Page Views & Custom Events and/or Troubleshooting Pendo** for set up with local environments.
- We load the Pendo agent from the CDN, rather than [self-hosting](https://support.pendo.io/hc/en-us/articles/360038969692-Self-hosting-the-Pendo-agent), and we have configured a [CNAME](https://support.pendo.io/hc/en-us/articles/360043539891-CNAME-for-Pendo).
- Pendo is only loaded if a valid `PENDO_API_KEY` is configured as an environment variable. In our preview, development, staging, and production environments, `PENDO_API_KEY` is available at build time. See **Locally Testing Page Views & Custom Events and/or Troubleshooting Pendo** for set up with local environments.
- We [self-host](https://support.pendo.io/hc/en-us/articles/360038969692-Self-hosting-the-Pendo-agent) and load the Pendo agent from Adobe Launch, rather than from the CDN, and we have configured a [CNAME](https://support.pendo.io/hc/en-us/articles/360043539891-CNAME-for-Pendo).
- As configured by Adobe Launch, Pendo will respect OneTrust cookie preferences in development, staging, and production environments and does not check cookie preferences in preview environments. Pendo will not run on localhost:3000 because it needs a Optanon cookie with the linode.com domain for consent.
- At initialization, we do string transformation on select URL patterns to **remove sensitive data**. When new URL patterns are added to Cloud Manager, verify that existing transforms remove sensitive data; if not, update the transforms.
- Pendo will respect OneTrust cookie preferences in development, staging, and production environments and does not check cookie preferences in the local environment.
- Pendo makes use of the existing `data-testid` properties, used in our automated testing, for tagging elements. They are more persistent and reliable than CSS properties, which are liable to change.

### Locally Testing Page Views & Custom Events and/or Troubleshooting Pendo

1. Set the `REACT_APP_PENDO_API_KEY` environment variable in `.env`.
2. Use the browser tools Network tab, filter requests by "psp.cloud", and check that successful network requests have been made to load Pendo scripts (also visible in the browser tools Sources tab).
3. In the browser console, type `pendo.validateEnvironment()`.
4. You should see command output in the console, and it should include an `accountId` and a `visitorId` that correspond with your APIv4 account `euuid` and profile `uid`, respectively. Each page view change or custom event that fires should be visible as a request in the Network tab.
5. If the console does not output the expected ids and instead outputs something like `Cookies are disabled in Pendo config. Is this expected?` in response to the above command, clear app storage with the browser tools. Once redirected back to Login, update the OneTrust cookie settings to enable cookies via "Manage Preferences" in the banner at the bottom of the screen. Log back into Cloud Manager and Pendo should load.
2. Confirm the Adobe Launch script has loaded. (View it in the browser console Sources tab under the assets.adobedtm.com directory.)
3. Use the browser tools Network tab, filter requests by "psp.cloud", and check that successful network requests have been made to load Pendo scripts (also visible in the browser tools Sources tab).
4. In the browser console, type `pendo.validateEnvironment()`.
5. You should see command output in the console, and it should include an `accountId` and a `visitorId` that correspond with your APIv4 account `euuid` and profile `uid`, respectively. Each page view change or custom event that fires should be visible as a request in the Network tab.
6. If the console does not output the expected ids and instead outputs something like `Cookies are disabled in Pendo config. Is this expected?` in response to the above command, clear app storage with the browser tools. Once redirected back to Login, update the OneTrust cookie settings to enable cookies via "Manage Preferences" in the banner at the bottom of the screen. Log back into Cloud Manager and Pendo should load.

## Adobe Analytics

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Changed
---

Switch to self-hosting the Pendo agent with Adobe Launch ([#12203](https://github.com/linode/manager/pull/12203))
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-12203-fixed-1747094899948.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Fixed
---

Bug in loadScript function not resolving promise if script already existed ([#12203](https://github.com/linode/manager/pull/12203))
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-12203-fixed-1747094966781.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Fixed
---

Bug where first pageview of landing page was not fired in Adobe Analytics ([#12203](https://github.com/linode/manager/pull/12203))
5 changes: 2 additions & 3 deletions packages/manager/cypress/e2e/core/general/analytics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@ import { ui } from 'support/ui';

const ADOBE_LAUNCH_URLS = [
'https://assets.adobedtm.com/fcfd3580c848/15e23aa7fce2/launch-92311d9d9637-development.min.js', // New dev Launch script
'https://assets.adobedtm.com/fcfd3580c848/795fdfec4a0e/launch-09b7ca9d43ad-development.min.js', // Existing dev Launch script
'https://assets.adobedtm.com/fcfd3580c848/795fdfec4a0e/launch-a50be9afbe1d-staging.min.js', // Existing staging Launch script
'https://assets.adobedtm.com/fcfd3580c848/795fdfec4a0e/launch-de0ca78667e7.min.js', // Existing prod Launch script
'https://assets.adobedtm.com/fcfd3580c848/15e23aa7fce2/launch-5bda4b7a1db9-staging.min.js', // New staging Launch script
'https://assets.adobedtm.com/fcfd3580c848/15e23aa7fce2/launch-9ea21650035a.min.js', // New prod Launch script
];

describe('Script loading and user interaction test', () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/manager/src/hooks/useAdobeAnalytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ export const useAdobeAnalytics = () => {
'Adobe Analytics error: Not all Adobe Launch scripts and extensions were loaded correctly; analytics cannot be sent.'
);
}

// Fire the first page view for the landing page
window._satellite.track('page view', {
url: window.location.pathname,
});
Comment on lines +33 to +36
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm curious as to why the first page view was not being sent through the useEffect below this one.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hey, good question. history.listen only fires when the location changes, and when the user first loads the app, the location hasn't changed.

})
.catch(() => {
// Do nothing; a user may have analytics script requests blocked.
Expand Down
120 changes: 55 additions & 65 deletions packages/manager/src/hooks/usePendo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,8 @@ import { useAccount, useProfile } from '@linode/queries';
import { loadScript } from '@linode/utilities'; // `loadScript` from `useScript` hook
import React from 'react';

import { APP_ROOT, PENDO_API_KEY } from 'src/constants';
import {
checkOptanonConsent,
getCookie,
ONE_TRUST_COOKIE_CATEGORIES,
} from 'src/utilities/analytics/utils';
import { ADOBE_ANALYTICS_URL, APP_ROOT, PENDO_API_KEY } from 'src/constants';
import { reportException } from 'src/exceptionReporting';

declare global {
interface Window {
Expand Down Expand Up @@ -70,21 +66,8 @@ export const usePendo = () => {
const accountId = getUniquePendoId(account?.euuid);
const visitorId = getUniquePendoId(profile?.uid.toString());

const optanonCookie = getCookie('OptanonConsent');
// Since OptanonConsent cookie always has a .linode.com domain, only check for consent in dev/staging/prod envs.
// When running the app locally, do not try to check for OneTrust cookie consent, just enable Pendo.
const hasConsentEnabled =
APP_ROOT.includes('localhost') ||
checkOptanonConsent(
optanonCookie,
ONE_TRUST_COOKIE_CATEGORIES['Performance Cookies']
);

// This URL uses a Pendo-configured CNAME (M3-8742).
const PENDO_URL = `https://content.psp.cloud.linode.com/agent/static/${PENDO_API_KEY}/pendo.js`;

React.useEffect(() => {
if (PENDO_API_KEY && hasConsentEnabled) {
Copy link
Contributor Author

@mjac0bs mjac0bs May 13, 2025

Choose a reason for hiding this comment

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

Here's an explanation of why PR preview links aren't initializing Pendo correctly before this PR's changes:

  1. hasConsentEnabled was our check for the OptanonConsent cookie consent before loading the Pendo script. The preview environment doesn’t have the same domain as the cookie. The hasConsentEnabled check for the cookie only works in dev, staging, and prod environments. The hasConsentEnabled check has been failing this check for preview environments and that’s still the case in develop. This has been fine, because we didn't need Pendo to be configured in preview environments. We could bypass the consent check and confirm Pendo loads in our local environments.

  2. The new Adobe Launch script currently bypasses the consent check for only preview environments (side note: I've asked MADS if we can update this to bypass for local too), but until this PR is merged, our preview environments will still not be initializing Pendo because of 1. This PR got rid of just the consent check and confirmed this - Pendo loaded fine!

if (PENDO_API_KEY && ADOBE_ANALYTICS_URL) {
// Adapted Pendo install script for readability
// Refer to: https://support.pendo.io/hc/en-us/articles/21362607464987-Components-of-the-install-script#01H6S2EXET8C9FGSHP08XZAE4F

Expand Down Expand Up @@ -115,54 +98,61 @@ export const usePendo = () => {
})(methodNames[index]);
});

// Load Pendo script into the head HTML tag, then initialize Pendo with metadata
loadScript(PENDO_URL, {
// Ensure the Adobe Launch script is loaded, then initialize Pendo with metadata
loadScript(ADOBE_ANALYTICS_URL, {
location: 'head',
}).then(() => {
window.pendo.initialize({
account: {
id: accountId, // Highly recommended, required if using Pendo Feedback
// name: // Optional
// is_paying: // Recommended if using Pendo Feedback
// monthly_value:// Recommended if using Pendo Feedback
// planLevel: // Optional
// planPrice: // Optional
// creationDate: // Optional

// You can add any additional account level key-values here,
// as long as it's not one of the above reserved names.
},
// Controls what URLs we send to Pendo. Refer to: https://agent.pendo.io/advanced/location/.
location: {
transforms: [
{
action: 'Clear',
attr: 'hash',
},
{
action: 'Clear',
attr: 'search',
},
{
action: 'Replace',
attr: 'pathname',
data(url: string) {
return transformUrl(url);
try {
window.pendo.initialize({
account: {
id: accountId, // Highly recommended, required if using Pendo Feedback
// name: // Optional
// is_paying: // Recommended if using Pendo Feedback
// monthly_value:// Recommended if using Pendo Feedback
// planLevel: // Optional
// planPrice: // Optional
// creationDate: // Optional

// You can add any additional account level key-values here,
// as long as it's not one of the above reserved names.
},
// Controls what URLs we send to Pendo. Refer to: https://agent.pendo.io/advanced/location/.
location: {
transforms: [
{
action: 'Clear',
attr: 'hash',
},
{
action: 'Clear',
attr: 'search',
},
{
action: 'Replace',
attr: 'pathname',
data(url: string) {
return transformUrl(url);
},
},
},
],
},
visitor: {
id: visitorId, // Required if user is logged in
// email: // Recommended if using Pendo Feedback, or NPS Email
// full_name: // Recommended if using Pendo Feedback
// role: // Optional

// You can add any additional visitor level key-values here,
// as long as it's not one of the above reserved names.
},
});
],
},
visitor: {
id: visitorId, // Required if user is logged in
// email: // Recommended if using Pendo Feedback, or NPS Email
// full_name: // Recommended if using Pendo Feedback
// role: // Optional

// You can add any additional visitor level key-values here,
// as long as it's not one of the above reserved names.
},
});
} catch (error) {
reportException(
'An error occurred when trying to initialize Pendo.',
{ error }
);
}
});
}
}, [PENDO_URL, accountId, hasConsentEnabled, visitorId]);
}, [accountId, visitorId]);
};
74 changes: 0 additions & 74 deletions packages/manager/src/utilities/analytics/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,85 +1,11 @@
import { generateTimeOfDay } from './customEventAnalytics';
import {
checkOptanonConsent,
getCookie,
getFormattedStringFromFormEventOptions,
ONE_TRUST_COOKIE_CATEGORIES,
waitForAdobeAnalyticsToBeLoaded,
} from './utils';

import type { FormEventOptions } from './types';

describe('getCookie', () => {
beforeAll(() => {
const mockCookies =
'mycookie=my-cookie-value; OptanonConsent=cookie-consent-here; mythirdcookie=my-third-cookie;';
vi.spyOn(document, 'cookie', 'get').mockReturnValue(mockCookies);
});

it('should return the value of a cookie from document.cookie given its name, given cookie in middle position', () => {
expect(getCookie('OptanonConsent')).toEqual('cookie-consent-here');
});

it('should return the value of a cookie from document.cookie given its name, given cookie in first position', () => {
expect(getCookie('mycookie')).toEqual('my-cookie-value');
});

it('should return the value of a cookie from document.cookie given its name, given cookie in last position', () => {
expect(getCookie('mythirdcookie')).toEqual('my-third-cookie');
});

it('should return undefined if the cookie does not exist in document.cookie', () => {
expect(getCookie('mysecondcookie')).toEqual(undefined);
});
});

describe('checkOptanonConsent', () => {
it('should return true if consent is enabled for the given Optanon cookie category', () => {
const mockPerformanceCookieConsentEnabled =
'somestuffhere&groups=C0001%3A1%2CC0002%3A1%2CC0003%3A1%2CC0004%3A1%2CC0005%3A1&intType=6';

expect(
checkOptanonConsent(
mockPerformanceCookieConsentEnabled,
ONE_TRUST_COOKIE_CATEGORIES['Performance Cookies']
)
).toEqual(true);
});

it('should return false if consent is disabled for the given Optanon cookie category', () => {
const mockPerformanceCookieConsentDisabled =
'somestuffhere&groups=C0001%3A1%2CC0002%3A0%2CC0003%3A1%2CC0004%3A1%2CC0005%3A1&intType=6';

expect(
checkOptanonConsent(
mockPerformanceCookieConsentDisabled,
ONE_TRUST_COOKIE_CATEGORIES['Performance Cookies']
)
).toEqual(false);
});

it('should return false if the consent category does not exist in the cookie', () => {
const mockNoPerformanceCookieCategory =
'somestuffhere&groups=C0001%3A1%2CC0003%3A1%2CC0004%3A1%2CC0005%3A1&intType=6';

expect(
checkOptanonConsent(
mockNoPerformanceCookieCategory,
ONE_TRUST_COOKIE_CATEGORIES['Performance Cookies']
)
).toEqual(false);
});

it('should return false if the cookie is undefined', () => {
expect(
checkOptanonConsent(
undefined,
ONE_TRUST_COOKIE_CATEGORIES['Performance Cookies']
)
).toEqual(false);
});
});

describe('generateTimeOfDay', () => {
it('should generate human-readable time of day', () => {
expect(generateTimeOfDay(0)).toBe('Early Morning');
Expand Down
54 changes: 0 additions & 54 deletions packages/manager/src/utilities/analytics/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,60 +11,6 @@ import type {
FormStepEvent,
} from './types';

/**
* Based on Login's OneTrust cookie list
*/
export const ONE_TRUST_COOKIE_CATEGORIES = {
'Functional Cookies': 'C0003',
'Performance Cookies': 'C0002', // Analytics cookies fall into this category
'Social Media Cookies': 'C0004',
'Strictly Necessary Cookies': 'C0001',
'Targeting Cookies': 'C0005',
} as const;

/**
* Given the name of a cookie, parses the document.cookie string and returns the cookie's value.
* @param name cookie's name
* @returns value of cookie if it exists in the document; else, undefined
*/
export const getCookie = (name: string) => {
const cookies = document.cookie.split(';');

const selectedCookie = cookies.find(
(cookie) => cookie.trim().startsWith(name + '=') // Trim whitespace so position in cookie string doesn't matter
);

return selectedCookie?.trim().substring(name.length + 1);
};

/**
* This function parses the categories in the OptanonConsent cookie to check if consent is provided.
* @param optanonCookie the OptanonConsent cookie from OneTrust
* @param selectedCategory the category code based on cookie type
* @returns true if the user has consented to cookie enablement for the category; else, false
*/
export const checkOptanonConsent = (
optanonCookie: string | undefined,
selectedCategory: (typeof ONE_TRUST_COOKIE_CATEGORIES)[keyof typeof ONE_TRUST_COOKIE_CATEGORIES]
): boolean => {
const optanonGroups = optanonCookie?.match(/groups=([^&]*)/);

if (!optanonCookie || !optanonGroups) {
return false;
}

// Optanon consent groups will be of the form: "C000[N]:[0/1]".
const unencodedOptanonGroups = decodeURIComponent(optanonGroups[1]).split(
','
);
return unencodedOptanonGroups.some((consentGroup) => {
if (consentGroup.includes(selectedCategory)) {
return Number(consentGroup.split(':')[1]) === 1; // Cookie enabled
}
return false;
});
};

/**
* Sends a direct call rule events to Adobe for a Component Click (and optionally, with `data`, Component Details).
* This should be used for all custom events other than form events, which should use sendFormEvent.
Expand Down
4 changes: 3 additions & 1 deletion packages/utilities/src/hooks/useScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ export const loadScript = (
}
} else {
// Grab existing script status from attribute and set to state.
options?.setStatus?.(script.getAttribute('data-status') as ScriptStatus);
const existingStatus = script.getAttribute('data-status') as ScriptStatus;
options?.setStatus?.(existingStatus);
resolve({ status: existingStatus });
}
// Script event handler to update status in state
// Note: Even if the script already exists we still need to add
Expand Down