Skip to content
89 changes: 77 additions & 12 deletions src/integrationCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getCookies,
getHref,
isEmpty,
valueof,
} from './utils';

export interface IntegrationCaptureProcessorFunction {
Expand Down Expand Up @@ -48,45 +49,79 @@ export const facebookClickIdProcessor: IntegrationCaptureProcessorFunction = (

return `fb.${subdomainIndex}.${_timestamp}.${clickId}`;
};

// Integration outputs are used to determine how click ids are used within the SDK
// CUSTOM_FLAGS are sent out when an Event is created via ServerModel.createEventObject
// PARTNER_IDENTITIES are sent out in a Batch when a group of events are converted to a Batch

const IntegrationOutputs = {
CUSTOM_FLAGS: 'custom_flags',
PARTNER_IDENTITIES: 'partner_identities',
} as const;

interface IntegrationMappingItem {
mappedKey: string;
output: valueof<typeof IntegrationOutputs>;
processor?: IntegrationCaptureProcessorFunction;
}

interface IntegrationIdMapping {
[key: string]: {
mappedKey: string;
processor?: IntegrationCaptureProcessorFunction;
};
[key: string]: IntegrationMappingItem;
}

const integrationMapping: IntegrationIdMapping = {
// Facebook / Meta
fbclid: {
mappedKey: 'Facebook.ClickId',
processor: facebookClickIdProcessor,
output: IntegrationOutputs.CUSTOM_FLAGS,
},
_fbp: {
mappedKey: 'Facebook.BrowserId',
output: IntegrationOutputs.CUSTOM_FLAGS,
},
_fbc: {
mappedKey: 'Facebook.ClickId',
output: IntegrationOutputs.CUSTOM_FLAGS,
},

// Google
gclid: {
mappedKey: 'GoogleEnhancedConversions.Gclid',
output: IntegrationOutputs.CUSTOM_FLAGS,
},
gbraid: {
mappedKey: 'GoogleEnhancedConversions.Gbraid',
output: IntegrationOutputs.CUSTOM_FLAGS,
},
wbraid: {
mappedKey: 'GoogleEnhancedConversions.Wbraid',
output: IntegrationOutputs.CUSTOM_FLAGS,
},

// TIKTOK
ttclid: {
mappedKey: 'TikTok.Callback',
output: IntegrationOutputs.CUSTOM_FLAGS,
},
_ttp: {
mappedKey: 'tiktok_cookie_id',
output: IntegrationOutputs.PARTNER_IDENTITIES,
},
};

export default class IntegrationCapture {
public clickIds: Dictionary<string>;
public readonly initialTimestamp: number;
public readonly filteredPartnerIdentityMappings: IntegrationIdMapping;
public readonly filteredCustomFlagMappings: IntegrationIdMapping;

constructor() {
this.initialTimestamp = Date.now();

// Cache filtered mappings for faster access
this.filteredPartnerIdentityMappings = this.filterMappings(IntegrationOutputs.PARTNER_IDENTITIES);
this.filteredCustomFlagMappings = this.filterMappings(IntegrationOutputs.CUSTOM_FLAGS);
}

/**
Expand Down Expand Up @@ -134,22 +169,42 @@ export default class IntegrationCapture {
* @returns {SDKEventCustomFlags} The custom flags.
*/
public getClickIdsAsCustomFlags(): SDKEventCustomFlags {
const customFlags: SDKEventCustomFlags = {};
return this.getClickIds(this.clickIds, this.filteredCustomFlagMappings);
}

if (!this.clickIds) {
return customFlags;
/**
* Returns only the `partner_identities` mapped integration output.
* @returns {Dictionary<string>} The partner identities.
*/
public getClickIdsAsPartnerIdentities(): Dictionary<string> {
return this.getClickIds(this.clickIds, this.filteredPartnerIdentityMappings);
}

private getClickIds(
clickIds: Dictionary<string>,
mappingList: IntegrationIdMapping
): Dictionary<string> {
const mappedClickIds: Dictionary<string> = {};

if (!clickIds) {
return mappedClickIds;
}

for (const [key, value] of Object.entries(this.clickIds)) {
const mappedKey = integrationMapping[key]?.mappedKey;
for (const [key, value] of Object.entries(clickIds)) {
const mappedKey = mappingList[key]?.mappedKey;
if (!isEmpty(mappedKey)) {
customFlags[mappedKey] = value;
mappedClickIds[mappedKey] = value;
}
}
return customFlags;

return mappedClickIds;
}

private applyProcessors(clickIds: Dictionary<string>, url?: string, timestamp?: number): Dictionary<string> {
private applyProcessors(
clickIds: Dictionary<string>,
url?: string,
timestamp?: number
): Dictionary<string> {
const processedClickIds: Dictionary<string> = {};

for (const [key, value] of Object.entries(clickIds)) {
Expand All @@ -163,4 +218,14 @@ export default class IntegrationCapture {

return processedClickIds;
}

private filterMappings(
outputType: valueof<typeof IntegrationOutputs>
): IntegrationIdMapping {
return Object.fromEntries(
Object.entries(integrationMapping).filter(
([, value]) => value.output === outputType
)
);
}
}
40 changes: 37 additions & 3 deletions src/sdkToEventsApiConverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,47 @@ import {
SDKCCPAConsentState,
} from './consent';
import Types from './types';
import { isEmpty } from './utils';
import { Dictionary, isEmpty } from './utils';
import { ISDKUserIdentity } from './identity-user-interfaces';
import { SDKIdentityTypeEnum } from './identity.interfaces';
import Constants from './constants';

const {
FeatureFlags
} = Constants;
const {
CaptureIntegrationSpecificIds
} = FeatureFlags;

type PartnerIdentities = Dictionary<string>;

// https://go.mparticle.com/work/SQDSDKS-6964
interface Batch extends EventsApi.Batch {
partner_identities?: PartnerIdentities;
}

export function convertEvents(
mpid: string,
sdkEvents: SDKEvent[],
mpInstance: MParticleWebSDK
): EventsApi.Batch | null {
): Batch | null {
if (!mpid) {
return null;
}
if (!sdkEvents || sdkEvents.length < 1) {
return null;
}

const {
_IntegrationCapture,
_Helpers,
} = mpInstance

const {
getFeatureFlag,
} = _Helpers;


const user = mpInstance.Identity.getCurrentUser();

const uploadEvents: EventsApi.BaseEvent[] = [];
Expand Down Expand Up @@ -56,7 +81,7 @@ export function convertEvents(
currentConsentState = user.getConsentState();
}

const upload: EventsApi.Batch = {
const upload: Batch = {
source_request_id: mpInstance._Helpers.generateUniqueId(),
mpid,
timestamp_unixtime_ms: new Date().getTime(),
Expand Down Expand Up @@ -102,6 +127,15 @@ export function convertEvents(
},
};
}

const isIntegrationCaptureEnabled: boolean = getFeatureFlag && Boolean(getFeatureFlag(CaptureIntegrationSpecificIds));
if (isIntegrationCaptureEnabled) {
const capturedPartnerIdentities: PartnerIdentities = _IntegrationCapture?.getClickIdsAsPartnerIdentities();
if (!isEmpty(capturedPartnerIdentities)) {
upload.partner_identities = capturedPartnerIdentities;
}
}

return upload;
}

Expand Down
58 changes: 55 additions & 3 deletions test/jest/integration-capture.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,26 @@ describe('Integration Capture', () => {
const integrationCapture = new IntegrationCapture();
expect(integrationCapture.clickIds).toBeUndefined();
});

it('should initialize with a filtered list of partner identity mappings', () => {
const integrationCapture = new IntegrationCapture();
const mappings = integrationCapture.filteredPartnerIdentityMappings;
expect(Object.keys(mappings)).toEqual(['_ttp']);
});

it('should initialize with a filtered list of custom flag mappings', () => {
const integrationCapture = new IntegrationCapture();
const mappings = integrationCapture.filteredCustomFlagMappings;
expect(Object.keys(mappings)).toEqual([
'fbclid',
'_fbp',
'_fbc',
'gclid',
'gbraid',
'wbraid',
'ttclid',
]);
});
});

describe('#capture', () => {
Expand Down Expand Up @@ -208,6 +228,7 @@ describe('Integration Capture', () => {
expect(clickIds).toEqual({
fbclid: 'fb.2.42.67890',
gclid: '54321',
ttclid: '12345',
});
});

Expand Down Expand Up @@ -278,28 +299,59 @@ describe('Integration Capture', () => {
});

describe('#getClickIdsAsCustomFlags', () => {
it('should return clickIds as custom flags', () => {
it('should return empty object if clickIds is empty or undefined', () => {
const integrationCapture = new IntegrationCapture();
const customFlags = integrationCapture.getClickIdsAsCustomFlags();

expect(customFlags).toEqual({});
});

it('should only return mapped clickIds as custom flags', () => {
const integrationCapture = new IntegrationCapture();
integrationCapture.clickIds = {
fbclid: '67890',
_fbp: '54321',
_ttp: '0823422223.23234',
ttclid: '12345',
gclid: '123233.23131',
invalidId: '12345',
};

const customFlags = integrationCapture.getClickIdsAsCustomFlags();

expect(customFlags).toEqual({
'Facebook.ClickId': '67890',
'Facebook.BrowserId': '54321',
'TikTok.Callback': '12345',
'GoogleEnhancedConversions.Gclid': '123233.23131',
});
});
});

describe('#getClickIdsAsPartnerIdentites', () => {
it('should return empty object if clickIds is empty or undefined', () => {
const integrationCapture = new IntegrationCapture();
const customFlags = integrationCapture.getClickIdsAsCustomFlags();
const partnerIdentities = integrationCapture.getClickIdsAsPartnerIdentities();

expect(customFlags).toEqual({});
expect(partnerIdentities).toEqual({});
});

it('should only return mapped clickIds as partner identities', () => {
const integrationCapture = new IntegrationCapture();
integrationCapture.clickIds = {
fbclid: '67890',
_fbp: '54321',
ttclid: '12345',
_ttp: '1234123999.123123',
gclid: '123233.23131',
invalidId: '12345',
};

const partnerIdentities = integrationCapture.getClickIdsAsPartnerIdentities();

expect(partnerIdentities).toEqual({
tiktok_cookie_id: '1234123999.123123',
});
});
});

Expand Down
31 changes: 30 additions & 1 deletion test/src/tests-integration-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ import Utils from './config/utils';
import fetchMock from 'fetch-mock/esm/client';
import { urls, apiKey, testMPID, MPConfig } from "./config/constants";

const { waitForCondition, fetchMockSuccess, deleteAllCookies, findEventFromRequest, hasIdentifyReturned } = Utils;
const {
waitForCondition,
fetchMockSuccess,
deleteAllCookies,
findEventFromRequest,
hasIdentifyReturned,
hasIdentityCallInflightReturned,
} = Utils;

const mParticle = window.mParticle;

Expand All @@ -26,6 +33,7 @@ describe('Integration Capture', () => {
window.document.cookie = 'foo=bar';
window.document.cookie = '_fbp=54321';
window.document.cookie = 'baz=qux';
window.document.cookie = '_ttp=45670808';


// Mock the query params capture function because we cannot mock window.location.href
Expand Down Expand Up @@ -293,4 +301,25 @@ describe('Integration Capture', () => {
'GoogleEnhancedConversions.Wbraid': '1234111',
});
});

it('should add captured integrations to batch partner identities', async () => {
await waitForCondition(hasIdentityCallInflightReturned);

window.mParticle.logEvent('Test Event 1');
window.mParticle.logEvent('Test Event 2');
window.mParticle.logEvent('Test Event 3');

window.mParticle.upload();

expect(fetchMock.calls().length).to.greaterThan(1);

const lastCall = fetchMock.lastCall();
const batch = JSON.parse(lastCall[1].body as string);

expect(batch).to.have.property('partner_identities');
expect(batch.partner_identities).to.deep.equal({
'tiktok_cookie_id': '45670808',
});

});
});
Loading