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
63 changes: 63 additions & 0 deletions src/integrationCapture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { SDKEventCustomFlags } from './sdkRuntimeModels';
import { Dictionary, queryStringParser, getCookies, getHref } from './utils';

const integrationIdMapping: Dictionary<string> = {
fbclid: 'Facebook.ClickId',
_fbp: 'Facebook.BrowserId',
};

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

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

/**
* Captures cookies based on the integration ID mapping.
*/
public captureCookies(): void {
const cookies = getCookies(Object.keys(integrationIdMapping));
this.clickIds = { ...this.clickIds, ...cookies };
}

/**
* Captures query parameters based on the integration ID mapping.
*/
public captureQueryParams(): void {
const parsedQueryParams = this.getQueryParams();
this.clickIds = { ...this.clickIds, ...parsedQueryParams };
}

/**
* 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(integrationIdMapping));
}

/**
* Converts captured click IDs to custom flags for SDK events.
* @returns {SDKEventCustomFlags} The custom flags.
*/
public getClickIdsAsCustomFlags(): SDKEventCustomFlags {
const customFlags: SDKEventCustomFlags = {};

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

for (const [key, value] of Object.entries(this.clickIds)) {
const mappedKey = integrationIdMapping[key];
if (mappedKey) {
customFlags[mappedKey] = value;
}
}
return customFlags;
}
}
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();
}

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
11 changes: 10 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 @@ -320,6 +321,14 @@ export default function ServerModel(
if (event.hasOwnProperty('toEventAPIObject')) {
eventObject = event.toEventAPIObject();
} else {
let customFlags: SDKEventCustomFlags = {...event.customFlags};

// https://go.mparticle.com/work/SQDSDKS-5053
if (mpInstance._Helpers.getFeatureFlag && mpInstance._Helpers.getFeatureFlag(Constants.FeatureFlags.CaptureIntegrationSpecificIds)) {
const transformedClickIDs = mpInstance._IntegrationCapture.getClickIdsAsCustomFlags();
customFlags = {...transformedClickIDs, ...customFlags};
}

eventObject = {
// This is an artifact from v2 events where SessionStart/End and AST event
// names are numbers (1, 2, or 10), but going forward with v3, these lifecycle
Expand All @@ -336,7 +345,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
84 changes: 84 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,87 @@ 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;
let results = {};

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);
}

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

return results;
};

interface URLSearchParamsFallback {
get: (key: string) => string | null;
}

const queryStringParserFallback = (url: string): URLSearchParamsFallback => {
var params = {};
var queryString = url.split('?')[1];
var 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];
},
};
};

// Get cookies as a dictionary
const getCookies = (keys?: string[]): Dictionary<string> => {
// 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)) {
results[key] = value;
}
}
return results;
};

// Parse cookies from document.cookie
const cookies = parseCookies();

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

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

export {
createCookieString,
revertCookieString,
Expand Down Expand Up @@ -262,4 +343,7 @@ export {
isValidCustomFlagProperty,
mergeObjects,
moveElementToEnd,
queryStringParser,
getCookies,
getHref,
};
125 changes: 125 additions & 0 deletions test/jest/integration-capture.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import IntegrationCapture from "../../src/integrationCapture";
import { deleteAllCookies } from "./utils";

describe('Integration Capture', () => {
describe('constructor', () => {
it('should initialize with clickIds as undefined', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I go back and forth with saying this should be undefined vs it being defined with a default value of {}. But not a hill i'm going to die on :)

const integrationCapture = new IntegrationCapture();
expect(integrationCapture.clickIds).toBeUndefined();
});
});

describe('#capture', () => {
it('should call captureCookies and captureQueryParams', () => {
const integrationCapture = new IntegrationCapture();
integrationCapture.captureCookies = jest.fn();
integrationCapture.captureQueryParams = jest.fn();

integrationCapture.capture();

expect(integrationCapture.captureCookies).toHaveBeenCalled();
expect(integrationCapture.captureQueryParams).toHaveBeenCalled();
});
});

describe('#captureQueryParams', () => {
const originalLocation = window.location;

beforeEach(() => {
delete (window as any).location;
(window as any).location = {
href: '',
search: '',
assign: jest.fn(),
replace: jest.fn(),
reload: jest.fn(),
};
});

afterEach(() => {
window.location = originalLocation;
});

it('should capture specific query params into clickIds object', () => {
const url = new URL("https://www.example.com/?ttclid=12345&fbclid=67890&_fbp=54321");

window.location.href = url.href;
window.location.search = url.search;

const integrationCapture = new IntegrationCapture();
integrationCapture.captureQueryParams();

expect(integrationCapture.clickIds).toEqual({
fbclid: '67890',
'_fbp': '54321',
});
});

it('should NOT capture query params if they are not mapped', () => {
const url = new URL("https://www.example.com/?invalidid=12345&foo=bar");

window.location.href = url.href;
window.location.search = url.search;

const integrationCapture = new IntegrationCapture();
integrationCapture.captureQueryParams();

expect(integrationCapture.clickIds).toEqual({});
});
});

describe('#captureCookies', () => {
beforeEach(() => {
deleteAllCookies();
});

it('should capture specific cookies into clickIds object', () => {
window.document.cookie = '_cookie1=1234';
window.document.cookie = '_cookie2=39895811.9165333198';
window.document.cookie = '_fbp=54321';
window.document.cookie = 'baz=qux';

const integrationCapture = new IntegrationCapture();
integrationCapture.captureCookies();

expect(integrationCapture.clickIds).toEqual({
'_fbp': '54321',
});
});

it('should NOT capture cookies if they are not mapped', () => {
window.document.cookie = '_cookie1=1234';
window.document.cookie = '_cookie2=39895811.9165333198';
window.document.cookie = 'baz=qux';

const integrationCapture = new IntegrationCapture();
integrationCapture.captureCookies();

expect(integrationCapture.clickIds).toEqual({});
});
});

describe('#getClickIdsAsCustomFlags', () => {
it('should return clickIds as custom flags', () => {
const integrationCapture = new IntegrationCapture();
integrationCapture.clickIds = {
fbclid: '67890',
'_fbp': '54321',
};

const customFlags = integrationCapture.getClickIdsAsCustomFlags();

expect(customFlags).toEqual({
'Facebook.ClickId': '67890',
'Facebook.BrowserId': '54321',
});
});

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

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