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
253 changes: 159 additions & 94 deletions spec/src/constructorio.js

Large diffs are not rendered by default.

93 changes: 93 additions & 0 deletions spec/src/modules/tracker.js
Original file line number Diff line number Diff line change
Expand Up @@ -17334,4 +17334,97 @@ 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.
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: Both integration tests in the additionalTrackingKeys describe block use a manual callCount counter with a checkComplete callback to detect the 2nd call. Since done(new Error(...)) is called when callCount > 2 but the test doesn't explicitly stop further success/error events from firing, the done callback could theoretically be called multiple times if the event emitter fires more events, which Mocha treats as an error. Consider using a stub or once listener instead:

tracker.once('success', () => {
  // first call — no assertions yet
  tracker.once('success', () => {
    expect(fetchSpy).to.have.been.calledTwice;
    // ...assertions...
    done();
  });
});

Alternatively, wrapping in Promise.all with two sinon stubs would be cleaner and remove the timing fragility.


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

if (callCount > 2) {
done(new Error(`Expected exactly 2 calls but received ${callCount}`));
return;
}

if (callCount === 2) {
Comment thread
t3t3c marked this conversation as resolved.
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) {
done(new Error(`Expected exactly 2 calls but received ${callCount}`));
return;
}

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

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

// Validation testing for additionalTrackingKeys param
[
{ description: 'an empty array', additionalTrackingKeys: [] },
{ description: 'null', additionalTrackingKeys: null },
{ description: 'undefined', additionalTrackingKeys: undefined },
].forEach(({ description, additionalTrackingKeys }) => {
it(`Should not add duplicates when additionalTrackingKeys is ${description}`, () => {
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);
});
});
});

Comment thread
evanyan13 marked this conversation as resolved.
describe('send', () => {
let fetchSpy = null;
let cleanup;
Expand Down
19 changes: 18 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[]|null} [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. Pass an empty array or `null` to stop sending events to additional keys.
* @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: helpers.toValidAdditionalTrackingKeys(additionalTrackingKeys, apiKey),
};

if (useWindowParameters === true) {
Expand Down Expand Up @@ -179,13 +182,20 @@ 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[]|null} [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. Pass an empty array or `null` to stop sending events to additional keys.
*/
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;

// If apiKey is updated, to re-validate the additionalTrackingKeys to ensure they are still valid
this.options.additionalTrackingKeys = helpers.toValidAdditionalTrackingKeys(
this.options.additionalTrackingKeys,
apiKey,
);
}

if (segments) {
Expand Down Expand Up @@ -214,6 +224,13 @@ class ConstructorIO {
const formattedServiceUrl = helpers.addHTTPSToString(normalizedServiceUrl, this.options.allowHttpServiceUrl);
this.options.serviceUrl = formattedServiceUrl || this.options.serviceUrl;
}

if (Array.isArray(additionalTrackingKeys) || additionalTrackingKeys === null) {
this.options.additionalTrackingKeys = helpers.toValidAdditionalTrackingKeys(
additionalTrackingKeys,
this.options.apiKey,
);
}
}
}
}
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[] | null;
}

export interface RequestFeature extends Record<string, any> {
Expand Down
13 changes: 12 additions & 1 deletion src/utils/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,18 @@ const utils = {

truncateString: (string, maxLength) => string.slice(0, maxLength),

// Filter testCells to only include entries with non-empty string values
toValidAdditionalTrackingKeys: (additionalTrackingKeys, primaryKey) => {
if (!Array.isArray(additionalTrackingKeys)) {
return null;
}

const uniqueKeys = [...new Set(
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
additionalTrackingKeys.filter((key) => key && typeof key === 'string' && key !== primaryKey),
)];

return uniqueKeys.length ? uniqueKeys : null;
},

toValidTestCells: (testCells) => {
if (!testCells || typeof testCells !== 'object' || Array.isArray(testCells)) {
return {};
Expand Down
20 changes: 20 additions & 0 deletions src/utils/request-queue.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,26 @@ class RequestQueue {
body,
networkParameters,
});

// Duplicate request for each additional tracking key
const additionalKeys = this.options?.additionalTrackingKeys;

if (additionalKeys?.length) {
const encodedOriginalKey = helpers.encodeURIComponentRFC3986(this.options.apiKey);

additionalKeys.forEach((additionalKey) => {
const encodedAdditionalKey = helpers.encodeURIComponentRFC3986(additionalKey);
const swappedUrl = url.replace(`key=${encodedOriginalKey}`, `key=${encodedAdditionalKey}`);
Comment on lines +61 to +65

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)?

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.

Thanks @jjl014 This is a good consideration!
We can definitely handle the sanitization check earlier in the system and save the time to conduct the check every time we queue an event 👍


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

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