diff --git a/docs/tooling/analytics.md b/docs/tooling/analytics.md index 3cd5f883d7b..ea48182cfe4 100644 --- a/docs/tooling/analytics.md +++ b/docs/tooling/analytics.md @@ -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 diff --git a/packages/manager/.changeset/pr-12203-changed-1747094831129.md b/packages/manager/.changeset/pr-12203-changed-1747094831129.md new file mode 100644 index 00000000000..22782cff002 --- /dev/null +++ b/packages/manager/.changeset/pr-12203-changed-1747094831129.md @@ -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)) diff --git a/packages/manager/.changeset/pr-12203-fixed-1747094899948.md b/packages/manager/.changeset/pr-12203-fixed-1747094899948.md new file mode 100644 index 00000000000..75f893b6cad --- /dev/null +++ b/packages/manager/.changeset/pr-12203-fixed-1747094899948.md @@ -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)) diff --git a/packages/manager/.changeset/pr-12203-fixed-1747094966781.md b/packages/manager/.changeset/pr-12203-fixed-1747094966781.md new file mode 100644 index 00000000000..f8707444078 --- /dev/null +++ b/packages/manager/.changeset/pr-12203-fixed-1747094966781.md @@ -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)) diff --git a/packages/manager/cypress/e2e/core/general/analytics.spec.ts b/packages/manager/cypress/e2e/core/general/analytics.spec.ts index c20555a720b..3ac17080cc3 100644 --- a/packages/manager/cypress/e2e/core/general/analytics.spec.ts +++ b/packages/manager/cypress/e2e/core/general/analytics.spec.ts @@ -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', () => { diff --git a/packages/manager/src/hooks/useAdobeAnalytics.ts b/packages/manager/src/hooks/useAdobeAnalytics.ts index d85391276da..666e1483b72 100644 --- a/packages/manager/src/hooks/useAdobeAnalytics.ts +++ b/packages/manager/src/hooks/useAdobeAnalytics.ts @@ -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, + }); }) .catch(() => { // Do nothing; a user may have analytics script requests blocked. diff --git a/packages/manager/src/hooks/usePendo.ts b/packages/manager/src/hooks/usePendo.ts index 6c8619d431a..08dde8ca39c 100644 --- a/packages/manager/src/hooks/usePendo.ts +++ b/packages/manager/src/hooks/usePendo.ts @@ -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 { @@ -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) { + 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 @@ -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]); }; diff --git a/packages/manager/src/utilities/analytics/utils.test.ts b/packages/manager/src/utilities/analytics/utils.test.ts index d2ec1e9b138..b606c03d238 100644 --- a/packages/manager/src/utilities/analytics/utils.test.ts +++ b/packages/manager/src/utilities/analytics/utils.test.ts @@ -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'); diff --git a/packages/manager/src/utilities/analytics/utils.ts b/packages/manager/src/utilities/analytics/utils.ts index 14a9ee8dd41..2f6b66f92a3 100644 --- a/packages/manager/src/utilities/analytics/utils.ts +++ b/packages/manager/src/utilities/analytics/utils.ts @@ -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. diff --git a/packages/utilities/src/hooks/useScript.ts b/packages/utilities/src/hooks/useScript.ts index 839c577854e..35069417dd0 100644 --- a/packages/utilities/src/hooks/useScript.ts +++ b/packages/utilities/src/hooks/useScript.ts @@ -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