diff --git a/spec/src/constructorio.js b/spec/src/constructorio.js index 989a794b..450ac0d5 100644 --- a/spec/src/constructorio.js +++ b/spec/src/constructorio.js @@ -918,6 +918,22 @@ if (!bundled) { expect(instance.options).to.have.property('sessionId').to.deep.equal(newSessionId); }); + it('Should use additionalTrackingKeys updated via setClientOptions in a DOM-less context', () => { + const instance = new ConstructorIO({ + apiKey: validApiKey, + clientId, + sessionId: 1, + }); + + expect(instance.options.additionalTrackingKeys).to.be.undefined; + + instance.setClientOptions({ + additionalTrackingKeys: ['extra-key-1', 'extra-key-2'], + }); + + expect(instance.options).to.have.property('additionalTrackingKeys').to.deep.equal(['extra-key-1', 'extra-key-2']); + }); + it('Should update the options for modules with new session id in a DOM-less context', () => { const oldSessionId = 1; const newSessionId = 2; diff --git a/spec/src/modules/tracker.js b/spec/src/modules/tracker.js index 18cb9443..fb399ec8 100644 --- a/spec/src/modules/tracker.js +++ b/spec/src/modules/tracker.js @@ -17318,4 +17318,87 @@ 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}`); + expect(secondCallUrl).to.not.contain(`key=${testApiKey}`); + + done(); + } + }; + + tracker.on('success', checkComplete); + tracker.on('error', checkComplete); + + expect(tracker.trackSessionStartV2()).to.equal(true); + }); + + it('Should send identical request bodies for 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 firstCallOptions = fetchSpy.getCall(0).args[1]; + const secondCallOptions = fetchSpy.getCall(1).args[1]; + + const firstBody = JSON.parse(firstCallOptions.body); + const secondBody = JSON.parse(secondCallOptions.body); + + expect(firstBody.key).to.equal(testApiKey); + expect(secondBody.key).to.equal(additionalKey); + + const secondCallUrl = fetchSpy.getCall(1).args[0]; + expect(secondCallUrl).to.not.contain(`key=${testApiKey}`); + + // Verify bodies are the same except for the key + const firstBodyWithoutKey = { ...firstBody }; + const secondBodyWithoutKey = { ...secondBody }; + delete firstBodyWithoutKey.key; + delete secondBodyWithoutKey.key; + + expect(firstBodyWithoutKey).to.deep.equal(secondBodyWithoutKey); + + done(); + } + }; + + tracker.on('success', checkComplete); + tracker.on('error', checkComplete); + + expect(tracker.trackInputFocusV2('test query')).to.equal(true); + }); + }); }); diff --git a/spec/src/utils/request-queue.js b/spec/src/utils/request-queue.js index 767e5bed..51f612f2 100644 --- a/spec/src/utils/request-queue.js +++ b/spec/src/utils/request-queue.js @@ -318,6 +318,137 @@ 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 requests for GET method', () => { + 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'); + }); + + 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'); + }); + + it('Should not duplicate events when additionalTrackingKeys contains the primary key or duplicates', () => { + store.session.set(humanityStorageKey, true); + const requests = new RequestQueue({ + sendTrackingEvents: true, + trackingSendDelay: 1, + apiKey: 'primary-key', + additionalTrackingKeys: ['extra-key', 'primary-key', 'extra-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(2); + expect(queue[0].url).to.contain('key=primary-key'); + expect(queue[1].url).to.contain('key=extra-key'); + }); + }); + describe('send', () => { let fetchSpy = null; let cleanup; diff --git a/src/constructorio.js b/src/constructorio.js index 7572724c..5e215f82 100644 --- a/src/constructorio.js +++ b/src/constructorio.js @@ -61,6 +61,7 @@ class ConstructorIO { * @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 {boolean} [parameters.useWindowParameters=false] - Indicates if window globals (cnstrc/cnstrcUserId/cnstrcTestCells/cnstrcUserSegments) should be used as fallback for userId, testCells, and segments + * @param {string[]} [parameters.additionalTrackingKeys] - Additional API keys that each receive a duplicate of every tracking event. Events are always sent to the primary `apiKey` and, in addition, to every key in this array. * @property {object} search - Interface to {@link module:search} * @property {object} browse - Interface to {@link module:browse} * @property {object} autocomplete - Interface to {@link module:autocomplete} @@ -97,6 +98,7 @@ class ConstructorIO { networkParameters, humanityCheckLocation, useWindowParameters, + additionalTrackingKeys, } = options; if (!apiKey || typeof apiKey !== 'string') { @@ -147,6 +149,7 @@ class ConstructorIO { networkParameters: networkParameters || {}, humanityCheckLocation: humanityCheckLocation || 'session', useWindowParameters: useWindowParameters === true, + additionalTrackingKeys, }; if (useWindowParameters === true) { @@ -179,10 +182,11 @@ class ConstructorIO { * @param {string} [options.userId] - User ID * @param {boolean} [options.sendTrackingEvents] - Indicates if tracking events should be dispatched * @param {string} [options.serviceUrl] - API URL endpoint (normalized to include an HTTPS protocol and strip a trailing slash) + * @param {string[]} [options.additionalTrackingKeys] - Additional API keys that each receive a duplicate of every tracking event. Events are always sent to the primary `apiKey` and, in addition, to every key in this array. */ setClientOptions(options) { if (Object.keys(options).length) { - const { apiKey, segments, testCells, sessionId, userId, sendTrackingEvents, serviceUrl } = options; + const { apiKey, segments, testCells, sessionId, userId, sendTrackingEvents, serviceUrl, additionalTrackingKeys } = options; if (apiKey) { this.options.apiKey = apiKey; @@ -214,6 +218,10 @@ class ConstructorIO { const formattedServiceUrl = helpers.addHTTPSToString(normalizedServiceUrl, this.options.allowHttpServiceUrl); this.options.serviceUrl = formattedServiceUrl || this.options.serviceUrl; } + + if (Array.isArray(additionalTrackingKeys)) { + this.options.additionalTrackingKeys = additionalTrackingKeys; + } } } } diff --git a/src/types/index.d.ts b/src/types/index.d.ts index ebd8d241..68d678aa 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -73,6 +73,7 @@ export interface ConstructorClientOptions { networkParameters?: NetworkParameters; humanityCheckLocation?: 'session' | 'local'; useWindowParameters?: boolean; + additionalTrackingKeys?: string[]; } export interface RequestFeature extends Record { diff --git a/src/utils/request-queue.js b/src/utils/request-queue.js index 291d0724..18c9ddbb 100644 --- a/src/utils/request-queue.js +++ b/src/utils/request-queue.js @@ -53,6 +53,30 @@ class RequestQueue { body, networkParameters, }); + + // Duplicate request for each additional tracking key + const additionalKeys = this.options?.additionalTrackingKeys; + + if (Array.isArray(additionalKeys) && additionalKeys.length) { + const encodedOriginalKey = helpers.encodeURIComponentRFC3986(this.options.apiKey); + + const uniqueKeys = new Set( + additionalKeys.filter((key) => key && typeof key === 'string' && key !== this.options.apiKey), + ); + + uniqueKeys.forEach((additionalKey) => { + const encodedAdditionalKey = helpers.encodeURIComponentRFC3986(additionalKey); + const swappedUrl = url.replace(`key=${encodedOriginalKey}`, `key=${encodedAdditionalKey}`); + + queue.push({ + url: obfuscatePiiRequest(swappedUrl), + method, + body: { ...body, key: additionalKey }, + networkParameters, + }); + }); + } + RequestQueue.set(queue); } }