Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add support for TikTok Click and Cookie Ids #956

Open
wants to merge 6 commits into
base: feat/click-ids
Choose a base branch
from
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
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
alexs-mparticle marked this conversation as resolved.
Show resolved Hide resolved
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',
alexs-mparticle marked this conversation as resolved.
Show resolved Hide resolved
});
});

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