From bbb5a65b4e6869ab18568302fa702c2b4e9e294f Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Thu, 6 Aug 2026 22:27:55 -0400 Subject: [PATCH 1/3] feat: capture page title and canonical URL in page view events Add optional pageTitle and canonicalUrl fields to captured page-view records so they surface in the page_events attribute sent to selectPlacements. - pageTitle is read from document.title - canonicalUrl is read from and sanitized through the same helper as pageUrl (query string stripped, hash fragment retained) Both fields are omitted when unavailable, matching the existing activeTimeOnSite handling. --- src/Rokt-Kit.ts | 27 ++++++++++ src/pageViewStorage.ts | 2 + test/src/tests.spec.ts | 110 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 0ba7f23..5009f4d 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -458,6 +458,15 @@ function sanitizeUrl(href: string): string { } } +function readCanonicalUrl(): string | undefined { + const link = document.querySelector('link[rel="canonical"]'); + const href = link?.href; + if (!href) { + return undefined; + } + return sanitizeUrl(href); +} + function generateIntegrationName(customIntegrationName?: string): string { const coreSdkVersion = mp().getVersion(); const kitVersion = process.env.PACKAGE_VERSION; @@ -867,6 +876,16 @@ class RoktKit implements KitInterface { timestamp: event.Timestamp, }; + const pageTitle = document.title; + if (pageTitle) { + pageView.pageTitle = pageTitle; + } + + const canonicalUrl = readCanonicalUrl(); + if (canonicalUrl) { + pageView.canonicalUrl = canonicalUrl; + } + if (Number.isFinite(event.ActiveTimeOnSite)) { pageView.activeTimeOnSite = event.ActiveTimeOnSite; } @@ -921,6 +940,14 @@ class RoktKit implements KitInterface { timestamp: pageView.timestamp, }; + if (pageView.pageTitle !== undefined) { + pageEvent.pageTitle = pageView.pageTitle; + } + + if (pageView.canonicalUrl !== undefined) { + pageEvent.canonicalUrl = pageView.canonicalUrl; + } + const activeTimeOnSite = pageView.activeTimeOnSite; const hasActiveTime = activeTimeOnSite !== undefined && Number.isFinite(activeTimeOnSite); if (hasActiveTime) { diff --git a/src/pageViewStorage.ts b/src/pageViewStorage.ts index 3c22086..dc4efa0 100644 --- a/src/pageViewStorage.ts +++ b/src/pageViewStorage.ts @@ -17,6 +17,8 @@ export interface PageEvent { pageUrl: string; sourceMessageId: string; timestamp: number; + pageTitle?: string; + canonicalUrl?: string; activeTimeOnSite?: number; activeTimeOnPage?: number; } diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index 7edfc49..82167d5 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -5691,6 +5691,79 @@ describe('Rokt Forwarder', () => { ]); }); + 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('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 +6311,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( { From 68785468324092647a6b31098205aa086a017d87 Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Thu, 13 Aug 2026 17:06:03 -0400 Subject: [PATCH 2/3] feat: prefer event title attribute over document.title for page views Address PR review: read pageTitle from event.EventAttributes.title when available, falling back to document.title. The event attribute is the authoritative title for the page view and covers the edge case where it differs from the live document.title. --- src/Rokt-Kit.ts | 2 +- test/src/tests.spec.ts | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 5009f4d..07649c1 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -876,7 +876,7 @@ class RoktKit implements KitInterface { timestamp: event.Timestamp, }; - const pageTitle = document.title; + const pageTitle = event.EventAttributes?.title || document.title; if (pageTitle) { pageView.pageTitle = pageTitle; } diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index 82167d5..636dbf3 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -5686,6 +5686,7 @@ describe('Rokt Forwarder', () => { pageUrl: window.location.href, sourceMessageId: 'source-message-id-1', timestamp: 1712345678000, + pageTitle: 'Home', activeTimeOnSite: 4200, }, ]); @@ -5734,6 +5735,46 @@ describe('Rokt Forwarder', () => { } }); + 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( { From 08ca8e229cd6cdbf9c7ff214f8e0bb12e8314b66 Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Fri, 14 Aug 2026 11:16:04 -0400 Subject: [PATCH 3/3] refactor: extract buildPageEvent and use spread-based construction --- src/Rokt-Kit.ts | 102 +++++++++-------------------------------- src/pageViewStorage.ts | 33 +++++++++++++ src/utils.ts | 13 ++++++ 3 files changed, 67 insertions(+), 81 deletions(-) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 07649c1..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,28 +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 readCanonicalUrl(): string | undefined { - const link = document.querySelector('link[rel="canonical"]'); - const href = link?.href; - if (!href) { - return undefined; - } - return sanitizeUrl(href); -} - function generateIntegrationName(customIntegrationName?: string): string { const coreSdkVersion = mp().getVersion(); const kitVersion = process.env.PACKAGE_VERSION; @@ -718,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 // ============================================================ @@ -869,27 +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, - }; - - const pageTitle = event.EventAttributes?.title || document.title; - if (pageTitle) { - pageView.pageTitle = pageTitle; - } - - const canonicalUrl = readCanonicalUrl(); - if (canonicalUrl) { - pageView.canonicalUrl = canonicalUrl; - } - - if (Number.isFinite(event.ActiveTimeOnSite)) { - pageView.activeTimeOnSite = event.ActiveTimeOnSite; - } - + const pageView = buildPageEvent(event); pageViews.push(pageView); if (!writePageViews(pageViews)) { @@ -932,42 +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, - }; - - if (pageView.pageTitle !== undefined) { - pageEvent.pageTitle = pageView.pageTitle; - } - - if (pageView.canonicalUrl !== undefined) { - pageEvent.canonicalUrl = pageView.canonicalUrl; - } - - 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; @@ -1503,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 dc4efa0..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'; @@ -59,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; + } +}