Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
9 changes: 9 additions & 0 deletions src/Rokt-Kit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ import {
writePageViews,
clearPageViews,
readCanonicalUrl,
captureUtmParams,
loadUtmParams,
clearUtmParams,
} from './pageViewStorage';
import { isLocalStorageAvailable } from './storage';

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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();
}
}

Expand Down Expand Up @@ -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<string, unknown> = {
Expand All @@ -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,
Expand Down
53 changes: 51 additions & 2 deletions src/pageViewStorage.ts
Original file line number Diff line number Diff line change
@@ -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<Record<UtmKey, string>>;

export interface PageEvent {
pageUrl: string;
sourceMessageId: string;
Expand Down Expand Up @@ -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',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

assuming this is just for the initial rollout verification and then we will remove?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Correct. I'll be removing these after the data has had time to soak.

});
}

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<HTMLLinkElement>('link[rel="canonical"]');
const href = link?.href;
Expand Down
80 changes: 80 additions & 0 deletions test/src/pageViewStorage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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<string, unknown>;
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<string, unknown>;
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);
});
});
});
Loading