Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions spec/src/constructorio.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The setClientOptions test for additionalTrackingKeys only covers the DOM-less path. Given that the existing tests for other options (e.g. apiKey, segments) include a parallel test that asserts the change propagates to sub-module options (instance.tracker.options, instance.search.options, etc.), consider adding the same assertion here — or, at minimum, asserting instance.tracker.requests.options.additionalTrackingKeys if direct access is available. This ensures the shared-reference contract (noted above in the constructorio.js comment) is actually tested.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude raised a good point. We should have equivalent tests for the DOM context.

});

it('Should update the options for modules with new session id in a DOM-less context', () => {
const oldSessionId = 1;
const newSessionId = 2;
Expand Down
83 changes: 83 additions & 0 deletions spec/src/modules/tracker.js
Original file line number Diff line number Diff line change
Expand Up @@ -17318,4 +17318,87 @@ describe(`ConstructorIO - Tracker${bundledDescriptionSuffix}`, () => {
).to.equal(true);
});
});

describe('additionalTrackingKeys', () => {
Comment thread
t3t3c marked this conversation as resolved.
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;
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.

const checkComplete = () => {
callCount += 1;

if (callCount === 2) {
expect(fetchSpy).to.have.been.calledTwice;
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.

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}`);
Comment thread
t3t3c marked this conversation as resolved.
expect(secondCallUrl).to.not.contain(`key=${testApiKey}`);

done();
}
};

tracker.on('success', checkComplete);
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
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);
Comment thread
evanyan13 marked this conversation as resolved.

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);
});
});
});
131 changes: 131 additions & 0 deletions spec/src/utils/request-queue.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.

// 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');
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.

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', () => {
Comment thread
evanyan13 marked this conversation as resolved.
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: This test is a good addition. However, the test verifies that queue.length === 2 (primary + one unique extra), which is correct. Consider also asserting that neither queue[0] nor queue[1] contains a duplicate of the primary key in both the URL and the body to make the intent of the deduplication explicit:

expect(queue[0].body.key).to.equal('primary-key');
expect(queue[1].body.key).to.equal('extra-key');

This guards against the body not being swapped even when the URL deduplication works correctly.

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');
});
});

Comment thread
evanyan13 marked this conversation as resolved.
describe('send', () => {
let fetchSpy = null;
let cleanup;
Expand Down
10 changes: 9 additions & 1 deletion src/constructorio.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -97,6 +98,7 @@ class ConstructorIO {
networkParameters,
humanityCheckLocation,
useWindowParameters,
additionalTrackingKeys,
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
} = options;
Comment thread
evanyan13 marked this conversation as resolved.

if (!apiKey || typeof apiKey !== 'string') {
Expand Down Expand Up @@ -147,6 +149,7 @@ class ConstructorIO {
networkParameters: networkParameters || {},
humanityCheckLocation: humanityCheckLocation || 'session',
useWindowParameters: useWindowParameters === true,
additionalTrackingKeys,
};

if (useWindowParameters === true) {
Expand Down Expand Up @@ -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.
*/
Comment thread
evanyan13 marked this conversation as resolved.
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;
Comment thread
evanyan13 marked this conversation as resolved.

if (apiKey) {
this.options.apiKey = apiKey;
Expand Down Expand Up @@ -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)) {
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a big deal, but if we wanted to stop sending events to multiple keys, we would have to send [] at the moment. Should also support null here?

this.options.additionalTrackingKeys = additionalTrackingKeys;
}
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export interface ConstructorClientOptions {
networkParameters?: NetworkParameters;
humanityCheckLocation?: 'session' | 'local';
useWindowParameters?: boolean;
additionalTrackingKeys?: string[];
}

export interface RequestFeature extends Record<string, any> {
Expand Down
24 changes: 24 additions & 0 deletions src/utils/request-queue.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment thread
t3t3c marked this conversation as resolved.
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}`);
Comment on lines +61 to +69

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason why we're doing this validation and uniqueness check every time we queue an event?

It feels like an additional step that could be handled earlier (e.g. during client instantiation in the construtor or when setClientOptions is called)?


queue.push({
url: obfuscatePiiRequest(swappedUrl),
method,
body: { ...body, key: additionalKey },
networkParameters,
});
});
}

RequestQueue.set(queue);
}
}
Expand Down
Loading