From ed25be9fc323572b96da5f2df08f07b9a940f4be Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Thu, 5 Dec 2024 12:57:17 -0500 Subject: [PATCH 1/6] feat: Add support for TikTok Click and Cookie Ids --- src/integrationCapture.ts | 89 +++++++++++++++++++++++---- src/sdkToEventsApiConverter.ts | 41 +++++++++++- test/jest/integration-capture.spec.ts | 84 +++++++++++++++++++++++++ test/src/tests-integration-capture.ts | 31 +++++++++- 4 files changed, 229 insertions(+), 16 deletions(-) diff --git a/src/integrationCapture.ts b/src/integrationCapture.ts index a4809b44..1e05f67a 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 Events 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.ClickId', + 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; + /** + * Converts the captured click IDs to partner identities. + * @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..eaf69425 100644 --- a/src/sdkToEventsApiConverter.ts +++ b/src/sdkToEventsApiConverter.ts @@ -13,15 +13,31 @@ 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 +45,16 @@ export function convertEvents( return null; } + const { + _IntegrationCapture, + _Helpers, + } = mpInstance + + const { + getFeatureFlag, + } = _Helpers; + + const user = mpInstance.Identity.getCurrentUser(); const uploadEvents: EventsApi.BaseEvent[] = []; @@ -56,7 +82,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 +128,15 @@ export function convertEvents( }, }; } + + const isIntegrationCaptureEnabled = getFeatureFlag && 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..926b1866 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', }); }); @@ -283,6 +304,7 @@ describe('Integration Capture', () => { integrationCapture.clickIds = { fbclid: '67890', _fbp: '54321', + ttclid: '12345', gclid: '123233.23131', }; @@ -291,6 +313,7 @@ describe('Integration Capture', () => { expect(customFlags).toEqual({ 'Facebook.ClickId': '67890', 'Facebook.BrowserId': '54321', + 'TikTok.ClickId': '12345', 'GoogleEnhancedConversions.Gclid': '123233.23131', }); }); @@ -301,6 +324,67 @@ describe('Integration Capture', () => { 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.ClickId': '12345', + 'GoogleEnhancedConversions.Gclid': '123233.23131', + }); + }); + }); + + describe('#getClickIdsAsPartnerIdentites', () => { + it('should return clickIds as partner identities', () => { + const integrationCapture = new IntegrationCapture(); + integrationCapture.clickIds = { + _ttp: '1234123999.123123', + }; + + const partnerIdentities = integrationCapture.getClickIdsAsPartnerIdentities(); + + expect(partnerIdentities).toEqual({ + tiktok_cookie_id: '1234123999.123123', + }); + }); + + it('should return empty object if clickIds is empty or undefined', () => { + const integrationCapture = new IntegrationCapture(); + const partnerIdentities = integrationCapture.getClickIdsAsPartnerIdentities(); + + expect(partnerIdentities).toEqual({}); + }); + + it.only('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', + }); + }); }); describe('#facebookClickIdProcessor', () => { 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 From 050902021b5aa0c3da67ea1a0f697dae02934c30 Mon Sep 17 00:00:00 2001 From: Alex S <49695018+alexs-mparticle@users.noreply.github.com> Date: Tue, 10 Dec 2024 13:29:06 -0500 Subject: [PATCH 2/6] Apply suggestions from code review Co-authored-by: Robert Ing --- src/integrationCapture.ts | 4 ++-- src/sdkToEventsApiConverter.ts | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/integrationCapture.ts b/src/integrationCapture.ts index 1e05f67a..69017a06 100644 --- a/src/integrationCapture.ts +++ b/src/integrationCapture.ts @@ -51,7 +51,7 @@ export const facebookClickIdProcessor: IntegrationCaptureProcessorFunction = ( }; // Integration outputs are used to determine how click ids are used within the SDK -// CUSTOM_FLAGS are sent out when an Events is created via ServerModel.createEventObject +// 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 = { @@ -173,7 +173,7 @@ export default class IntegrationCapture { } /** - * Converts the captured click IDs to partner identities. + * Returns only the `partner_identities` mapped integration output. * @returns {Dictionary} The partner identities. */ public getClickIdsAsPartnerIdentities(): Dictionary { diff --git a/src/sdkToEventsApiConverter.ts b/src/sdkToEventsApiConverter.ts index eaf69425..4ea73032 100644 --- a/src/sdkToEventsApiConverter.ts +++ b/src/sdkToEventsApiConverter.ts @@ -27,7 +27,6 @@ const { type PartnerIdentities = Dictionary; - // https://go.mparticle.com/work/SQDSDKS-6964 interface Batch extends EventsApi.Batch { partner_identities?: PartnerIdentities; @@ -129,7 +128,7 @@ export function convertEvents( }; } - const isIntegrationCaptureEnabled = getFeatureFlag && getFeatureFlag(CaptureIntegrationSpecificIds) + const isIntegrationCaptureEnabled: boolean = getFeatureFlag && getFeatureFlag(CaptureIntegrationSpecificIds); if (isIntegrationCaptureEnabled) { const capturedPartnerIdentities: PartnerIdentities = _IntegrationCapture?.getClickIdsAsPartnerIdentities(); if (!isEmpty(capturedPartnerIdentities)) { From 4b89f99dd6e40c2c100067be55a4d8f72da7c1f3 Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Tue, 10 Dec 2024 13:33:50 -0500 Subject: [PATCH 3/6] Address PR Comments --- test/jest/integration-capture.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/jest/integration-capture.spec.ts b/test/jest/integration-capture.spec.ts index 926b1866..d2993f46 100644 --- a/test/jest/integration-capture.spec.ts +++ b/test/jest/integration-capture.spec.ts @@ -368,7 +368,7 @@ describe('Integration Capture', () => { expect(partnerIdentities).toEqual({}); }); - it.only('should only return mapped clickIds as partner identities', () => { + it('should only return mapped clickIds as partner identities', () => { const integrationCapture = new IntegrationCapture(); integrationCapture.clickIds = { fbclid: '67890', From 36ea2369dc5973b1743ec17cd96903eae05ca0c1 Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Tue, 10 Dec 2024 13:51:34 -0500 Subject: [PATCH 4/6] Address PR Comments --- src/integrationCapture.ts | 2 +- test/jest/integration-capture.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/integrationCapture.ts b/src/integrationCapture.ts index 69017a06..b68cfe68 100644 --- a/src/integrationCapture.ts +++ b/src/integrationCapture.ts @@ -101,7 +101,7 @@ const integrationMapping: IntegrationIdMapping = { // TIKTOK ttclid: { - mappedKey: 'TikTok.ClickId', + mappedKey: 'TikTok.Callback', output: IntegrationOutputs.CUSTOM_FLAGS, }, _ttp: { diff --git a/test/jest/integration-capture.spec.ts b/test/jest/integration-capture.spec.ts index d2993f46..dc1bd3b0 100644 --- a/test/jest/integration-capture.spec.ts +++ b/test/jest/integration-capture.spec.ts @@ -313,7 +313,7 @@ describe('Integration Capture', () => { expect(customFlags).toEqual({ 'Facebook.ClickId': '67890', 'Facebook.BrowserId': '54321', - 'TikTok.ClickId': '12345', + 'TikTok.Callback': '12345', 'GoogleEnhancedConversions.Gclid': '123233.23131', }); }); @@ -341,7 +341,7 @@ describe('Integration Capture', () => { expect(customFlags).toEqual({ 'Facebook.ClickId': '67890', 'Facebook.BrowserId': '54321', - 'TikTok.ClickId': '12345', + 'TikTok.Callback': '12345', 'GoogleEnhancedConversions.Gclid': '123233.23131', }); }); From d145d6ac0f1b1fe5dcae4c7c8c717ac4cc39604b Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Wed, 11 Dec 2024 10:39:06 -0500 Subject: [PATCH 5/6] Address PR Comments --- test/jest/integration-capture.spec.ts | 32 --------------------------- 1 file changed, 32 deletions(-) diff --git a/test/jest/integration-capture.spec.ts b/test/jest/integration-capture.spec.ts index dc1bd3b0..cf728a8c 100644 --- a/test/jest/integration-capture.spec.ts +++ b/test/jest/integration-capture.spec.ts @@ -299,25 +299,6 @@ describe('Integration Capture', () => { }); describe('#getClickIdsAsCustomFlags', () => { - it('should return clickIds as custom flags', () => { - const integrationCapture = new IntegrationCapture(); - integrationCapture.clickIds = { - fbclid: '67890', - _fbp: '54321', - ttclid: '12345', - gclid: '123233.23131', - }; - - const customFlags = integrationCapture.getClickIdsAsCustomFlags(); - - expect(customFlags).toEqual({ - 'Facebook.ClickId': '67890', - 'Facebook.BrowserId': '54321', - 'TikTok.Callback': '12345', - 'GoogleEnhancedConversions.Gclid': '123233.23131', - }); - }); - it('should return empty object if clickIds is empty or undefined', () => { const integrationCapture = new IntegrationCapture(); const customFlags = integrationCapture.getClickIdsAsCustomFlags(); @@ -348,19 +329,6 @@ describe('Integration Capture', () => { }); describe('#getClickIdsAsPartnerIdentites', () => { - it('should return clickIds as partner identities', () => { - const integrationCapture = new IntegrationCapture(); - integrationCapture.clickIds = { - _ttp: '1234123999.123123', - }; - - const partnerIdentities = integrationCapture.getClickIdsAsPartnerIdentities(); - - expect(partnerIdentities).toEqual({ - tiktok_cookie_id: '1234123999.123123', - }); - }); - it('should return empty object if clickIds is empty or undefined', () => { const integrationCapture = new IntegrationCapture(); const partnerIdentities = integrationCapture.getClickIdsAsPartnerIdentities(); From c743c0e6d344de2cf0fbb57d22e9fd9ca7cba073 Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Wed, 11 Dec 2024 10:48:14 -0500 Subject: [PATCH 6/6] Cast Feature Flag as Boolean --- src/sdkToEventsApiConverter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sdkToEventsApiConverter.ts b/src/sdkToEventsApiConverter.ts index 4ea73032..f4349bdb 100644 --- a/src/sdkToEventsApiConverter.ts +++ b/src/sdkToEventsApiConverter.ts @@ -128,7 +128,7 @@ export function convertEvents( }; } - const isIntegrationCaptureEnabled: boolean = getFeatureFlag && getFeatureFlag(CaptureIntegrationSpecificIds); + const isIntegrationCaptureEnabled: boolean = getFeatureFlag && Boolean(getFeatureFlag(CaptureIntegrationSpecificIds)); if (isIntegrationCaptureEnabled) { const capturedPartnerIdentities: PartnerIdentities = _IntegrationCapture?.getClickIdsAsPartnerIdentities(); if (!isEmpty(capturedPartnerIdentities)) {