From c007ac3b8967476f64c8a9379a3f07ab7cf3dc40 Mon Sep 17 00:00:00 2001 From: James Newman Date: Tue, 18 Aug 2026 11:01:58 -0400 Subject: [PATCH 1/2] feat: send the mParticle device id as a selectPlacements attribute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kit already sends mparticle_session_id, but the session id is short-lived: core mints a new one after sessionTimeout (30 minutes by default), when another tab starts a session, and on an explicit endSession(). That makes it a poor key for anything that needs to span a visit. The device application stamp (das, exposed as mParticle.getDeviceId()) is strictly stickier. nullifySession() clears the session id and its dates but never deviceId, and that is the function called on timeout, cross-tab rotation and endSession(). It also survives login/logout — the identity flows only read _Store.deviceId — and persists in localStorage/cookie for cookieExpiration days (365 by default). Send it as the mparticle_device_id attribute on each selectPlacements call. Reading it per call rather than pinning it at launcher creation means a partner's setDeviceId() reassignment is picked up on the next call. getDeviceId is typed optional on MParticleExtended, so a core that predates it omits the attribute instead of throwing. The attribute is also omitted when the accessor returns nothing, rather than sent as an empty string. Co-Authored-By: Claude Opus 5 (1M context) --- src/Rokt-Kit.ts | 18 +++++++ test/src/tests.spec.ts | 105 +++++++++++++++++++++++++++++++++++++++++ test/vitest.setup.ts | 1 + 3 files changed, 124 insertions(+) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index d3b5b9b..3b3c5ba 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -162,6 +162,7 @@ interface MParticleExtended { logEvent(name: string, type: number, attrs?: Record): void; EventType: { Other: number }; getInstance(): MParticleInstance; + getDeviceId?(): string; sessionManager?: { getSession?(): string; getSessionId?(): string }; _getActiveForwarders(): Array<{ name: string }>; config?: { isLocalLauncherEnabled?: boolean; isLoggingEnabled?: boolean }; @@ -261,6 +262,7 @@ 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 MPARTICLE_SESSION_ID_KEY = 'mparticle_session_id'; +const MPARTICLE_DEVICE_ID_KEY = 'mparticle_device_id'; // Bound on how long selectPlacements will wait for an in-flight Workspace // IDSync search before proceeding without the userIdentifiedInWorkspace flag. @@ -979,6 +981,20 @@ class RoktKit implements KitInterface { return readSessionId.call(sessionManager) || undefined; } + private readMpDeviceId(): string | undefined { + // The device application stamp (`das`) outlives the session id: core clears the session on + // timeout, cross-tab rotation and endSession(), but never the device id, which lives in + // localStorage/cookie for cookieExpiration days (365 by default) and survives login/logout. + // Read it per call anyway — a partner can reassign it at any time via setDeviceId(). + const mParticleGlobal = mp(); + const readDeviceId = mParticleGlobal?.getDeviceId; + if (!isFunction(readDeviceId)) { + return undefined; + } + + return readDeviceId.call(mParticleGlobal) || undefined; + } + private attachLauncher( accountId: string, launcherOptions: Record, @@ -1467,6 +1483,7 @@ class RoktKit implements KitInterface { const sessionAttributes = this.returnLocalSessionAttributes(); const pageEvents = buildPageEvents(loadPageViews(this.loggingService)); const mpSessionId = this.readMpSessionId(); + const mpDeviceId = this.readMpDeviceId(); const selectPlacementsAttributes: Record = { ...(filteredUserIdentities as Record), @@ -1476,6 +1493,7 @@ class RoktKit implements KitInterface { ...(pageEvents.length ? { [PAGE_EVENTS_KEY]: JSON.stringify(pageEvents) } : {}), ...(this.userIdentifiedInWorkspace ? { [USER_IDENTIFIED_IN_WORKSPACE_KEY]: true } : {}), ...(mpSessionId ? { [MPARTICLE_SESSION_ID_KEY]: mpSessionId } : {}), + ...(mpDeviceId ? { [MPARTICLE_DEVICE_ID_KEY]: mpDeviceId } : {}), mpid, }; diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index 40c6ed2..537a127 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -104,6 +104,9 @@ describe('Rokt Forwarder', () => { return 'test-mp-session-id'; }, }; + mParticle.getDeviceId = function () { + return 'test-mp-device-id'; + }; mParticle._getActiveForwarders = function () { return []; }; @@ -848,6 +851,9 @@ describe('Rokt Forwarder', () => { return 'test-mp-session-id'; }, }; + (window as any).mParticle.getDeviceId = function () { + return 'test-mp-device-id'; + }; (window as any).mParticle.Rokt.setLocalSessionAttribute = function (key: any, value: any) { mParticle._Store.localSessionAttributes[key] = value; }; @@ -900,6 +906,7 @@ describe('Rokt Forwarder', () => { attributes: { test: 'test', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '123', }, }); @@ -983,6 +990,70 @@ describe('Rokt Forwarder', () => { expect((window as any).Rokt.selectPlacementsOptions.attributes).not.toHaveProperty('mparticle_session_id'); }); + it('should keep sending the same device id after the session id rotates', async () => { + let currentSessionId = 'first-mp-session'; + (window as any).mParticle.sessionManager = { + getSession: function () { + return currentSessionId; + }, + }; + + await (window as any).mParticle.forwarder.init({ accountId: '123456' }, reportService.cb, true, null, {}); + + await (window as any).mParticle.forwarder.selectPlacements({ attributes: {} }); + + expect((window as any).Rokt.selectPlacementsOptions.attributes.mparticle_session_id).toBe('first-mp-session'); + expect((window as any).Rokt.selectPlacementsOptions.attributes.mparticle_device_id).toBe('test-mp-device-id'); + + currentSessionId = 'second-mp-session'; + + await (window as any).mParticle.forwarder.selectPlacements({ attributes: {} }); + + expect((window as any).Rokt.selectPlacementsOptions.attributes.mparticle_session_id).toBe('second-mp-session'); + expect((window as any).Rokt.selectPlacementsOptions.attributes.mparticle_device_id).toBe('test-mp-device-id'); + }); + + it('should send the device id current at the time of each call', async () => { + let currentDeviceId = 'first-mp-device'; + (window as any).mParticle.getDeviceId = function () { + return currentDeviceId; + }; + + await (window as any).mParticle.forwarder.init({ accountId: '123456' }, reportService.cb, true, null, {}); + + await (window as any).mParticle.forwarder.selectPlacements({ attributes: {} }); + + expect((window as any).Rokt.selectPlacementsOptions.attributes.mparticle_device_id).toBe('first-mp-device'); + + currentDeviceId = 'second-mp-device'; + + await (window as any).mParticle.forwarder.selectPlacements({ attributes: {} }); + + expect((window as any).Rokt.selectPlacementsOptions.attributes.mparticle_device_id).toBe('second-mp-device'); + }); + + it('should omit the device id when getDeviceId is unavailable', async () => { + delete (window as any).mParticle.getDeviceId; + + await (window as any).mParticle.forwarder.init({ accountId: '123456' }, reportService.cb, true, null, {}); + + await (window as any).mParticle.forwarder.selectPlacements({ attributes: {} }); + + expect((window as any).Rokt.selectPlacementsOptions.attributes).not.toHaveProperty('mparticle_device_id'); + }); + + it('should omit the device id when getDeviceId returns nothing', async () => { + (window as any).mParticle.getDeviceId = function () { + return undefined; + }; + + await (window as any).mParticle.forwarder.init({ accountId: '123456' }, reportService.cb, true, null, {}); + + await (window as any).mParticle.forwarder.selectPlacements({ attributes: {} }); + + expect((window as any).Rokt.selectPlacementsOptions.attributes).not.toHaveProperty('mparticle_device_id'); + }); + it('should collect mpid and send to launcher.selectPlacements', async () => { await (window as any).mParticle.forwarder.init( { @@ -1009,6 +1080,7 @@ describe('Rokt Forwarder', () => { attributes: { 'user-attribute': 'user-attribute-value', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '123', }, }); @@ -1048,6 +1120,7 @@ describe('Rokt Forwarder', () => { identifier: 'test-placement', attributes: { mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '123', 'test-attribute': 'test-value', 'custom-local-attribute': true, @@ -1123,6 +1196,7 @@ describe('Rokt Forwarder', () => { 'user-attribute': 'user-attribute-value', 'unfiltered-attribute': 'unfiltered-value', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '123', }, }); @@ -1178,6 +1252,7 @@ describe('Rokt Forwarder', () => { 'unfiltered-attribute': 'unfiltered-value', 'changed-attribute': 'new-value', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '123', }, }); @@ -1216,6 +1291,7 @@ describe('Rokt Forwarder', () => { loyaltyTier: 'gold', page: 'checkout', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '123', }, }); @@ -1264,6 +1340,7 @@ describe('Rokt Forwarder', () => { shippingMethod: 'ground', page: 'checkout', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '123', }, }); @@ -1302,6 +1379,7 @@ describe('Rokt Forwarder', () => { active_time_on_site_ms: 12345, page: 'checkout', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '123', }, }); @@ -1341,6 +1419,7 @@ describe('Rokt Forwarder', () => { attributes: { active_time_on_site_ms: 67890, mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '123', }, }); @@ -1372,6 +1451,7 @@ describe('Rokt Forwarder', () => { attributes: { favoriteStore: 'test-store', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '123', }, }); @@ -1454,6 +1534,7 @@ describe('Rokt Forwarder', () => { expect((window as any).Rokt.selectPlacementsOptions.attributes).toEqual({ 'test-attribute': 'test-value', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: 'abc', }); }); @@ -1509,6 +1590,7 @@ describe('Rokt Forwarder', () => { customerid: 'customer123', email: 'test@example.com', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '234', }); }); @@ -1569,6 +1651,7 @@ describe('Rokt Forwarder', () => { customerid: 'customer123', email: 'test@example.com', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '123', }); }); @@ -1615,6 +1698,7 @@ describe('Rokt Forwarder', () => { expect((window as any).Rokt.selectPlacementsOptions.attributes).toEqual({ 'test-attribute': 'test-value', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: null, }); }); @@ -1665,6 +1749,7 @@ describe('Rokt Forwarder', () => { expect((window as any).Rokt.selectPlacementsOptions.attributes).toEqual({ 'test-attribute': 'test-value', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '123', }); }); @@ -1721,6 +1806,7 @@ describe('Rokt Forwarder', () => { customerid: 'customer123', emailsha256: 'sha256-test@gmail.com', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '234', }); }); @@ -1778,6 +1864,7 @@ describe('Rokt Forwarder', () => { expect(attrs).toEqual({ customerid: 'customer123', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '234', }); expect(attrs).not.toHaveProperty('emailsha256'); @@ -1841,6 +1928,7 @@ describe('Rokt Forwarder', () => { customerid: 'customer123', other: 'other-attribute', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '123', }); }); @@ -1904,6 +1992,7 @@ describe('Rokt Forwarder', () => { other: 'continues-to-exist', emailsha256: 'other-id', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '123', }); }); @@ -1965,6 +2054,7 @@ describe('Rokt Forwarder', () => { 'test-attribute': 'test-value', emailsha256: 'hashed-customer-id-value', // mapped from customerid in userIdentities mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '789', }); }); @@ -2026,6 +2116,7 @@ describe('Rokt Forwarder', () => { 'test-attr': 'test-value', other: 'hashed-custom-identity-value', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '999', }); }); @@ -2085,6 +2176,7 @@ describe('Rokt Forwarder', () => { email: 'test@example.com', emailsha256: 'hashed-email-value', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '456', }); }); @@ -2145,6 +2237,7 @@ describe('Rokt Forwarder', () => { email: 'developer-email@example.com', emailsha256: 'hashed-email-value', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '789', }); }); @@ -2206,6 +2299,7 @@ describe('Rokt Forwarder', () => { customerid: 'customer456', someAttribute: 'someValue', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '901', }); }); @@ -2267,6 +2361,7 @@ describe('Rokt Forwarder', () => { emailsha256: 'hashed-from-other', customerid: 'customer789', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '912', }); }); @@ -2326,6 +2421,7 @@ describe('Rokt Forwarder', () => { email: 'developer-email@example.com', customerid: 'customer202', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '934', }); }); @@ -2382,6 +2478,7 @@ describe('Rokt Forwarder', () => { emailsha256: 'developer-hashed-email', customerid: 'customer303', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '945', }); }); @@ -2435,6 +2532,7 @@ describe('Rokt Forwarder', () => { expect((window as any).Rokt.selectPlacementsOptions.attributes).toEqual({ customerid: 'customer505', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '967', }); }); @@ -2491,6 +2589,7 @@ describe('Rokt Forwarder', () => { emailsha256: 'hashed-from-useridentities', customerid: 'customer606', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '978', }); }); @@ -2553,6 +2652,7 @@ describe('Rokt Forwarder', () => { emailsha256: 'developer-hashed-email', customerid: 'customer909', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '992', }); }); @@ -2609,6 +2709,7 @@ describe('Rokt Forwarder', () => { expect((window as any).Rokt.selectPlacementsOptions.attributes).toEqual({ email: 'test@gmail.com', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '234', }); }); @@ -2665,6 +2766,7 @@ describe('Rokt Forwarder', () => { expect((window as any).Rokt.selectPlacementsOptions.attributes).toEqual({ email: 'test@gmail.com', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '345', }); }); @@ -2721,6 +2823,7 @@ describe('Rokt Forwarder', () => { expect((window as any).Rokt.selectPlacementsOptions.attributes).toEqual({ email: 'test@gmail.com', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '456', }); }); @@ -2777,6 +2880,7 @@ describe('Rokt Forwarder', () => { expect((window as any).Rokt.selectPlacementsOptions.attributes).toEqual({ email: 'test@gmail.com', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '567', }); }); @@ -2833,6 +2937,7 @@ describe('Rokt Forwarder', () => { expect((window as any).Rokt.selectPlacementsOptions.attributes).toEqual({ email: 'test@gmail.com', mparticle_session_id: 'test-mp-session-id', + mparticle_device_id: 'test-mp-device-id', mpid: '678', }); }); diff --git a/test/vitest.setup.ts b/test/vitest.setup.ts index 162c5de..84511df 100644 --- a/test/vitest.setup.ts +++ b/test/vitest.setup.ts @@ -26,6 +26,7 @@ sessionManager: { getSession: () => 'test-mp-session-id', }, + getDeviceId: () => 'test-mp-device-id', _getActiveForwarders: () => [], generateHash: (input: string) => 'hashed-<' + input + '>-value', loggedEvents: [] as unknown[], From 33928eaceda87f57335d100427776d51cd16665c Mon Sep 17 00:00:00 2001 From: James Newman Date: Tue, 18 Aug 2026 15:41:47 -0400 Subject: [PATCH 2/2] refactor: call getDeviceId directly in readMpDeviceId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aliased-reference-plus-.call() pattern was copied from readMpSessionId, where it is load-bearing: that helper picks between getSessionId and getSession, so it needs a local alias and an explicit receiver. readMpDeviceId has a single accessor, so optional chaining does the same job — ?.() still covers a core that predates getDeviceId, and the call keeps its natural mParticle receiver. No behaviour change; the empty-string-to-undefined normalization stays. Co-Authored-By: Claude Opus 5 (1M context) --- src/Rokt-Kit.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 3b3c5ba..1ff847f 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -986,13 +986,7 @@ class RoktKit implements KitInterface { // timeout, cross-tab rotation and endSession(), but never the device id, which lives in // localStorage/cookie for cookieExpiration days (365 by default) and survives login/logout. // Read it per call anyway — a partner can reassign it at any time via setDeviceId(). - const mParticleGlobal = mp(); - const readDeviceId = mParticleGlobal?.getDeviceId; - if (!isFunction(readDeviceId)) { - return undefined; - } - - return readDeviceId.call(mParticleGlobal) || undefined; + return mp()?.getDeviceId?.() || undefined; } private attachLauncher(