diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 0ba7f23..937cc4f 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -26,13 +26,15 @@ import { import { PageEvent, + buildPageEvents, migrateLegacyPageViewStorage, loadPageViews, writePageViews, clearPageViews, + readCanonicalUrl, } from './pageViewStorage'; -import { isObject, isString, isEmpty } from './utils'; +import { isObject, isString, isEmpty, sanitizeUrl } from './utils'; interface RoktKitSettings { accountId: string; @@ -445,19 +447,6 @@ function hashEventMessage(messageType: number, eventType: number, eventName: str return mp().generateHash([messageType, eventType, eventName].join('')); } -// Strips the query string from a page-view URL before it is persisted and sent -// to Rokt, since query params commonly carry PII (emails, tokens, order refs). -// Returns the input unchanged if it can't be parsed as a URL. -function sanitizeUrl(href: string): string { - try { - const url = new URL(href); - url.search = ''; - return url.toString(); - } catch { - return href; - } -} - function generateIntegrationName(customIntegrationName?: string): string { const coreSdkVersion = mp().getVersion(); const kitVersion = process.env.PACKAGE_VERSION; @@ -709,6 +698,22 @@ class LoggingService { } } +function buildPageEvent(event: SDKEvent): PageEvent { + const pageUrl = sanitizeUrl(window.location.href); + const pageTitle = event.EventAttributes?.title || document.title; + const canonicalUrl = readCanonicalUrl(); + const activeTimeOnSite = event.ActiveTimeOnSite; + + return { + pageUrl, + sourceMessageId: event.SourceMessageId, + timestamp: event.Timestamp, + ...(pageTitle ? { pageTitle } : {}), + ...(canonicalUrl !== undefined ? { canonicalUrl } : {}), + ...(Number.isFinite(activeTimeOnSite) ? { activeTimeOnSite } : {}), + }; +} + // ============================================================ // RoktKit class // ============================================================ @@ -860,17 +865,7 @@ class RoktKit implements KitInterface { pageUrl = sanitizeUrl(window.location.href); const pageViews = loadPageViews(this.loggingService); - - const pageView: PageEvent = { - pageUrl, - sourceMessageId: event.SourceMessageId, - timestamp: event.Timestamp, - }; - - if (Number.isFinite(event.ActiveTimeOnSite)) { - pageView.activeTimeOnSite = event.ActiveTimeOnSite; - } - + const pageView = buildPageEvent(event); pageViews.push(pageView); if (!writePageViews(pageViews)) { @@ -913,34 +908,6 @@ class RoktKit implements KitInterface { return mp().Rokt.getLocalSessionAttributes!(); } - private buildPageEvents(pageViews: PageEvent[]): PageEvent[] { - return pageViews.map((pageView, index) => { - const pageEvent: PageEvent = { - pageUrl: pageView.pageUrl, - sourceMessageId: pageView.sourceMessageId, - timestamp: pageView.timestamp, - }; - - const activeTimeOnSite = pageView.activeTimeOnSite; - const hasActiveTime = activeTimeOnSite !== undefined && Number.isFinite(activeTimeOnSite); - if (hasActiveTime) { - pageEvent.activeTimeOnSite = activeTimeOnSite; - } - - const next = pageViews[index + 1]; - const nextActiveTimeOnSite = next?.activeTimeOnSite; - const hasNextActiveTimeOnSite = nextActiveTimeOnSite !== undefined && Number.isFinite(nextActiveTimeOnSite); - - if (hasActiveTime && hasNextActiveTimeOnSite) { - const diff = nextActiveTimeOnSite - activeTimeOnSite; - if (diff >= 0) { - pageEvent.activeTimeOnPage = diff; - } - } - return pageEvent; - }); - } - private replaceOtherIdentityWithEmailsha256(userIdentities: IUserIdentities): Record { const newUserIdentities: Record = { ...(userIdentities || {}) }; const key = this._mappedEmailSha256Key; @@ -1476,7 +1443,7 @@ class RoktKit implements KitInterface { const filteredUserIdentities = this.returnUserIdentities(filteredUser); const sessionAttributes = this.returnLocalSessionAttributes(); - const pageEvents = this.buildPageEvents(loadPageViews(this.loggingService)); + const pageEvents = buildPageEvents(loadPageViews(this.loggingService)); const selectPlacementsAttributes: Record = { ...(filteredUserIdentities as Record), diff --git a/src/pageViewStorage.ts b/src/pageViewStorage.ts index 3c22086..e52ee77 100644 --- a/src/pageViewStorage.ts +++ b/src/pageViewStorage.ts @@ -7,6 +7,7 @@ import { removeNamespacedField, writeNamespacedFieldWithinBudget, } from './storage'; +import { sanitizeUrl } from './utils'; const LS_NAMESPACE_KEY = 'mp-rokt-kit'; const LS_PAGE_VIEWS_FIELD = 'pageViews'; @@ -17,6 +18,8 @@ export interface PageEvent { pageUrl: string; sourceMessageId: string; timestamp: number; + pageTitle?: string; + canonicalUrl?: string; activeTimeOnSite?: number; activeTimeOnPage?: number; } @@ -57,3 +60,35 @@ export function writePageViews(pageViews: PageEvent[]): boolean { export function clearPageViews(): void { removeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD); } + +export function buildPageEvents(pageViews: PageEvent[]): PageEvent[] { + return pageViews.map((pageView, index) => { + const activeTimeOnSite = pageView.activeTimeOnSite; + const hasActiveTime = activeTimeOnSite !== undefined && Number.isFinite(activeTimeOnSite); + + const next = pageViews[index + 1]; + const nextActiveTimeOnSite = next?.activeTimeOnSite; + const hasNextActiveTimeOnSite = nextActiveTimeOnSite !== undefined && Number.isFinite(nextActiveTimeOnSite); + + const diff = hasActiveTime && hasNextActiveTimeOnSite ? nextActiveTimeOnSite - activeTimeOnSite : undefined; + + return { + pageUrl: pageView.pageUrl, + sourceMessageId: pageView.sourceMessageId, + timestamp: pageView.timestamp, + ...(pageView.pageTitle !== undefined ? { pageTitle: pageView.pageTitle } : {}), + ...(pageView.canonicalUrl !== undefined ? { canonicalUrl: pageView.canonicalUrl } : {}), + ...(hasActiveTime ? { activeTimeOnSite } : {}), + ...(diff !== undefined && diff >= 0 ? { activeTimeOnPage: diff } : {}), + }; + }); +} + +export function readCanonicalUrl(): string | undefined { + const link = document.querySelector('link[rel="canonical"]'); + const href = link?.href; + if (!href) { + return undefined; + } + return sanitizeUrl(href); +} diff --git a/src/utils.ts b/src/utils.ts index 0d839b2..dbeebf4 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -13,3 +13,16 @@ export function isEmpty(value: unknown): boolean { } return false; } + +// Strips the query string from a URL before it is persisted and sent to Rokt, +// since query params commonly carry PII (emails, tokens, order refs). +// Returns the input unchanged if it can't be parsed as a URL. +export function sanitizeUrl(href: string): string { + try { + const url = new URL(href); + url.search = ''; + return url.toString(); + } catch { + return href; + } +} diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index 7edfc49..636dbf3 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -5686,11 +5686,125 @@ describe('Rokt Forwarder', () => { pageUrl: window.location.href, sourceMessageId: 'source-message-id-1', timestamp: 1712345678000, + pageTitle: 'Home', activeTimeOnSite: 4200, }, ]); }); + it('captures the page title and canonical URL when present', async () => { + document.title = 'Home Page Title'; + const canonical = document.createElement('link'); + canonical.setAttribute('rel', 'canonical'); + canonical.setAttribute('href', 'https://example.com/canonical?tracking=abc#section'); + document.head.appendChild(canonical); + + try { + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + + (window as any).mParticle.forwarder.process({ + EventName: 'Home Page', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + SourceMessageId: 'source-message-id-title', + Timestamp: 1712345678000, + }); + + expect(readStoredPageViews()).toEqual([ + { + pageUrl: window.location.href, + sourceMessageId: 'source-message-id-title', + timestamp: 1712345678000, + pageTitle: 'Home Page Title', + canonicalUrl: 'https://example.com/canonical#section', + }, + ]); + } finally { + document.title = ''; + document.head.removeChild(canonical); + } + }); + + it('prefers the event title over document.title when both are present', async () => { + document.title = 'Document Title'; + + try { + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + + (window as any).mParticle.forwarder.process({ + EventName: 'Home Page', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + SourceMessageId: 'source-message-id-event-title', + Timestamp: 1712345678000, + EventAttributes: { + title: 'Event Title', + }, + }); + + expect(readStoredPageViews()).toEqual([ + { + pageUrl: window.location.href, + sourceMessageId: 'source-message-id-event-title', + timestamp: 1712345678000, + pageTitle: 'Event Title', + }, + ]); + } finally { + document.title = ''; + } + }); + + it('omits pageTitle and canonicalUrl when neither is available', async () => { + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + + (window as any).mParticle.forwarder.process({ + EventName: 'Home Page', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + SourceMessageId: 'source-message-id-bare', + Timestamp: 1712345678000, + }); + + expect(readStoredPageViews()).toEqual([ + { + pageUrl: window.location.href, + sourceMessageId: 'source-message-id-bare', + timestamp: 1712345678000, + }, + ]); + }); + it('omits activeTimeOnSite when the source value is non-finite', async () => { await (window as any).mParticle.forwarder.init( { @@ -6238,6 +6352,43 @@ describe('Rokt Forwarder', () => { ]); }); + it('carries pageTitle and canonicalUrl through selectPlacements when stored', async () => { + seedStoredPageViews([ + { + pageUrl: 'https://example.com/a', + sourceMessageId: 'source-message-id-a', + timestamp: 1712345678000, + pageTitle: 'Page A', + canonicalUrl: 'https://example.com/canonical-a', + }, + ]); + + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + + await (window as any).mParticle.forwarder.selectPlacements({ attributes: {} }); + + const forwardedAttributes = (window as any).mParticle.Rokt.selectPlacementsOptions.attributes; + expect(JSON.parse(forwardedAttributes.page_events)).toEqual([ + { + pageUrl: 'https://example.com/a', + sourceMessageId: 'source-message-id-a', + timestamp: 1712345678000, + pageTitle: 'Page A', + canonicalUrl: 'https://example.com/canonical-a', + }, + ]); + }); + it('does not add a page_events attribute when no page views are stored', async () => { await (window as any).mParticle.forwarder.init( {