@unifygtm/intent-client
Version:
JavaScript client for interacting with the Unify Intent API in the browser.
723 lines (631 loc) • 22.4 kB
text/typescript
import {
type AutoTrackOptions,
type UCompany,
type UnifyIntentContext,
UnifyStandardTrackEvent,
type UPerson,
} from '../../types';
import { IdentifyActivity, PageActivity, TrackActivity } from '../activities';
import { validateEmail } from '../utils/helpers';
import { logUnifyError } from '../utils/logging';
import {
DEFAULT_EVENT_TYPE_TO_ORIGIN_MAP,
DEFAULT_FORMS_IFRAME_ORIGIN,
DEFAULT_SCHEDULER_IFRAME_ORIGIN,
NAVATTIC_IFRAME_ORIGIN,
UNIFY_CLICK_EVENT_NAME_DATA_ATTR,
UNIFY_CLICK_EVENT_NAME_DATA_ATTR_SELECTOR_NAME,
UNIFY_TRACK_CLICK_DATA_ATTR,
UNIFY_TRACK_CLICK_DATA_ATTR_SELECTOR_NAME,
} from './constants';
import { type DefaultEventData, DefaultEventType } from './types/default';
import {
NavatticDefaultCustomPropertyName,
type NavatticEventData,
NavatticObject,
} from './types/navattic';
import {
elementHasDataAttr,
extractUnifyCapturePropertiesFromElement,
getElementDataAttr,
getElementLabel,
isActionableElement,
} from './utils/helpers';
import {
getUAttributesForNavatticEventData,
maybeTrackNavatticEvent,
} from './utils/navattic';
import {
getUAttributesForDefaultEventData,
maybeTrackDefaultEvent,
} from './utils/default';
import { isDefaultFormEventData } from './utils/default';
/**
* This class acts as an agent to automatically monitor user
* intent-related activity and log relevant events and data to Unify.
*/
export class UnifyIntentAgent {
private readonly _intentContext: UnifyIntentContext;
private readonly _monitoredInputs: Set<HTMLInputElement> =
new Set<HTMLInputElement>();
private readonly _submittedEmails: Set<string> = new Set<string>();
private _autoPage: boolean;
private _autoIdentify: boolean;
private _autoTrackOptions: AutoTrackOptions;
private _historyMonitored: boolean = false;
private _lastLocation?: Location;
private _isTrackingClicks: boolean = false;
private _isSubscribedToThirdPartyMessages: boolean = false;
/**
* There is a bug in the Default scheduler which causes some events to
* fire twice. We use this instance variable to track when an event
* was _just_ tracked to prevent double-tracking it.
*/
private _justTrackedDefaultEventType: DefaultEventType | null = null;
constructor(intentContext: UnifyIntentContext) {
this._intentContext = intentContext;
this._autoPage = intentContext.clientConfig.autoPage ?? false;
this._autoIdentify = intentContext.clientConfig.autoIdentify ?? false;
this._autoTrackOptions = { ...intentContext.clientConfig.autoTrackOptions };
if (this._autoPage) {
this.startAutoPage();
// Make sure to track the initial page
this.maybeTrackPage();
}
if (this._autoIdentify) {
this.startAutoIdentify();
}
this.startAutoTrack();
this.subscribeToThirdPartyMessages();
}
/**
* Stops all monitoring done by the Unify Intent Agent.
*/
public unmount = () => {
this.stopAutoIdentify();
this.stopAutoPage();
this.stopAutoTrack();
this.unsubscribeFromThirdPartyMessages();
};
/**
* Tells the Unify Intent Agent to trigger page events when the
* user's current page changes. Note that this will NOT trigger
*/
public startAutoPage = () => {
// We don't start monitoring history until auto-page is turned on
// for the first time.
if (!this._historyMonitored) {
this.monitorHistory();
}
this._autoPage = true;
};
/**
* Tells the Unify Intent Agent to NOT trigger page events when the
* user's current page changes.
*/
public stopAutoPage = () => {
if (this._historyMonitored) {
window.removeEventListener('popstate', this.maybeTrackPage);
}
this._autoPage = false;
};
/**
* Tells the Unify Intent Agent to continuously monitor identity-related
* input elements for changes, and automatically submit identify actions
* to Unify when the user self-identifies.
*/
public startAutoIdentify = () => {
this._autoIdentify = true;
this.refreshMonitoredInputs();
setInterval(this.refreshMonitoredInputs, 2000);
};
/**
* Tells the Unify Intent Agent to stop continously monitoring inputs
* for changes.
*/
public stopAutoIdentify = () => {
this._monitoredInputs.forEach((input) => {
if (input.isConnected) {
input.removeEventListener('blur', this.handleInputBlur);
input.removeEventListener('keydown', this.handleInputKeydown);
}
});
this._monitoredInputs.clear();
this._autoIdentify = false;
};
/**
* Tells the Unify Intent Agent to start continuously tracking user actions
* based on the `AutoTrackOptions` in the client config.
*
* @param options - auto-track options to use. If `undefined`, the previously
* specified auto-track options will be re-used.
*/
public startAutoTrack = (options?: AutoTrackOptions) => {
if (options) {
this._autoTrackOptions = options;
}
this.startTrackingClicks();
};
/**
* Tells the Unify Intent Agent to stop continuously monitoring user actions.
*/
public stopAutoTrack = () => {
this.stopTrackingClicks();
};
/**
* This function adds event listeners and overrides to various
* history-related browser functions to automatically track when the
* current page changes. This is important for tracking page changes
* in single page apps because the Unify Intent Client will only
* be instantiated a single time.
*/
private monitorHistory = () => {
try {
// `pushState` is usually triggered to navigate to a new page
const pushState = history.pushState;
history.pushState = (...args) => {
// Update history
pushState.apply(history, args);
// Track page if valid page change
this.maybeTrackPage();
};
// Sometimes `replaceState` is used to navigate to a new page, but
// sometimes it is used to e.g. update query params
const replaceState = history.replaceState;
history.replaceState = (...args) => {
// Update history
replaceState.apply(history, args);
// Track page if valid page change
this.maybeTrackPage();
};
// `popstate` is triggered when the user clicks the back button
window.addEventListener('popstate', this.maybeTrackPage);
this._historyMonitored = true;
} catch (error: unknown) {
this.logError('Error occurred in monitorHistory', error);
}
};
/**
* Triggers a page event for the current page and context if auto-page
* is currently set to `true` and the page has actually changed.
*/
private maybeTrackPage = () => {
if (!this._autoPage) return;
try {
if (
!this._lastLocation ||
isNewPage(this._lastLocation, window.location)
) {
new PageActivity(this._intentContext).track();
this._lastLocation = { ...window.location };
}
} catch (error: unknown) {
this.logError('Error occurred in maybeTrackPage', error);
}
};
/**
* Listens for click events in the Document and tracks them if they occurred
* within an actionable button with a qualified label.
*/
private startTrackingClicks = () => {
if (this._isTrackingClicks) return;
document.addEventListener('click', this.handleDocumentClick);
this._isTrackingClicks = true;
};
/**
* Stops tracking click events in the document if the intent client is
* currently tracking them.
*/
private stopTrackingClicks = () => {
if (!this._isTrackingClicks) return;
document.removeEventListener('click', this.handleDocumentClick);
this._isTrackingClicks = false;
};
/**
* Document click handler function which implements auto-tracking of clicks
* on relevant elements. By default, will log track clicks with the
* click-tracking data attributes, but will also include elements that match
* the click-tracking CSS selectors if specified.
*
* @param event - the Document click event
*/
private handleDocumentClick = (event: MouseEvent) => {
try {
const target = event.target as Element | null;
if (!target || typeof target.closest !== 'function') return;
// TODO: deprecate this old selector
const legacyDefaultSelector = `[${UNIFY_TRACK_CLICK_DATA_ATTR_SELECTOR_NAME}]`;
// Default selector for tracking a custom event
const defaultSelector = `[${UNIFY_CLICK_EVENT_NAME_DATA_ATTR_SELECTOR_NAME}]`;
// Optional custom CSS selectors
const customCssSelectors =
this._autoTrackOptions.clickTrackingSelectors ?? [];
// Combine all selectors
const selectors = [
legacyDefaultSelector,
defaultSelector,
...customCssSelectors.map((selectorOrOptions) => {
if (typeof selectorOrOptions === 'string') {
return selectorOrOptions;
}
return selectorOrOptions.selector;
}),
];
// Find the closest ancestor which matches any selector
const element = target.closest(selectors.join(', '));
if (
!element ||
!(element instanceof HTMLElement) ||
!isActionableElement(element)
)
return;
// The event name for the legacy default selector is always `Element Clicked`
const legacyDefaultMatchEventName = elementHasDataAttr(
element,
UNIFY_TRACK_CLICK_DATA_ATTR,
)
? UnifyStandardTrackEvent.ELEMENT_CLICKED
: null;
// The new default selector will have a custom event name
const defaultMatchEventName = getElementDataAttr(
element,
UNIFY_CLICK_EVENT_NAME_DATA_ATTR,
);
// Get event names for each custom CSS selector which the element matches
const customMatchEventNames = customCssSelectors.map(
(selectorOrOptions) => {
// If the selector is a simple CSS string
if (typeof selectorOrOptions === 'string') {
// Do not double-track an element matched by default selector(s) and
// custom CSS selector without a custom event name.
if (legacyDefaultMatchEventName || defaultMatchEventName)
return null;
// Never a custom event name in this case, use the default
if (element.matches(selectorOrOptions)) {
return UnifyStandardTrackEvent.ELEMENT_CLICKED;
}
// No match, so no event name to track
return null;
}
// If the selector is an object containing an optional custom event name
if (element.matches(selectorOrOptions.selector)) {
// Do not double-track an element matched by default selector(s) and
// custom CSS selector without a custom event name.
if (legacyDefaultMatchEventName || defaultMatchEventName) {
if (!selectorOrOptions.eventName) return null;
}
// Use the custom event name if provided, else fall back to the default
return (
selectorOrOptions.eventName ??
UnifyStandardTrackEvent.ELEMENT_CLICKED
);
}
// No match, so no event name to track
return null;
},
);
// Filter to all eligible event names to track
const eventNamesToTrack = [
defaultMatchEventName,
...customMatchEventNames,
].filter((eventName): eventName is string => !!eventName);
// Track an event for each eligible name
eventNamesToTrack.forEach((eventName) => {
this.maybeTrackClick({ element, eventName });
});
// TODO: deprecate legacy tracking
if (legacyDefaultMatchEventName) {
this.maybeTrackClick({
element,
eventName: legacyDefaultMatchEventName,
isLegacy: true,
});
}
} catch (error: unknown) {
this.logError('Error occurred in handleDocumentClick', error);
}
};
/**
* Discards inputs no longer in the DOM and adds new inputs in the DOM
* to the set of monitored inputs if they qualify for auto-identity.
*/
private refreshMonitoredInputs = () => {
if (!this._autoIdentify) return;
try {
// Discard input elements no longer in the DOM
this._monitoredInputs.forEach((input) => {
if (!input.isConnected) {
this._monitoredInputs.delete(input);
}
});
// Get all candidate input elements
const inputs = Array.from(document.getElementsByTagName('input')).filter(
(input) =>
!this._monitoredInputs.has(input) && isCandidateIdentityInput(input),
);
// Setup event listeners to monitor the input elements
inputs.forEach((input) => {
input.addEventListener('blur', this.handleInputBlur);
input.addEventListener('keydown', this.handleInputKeydown);
this._monitoredInputs.add(input);
});
} catch (error: unknown) {
this.logError('Error occurred in refreshMonitoredInputs', error);
}
};
/**
* Listens for messages posted to the window from third-party
* integrations which communicate via `window.postMessage`. Sets
* up event handlers for each supported integration.
*/
private subscribeToThirdPartyMessages = () => {
if (!this._isSubscribedToThirdPartyMessages) {
window.addEventListener('message', this.handleThirdPartyMessage);
}
this._isSubscribedToThirdPartyMessages = true;
};
/**
* Removes event listeners setup in `subscribeToThirdPartyMessages`.
*/
private unsubscribeFromThirdPartyMessages = () => {
if (this._isSubscribedToThirdPartyMessages) {
window.removeEventListener('message', this.handleThirdPartyMessage);
}
this._isSubscribedToThirdPartyMessages = false;
};
/**
* Handles an event posted to the window via `window.postMessage` by
* a third-party integration embedded in an iframe, e.g. a Default form.
*
* @param event - the event from `window.postMessage`
*/
private handleThirdPartyMessage = (event: MessageEvent) => {
let thirdParty: string | undefined;
try {
switch (event.origin) {
case DEFAULT_FORMS_IFRAME_ORIGIN:
case DEFAULT_SCHEDULER_IFRAME_ORIGIN: {
thirdParty = 'Default';
this.handleDefaultFormMessage(
event as MessageEvent<DefaultEventData>,
);
break;
}
case NAVATTIC_IFRAME_ORIGIN: {
thirdParty = 'Navattic';
this.handleNavatticDemoMessage(
event as MessageEvent<NavatticEventData>,
);
break;
}
}
} catch (error: unknown) {
this.logError(
`Error occurred in handleThirdPartyMessage for third-party ${thirdParty}`,
error,
);
}
};
/**
* Message handler for messages posted from embedded Default forms.
*
* @param event - the event from `window.postMessage`
*/
private handleDefaultFormMessage = (
event: MessageEvent<DefaultEventData>,
) => {
try {
// Default will emit some events with JSON string data, we can safely ignore these.
if (typeof event.data === 'string') return;
// Some events are emitted by both the Default form AND scheduler iframes.
// We add this check so that they are not processed more than once.
if (event.origin !== DEFAULT_EVENT_TYPE_TO_ORIGIN_MAP[event.data.event]) {
return;
}
if (this._autoIdentify && isDefaultFormEventData(event.data)) {
const email = event.data.payload.email;
if (email) {
this.maybeIdentifyInputEmail(
email,
getUAttributesForDefaultEventData(
event.data,
this._intentContext.apiClient,
),
);
}
}
// Optionally auto-track eligible events from Default form/scheduler
if (this._autoTrackOptions.defaultForms) {
// Prevent double-tracking
if (event.data.event === this._justTrackedDefaultEventType) {
return;
}
const trackedEvent = maybeTrackDefaultEvent({
data: event.data,
autoTrackOptions: this._autoTrackOptions,
intentContext: this._intentContext,
});
if (trackedEvent) {
this._justTrackedDefaultEventType = event.data.event;
setTimeout(() => {
this._justTrackedDefaultEventType = null;
}, 500);
}
}
} catch (error: unknown) {
this.logError('Error occurred in handleDefaultFormMessage', error);
}
};
/**
* Message handler for messages posted from embedded Navattic demos.
*
* @param event - the event from `window.postMessage`
*/
private handleNavatticDemoMessage = (
event: MessageEvent<NavatticEventData>,
) => {
try {
// Optionally auto-identify user from Navattic demo
if (this._autoIdentify) {
const eventDataProperties = event.data?.properties ?? [];
const email = eventDataProperties.find(
({ object, name }) =>
object === NavatticObject.END_USER &&
name === NavatticDefaultCustomPropertyName.Email,
);
if (email?.value) {
this.maybeIdentifyInputEmail(
email.value,
getUAttributesForNavatticEventData(
event.data,
this._intentContext.apiClient,
),
);
}
}
// Optionally auto-track eligible events from Navattic demo
if (this._autoTrackOptions.navatticProductDemos) {
maybeTrackNavatticEvent({
data: event.data,
autoTrackOptions: this._autoTrackOptions,
intentContext: this._intentContext,
});
}
} catch (error: unknown) {
this.logError('Error occurred in handleNavatticDemoMessage', error);
}
};
/**
* Blur event handler for a monitored input element.
*
* @param event - the relevant event to handle
*/
private handleInputBlur = (event: FocusEvent) => {
if (!this._autoIdentify) return;
try {
if (event.target instanceof HTMLInputElement) {
this.maybeIdentifyInputEmail(event.target.value);
}
} catch (error: unknown) {
this.logError('Error occurred in handleInputBlur', error);
}
};
/**
* Keydown event handler for a monitored input element. Only actions
* on the 'Enter' key.
*
* @param event - the relevant event to handle
*/
private handleInputKeydown = (event: KeyboardEvent) => {
if (!this._autoIdentify) return;
try {
if (event.key === 'Enter' && event.target instanceof HTMLInputElement) {
this.maybeIdentifyInputEmail(event.target.value);
}
} catch (error: unknown) {
this.logError('Error occurred in handleInputKeyDown', error);
}
};
/**
* This function checks if the current value of a monitored input is a valid
* email address and logs an identity action if it has not already been logged.
*
* @param event - the event object from a monitored input blur or keydown event
*/
private maybeIdentifyInputEmail = (
email: string,
options?: { person?: UPerson; company?: UCompany },
) => {
if (!this._autoIdentify) return;
try {
if (email) {
// Validate that the input is a valid email address and has not already
// been submitted for an identify action.
if (!validateEmail(email) || this._submittedEmails.has(email)) {
return;
}
// Log an identify event
const identifyAction = new IdentifyActivity(this._intentContext, {
email,
person: options?.person,
company: options?.company,
});
identifyAction.track();
// Make sure we don't auto-identify this email again later
this._submittedEmails.add(email);
}
} catch (error: unknown) {
this.logError('Error occurred in maybeIdentifyInputEmail', error);
}
};
private maybeTrackClick = ({
element,
eventName,
isLegacy = false,
}: {
element: HTMLElement;
eventName: string;
isLegacy?: boolean;
}) => {
try {
const elementLabel = getElementLabel(element);
if (!elementLabel) return;
const customProperties =
extractUnifyCapturePropertiesFromElement(element);
const trackActivity = new TrackActivity(this._intentContext, {
name: eventName,
properties: {
...customProperties,
label: elementLabel,
// TODO: deprecate `elementName` when no longer needed
...(isLegacy && { elementName: elementLabel }),
wasAutoTracked: true,
},
});
trackActivity.track();
} catch (error: unknown) {
this.logError('Error occurred in maybeTrackClick', error);
}
};
private logError = (message: string, error: unknown) => {
logUnifyError({
message: `UnifyIntentAgent: ${message}`,
error: error as Error,
apiClient: this._intentContext.apiClient,
});
};
/**
* DO NOT USE: These methods are exposed only for testing purposes.
*/
__getMonitoredInputs = () => this._monitoredInputs;
/**
* DO NOT USE: These methods are exposed only for testing purposes.
*/
__getSubmittedEmails = () => this._submittedEmails;
}
/**
* Sometimes `history.replaceState` is called to manipulate the current URL,
* but not in a way which qualifies the new URL as a "new page". For example,
* `history.replaceState` is often used to update query params. When this
* happens, we do not want to auto-trigger a page event.
*
* This function compares two URLs to determine whether the second URL
* constitutes a "new page" to auto-trigger a page event.
*/
function isNewPage(oldLocation: Location, newLocation: Location): boolean {
return (
oldLocation.hostname !== newLocation.hostname ||
oldLocation.pathname !== newLocation.pathname
);
}
/**
* Helper function to filter input elements to those which qualify for
* auto-identity.
*
* @param element - the element to check
* @returns `true` if this is an input we can get identity from,
* otherwise `false`
*/
function isCandidateIdentityInput(element: HTMLInputElement) {
return element.type === 'email' || element.type === 'text';
}