From bfb3d4558f12fb0835b6e29592a1aeece1b14d57 Mon Sep 17 00:00:00 2001 From: Hubert Gajewski Date: Mon, 22 Jun 2026 12:27:08 +0200 Subject: [PATCH 1/4] [CDX-470] Add `additionalTrackingKeys` option to duplicate tracking events Adds a new `additionalTrackingKeys` client option that duplicates tracking events to additional API keys. When configured, each tracking event queued via RequestQueue is also sent to each additional key with the `key` parameter swapped in both the URL and POST body. --- spec/src/modules/tracker.js | 35 ++++++++++ spec/src/utils/request-queue.js | 115 ++++++++++++++++++++++++++++++++ src/constructorio.js | 2 + src/types/index.d.ts | 1 + src/utils/request-queue.js | 22 ++++++ 5 files changed, 175 insertions(+) diff --git a/spec/src/modules/tracker.js b/spec/src/modules/tracker.js index 6147c992..94d545cf 100644 --- a/spec/src/modules/tracker.js +++ b/spec/src/modules/tracker.js @@ -16345,4 +16345,39 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => { ).to.equal(true); }); }); + + describe('additionalTrackingKeys', () => { + it('Should send tracking events to both primary and additional keys', (done) => { + const additionalKey = 'extra-test-key'; + const { tracker } = new ConstructorIO({ + apiKey: testApiKey, + fetch: fetchSpy, + ...requestQueueOptions, + additionalTrackingKeys: [additionalKey], + }); + + let callCount = 0; + + const checkComplete = () => { + callCount += 1; + + if (callCount === 2) { + expect(fetchSpy).to.have.been.calledTwice; + + const firstCallUrl = fetchSpy.getCall(0).args[0]; + const secondCallUrl = fetchSpy.getCall(1).args[0]; + + expect(firstCallUrl).to.contain(`key=${testApiKey}`); + expect(secondCallUrl).to.contain(`key=${additionalKey}`); + + done(); + } + }; + + tracker.on('success', checkComplete); + tracker.on('error', checkComplete); + + expect(tracker.trackSessionStartV2()).to.equal(true); + }); + }); }); diff --git a/spec/src/utils/request-queue.js b/spec/src/utils/request-queue.js index 767e5bed..d05446d2 100644 --- a/spec/src/utils/request-queue.js +++ b/spec/src/utils/request-queue.js @@ -318,6 +318,121 @@ describe('ConstructorIO - Utils - Request Queue', function utilsRequestQueue() { }); }); + describe('additionalTrackingKeys', () => { + let defaultAgent; + let cleanup; + + before(() => { + helpers.clearStorage(); + }); + + beforeEach(() => { + global.CLIENT_VERSION = 'cio-mocha'; + cleanup = jsdom(); + defaultAgent = window.navigator.userAgent; + }); + + afterEach(() => { + window.navigator.__defineGetter__('userAgent', () => defaultAgent); + delete global.CLIENT_VERSION; + cleanup(); + helpers.clearStorage(); + }); + + it('Should add duplicate requests for each additional tracking key', () => { + store.session.set(humanityStorageKey, true); + const requests = new RequestQueue({ + sendTrackingEvents: true, + trackingSendDelay: 1, + apiKey: 'primary-key', + additionalTrackingKeys: ['extra-key-1', 'extra-key-2'], + }); + + requests.queue('https://ac.cnstrc.com/behavior?action=session_start&key=primary-key&_dt=123', 'POST', { action: 'session_start', key: 'primary-key' }); + + const queue = RequestQueue.get(); + expect(queue).to.be.an('array').length(3); + + // Primary request + expect(queue[0].url).to.contain('key=primary-key'); + expect(queue[0].body.key).to.equal('primary-key'); + + // First additional key + expect(queue[1].url).to.contain('key=extra-key-1'); + expect(queue[1].url).to.not.contain('key=primary-key'); + expect(queue[1].body.key).to.equal('extra-key-1'); + + // Second additional key + expect(queue[2].url).to.contain('key=extra-key-2'); + expect(queue[2].url).to.not.contain('key=primary-key'); + expect(queue[2].body.key).to.equal('extra-key-2'); + }); + + it('Should not add duplicates when additionalTrackingKeys is an empty array', () => { + store.session.set(humanityStorageKey, true); + const requests = new RequestQueue({ + sendTrackingEvents: true, + trackingSendDelay: 1, + apiKey: 'primary-key', + additionalTrackingKeys: [], + }); + + requests.queue('https://ac.cnstrc.com/behavior?action=session_start&key=primary-key&_dt=123'); + + expect(RequestQueue.get()).to.be.an('array').length(1); + }); + + it('Should not add duplicates when additionalTrackingKeys is not provided', () => { + store.session.set(humanityStorageKey, true); + const requests = new RequestQueue({ + sendTrackingEvents: true, + trackingSendDelay: 1, + apiKey: 'primary-key', + }); + + requests.queue('https://ac.cnstrc.com/behavior?action=session_start&key=primary-key&_dt=123'); + + expect(RequestQueue.get()).to.be.an('array').length(1); + }); + + it('Should add duplicate GET requests without a body', () => { + store.session.set(humanityStorageKey, true); + const requests = new RequestQueue({ + sendTrackingEvents: true, + trackingSendDelay: 1, + apiKey: 'primary-key', + additionalTrackingKeys: ['extra-key-1'], + }); + + requests.queue('https://ac.cnstrc.com/behavior?action=session_start&key=primary-key&_dt=123'); + + const queue = RequestQueue.get(); + expect(queue).to.be.an('array').length(2); + + expect(queue[0].url).to.contain('key=primary-key'); + expect(queue[1].url).to.contain('key=extra-key-1'); + expect(queue[1].url).to.contain('action=session_start'); + expect(queue[1].body.key).to.equal('extra-key-1'); + }); + + it('Should skip invalid entries in additionalTrackingKeys', () => { + store.session.set(humanityStorageKey, true); + const requests = new RequestQueue({ + sendTrackingEvents: true, + trackingSendDelay: 1, + apiKey: 'primary-key', + additionalTrackingKeys: ['valid-key', '', null, 123, 'another-valid-key'], + }); + + requests.queue('https://ac.cnstrc.com/behavior?action=session_start&key=primary-key&_dt=123', 'POST', { action: 'session_start', key: 'primary-key' }); + + const queue = RequestQueue.get(); + expect(queue).to.be.an('array').length(3); + expect(queue[1].url).to.contain('key=valid-key'); + expect(queue[2].url).to.contain('key=another-valid-key'); + }); + }); + describe('send', () => { let fetchSpy = null; let cleanup; diff --git a/src/constructorio.js b/src/constructorio.js index b0691dd3..907685ae 100644 --- a/src/constructorio.js +++ b/src/constructorio.js @@ -93,6 +93,7 @@ class ConstructorIO { beaconMode, networkParameters, humanityCheckLocation, + additionalTrackingKeys, } = options; if (!apiKey || typeof apiKey !== 'string') { @@ -141,6 +142,7 @@ class ConstructorIO { beaconMode: (beaconMode === false) ? false : true, // Defaults to 'true', networkParameters: networkParameters || {}, humanityCheckLocation: humanityCheckLocation || 'session', + additionalTrackingKeys, }; // Expose global modules diff --git a/src/types/index.d.ts b/src/types/index.d.ts index bd093e43..15b3ba1e 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -71,6 +71,7 @@ export interface ConstructorClientOptions { beaconMode?: boolean; networkParameters?: NetworkParameters; humanityCheckLocation?: 'session' | 'local'; + additionalTrackingKeys?: string[]; } export interface RequestFeature extends Record { diff --git a/src/utils/request-queue.js b/src/utils/request-queue.js index 291d0724..d79180a0 100644 --- a/src/utils/request-queue.js +++ b/src/utils/request-queue.js @@ -53,6 +53,28 @@ class RequestQueue { body, networkParameters, }); + + // Duplicate request for each additional tracking key + const additionalKeys = this.options?.additionalTrackingKeys; + + if (Array.isArray(additionalKeys) && additionalKeys.length) { + const encodedOriginalKey = encodeURIComponent(this.options.apiKey); + + const validKeys = additionalKeys.filter((key) => key && typeof key === 'string'); + + validKeys.forEach((additionalKey) => { + const encodedKey = encodeURIComponent(additionalKey); + const swappedUrl = url.replace(`key=${encodedOriginalKey}`, `key=${encodedKey}`); + + queue.push({ + url: obfuscatePiiRequest(swappedUrl), + method, + body: { ...body, key: additionalKey }, + networkParameters, + }); + }); + } + RequestQueue.set(queue); } } From ad68a1864e0699efd2f1e4785d72e4297512a284 Mon Sep 17 00:00:00 2001 From: Hubert Gajewski Date: Mon, 22 Jun 2026 12:36:51 +0200 Subject: [PATCH 2/4] Change encodeURIComponent to encodeURIComponentRFC3986 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/utils/request-queue.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/request-queue.js b/src/utils/request-queue.js index d79180a0..d398c85b 100644 --- a/src/utils/request-queue.js +++ b/src/utils/request-queue.js @@ -58,12 +58,12 @@ class RequestQueue { const additionalKeys = this.options?.additionalTrackingKeys; if (Array.isArray(additionalKeys) && additionalKeys.length) { - const encodedOriginalKey = encodeURIComponent(this.options.apiKey); + const encodedOriginalKey = helpers.encodeURIComponentRFC3986(this.options.apiKey); const validKeys = additionalKeys.filter((key) => key && typeof key === 'string'); validKeys.forEach((additionalKey) => { - const encodedKey = encodeURIComponent(additionalKey); + const encodedKey = helpers.encodeURIComponentRFC3986(additionalKey); const swappedUrl = url.replace(`key=${encodedOriginalKey}`, `key=${encodedKey}`); queue.push({ From 35750b3e0e21e3ffe27e410977f90107f303d54c Mon Sep 17 00:00:00 2001 From: Hubert Gajewski Date: Mon, 22 Jun 2026 12:38:14 +0200 Subject: [PATCH 3/4] Add additionalTrackingKeys parameter to ConstructorIO client --- src/constructorio.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/constructorio.js b/src/constructorio.js index 907685ae..ea8c2ec9 100644 --- a/src/constructorio.js +++ b/src/constructorio.js @@ -59,6 +59,7 @@ class ConstructorIO { * @param {object} [parameters.networkParameters] - Parameters relevant to network requests * @param {number} [parameters.networkParameters.timeout] - Request timeout (in milliseconds) - may be overridden within individual method calls * @param {string} [parameters.humanityCheckLocation='session'] - Storage location for the humanity check flag ('session' for sessionStorage, 'local' for localStorage) + * @param {string[]} [parameters.additionalTrackingKeys] - Additional API keys to duplicate tracking events to * @property {object} search - Interface to {@link module:search} * @property {object} browse - Interface to {@link module:browse} * @property {object} autocomplete - Interface to {@link module:autocomplete} From b2b471bd9f378e127c330c74b279579c9cee3c85 Mon Sep 17 00:00:00 2001 From: Hubert Gajewski Date: Mon, 22 Jun 2026 12:43:29 +0200 Subject: [PATCH 4/4] Address PR feedback: add JSDoc for additionalTrackingKeys, rename test --- spec/src/utils/request-queue.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/src/utils/request-queue.js b/spec/src/utils/request-queue.js index d05446d2..60824589 100644 --- a/spec/src/utils/request-queue.js +++ b/spec/src/utils/request-queue.js @@ -395,7 +395,7 @@ describe('ConstructorIO - Utils - Request Queue', function utilsRequestQueue() { expect(RequestQueue.get()).to.be.an('array').length(1); }); - it('Should add duplicate GET requests without a body', () => { + it('Should add duplicate requests for GET method', () => { store.session.set(humanityStorageKey, true); const requests = new RequestQueue({ sendTrackingEvents: true,