/** * Mesh Analytics SDK * @author [Vivek Sudarsan] */ // Import third party libraries import Cookies from 'js-cookie'; // Import helpers and util import apiRequest from './services/api/request'; import { handleJsonResponse } from './services/api/response'; import { UserInteraction } from './interactions'; import { getIpAddress, getReferrer, getUrlParams, getScrollPercentage, postMessageToAllFrames, handleFrameMessage, initFormHandlingOnBody, avinaLog, getBrowserDetails, addMeshVisitorIdToForms, detectUrlChange, injectScript, poll, } from './util/helpers'; import { handleCalendlyEmbed } from './util/integrations/calendly'; import { HEARBEAT_INTERVAL, INTERACTION_CUSTOMERS_EXCLUDE, THIRD_PARTY_SCRIPTS, TOUCH_TYPES, TRACK_TYPE_MAP, } from './util/constants'; import { handleHotjarIntegration } from './util/integrations/hotjar'; // The SDK version, populated by `../webpack.config.js` at build time declare const MESH_VERSION: string; const POLL_INTERVAL_MS = 5000; // 30 minutes, expressed in days for js-cookie's `expires` const SESSION_EXPIRY_DAYS = 1 / 48; // Set type definitions type Mesh = { init?: (key: string, config: any) => void; identify?: (contactInfo: Contact) => void; }; type Contact = { firstName?: string; lastName?: string; ipAddress?: string; personId?: string; hem?: string; maid?: string; company?: string; title?: string; email?: string; vectorId?: string; }; // Default global state (used for initialization and reset) const DEFAULT_GLOB = () => ({ sdkKey: null, sdkAttributes: { track: { session: true, forms: true, }, appMode: false, integrations: { hotjar: false, }, }, workspaceName: null, workspaceId: null, workspaceHashSalt: null, fullContactKey: null, vectorKey: null, visitorId: null, touchpoints: {}, fingerprints: {}, formData: {}, sessionData: { percentPageViewed: 0, }, ongoingRequests: { [TOUCH_TYPES.VIEW]: false, [TOUCH_TYPES.FORM_FILL]: false, }, settings: { sessionHandlingSet: false, formHandlingSet: false, }, rb2bEnabled: false, vectorEnabled: false, primaryResolverTimeoutMs: 35000, resolutionDelayMs: 0, resolutionInProgress: false, contactResolutionPayload: null, }); // Set global variables export let glob: any = DEFAULT_GLOB(); /** Reset glob to its default state (used in tests) */ export const resetGlob = () => { const defaults = DEFAULT_GLOB(); Object.keys(glob).forEach(key => delete glob[key]); Object.assign(glob, defaults); }; declare global { interface Window { mesh: any; avina: any; meshLogs: any; debugInteractions: any; vector: any; reb2b: any; rb2bConfig: any; hj: any; avina_vectorScriptInjected: boolean; avina_rb2bScriptInjected: boolean; } } // initialize fingerprint let fpPromise: any; /** * On load resolve identity and create touchpoint */ const initialize = async (config: { loadInteractions: boolean; loadForms: boolean; }) => { avinaLog('[Beta] Initializing...'); // get the sdk key from the script tag getScriptAttributes(); // get corresponding workspace and secrets for sdk key await getWorkspaceDetails(); // if workspace is not active, exit early if (!glob.active) { avinaLog('Workspace is not active, exiting...'); return; } // load event listeners and prepare third parties await preLoadEvents(); // initialize form handling initFormHandlingOnBody(onFormFillChange); // determine the identity of the visitor var identity: any | undefined; if ( glob.sdkAttributes.track === undefined || (glob.sdkAttributes.track && glob.sdkAttributes.track[TRACK_TYPE_MAP.VIEW] !== false) ) { identity = await initializeVisitor(); // integration specific handling for visitor handleIntegrationFrames(); await startContactResolution(); } else { avinaLog('Session tracking disabled...'); } // initialize user interaction tracking if ( !INTERACTION_CUSTOMERS_EXCLUDE.includes(glob.workspaceName) && config.loadInteractions ) { const userInteraction = new UserInteraction(); userInteraction.init(); } // add visitor id to all forms on page addMeshVisitorIdToForms(); // exit early if in app mode because we don't // need to track the session data outside of the touchpoint if (glob.sdkAttributes.appMode === true) return; // handle session data if session tracking is allowed // TODO: combine this with the above if statement if ( glob.sdkAttributes.track === undefined || (glob.sdkAttributes.track && glob.sdkAttributes.track[TRACK_TYPE_MAP.VIEW] !== false) ) handleSessionData(); }; /** * Load event listeners and prepare for third-party scripts * @returns */ export const preLoadEvents = async () => { // Load event listeners window.addEventListener('message', handleFrameMessage, false); // Remove any conflicting scripts if (glob.contactResolutionEnabled) { try { await removeConflictingScripts(); } catch (e) { avinaLog('Error with conflicting scripts...'); } } }; /** * Get attributes from script to save in global state * @returns */ export const getScriptAttributes = () => { // get the sdk key from the script tag let sdkScript = document.getElementById('mesh-analytics-sdk'); glob.sdkKey = sdkScript?.getAttribute('data-mesh-sdk'); glob.sdkAttributes = JSON.parse( sdkScript?.getAttribute('data-mesh-sdk-attributes') || '{}' ); }; /** Remove any conflicting scripts on the page */ export const removeConflictingScripts = async (): Promise => { const VECTOR_RE = /cdn\.vector\.co\/pixel\.js|Vector snippet included more than once/i; const RB2B_RE = /reb2b|ddwl4m2hdecbv\.cloudfront\.net/i; // Find and remove conflicting external scripts, if any exist let foundConflict = false; const vectorPixel = document.querySelector( 'script[src*="cdn.vector.co/pixel.js"]' ); if (vectorPixel) { vectorPixel.parentNode?.removeChild(vectorPixel); foundConflict = true; } const rb2bPixel = document.querySelector( 'script[src*="ddwl4m2hdecbv.cloudfront.net"]' ); if (rb2bPixel) { rb2bPixel.parentNode?.removeChild(rb2bPixel); foundConflict = true; } if (!foundConflict) { avinaLog('No conflicts found, skipping...'); return; } // Spend a few seconds finding and removing conflicting inline scripts, if any exist await new Promise(resolve => { const done = () => { clearInterval(interval); clearTimeout(timeout); resolve(); }; const interval = setInterval(() => { Array.from(document.scripts).forEach(function(s) { const content = s.textContent || ''; if (VECTOR_RE.test(content) || RB2B_RE.test(content)) { avinaLog(`Inline script found, removing... ${s.src}`); s.parentNode?.removeChild(s); } }); }, 250); const timeout = setTimeout(done, 5000); }); // Clean up global objects try { delete (window as any).vector; } catch { (window as any).vector = undefined; } try { delete (window as any).reb2b; } catch { (window as any).reb2b = undefined; } return; }; /** * Get and set workspace details for the sdk key * @returns */ export const getWorkspaceDetails = async () => { // get corresponding workspace and secrets for sdk key const response = await apiRequest({ method: 'post', resource: 'init-sdk', id: [], data: { domain: window.location.href, sdkKey: glob.sdkKey, }, }); const data = await handleJsonResponse(response); // assign globals from sdk response glob.workspaceId = data.workspaceId; glob.workspaceName = data.workspaceName; glob.fullContactKey = data.keys.fullContactKey; glob.active = data.active; glob.contactResolutionEnabled = data.contactResolutionEnabled; glob.rb2bEnabled = data.rb2bEnabled || false; glob.vectorEnabled = data.vectorEnabled || false; glob.primaryResolverTimeoutMs = data.primaryResolverTimeoutMs || 35000; glob.resolutionDelayMs = data.resolutionDelayMs || 0; }; /** * Handle iFrames for specific integrations * ex. Calendly, Drift, etc. */ const handleIntegrationFrames = () => { // handle calendly frame handleCalendlyEmbed(glob.visitorId); // handle hotjar integration if enabled if (glob.sdkAttributes.integrations?.hotjar) { handleHotjarIntegration(glob.visitorId); } }; /** * Get the visitor's ID (if returning), log their Touch, * gather their fingerprints, and create a Contact (if one does not exist). * @returns visitorId */ export const initializeVisitor = async () => { // Look for an existing ID let visitorId = Cookies.get('mesh-visitor-id'); const isReturningVisitor = !!visitorId; // Log the visitor's ID (if returning) if (isReturningVisitor) { avinaLog(`Returning visitor: ${visitorId}`); } // Gather the visitor's fingerprints let personId = null; if (glob.sdkAttributes.useFingerprint === true) { avinaLog('Cookieless enabled...'); let fp = await fpPromise; let result = await fp.get(); personId = result.visitorId; avinaLog('personId resolved...'); } const fingerprints: any = { hem: null, maid: null, personId: personId, }; const urlParams = new URLSearchParams(window.location.search); const email = urlParams.get('m_email'); if (email) fingerprints['email'] = email; glob.fingerprints = fingerprints; // Generate an ID for the visitor (if new) if (!isReturningVisitor) { visitorId = crypto.randomUUID(); avinaLog(`New visitor: ${visitorId}`); // Set a cookie for future visits (if cookieless tracking is disabled or not feasible) if ( glob.sdkAttributes.useFingerprint === false || glob.sdkAttributes.useFingerprint === undefined || glob.sdkAttributes.appMode === true ) { Cookies.set('mesh-visitor-id', visitorId, { expires: 30 }); } } // Save the visitor's ID as global variables for downstream use glob.visitorId = visitorId; glob.contactResolutionPayload = JSON.stringify({ contact_id__mesh_web: visitorId, workspace_id: glob.workspaceId, }); // Create a Contact (if new) and a Touch for the visitor if (isReturningVisitor) { createTouchpoint().catch(e => { avinaLog(`Failed to create touchpoint: ${e}`); }); } else { createIdentityAndTouchpoint(visitorId!).catch(e => { avinaLog(`Failed to create identity and touchpoint: ${e}`); }); } // Send the newly resolved visitorID to any embedded frames postMessageToAllFrames({ visitorId }); return visitorId; }; /** * Create a Contact and a Touch for a new visitor. * @param visitorId * @returns visitorId */ const createIdentityAndTouchpoint = async (visitorId: string) => { avinaLog('Creating a new Contact and Touch...'); // Create a Contact const response = await apiRequest({ method: 'post', resource: 'contacts', id: [], data: { id: visitorId, ipAddress: await getIpAddress(), workspaceId: glob.workspaceId, ...glob.fingerprints, }, }); await handleJsonResponse(response); // Create a Touch await createTouchpoint(); return visitorId; }; /** * Update a contact with new visitor information * @returns visitorId */ const updateIdentity = async (contactInfo: Contact) => { // Check if contactInfo is not provided if (!contactInfo) { throw new Error('No contact information provided'); } // update an existing contact const response = await apiRequest({ method: 'patch', resource: ['contacts'], id: [glob.visitorId], data: contactInfo, }); // debug: log the updated identity const data = await handleJsonResponse(response); return data; }; /** * Add a touchpoint for the identified visitor * @returns touchpointId */ const createTouchpoint = async (type?: string) => { // exit early if no visitor id if (!glob.visitorId) { avinaLog('No visitor id to create touchpoint...'); throw new Error('[Mesh] No visitor id to create touchpoint'); } // if no type provided default to VIEW if (!type) { if (glob.sdkAttributes.appMode === true) { type = TOUCH_TYPES.IN_APP_VIEW; } else { type = TOUCH_TYPES.VIEW; } } // if type if form fill, add that to the request let formData = { formData: null }; if (type == TOUCH_TYPES.FORM_FILL) formData = glob.formData; // check tracking perferences before writing touchpoint if ( glob.sdkAttributes.track && glob.sdkAttributes.track[TRACK_TYPE_MAP[type]] === false ) return; // check if there is an ongoing request for this type if (glob.ongoingRequests[type] === true) { avinaLog('Ongoing request, skipping...'); return; } // set ongoing request for this type glob.ongoingRequests[type] = true; // create session data let sessionData = { debug: { sdkVersion: MESH_VERSION, consoleLogs: window.meshLogs, browser: getBrowserDetails(), }, }; // create a new touchpoint const response = await apiRequest({ method: 'post', resource: 'touches', id: [], data: { touchType: type, contactId: glob.visitorId, pageUrl: window.location.href, ipAddress: await getIpAddress(), utmParams: await getUrlParams(), referrer: await getReferrer(), workspaceId: glob.workspaceId, formData: JSON.stringify(formData), sessionData: JSON.stringify(sessionData), ...glob.fingerprints, }, }); const data = await handleJsonResponse(response); // keep track of cur touchpoint ids for each // type of touchpoint glob.touchpoints[type] = data.id; // reset ongoing request for this type glob.ongoingRequests[type] = false; return data.id; }; /** * Update a current touchpoint with new data or info * @returns touchpointId */ const updateTouchpoint = async (type: string, updatedTouch: any) => { // exit early if no visitor id if (!glob.visitorId) { avinaLog('No visitor id to update touchpoint...'); throw new Error('[Mesh] No visitor id to create touchpoint'); } // get touchpoint id for the given type const touchpointId = glob.touchpoints[type]; if (!touchpointId) { avinaLog('No touchpoint to update...'); throw new Error('[Mesh] No touchpoint to update'); } // check tracking perferences before writing touchpoint if ( glob.sdkAttributes.track && glob.sdkAttributes.track[TRACK_TYPE_MAP[type]] === false ) return; // check if there is an ongoing request for this type if (glob.ongoingRequests[type] === true) { avinaLog('Ongoing request, skipping...'); return; } // set ongoing request for this type glob.ongoingRequests[type] = true; // create updated session data let sessionData = { debug: { sdkVersion: MESH_VERSION, consoleLogs: window.meshLogs, browser: getBrowserDetails(), }, }; updatedTouch = { ...updatedTouch, sessionData: JSON.stringify(sessionData), }; // update an existing contact const response = await apiRequest({ method: 'patch', resource: ['touches'], id: [touchpointId], data: updatedTouch, }); // debug: log the updated touchpoint const data = await handleJsonResponse(response); // reset ongoing request for this type glob.ongoingRequests[type] = false; return data.id; }; /** * Handle form changes and keep track of the * full form data to add to a form fill touchpoint */ const onFormFillChange = async (formData: Contact) => { // don't update if no formData if (Object.keys(formData).length == 0) return; // update the information on the contact await updateIdentity(formData); // update and merge form data const curFormData = glob.formData; const updatedFormData = Object.assign(curFormData, formData); glob.formData = updatedFormData; // if there is no form fill touch point for // this specific page, create one else update it if (!glob.touchpoints[TOUCH_TYPES.FORM_FILL]) { createTouchpoint(TOUCH_TYPES.FORM_FILL); } else { updateTouchpoint(TOUCH_TYPES.FORM_FILL, { formData: JSON.stringify({ ...glob.formData, }), }); } }; /** * Handle session data heartbeat * * Sends data about the session to the server * at a set interval so that we can track * session activity up till the session ends. * * ex. send a heartbeat request every 500ms to keep * the session duration accurate. */ const handleSessionData = async () => { // exit early if session handling is already set if (glob.settings.sessionHandlingSet) return; setInterval(() => { // if the window/tab is not active or visible, exit early if (document.hidden) return; // if there is no view touch point for // this specific page, create one else update it if (!glob.touchpoints[TOUCH_TYPES.VIEW]) return; // calculate session percent viewed as float const percentPageViewed = Math.max( glob.sessionData.percentPageViewed, getScrollPercentage() ); glob.sessionData.percentPageViewed = percentPageViewed; // get current session time to record end time const currentSessionTime = new Date().toISOString(); updateTouchpoint(TOUCH_TYPES.VIEW, { sessionEndedAt: currentSessionTime, sessionPercentViewed: percentPageViewed, }); }, HEARBEAT_INTERVAL); // set session handling set to true glob.settings.sessionHandlingSet = true; }; export const waitForResolution = async ( timeoutMs: number ): Promise => { avinaLog(`Starting resolution polling (timeout: ${timeoutMs}ms)...`); const pollForResolution = async () => { const response = await apiRequest({ method: 'get', resource: `contacts/${glob.visitorId}/resolution-status`, id: [], data: {}, }); const data = await handleJsonResponse(response); return { done: data.resolved, result: { resolvedBy: data.resolvedBy } }; }; const { success, result, elapsedMs, attempts } = await poll( pollForResolution, POLL_INTERVAL_MS, timeoutMs ); if (success) { avinaLog( `Contact resolved by ${result?.resolvedBy} in ${elapsedMs}ms (attempt ${attempts})` ); } else { avinaLog(`Resolution timed out after ${elapsedMs}ms`); } return success; }; export const activateVector = async () => { // Inject Vector's script (if not already injected) if (!window.avina_vectorScriptInjected) { injectScript(THIRD_PARTY_SCRIPTS.vector); window.avina_vectorScriptInjected = true; avinaLog('V resolution loaded...'); } // Ask Vector to identify the visitor let attempts = 0; const activate = async () => { try { // @ts-ignore if (typeof vector === 'undefined' && attempts < 5) { avinaLog('V resolution not yet loaded, retrying...'); attempts++; setTimeout(activate, 2500); return; } // Pass the visitor's ID to Vector if (window.vector) { window.vector.partnerId = glob.contactResolutionPayload; } // @ts-ignore await vector?.load('c80d8374-fa6c-444a-b596-2869133db0f7'); avinaLog('V resolution attempted'); avinaLog(`CR_ID: ${glob.contactResolutionPayload}`); // Set a cookie to remind us that Vector recently tried to identify this visitor Cookies.set('mesh-vec-status', 'success', { expires: 30 }); } catch (e) { avinaLog(`V resolution failed: ${e}`); } }; await activate(); }; export const activateRb2b = () => { if (window.avina_rb2bScriptInjected) return; // Clear `window.reb2b` in case it's set by a pre-existing instance of RB2B. window.reb2b = undefined as any; // Pass the visitor's ID (and timestamp, for metrics) to RB2B const payload = JSON.parse(glob.contactResolutionPayload); payload.rb2b_activated_at = new Date().toISOString(); window.rb2bConfig = { options: { customer_id: JSON.stringify(payload) }, }; // Inject RB2B's script injectScript(THIRD_PARTY_SCRIPTS.rb2b); window.avina_rb2bScriptInjected = true; avinaLog('R resolution loaded...'); avinaLog(`CR_ID: ${glob.contactResolutionPayload}`); // Set a cookie to remind us that RB2B recently tried to identify the visitor Cookies.set('mesh-rb2b-status', 'success', { expires: 30 }); }; /** * Waits until the visitor's session has lasted at least `resolutionDelayMs` * before returning `true`. Time elapsed is tracked with a cookie. * * The boolean return is useless as of writing, but remains reserved in case we ever want * this function to short-circuit `startContactResolution()`. */ export const waitForSessionDuration = async (): Promise => { if (glob.resolutionDelayMs <= 0) return true; let sessionStart = Cookies.get('mesh-session-start'); if (!sessionStart) { sessionStart = new Date().toISOString(); Cookies.set('mesh-session-start', sessionStart, { expires: SESSION_EXPIRY_DAYS, }); } const startTime = new Date(sessionStart).getTime(); // If we can't tell when the visitor's session began, get to resolvin' if (isNaN(startTime)) return true; const elapsedMs = Date.now() - startTime; const remainingMs = glob.resolutionDelayMs - elapsedMs; if (remainingMs <= 0) { avinaLog(`Session delay met after ${elapsedMs}ms.`); return true; } avinaLog(`Waiting another ${remainingMs}ms before resolution...`); await new Promise(resolve => setTimeout(resolve, remainingMs)); return true; }; export const startContactResolution = async () => { // Bail if not enabled if (!glob.contactResolutionEnabled) return; // Bail if already resolving if (glob.resolutionInProgress) { avinaLog('Resolution already in progress, skipping...'); return; } const rb2bStatus = Cookies.get('mesh-rb2b-status'); const vectorStatus = Cookies.get('mesh-vec-status'); // Bail if recently attempted if (rb2bStatus === 'success' || vectorStatus === 'success') { avinaLog('Resolution recently attempted, skipping...'); return; } glob.resolutionInProgress = true; try { // Wait for the visitor's session to exceed `resolutionDelayMs`. // NOTE: `waitForSessionDuration()` always returns `true`. The guard // below reserves a(n as of yet, unused) path for bailing on stale sessions. const sessionReady = await waitForSessionDuration(); if (!sessionReady) return; // Try RB2B first, because it's fast if (glob.rb2bEnabled) { avinaLog('Attempting R resolution...'); activateRb2b(); // Start tracking resolution runtime with a cookie Cookies.set('mesh-resolution-start', new Date().toISOString(), { expires: SESSION_EXPIRY_DAYS, }); const resolved = await waitForResolution(glob.primaryResolverTimeoutMs); if (resolved) { avinaLog('R resolution successful.'); return; } // Try Vector as a fallback, if RB2B timed out if (glob.vectorEnabled) { avinaLog('R resolution timed out, waterfalling to V...'); await activateVector(); } else { avinaLog('R resolution timed out, waterfalling not feasible.'); } } // Try Vector as a fallback, if RB2B was not enabled else if (glob.vectorEnabled) { avinaLog('Attempting V resolution...'); await activateVector(); } } catch (err) { avinaLog(`Resolution error: ${err}`); } finally { glob.resolutionInProgress = false; } }; /** * IIFE that runs on load and attach * global mesh functions to window */ if (typeof process === 'undefined' || process.env.NODE_ENV !== 'test') { (function() { var mesh: Mesh = {}; var avina: Mesh = {}; // initialize sdk initialize({ loadInteractions: true, loadForms: true, }); // sdk function mesh.init = async (key, config) => {}; avina.init = async (key, config) => {}; // expose ability to write user data mesh.identify = async (contactInfo: Contact) => { updateIdentity(contactInfo); }; // expose avina variant of identify api avina.identify = async (contactInfo: Contact) => { updateIdentity(contactInfo); }; // Wait for the window to load before // making the SDK available globally window.onload = function() { // define the namespace mesh window.mesh = mesh; // define the namespace avina window.avina = avina; }; // listen for popstate events to reinitialize // and run url change detection to handle the case // where it's an SPA and the page doesn't reload // when the url changes detectUrlChange(initialize); window.addEventListener('popstate', () => { avinaLog('URL changed, reinitializing...'); initialize({ loadInteractions: false, loadForms: false, }); }); })(); }