diff --git a/src/integrationCapture.ts b/src/integrationCapture.ts index a4809b44..b68cfe68 100644 --- a/src/integrationCapture.ts +++ b/src/integrationCapture.ts @@ -5,6 +5,7 @@ import { getCookies, getHref, isEmpty, + valueof, } from './utils'; export interface IntegrationCaptureProcessorFunction { @@ -48,11 +49,24 @@ 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; + processor?: IntegrationCaptureProcessorFunction; +} + interface IntegrationIdMapping { - [key: string]: { - mappedKey: string; - processor?: IntegrationCaptureProcessorFunction; - }; + [key: string]: IntegrationMappingItem; } const integrationMapping: IntegrationIdMapping = { @@ -60,33 +74,54 @@ const integrationMapping: IntegrationIdMapping = { 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; 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); } /** @@ -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} The partner identities. + */ + public getClickIdsAsPartnerIdentities(): Dictionary { + return this.getClickIds(this.clickIds, this.filteredPartnerIdentityMappings); + } + + private getClickIds( + clickIds: Dictionary, + mappingList: IntegrationIdMapping + ): Dictionary { + const mappedClickIds: Dictionary = {}; + + 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, url?: string, timestamp?: number): Dictionary { + private applyProcessors( + clickIds: Dictionary, + url?: string, + timestamp?: number + ): Dictionary { const processedClickIds: Dictionary = {}; for (const [key, value] of Object.entries(clickIds)) { @@ -163,4 +218,14 @@ export default class IntegrationCapture { return processedClickIds; } + + private filterMappings( + outputType: valueof + ): IntegrationIdMapping { + return Object.fromEntries( + Object.entries(integrationMapping).filter( + ([, value]) => value.output === outputType + ) + ); + } } diff --git a/src/sdkToEventsApiConverter.ts b/src/sdkToEventsApiConverter.ts index f4c51b31..f4349bdb 100644 --- a/src/sdkToEventsApiConverter.ts +++ b/src/sdkToEventsApiConverter.ts @@ -13,15 +13,30 @@ 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; + +// 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; } @@ -29,6 +44,16 @@ export function convertEvents( return null; } + const { + _IntegrationCapture, + _Helpers, + } = mpInstance + + const { + getFeatureFlag, + } = _Helpers; + + const user = mpInstance.Identity.getCurrentUser(); const uploadEvents: EventsApi.BaseEvent[] = []; @@ -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(), @@ -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; } diff --git a/test/jest/integration-capture.spec.ts b/test/jest/integration-capture.spec.ts index d219d136..cf728a8c 100644 --- a/test/jest/integration-capture.spec.ts +++ b/test/jest/integration-capture.spec.ts @@ -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', () => { @@ -208,6 +228,7 @@ describe('Integration Capture', () => { expect(clickIds).toEqual({ fbclid: 'fb.2.42.67890', gclid: '54321', + ttclid: '12345', }); }); @@ -278,12 +299,22 @@ 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(); @@ -291,15 +322,36 @@ describe('Integration Capture', () => { 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', + }); }); }); diff --git a/test/src/tests-integration-capture.ts b/test/src/tests-integration-capture.ts index d8c78bc0..39b00c55 100644 --- a/test/src/tests-integration-capture.ts +++ b/test/src/tests-integration-capture.ts @@ -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; @@ -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 @@ -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', + }); + + }); }); \ No newline at end of file