import { build, CountMinSketchOptions } from '@getkoala/edge-api-client' import { RawProfile } from '@getkoala/edge-api-client/dist/raw-profile' import { CookieAttributes } from 'js-cookie' import { BootstrapData } from '../api/bootstrap' import { AnonymousProfile, collectIdentify, collectPages, Identify, Qualification, QualificationResult, qualify } from '../api/collect' import * as utkApi from '../api/utk' import { ProjectSettings } from '../browser' import { consumer } from '../channels' import { createProfileSubscription, ProfileSubscription } from '../channels/profile-channel' import { version } from '../generated/version' import { domReady } from '../lib/dom-ready' import { isValidBase64 } from '../lib/is-base-64' import { when } from '../lib/when' import { getCookie } from './cookies' import { EventContext, EventOptions } from './event-context' import { Emitter } from './event-emitter' import type { Event } from './event-queue' import { EventQueue } from './event-queue' import { collectFormSubmissions, stopCollectingForms, validEmail } from './forms' import { MetricsQueue } from './metrics-queue' import { PageTracker, PageView } from './page/page-tracker' import { initArcade } from './plugins/arcade' import { initDrift } from './plugins/drift' import { initFullstory } from './plugins/fullstory' import { initHubSpot } from './plugins/hubspot' import { initIntercom } from './plugins/intercom' import { initNavattic } from './plugins/navattic' import { initPostHogScreenRecording } from './plugins/posthog-screen-recording' import { initQualified } from './plugins/qualified' import { Session, session } from './session' import { topDomain } from './top-domain' import { User, UserStore } from './user' import { FormCollectionOptions } from './forms' import { initCalendly } from './plugins/calendly' import { initStorylane } from './plugins/storylane' declare global { interface Window { drift?: any navattic?: any rudderanalytics?: any qualified?: any Intercom?: any FS?: any posthog?: any _hsq?: { push: (callParam: any) => void } _hstc_ran?: boolean _ko_hsq?: boolean } } const search = window.location.search let domain = window.location.hostname try { domain = topDomain(new URL(window.location.href)) || window.location.hostname } catch (_) { // ignore } export interface Profile { page_views: PageView[] user: User email?: string referrer: string events: unknown[] | undefined traits: object | undefined qualification?: QualificationResult } interface Serialized { p?: PageView[] r?: string s?: Date | string e?: Event[] q?: QualificationResult a?: AnonymousProfile rp?: RawProfile } export interface Options { project: string profileId?: string a?: AnonymousProfile | null // Should Koala automatically hook into Segment, default is `true` hookSegment?: boolean } export interface KoalaEventMap { initialized: [BootstrapData] track: [string, { [key: string]: unknown }] identify: [string | undefined, Record] 'profile-update': [] 'profile-id-update': [string] qualification: [Qualification] } export type CollectorOptions = Partial & Options function checkReferrer(referrers: string[]) { if (referrers.length === 0) return true const host = window.location.host const allowed = referrers.some((referrer) => { try { return new RegExp(referrer).test(host) } catch (e) { return true } }) return allowed } const wrap = (value: any, fn: any) => (...args: any[]) => fn(value, ...args) export class AnalyticsCollector extends Emitter { version = version qualification?: QualificationResult stats: MetricsQueue options: CollectorOptions private referrer: string eventQueue: EventQueue private initialized = false subscription: ProfileSubscription | null = null pageTracker: PageTracker private bootstrapData?: BootstrapData private autocapture = true private referrerAllowed = true private geoAllowed = true user: UserStore context: EventContext edge: ReturnType constructor(options: CollectorOptions) { super() this.options = options this.referrerAllowed = checkReferrer(options.sdk_settings?.authorized_referrers || []) this.geoAllowed = options.sdk_settings?.geo_allowed ?? true this.autocapture = this.referrerAllowed && this.geoAllowed && (options.sdk_settings?.autocapture ?? true) const project = this.options.project as string const existing = this.deserialize() this.referrer = existing.r || document.referrer this.user = new UserStore({ cookies: this.options.sdk_settings?.cookie_defaults }) this.context = new EventContext(this.options) this.qualification = existing.q const anonymousProfile = this.options.a || existing.a || {} const rawProfile = existing.rp || {} this.edge = build(anonymousProfile) if (rawProfile) { this.edge.rawProfile = rawProfile as RawProfile } this.stats = new MetricsQueue({ flushInterval: 1_000 }, project, this.context) this.eventQueue = new EventQueue({ flushInterval: 1_000 }, project, this.context) this.pageTracker = new PageTracker(this.context) this.pageTracker.on('page', (pages: PageView[]) => { if (!pages?.length) return if (!this.autocapture) return if (!this.referrerAllowed) return if (!this.geoAllowed) return // only index the latest page const latest = pages[pages.length - 1] this.edge.index(latest) const hasId = () => this.initialized && Boolean(this.user.id()) const collect = () => { const profileId = this.user.id() as string collectPages(project, profileId, pages) } if (!hasId()) { this.when(hasId, collect, { retries: 10, alwaysResolve: true }) } else { collect() } }) // Flush stats + profile when page is hidden document.addEventListener('visibilitychange', () => { if (document.hidden) { this.flush() } }) if (options.hookSegment !== false) { this.detectSegment() } this.detectRudder() this.detectHubspot() setTimeout(() => { this.detectorStats() }, 5000) this.once('initialized', (settings: BootstrapData) => { this.initialized = true this.bootstrapData = settings // Track initial load of the SDK w/ configuration this.stats.increment('sdk.loaded', { page: window.location.pathname }) if (!this.referrerAllowed) { console.warn('[KOALA]', 'Current domain not allowed to load the SDK') this.stats.increment('sdk.referrer.blocked', { host: window.location.host }) } if (!this.geoAllowed) { this.stats.increment('sdk.geo.blocked', { host: window.location.host }) } this.detectIdLink() if (this.autocapture) { this.initPlugins() } if (settings.sdk_settings.querystring_collection !== 'off') { this.detectKoTraits() } if (this.autocapture && settings.sdk_settings.form_collection !== 'off') { this.collectForms({ ignoredForms: this.options.sdk_settings?.ignored_forms || [] }) } }) } async ready(fn?: () => Promise | unknown) { return domReady(async () => { if (this.initialized || this.qualification) { if (fn) { await fn() } return Promise.resolve(undefined) } return new Promise((resolve) => { this.once('initialized', async () => { if (fn) { await fn() } resolve(undefined) }) }) }) } public cookieDefaults(): CookieAttributes { return this.options.sdk_settings?.cookie_defaults || {} } public collectForms = (options: FormCollectionOptions = {}) => { // Most form submits should be same origin, but we should allowlist some others // note: hubspot is tracked via their window.message event const allowedOrigins = [domain, 'salesforce.com', 'pardot.com', 'list-manage.com'] collectFormSubmissions(async (details) => { let allowedOrigin = true if (details.action) { const formUrl = new URL(details.action) if (formUrl.hostname) { allowedOrigin = allowedOrigins.some((o) => formUrl.hostname.endsWith(o)) } } if (allowedOrigin) { if (Object.keys(details.formData).length > 0) { this.track('$submit', details as any) } const traits = { ...details.traits } as Record if (traits.email) { traits.email = traits.email.trim().toLowerCase() } else { // remove it if empty ('', null, undefined, etc) delete traits.email } // Prevent overriding emails from form autotracking when we already have one // if the traits from the form are associated with a different email, // we should exclude the identify entirely if (Object.keys(traits).length > 0 && (!traits.email || !this.email || traits.email === this.email)) { this.identify(traits, { source: 'form' }) } } }, options) } private detectIdLink() { this.detectUtmId() this.detectKoEmail() // TODO: add SDK option to clear tracking params } // Collect and identify params that startwith ko_ca private detectKoTraits() { const searchParams = new URLSearchParams(window.location.search) const traitParams = Array.from(searchParams.entries()) .filter(([key]) => key.startsWith('ko_trait_')) .reduce( (acc, [key, value]) => { const traitKey = key.replace('ko_trait_', '') acc[traitKey] = value return acc }, {} as Record ) if (Object.keys(traitParams).length > 0) { this.identify(traitParams, { source: 'querystring' }) } } private detectKoEmail() { const searchParams = new URLSearchParams(search) let koalaEmail = searchParams.get('ko_e') || searchParams.get('ko_email') if (koalaEmail && !this.email) { koalaEmail = koalaEmail.trim() try { if (validEmail(koalaEmail)) { const source = searchParams.get('k_is') || 'ko_email' this.identify({ email: koalaEmail }, { source }) } } catch (_err) { // ignore } } } private detectUtmId() { const searchParams = new URLSearchParams(search) const utmId = searchParams.get('utm_id') if (utmId && isValidBase64(utmId) && !this.email) { try { const decoded = atob(utmId.trim()) if (validEmail(decoded)) { const source = searchParams.get('k_is') || 'utm_id' this.identify({ email: decoded }, { source }) } } catch (_err) { // ignore } } } private initPlugins = () => { const settings = this.options?.sdk_settings || {} initHubSpot(this) if (settings.autotrack_arcade) initArcade(this) if (settings.autotrack_calendly) initCalendly(this) if (settings.autotrack_drift) initDrift(this) if (settings.autotrack_intercom) initIntercom(this) if (settings.autotrack_navattic) initNavattic(this) if (settings.autotrack_qualified) initQualified(this) if (settings.autotrack_fullstory) initFullstory(this) if (settings.autotrack_posthog_screen_recording) initPostHogScreenRecording(this) if (settings.autotrack_storylane) initStorylane(this) } private detectorStats() { try { const w = window as any const integrations: Record = { '6sense': !!localStorage.getItem('_6senseCompanyDetails'), Albacross: !!w.AlbacrossReveal?.company, Clearbit: !!w.reveal, Dealfront: !!w.discover?.data?.company, Demandbase: !!w.Demandbase?.Segments?.CompanyProfile, Drift: !!w.drift, Intercom: !!w.Intercom, Klaviyo: !!w.klaviyo, Marketo: !!w.MktoForms2, Leadoo: !!w.Leadoo, Pardot: !!w.getPardotUrl, Qualified: !!w.qualified, Rollworks: !!w.__adroll_loaded, Triblio: !!w.Triblio?.getAccountIdentification(), ZoomInfo: !!localStorage.getItem('_ziVisitorInfo') } Object.keys(integrations).forEach((key) => { if (integrations[key]) { this.stats.increment(`sdk.${key}`) } }) } catch (_err) { // do nothing } } private flush() { this.serialize() this.eventQueue.flush() this.stats.flush() } public stopAutocapture() { if (!this.autocapture) { return } this.autocapture = false this.pageTracker.stopAutocapture() this.unsubscribe() stopCollectingForms() } public startAutocapture() { if (this.autocapture) { return } this.autocapture = true this.pageTracker.startAutocapture() this.subscribe() if (this.options.sdk_settings?.form_collection !== 'off') { this.collectForms({ ignoredForms: this.options.sdk_settings?.ignored_forms || [] }) } } public get session(): Session { return session.fetch(this.options.sdk_settings) } public get email() { return this.user.email() } private detectHubspot() { const condition = () => window._hstc_ran && window._hsq && window._hsq.push !== Array.prototype.push const callback = () => { try { const utk = getCookie('hubspotutk') if (utk) { utkApi.utk({ project: this.options.project, profile_id: this.user.id() as string, utk }) } } catch (error) { console.warn('[KOALA]', error) } if (!window._hsq || !!window._ko_hsq) { return } // so we don't attempt to wrap it twice ever window._ko_hsq = true window._hsq.push = wrap(window._hsq.push, (og: any, ...args: any[]) => { try { const call = args[0] if (Array.isArray(call)) { const [event, properties] = call if (event === 'identify') { this.identify(properties, { source: 'hubspot_hsq' }) } // do not double count events if segment is also installed // TODO: check if segment is loading hubspot or implement deduping of events // in the SDK if (event === 'trackCustomBehavioralEvent' && !window.analytics) { this.track(properties.name, properties.properties) } } // needs to bind to `window._hsq` array otherwise this will fail! return og.apply(window._hsq, args) } catch (error) { console.warn('[KOALA] HubSpot wrap error:', error) } }) } this.when(condition, callback, { timeout: 1000, retries: 10 }) } private detectSegment() { // This allows Segment to be loaded before or after Koala, it doesn't matter // We'll recheck if ajs is present up to 10 times (we dont want to keep checking if AJS isn't installed!) const condition = () => typeof window.analytics !== 'undefined' && typeof window.analytics.ready === 'function' const callback = () => { if (!condition()) return window.analytics.ready(() => { const ajs = window.analytics const userTraits = ajs.user().traits() this.identify(userTraits as unknown as Record, { source: 'segment' }) ajs.on('invoke', () => { const userTraits = ajs.user().traits() this.identify(userTraits as unknown as Record, { source: 'segment' }) }) ajs.on('track', (event, properties) => { if (this.bootstrapData?.sdk_settings.segment_auto_track !== 'off') { this.track(event, properties as { [key: string]: unknown }) } }) ajs.on('identify', (_id, traits) => { this.identify(traits as Record, { source: 'segment' }) }) ajs.on('reset', () => { this.reset() }) }) } this.when(condition, callback, { timeout: 100, retries: 20, alwaysResolve: true }) } private detectRudder() { // This allows Rudder to be loaded before or after Koala, it doesn't matter // We'll recheck if rudder is present up to 10 times (we dont want to keep checking if Rudder isn't installed!) const condition = () => typeof window.rudderanalytics !== 'undefined' && typeof window.rudderanalytics.ready === 'function' const callback = () => { if (!condition()) return window.rudderanalytics.ready(() => { const rudder = window.rudderanalytics const userTraits = rudder.getUserTraits() let groupTraits = {} if ('getGroupTraits' in rudder) { groupTraits = rudder.getGroupTraits() || {} } if (Object.keys(userTraits).length > 0) { let traits = userTraits as unknown as Record if (Object.keys(groupTraits).length > 0) { traits = { ...traits, $account: groupTraits } } this.identify(traits, { source: 'rudderstack' }) } if (this.options.sdk_settings?.autotrack_rudderstack !== false) { rudder.track = wrap(rudder.track, (og: any, ...args: any) => { const name = args[0] const props = args[1] if (typeof name === 'string') { this.track(name, props || {}).catch((err) => { console.warn('[KOALA]', err) }) } return og(...args) }) } rudder.identify = wrap(rudder.identify, (og: any, ...args: any) => { const id = args[0] const traits = args[1] || {} if (typeof id === 'string' && typeof traits === 'object' && Object.keys(traits as object).length > 0) { this.identify(traits as Record, { source: 'rudderstack' }).catch((err) => { console.warn('[KOALA]', err) }) } return og(...args) }) }) } this.when(condition, callback, { timeout: 1000, retries: 10, alwaysResolve: true }) } async track(event: string, properties: { [key: string]: unknown } = {}, options?: EventOptions) { event = event.trim() if (!event || !this.referrerAllowed || !this.geoAllowed) return const type = event === '$submit' ? 'submit' : 'track' this.eventQueue.track(event, properties, type, options) this.edge.index({ event, properties }) // flush immediately if it's a form submit if (type === 'submit') { this.flush() } this.emit('track', event, properties) } async identify(email: string, traits?: Record, options?: EventOptions): Promise async identify(traits: Record, options?: EventOptions): Promise async identify(...args: any[]) { if (!this.referrerAllowed || !this.geoAllowed) return let traits: Record = {} let options: EventOptions = {} if (typeof args[0] === 'string') { traits = { ...(args[1] || {}), email: args[0] } options = args[2] || {} } else { traits = args[0] options = args[1] || {} } if (!traits || Object.keys(traits).length === 0) { return } // pull from canonical `email` trait, but look for others that have a valid address const emails = [traits.email, traits.email_address, traits.emailAddress].filter((value) => { return value && validEmail(value) }) // use the first valid email if (emails.length > 0) { traits.email = String(emails[0]).trim() } else { delete traits.email } const incomingTraits = this.user.netNewTraits(traits) // always include email, if present if (traits.email) { incomingTraits.email = traits.email } if (Object.keys(incomingTraits).length === 0) { return } // New identity detected, refresh the profile if (this.email && incomingTraits.email && incomingTraits.email != this.email) { this.reset() } this.user.upsertTraits(incomingTraits) this.edge.index(incomingTraits) const event: Identify = { context: { ...this.context.current('identify'), source: options.source || 'identify' }, type: 'identify', traits: incomingTraits, sent_at: new Date().toISOString() } collectIdentify(this.options.project, this.profile, event) this.emit('identify', this.user.id(), traits) } public subscribe() { if (!this.referrerAllowed) return if (!this.geoAllowed) return if (this.bootstrapData?.sdk_settings?.websocket_connection === 'off') return if (this.bootstrapData?.edge_api === false) return this.when(() => Boolean(this.user.id())) .then(() => { const profileId = this.user.id() as string const project = this.options.project this.unsubscribe() const client = consumer(profileId, project) this.subscription = createProfileSubscription(client, this, (data: any) => { if (data.action === 'score') { this.updateQualification(data.data) } if (data.action === 'anonymous_profile') { this.buildAnonymousProfile(data.data) } if (data.action === 'edge_profile') { this.edge.rawProfile = data.data } }) }) .catch((error) => { console.warn('[KOALA]', 'Error subscribing to profile.', error) }) } public unsubscribe() { this.subscription?.unsubscribe() this.subscription = null } private buildAnonymousProfile(anonymousProfile: AnonymousProfile) { const rawProfile = this.edge.rawProfile this.edge = build(anonymousProfile || {}) if (rawProfile) { this.edge.rawProfile = rawProfile } this.emit('profile-update') } private updateQualification(result: Qualification) { const { profile_id, qualification, a } = result this.qualification = qualification this.emit('qualification', result) if (a) { this.buildAnonymousProfile(a) } if (profile_id !== this.user.id()) { this.user.setId(profile_id) this.emit('profile-id-update', profile_id) } } async qualify(email?: string) { try { email = email?.trim() if (email) { this.user.upsertTraits({ email }) this.edge.index({ email }) } const result = await qualify(this.options.project, this.profile) this.updateQualification(result) return result } catch (error) { this.trackError(error as Error, 'qualify') throw error } } private serialize() { const raw: Serialized = { r: this.referrer, q: this.qualification, a: { b: this.edge.raw.bloom.toHash(), c: this.edge.raw.counts.toHash() as CountMinSketchOptions }, rp: this.edge.rawProfile } window.localStorage.setItem('ka', JSON.stringify(raw)) } private deserialize() { const serialized = window.localStorage.getItem('ka') || '{}' return JSON.parse(serialized) as Serialized } public get profile(): Profile { return { page_views: this.pageTracker.allPages(), user: this.user.userInfo(), referrer: this.referrer, events: this.eventQueue.events, email: this.user.traits().email, traits: this.user.traits(), qualification: this.qualification } } public async reset() { this.eventQueue.send(true) this.eventQueue.reset() this.stats.send(true) this.stats.reset() this.pageTracker.reset() this.unsubscribe() this.user.reset() window.localStorage.removeItem('ka') this.qualification = undefined this.edge = build({}) session.clear() this.subscribe() } public trackError = (error: Error, method?: string) => { if (error) { this.stats.increment('sdk.error', { method: method || 'general', message: error?.message }) } } public get when() { return when } /** Backwards compatibility **/ public get e() { return this.edge.events } public get p() { return this.edge.traits } public get page() { return this.edge.page } public mountWidget() {} }