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: Capture Integration Ids and assign to events #926

Merged
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ const Constants = {
DirectUrlRouting: 'directURLRouting',
CacheIdentity: 'cacheIdentity',
AudienceAPI: 'audienceAPI',
CaptureIntegrationSpecificIds: 'captureIntegrationSpecificIds',
},
DefaultInstance: 'default_instance',
CCPAPurpose: 'data_sale_opt_out',
Expand Down
153 changes: 153 additions & 0 deletions src/integrationCapture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { SDKEventCustomFlags } from './sdkRuntimeModels';
import {
Dictionary,
queryStringParser,
getCookies,
getHref,
isEmpty,
} from './utils';

export interface IntegrationCaptureProcessorFunction {
(clickId: string, url: string, timestamp?: number): string;
}

// Facebook Click ID has specific formatting rules
// The formatted ClickID value must be of the form version.subdomainIndex.creationTime.<fbclid>, where:
// - version is always this prefix: fb
// - subdomainIndex is which domain the cookie is defined on ('com' = 0, 'example.com' = 1, 'www.example.com' = 2)
// - creationTime is the UNIX time since epoch in milliseconds when the _fbc was stored. If you don't save the _fbc cookie, use the timestamp when you first observed or received this fbclid value
// - <fbclid> is the value for the fbclid query parameter in the page URL.
// https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc
export const facebookClickIdProcessor: IntegrationCaptureProcessorFunction = (
clickId: string,
url: string,
timestamp?: number,
): string => {
if (!clickId || !url) {
return '';
}

const urlSegments = url?.split('//')
if (!urlSegments) {
return '';
}

const urlParts = urlSegments[1].split('/');
const domainParts = urlParts[0].split('.');
let subdomainIndex: number = 1;

// The rules for subdomainIndex are for parsing the domain portion
// of the URL for cookies, but in this case we are parsing the URL
// itself, so we can ignore the use of 0 for 'com'
if (domainParts.length >= 3) {
subdomainIndex = 2;
}

// If timestamp is not provided, use the current time
const _timestamp = timestamp || Date.now();

return `fb.${subdomainIndex}.${_timestamp}.${clickId}`;
};
interface IntegrationIdMapping {
[key: string]: {
mappedKey: string;
processor?: IntegrationCaptureProcessorFunction;
};
}

const integrationMapping: IntegrationIdMapping = {
alexs-mparticle marked this conversation as resolved.
Show resolved Hide resolved
fbclid: {
mappedKey: 'Facebook.ClickId',
processor: facebookClickIdProcessor,
},
_fbp: {
mappedKey: 'Facebook.BrowserId',
},
_fbc: {
mappedKey: 'Facebook.ClickId',
},
};

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

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

/**
* Captures Integration Ids from cookies and query params and stores them in clickIds object
*/
public capture(): void {
const queryParams = this.captureQueryParams() || {};
const cookies = this.captureCookies() || {};

// Exclude _fbc if fbclid is present
// https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc#retrieve-from-fbclid-url-query-parameter
if (queryParams['fbclid'] && cookies['_fbc']) {
delete cookies['_fbc'];
}

this.clickIds = { ...this.clickIds, ...queryParams, ...cookies };
}

/**
* Captures cookies based on the integration ID mapping.
*/
public captureCookies(): Dictionary<string> {
const cookies = getCookies(Object.keys(integrationMapping));
return this.applyProcessors(cookies);
}

/**
* Captures query parameters based on the integration ID mapping.
*/
public captureQueryParams(): Dictionary<string> {
const queryParams = this.getQueryParams();
return this.applyProcessors(queryParams, getHref(), this.initialTimestamp);
}

/**
* Gets the query parameters based on the integration ID mapping.
* @returns {Dictionary<string>} The query parameters.
*/
public getQueryParams(): Dictionary<string> {
return queryStringParser(getHref(), Object.keys(integrationMapping));
}

/**
* Converts captured click IDs to custom flags for SDK events.
* @returns {SDKEventCustomFlags} The custom flags.
*/
public getClickIdsAsCustomFlags(): SDKEventCustomFlags {
alexs-mparticle marked this conversation as resolved.
Show resolved Hide resolved
const customFlags: SDKEventCustomFlags = {};

if (!this.clickIds) {
return customFlags;
}

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

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

for (const [key, value] of Object.entries(clickIds)) {
const processor = integrationMapping[key]?.processor;
if (processor) {
processedClickIds[key] = processor(value, url, timestamp);
} else {
processedClickIds[key] = value;
}
}

return processedClickIds;
}
}
8 changes: 7 additions & 1 deletion src/mp-instance.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ import IdentityAPIClient from './identityApiClient';
import { isEmpty, isFunction } from './utils';
import { LocalStorageVault } from './vault';
import { removeExpiredIdentityCacheDates } from './identity-utils';
import IntegrationCapture from './integrationCapture';

const { Messages, HTTPCodes, FeatureFlags } = Constants;
const { ReportBatching } = FeatureFlags;
const { ReportBatching, CaptureIntegrationSpecificIds } = FeatureFlags;
const { StartingInitialization } = Messages.InformationMessages;

/**
Expand Down Expand Up @@ -77,6 +78,7 @@ export default function mParticleInstance(instanceName) {
integrationDelays: {},
forwarderConstructors: [],
};
this._IntegrationCapture = new IntegrationCapture();

// required for forwarders once they reference the mparticle instance
this.IdentityType = Types.IdentityType;
Expand Down Expand Up @@ -1336,6 +1338,10 @@ function completeSDKInitialization(apiKey, config, mpInstance) {
mpInstance._ForwardingStatsUploader.startForwardingStatsTimer();
}

if (mpInstance._Helpers.getFeatureFlag(CaptureIntegrationSpecificIds)) {
mpInstance._IntegrationCapture.capture();
alexs-mparticle marked this conversation as resolved.
Show resolved Hide resolved
}

mpInstance._Forwarders.processForwarders(
config,
mpInstance._APIClient.prepareForwardingStats
Expand Down
2 changes: 2 additions & 0 deletions src/sdkRuntimeModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
ISDKUserAttributes,
} from './identity-user-interfaces';
import { IIdentityType } from './types.interfaces';
import IntegrationCapture from './integrationCapture';

// TODO: Resolve this with version in @mparticle/web-sdk
export type SDKEventCustomFlags = Dictionary<any>;
Expand Down Expand Up @@ -150,6 +151,7 @@ interface IEvents {

export interface MParticleWebSDK {
addForwarder(mockForwarder: MPForwarder): void;
_IntegrationCapture: IntegrationCapture;
IdentityType: IIdentityType;
_Identity: IIdentity;
Identity: SDKIdentityApi;
Expand Down
15 changes: 14 additions & 1 deletion src/serverModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
BaseEvent,
MParticleWebSDK,
SDKEvent,
SDKEventCustomFlags,
SDKGeoLocation,
SDKProduct,
} from './sdkRuntimeModels';
Expand Down Expand Up @@ -317,6 +318,18 @@ export default function ServerModel(
event.messageType === Types.MessageType.OptOut ||
mpInstance._Store.webviewBridgeEnabled
) {
let customFlags: SDKEventCustomFlags = {...event.customFlags};

// https://go.mparticle.com/work/SQDSDKS-5053
if (mpInstance._Helpers.getFeatureFlag && mpInstance._Helpers.getFeatureFlag(Constants.FeatureFlags.CaptureIntegrationSpecificIds)) {

// Attempt to recapture click IDs in case a third party integration
// has added or updated new click IDs since the last event was sent.
mpInstance._IntegrationCapture.capture();
const transformedClickIDs = mpInstance._IntegrationCapture.getClickIdsAsCustomFlags();
customFlags = {...transformedClickIDs, ...customFlags};
}

if (event.hasOwnProperty('toEventAPIObject')) {
eventObject = event.toEventAPIObject();
} else {
Expand All @@ -336,7 +349,7 @@ export default function ServerModel(
event.sourceMessageId ||
mpInstance._Helpers.generateUniqueId(),
EventDataType: event.messageType,
CustomFlags: event.customFlags || {},
CustomFlags: customFlags,
UserAttributeChanges: event.userAttributeChanges,
UserIdentityChanges: event.userIdentityChanges,
};
Expand Down
2 changes: 2 additions & 0 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,7 @@ export function processFlags(config: SDKInitConfig): IFeatureFlags {
DirectUrlRouting,
CacheIdentity,
AudienceAPI,
CaptureIntegrationSpecificIds,
} = Constants.FeatureFlags;

if (!config.flags) {
Expand All @@ -727,6 +728,7 @@ export function processFlags(config: SDKInitConfig): IFeatureFlags {
flags[DirectUrlRouting] = config.flags[DirectUrlRouting] === 'True';
flags[CacheIdentity] = config.flags[CacheIdentity] === 'True';
flags[AudienceAPI] = config.flags[AudienceAPI] === 'True';
flags[CaptureIntegrationSpecificIds] = config.flags[CaptureIntegrationSpecificIds] === 'True';

return flags;
}
Expand Down
106 changes: 105 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ const isDataPlanSlug = (str: string): boolean => str === toDataPlanSlug(str);
const isStringOrNumber = (value: any): boolean =>
isString(value) || isNumber(value);

const isEmpty = (value: Dictionary<any> | null | undefined): boolean =>
const isEmpty = (value: Dictionary<any> | string | null | undefined): boolean =>
value == null || !(Object.keys(value) || value).length;

const mergeObjects = <T extends object>(...objects: T[]): T => {
Expand All @@ -232,6 +232,107 @@ const mergeObjects = <T extends object>(...objects: T[]): T => {
const moveElementToEnd = <T>(array: T[], index: number): T[] =>
array.slice(0, index).concat(array.slice(index + 1), array[index]);

const queryStringParser = (
url: string,
keys: string[] = []
): Dictionary<string> => {
let urlParams: URLSearchParams | URLSearchParamsFallback;
let results: Dictionary<string> = {};

if (!url) return results;

if (typeof URL !== 'undefined' && typeof URLSearchParams !== 'undefined') {
const urlObject = new URL(url);
urlParams = new URLSearchParams(urlObject.search);
} else {
urlParams = queryStringParserFallback(url);
}

if (isEmpty(keys)) {
urlParams.forEach((value, key) => {
results[key] = value;
});
} else {
keys.forEach(key => {
const value = urlParams.get(key);
if (value) {
results[key] = value;
}
});
}

return results;
};

interface URLSearchParamsFallback {
get: (key: string) => string | null;
forEach: (callback: (value: string, key: string) => void) => void;
}

const queryStringParserFallback = (url: string): URLSearchParamsFallback => {
let params: Dictionary<string> = {};
const queryString = url.split('?')[1] || '';
const pairs = queryString.split('&');

pairs.forEach(pair => {
var [key, value] = pair.split('=');
if (key && value) {
params[key] = decodeURIComponent(value || '');
}
});

return {
get: function(key: string) {
return params[key];
},
forEach: function(callback: (value: string, key: string) => void) {
for (var key in params) {
if (params.hasOwnProperty(key)) {
callback(params[key], key);
}
}
alexs-mparticle marked this conversation as resolved.
Show resolved Hide resolved
},
};
};

// Get cookies as a dictionary
const getCookies = (keys?: string[]): Dictionary<string> => {
alexs-mparticle marked this conversation as resolved.
Show resolved Hide resolved
// Helper function to parse cookies from document.cookie
const parseCookies = (): string[] => {
if (typeof window === 'undefined') {
return [];
}
return window.document.cookie.split(';').map(cookie => cookie.trim());
};

// Helper function to filter cookies by keys
const filterCookies = (
cookies: string[],
keys?: string[]
): Dictionary<string> => {
const results: Dictionary<string> = {};
for (const cookie of cookies) {
const [key, value] = cookie.split('=');
if (!keys || keys.includes(key)) {
alexs-mparticle marked this conversation as resolved.
Show resolved Hide resolved
results[key] = value;
}
}
return results;
};

// Parse cookies from document.cookie
const parsedCookies: string[] = parseCookies();

// Filter cookies by keys if provided
return filterCookies(parsedCookies, keys);
};

const getHref = (): string => {
return typeof window !== 'undefined' && window.location
? window.location.href
: '';
};

export {
createCookieString,
revertCookieString,
Expand Down Expand Up @@ -262,4 +363,7 @@ export {
isValidCustomFlagProperty,
mergeObjects,
moveElementToEnd,
queryStringParser,
getCookies,
getHref,
};
Loading
Loading