Skip to content
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

change: [M3-8390] - Initialize Pendo on Cloud Manager #10982

Merged
merged 15 commits into from
Oct 7, 2024
Merged
Show file tree
Hide file tree
Changes from 14 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Tech Stories
---

Add Pendo to Cloud Manager ([#10982](https://github.com/linode/manager/pull/10982))
3 changes: 3 additions & 0 deletions packages/manager/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ REACT_APP_LKE_HIGH_AVAILABILITY_PRICE='60'
# Adobe Analytics:
# REACT_APP_ADOBE_ANALYTICS_URL=

# Pendo:
# REACT_APP_PENDO_API_KEY=

# Linode Docs search with Algolia:
# REACT_APP_ALGOLIA_APPLICATION_ID=
# REACT_APP_ALGOLIA_SEARCH_KEY=
Expand Down
1 change: 1 addition & 0 deletions packages/manager/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"highlight.js": "~10.4.1",
"immer": "^9.0.6",
"ipaddr.js": "^1.9.1",
"js-sha256": "^0.11.0",
mjac0bs marked this conversation as resolved.
Show resolved Hide resolved
"jspdf": "^2.5.2",
"jspdf-autotable": "^3.5.14",
"launchdarkly-react-client-sdk": "3.0.10",
Expand Down
2 changes: 2 additions & 0 deletions packages/manager/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { GoTo } from './GoTo';
import { useAdobeAnalytics } from './hooks/useAdobeAnalytics';
import { useInitialRequests } from './hooks/useInitialRequests';
import { useNewRelic } from './hooks/useNewRelic';
import { usePendo } from './hooks/usePendo';
import { MainContent } from './MainContent';
import { useEventsPoller } from './queries/events/events';
// import { Router } from './Router';
Expand Down Expand Up @@ -63,6 +64,7 @@ const BaseApp = withDocumentTitleProvider(
const GlobalListeners = () => {
useEventsPoller();
useAdobeAnalytics();
usePendo();
useNewRelic();
return null;
};
5 changes: 5 additions & 0 deletions packages/manager/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,15 @@ export const LONGVIEW_ROOT = 'https://longview.linode.com/fetch';
export const SENTRY_URL = import.meta.env.REACT_APP_SENTRY_URL;
export const LOGIN_SESSION_LIFETIME_MS = 45 * 60 * 1000;
export const OAUTH_TOKEN_REFRESH_TIMEOUT = LOGIN_SESSION_LIFETIME_MS / 2;

/** Adobe Analytics */
export const ADOBE_ANALYTICS_URL = import.meta.env
.REACT_APP_ADOBE_ANALYTICS_URL;
export const NUM_ADOBE_SCRIPTS = 3;

/** Pendo */
export const PENDO_API_KEY = import.meta.env.REACT_APP_PENDO_API_KEY;

/** for hard-coding token used for API Requests. Example: "Bearer 1234" */
export const ACCESS_TOKEN = import.meta.env.REACT_APP_ACCESS_TOKEN;

Expand Down
1 change: 1 addition & 0 deletions packages/manager/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ interface ImportMetaEnv {
REACT_APP_MOCK_SERVICE_WORKER?: string;
REACT_APP_PAYPAL_CLIENT_ID?: string;
REACT_APP_PAYPAL_ENV?: string;
REACT_APP_PENDO_API_KEY?: string;
// TODO: Parent/Child - Remove once we're off mocks.
REACT_APP_PROXY_PAT?: string;
REACT_APP_SENTRY_URL?: string;
Expand Down
136 changes: 136 additions & 0 deletions packages/manager/src/hooks/usePendo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { sha256 } from 'js-sha256';
import React from 'react';

import { APP_ROOT, PENDO_API_KEY } from 'src/constants';
import { useAccount } from 'src/queries/account/account.js';
import { useProfile } from 'src/queries/profile/profile';

import { loadScript } from './useScript';

declare global {
interface Window {
pendo: any;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Unfortunately, Pendo docs recommend typing this as any.

}
}

/**
* This function prevents address ID collisions leading to muddled data between environments. Account and visitor IDs must be unique per API-key.
* See: https://support.pendo.io/hc/en-us/articles/360031862352-Pendo-in-multiple-environments-for-development-and-testing
* @returns Unique SHA256 hash of ID and the environment; else, undefined if missing values to hash.
*/
const hashUniquePendoId = (id: string | undefined) => {
const pendoEnv =
APP_ROOT === 'https://cloud.linode.com' ? 'production' : 'non-production';

if (!id || !APP_ROOT) {
return;
}

return sha256(id + pendoEnv);
};

/**
* Initializes our Pendo analytics script on mount.
*/
export const usePendo = () => {
const { data: account } = useAccount();
const { data: profile } = useProfile();

const accountId = hashUniquePendoId(account?.euuid);
const visitorId = hashUniquePendoId(profile?.uid.toString());

const PENDO_URL = `https://cdn.pendo.io/agent/static/${PENDO_API_KEY}/pendo.js`;

React.useEffect(() => {
// Adapted Pendo install script for readability:

// Set up Pendo namespace and queue
const pendo = (window['pendo'] = window['pendo'] || {});
pendo._q = pendo._q || [];

// Define the methods Pendo uses in a queue
const methodNames = [
'initialize',
'identify',
'updateOptions',
'pageLoad',
'track',
];

// Enqueue methods and their arguments on the Pendo object
methodNames.forEach((_, index) => {
(function (method) {
pendo[method] =
pendo[method] ||
function () {
pendo._q[method === methodNames[0] ? 'unshift' : 'push'](
// eslint-disable-next-line prefer-rest-params
[method].concat([].slice.call(arguments, 0))
);
};
})(methodNames[index]);
});
Copy link
Contributor Author

Choose a reason for hiding this comment

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

All this stuff is a slightly more readable version of the Pendo install script: https://support.pendo.io/hc/en-us/articles/21362607464987-Components-of-the-install-script.

Copy link
Contributor

@coliu-akamai coliu-akamai Oct 7, 2024

Choose a reason for hiding this comment

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

would we need to cite this in our code?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't think we need to as far as Pendo is concerned, but it's good context to have, since we set this up in a way that is consistent with our repo, deconstructing a bit to make use of our loadScript function. I added a link to the Pendo doc in a comment in the code so it's not just ephemerally in this PR - good call out.


// Load Pendo script into the head HTML tag, then initialize Pendo with metadata
loadScript(PENDO_URL, {
location: 'head',
}).then(() => {
window.pendo.initialize({
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I left the commented-out sections for reference.

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) {
const idMatchingRegex = /\d+$/;
const userPathMatchingRegex = /(users\/).*/;
const oauthPathMatchingRegex = /oauth\/callback#access_token/;
if (
idMatchingRegex.test(url) ||
oauthPathMatchingRegex.test(url)
) {
// Removes everything after the last /
return url.replace(/\/[^\/]*$/, '/');
} else if (userPathMatchingRegex.test(url)) {
// Removes everything after /users
return url.replace(userPathMatchingRegex, '$1');
}
return 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.
},
});
});
}, [PENDO_URL, accountId, visitorId]);
};
2 changes: 1 addition & 1 deletion packages/manager/src/hooks/useScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const loadScript = (
return resolve({ status: 'idle' });
}
// Fetch existing script element by src
// It may have been added by another intance of this hook
// It may have been added by another instance of this hook
let script = document.querySelector(
`script[src='${src}']`
) as HTMLScriptElement;
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6537,6 +6537,11 @@ joycon@^3.1.1:
resolved "https://registry.yarnpkg.com/joycon/-/joycon-3.1.1.tgz#bce8596d6ae808f8b68168f5fc69280996894f03"
integrity sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==

js-sha256@^0.11.0:
version "0.11.0"
resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.11.0.tgz#256a921d9292f7fe98905face82e367abaca9576"
integrity sha512-6xNlKayMZvds9h1Y1VWc0fQHQ82BxTXizWPEtEeGvmOUYpBRy4gbWroHLpzowe6xiQhHpelCQiE7HEdznyBL9Q==

"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
Expand Down