diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index d3b5b9b..0c7c409 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -32,6 +32,9 @@ import { writePageViews, clearPageViews, readCanonicalUrl, + captureUtmParams, + loadUtmParams, + clearUtmParams, } from './pageViewStorage'; import { isLocalStorageAvailable } from './storage'; @@ -260,6 +263,7 @@ const USER_IDENTIFIED_IN_WORKSPACE_KEY = 'userIdentifiedInWorkspace'; const MESSAGE_TYPE_PAGE_VIEW = 3; // mParticle MessageType.PageView const MESSAGE_TYPE_SESSION_END = 2; // mParticle MessageType.SessionEnd const PAGE_EVENTS_KEY = 'page_events'; +const PAGE_VIEW_ATTRIBUTES_KEY = 'page_view_attributes'; const MPARTICLE_SESSION_ID_KEY = 'mparticle_session_id'; // Bound on how long selectPlacements will wait for an in-flight Workspace @@ -1158,6 +1162,7 @@ class RoktKit implements KitInterface { if (this.isTargetingDisabled()) { try { clearPageViews(); + clearUtmParams(); } catch (err) { this.errorReportingService?.report({ message: 'Rokt Kit: Failed to clear page views when targeting is disabled', @@ -1241,12 +1246,14 @@ class RoktKit implements KitInterface { public process(event: SDKEvent): string { if (!this.isTargetingDisabled()) { if (event.EventDataType === MESSAGE_TYPE_PAGE_VIEW) { + captureUtmParams(this.loggingService); this.capturePageView(event); } if (event.EventDataType === MESSAGE_TYPE_SESSION_END) { migrateLegacyPageViewStorage(this.loggingService); clearPageViews(); + clearUtmParams(); } } @@ -1466,6 +1473,7 @@ class RoktKit implements KitInterface { const sessionAttributes = this.returnLocalSessionAttributes(); const pageEvents = buildPageEvents(loadPageViews(this.loggingService)); + const utmParams = loadUtmParams(); const mpSessionId = this.readMpSessionId(); const selectPlacementsAttributes: Record = { @@ -1474,6 +1482,7 @@ class RoktKit implements KitInterface { ...optimizelyAttributes, ...sessionAttributes, ...(pageEvents.length ? { [PAGE_EVENTS_KEY]: JSON.stringify(pageEvents) } : {}), + ...(utmParams ? { [PAGE_VIEW_ATTRIBUTES_KEY]: utmParams } : {}), ...(this.userIdentifiedInWorkspace ? { [USER_IDENTIFIED_IN_WORKSPACE_KEY]: true } : {}), ...(mpSessionId ? { [MPARTICLE_SESSION_ID_KEY]: mpSessionId } : {}), mpid, diff --git a/src/pageViewStorage.ts b/src/pageViewStorage.ts index c06a8a3..8324ef1 100644 --- a/src/pageViewStorage.ts +++ b/src/pageViewStorage.ts @@ -1,12 +1,24 @@ import type { LoggingService } from './Rokt-Kit'; -import { readJSON, removeKey, readNamespacedField, writeNamespacedField, removeNamespacedField } from './storage'; -import { sanitizeUrl } from './utils'; +import { + readJSON, + removeKey, + readNamespacedField, + writeNamespacedField, + removeNamespacedField, + isLocalStorageAvailable, +} from './storage'; +import { sanitizeUrl, isObject } from './utils'; const LS_NAMESPACE_KEY = 'mp-rokt-kit'; const LS_PAGE_VIEWS_FIELD = 'pageViews'; +const LS_UTM_PARAMS_FIELD = 'utmParams'; const LEGACY_PAGE_VIEWS_KEY = 'mpPageViews'; const PAGE_VIEWS_MAX_COUNT = 25; +const UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'] as const; +type UtmKey = (typeof UTM_KEYS)[number]; +export type UtmParams = Partial>; + export interface PageEvent { pageUrl: string; sourceMessageId: string; @@ -79,6 +91,43 @@ export function buildPageEvents(pageViews: PageEvent[]): PageEvent[] { }); } +export function captureUtmParams(loggingService: LoggingService | null): void { + if (readNamespacedField(LS_NAMESPACE_KEY, LS_UTM_PARAMS_FIELD) !== undefined) { + return; + } + const search = new URLSearchParams(window.location.search); + const params: UtmParams = {}; + for (const key of UTM_KEYS) { + const value = search.get(key); + if (value) params[key] = value; + } + if (Object.keys(params).length === 0) { + return; + } + const captured = Object.keys(params).join(', '); + if (!writeNamespacedField(LS_NAMESPACE_KEY, LS_UTM_PARAMS_FIELD, params)) { + const reason = isLocalStorageAvailable() ? 'quota' : 'ls_unavailable'; + loggingService?.log({ + message: `Rokt Kit: Failed to persist UTM params [reason: ${reason}]`, + code: 'UTM_CAPTURE_FAILED', + }); + return; + } + loggingService?.log({ + message: `Rokt Kit: Captured UTM params [${captured}]`, + code: 'UTM_CAPTURE_SUCCESS', + }); +} + +export function loadUtmParams(): UtmParams | null { + const stored = readNamespacedField(LS_NAMESPACE_KEY, LS_UTM_PARAMS_FIELD); + return isObject(stored) ? (stored as UtmParams) : null; +} + +export function clearUtmParams(): void { + removeNamespacedField(LS_NAMESPACE_KEY, LS_UTM_PARAMS_FIELD); +} + export function readCanonicalUrl(): string | undefined { const link = document.querySelector('link[rel="canonical"]'); const href = link?.href; diff --git a/test/src/pageViewStorage.spec.ts b/test/src/pageViewStorage.spec.ts index 325eb48..61edaa6 100644 --- a/test/src/pageViewStorage.spec.ts +++ b/test/src/pageViewStorage.spec.ts @@ -6,13 +6,21 @@ import { writePageViews, clearPageViews, buildPageEvents, + captureUtmParams, + loadUtmParams, + clearUtmParams, } from '../../src/pageViewStorage'; import type { LoggingService } from '../../src/Rokt-Kit'; const NAMESPACE_KEY = 'mp-rokt-kit'; const PAGE_VIEWS_FIELD = 'pageViews'; +const UTM_PARAMS_FIELD = 'utmParams'; const LEGACY_PAGE_VIEWS_KEY = 'mpPageViews'; +function stubSearch(search: string): void { + vi.stubGlobal('location', { ...window.location, search }); +} + const pageView = (id: string) => ({ pageUrl: 'https://example.com/' + id, sourceMessageId: id, timestamp: 1 }); describe('pageViewStorage', () => { @@ -146,4 +154,76 @@ describe('pageViewStorage', () => { expect(blob).toHaveProperty('unrelatedField', 'keep-me'); }); }); + + describe('captureUtmParams', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('stores present UTM params on first call', () => { + stubSearch('?utm_source=google&utm_medium=cpc&utm_campaign=spring'); + captureUtmParams(null); + expect(readJSON(NAMESPACE_KEY)).toHaveProperty(UTM_PARAMS_FIELD, { + utm_source: 'google', + utm_medium: 'cpc', + utm_campaign: 'spring', + }); + }); + + it('only stores the keys that are present', () => { + stubSearch('?utm_source=email'); + captureUtmParams(null); + const stored = readJSON(NAMESPACE_KEY) as Record; + expect(stored[UTM_PARAMS_FIELD]).toEqual({ utm_source: 'email' }); + }); + + it('does nothing when no UTM params are in the URL', () => { + stubSearch('?unrelated=value'); + captureUtmParams(null); + expect(readJSON(NAMESPACE_KEY)).toBeNull(); + }); + + it('does nothing when the URL has no query string', () => { + stubSearch(''); + captureUtmParams(null); + expect(readJSON(NAMESPACE_KEY)).toBeNull(); + }); + + it('first touch wins — does not overwrite when UTMs are already stored', () => { + stubSearch('?utm_source=google'); + captureUtmParams(null); + stubSearch('?utm_source=facebook&utm_medium=paid'); + captureUtmParams(null); + const stored = readJSON(NAMESPACE_KEY) as Record; + expect(stored[UTM_PARAMS_FIELD]).toEqual({ utm_source: 'google' }); + }); + }); + + describe('loadUtmParams', () => { + it('returns null when nothing is stored', () => { + expect(loadUtmParams()).toBeNull(); + }); + + it('returns the stored UTM params', () => { + writeNamespacedField(NAMESPACE_KEY, UTM_PARAMS_FIELD, { utm_source: 'google', utm_medium: 'cpc' }); + expect(loadUtmParams()).toEqual({ utm_source: 'google', utm_medium: 'cpc' }); + }); + + it('returns null when the stored value is not an object', () => { + writeNamespacedField(NAMESPACE_KEY, UTM_PARAMS_FIELD, 'invalid'); + expect(loadUtmParams()).toBeNull(); + }); + }); + + describe('clearUtmParams', () => { + it('removes the utmParams field without affecting other fields', () => { + writeNamespacedField(NAMESPACE_KEY, UTM_PARAMS_FIELD, { utm_source: 'google' }); + writeNamespacedField(NAMESPACE_KEY, PAGE_VIEWS_FIELD, [pageView('home')]); + clearUtmParams(); + + const blob = readJSON(NAMESPACE_KEY); + expect(blob).not.toHaveProperty(UTM_PARAMS_FIELD); + expect(blob).toHaveProperty(PAGE_VIEWS_FIELD); + }); + }); });