Skip to content

Commit

Permalink
fix: update attribution plugin to apply utm params to the `session_st…
Browse files Browse the repository at this point in the history
…art` event (#619)

Co-authored-by: Marvin Liu <[email protected]>
Co-authored-by: Kevin Pagtakhan <[email protected]>
  • Loading branch information
3 people authored Nov 22, 2023
1 parent 0d9605a commit bf45ca6
Show file tree
Hide file tree
Showing 15 changed files with 826 additions and 229 deletions.
270 changes: 214 additions & 56 deletions packages/analytics-browser-test/test/index.test.ts

Large diffs are not rendered by default.

35 changes: 25 additions & 10 deletions packages/analytics-browser/src/browser-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
isFormInteractionTrackingEnabled,
setConnectorDeviceId,
setConnectorUserId,
isNewSession,
isSessionExpired,
} from '@amplitude/analytics-client-common';
import {
BrowserClient,
Expand Down Expand Up @@ -38,6 +38,7 @@ export class AmplitudeBrowser extends AmplitudeCore implements BrowserClient {
config: BrowserConfig;
previousSessionDeviceId: string | undefined;
previousSessionUserId: string | undefined;
sessionStartEventTime: number | undefined;

init(apiKey = '', userIdOrOptions?: string | BrowserOptions, maybeOptions?: BrowserOptions) {
let userId: string | undefined;
Expand Down Expand Up @@ -104,7 +105,16 @@ export class AmplitudeBrowser extends AmplitudeCore implements BrowserClient {
// Add web attribution plugin
if (isAttributionTrackingEnabled(this.config.defaultTracking)) {
const attributionTrackingOptions = getAttributionTrackingConfig(this.config);
const webAttribution = webAttributionPlugin(attributionTrackingOptions);
const resetSessionOnNewCampaign =
!this.config.lastEventTime || isSessionExpired(this.config.sessionTimeout, this.config.lastEventTime)
? // A new session just started OR will start soon organically, no need to trigger another restart
false
: // Previous session is still valid, allow reset if configured
attributionTrackingOptions.resetSessionOnNewCampaign;
const webAttribution = webAttributionPlugin({
...attributionTrackingOptions,
resetSessionOnNewCampaign,
});
await this.add(webAttribution).promise;
}

Expand Down Expand Up @@ -171,8 +181,8 @@ export class AmplitudeBrowser extends AmplitudeCore implements BrowserClient {
}

const previousSessionId = this.getSessionId();
const lastEventTime = this.config.lastEventTime;
let lastEventId = this.config.lastEventId ?? -1;
const lastEventTime = this.config.lastEventTime ?? this.sessionStartEventTime;
this.config.lastEventId = this.config.lastEventId ?? 0;

this.config.sessionId = sessionId;
this.config.lastEventTime = undefined;
Expand All @@ -181,19 +191,24 @@ export class AmplitudeBrowser extends AmplitudeCore implements BrowserClient {
if (previousSessionId && lastEventTime) {
this.track(DEFAULT_SESSION_END_EVENT, undefined, {
device_id: this.previousSessionDeviceId,
event_id: ++lastEventId,
event_id: ++this.config.lastEventId,
session_id: previousSessionId,
time: lastEventTime + 1,
user_id: this.previousSessionUserId,
});
this.sessionStartEventTime = undefined;
}

this.config.lastEventTime = this.config.sessionId;
this.track(DEFAULT_SESSION_START_EVENT, undefined, {
event_id: ++lastEventId,
event_id: ++this.config.lastEventId,
session_id: this.config.sessionId,
time: this.config.lastEventTime,
time: this.config.sessionId,
});
// `this.lastSessionStartEventTime` is used to handle extreme edge case where session
// is restarted before session start event is processed by context plugin to set
// the globally used this.config.lastEventTime. `this.config.lastEventTime` should
// only be modified by config builder and context plugin to limit tampering.
this.sessionStartEventTime = this.config.sessionId;
}

this.previousSessionDeviceId = this.config.deviceId;
Expand Down Expand Up @@ -251,13 +266,13 @@ export class AmplitudeBrowser extends AmplitudeCore implements BrowserClient {

async process(event: Event) {
const currentTime = Date.now();
const isEventInNewSession = isNewSession(this.config.sessionTimeout, this.config.lastEventTime);
const isMostRecentSessionExpired = isSessionExpired(this.config.sessionTimeout, this.config.lastEventTime);

if (
event.event_type !== DEFAULT_SESSION_START_EVENT &&
event.event_type !== DEFAULT_SESSION_END_EVENT &&
(!event.session_id || event.session_id === this.getSessionId()) &&
isEventInNewSession
isMostRecentSessionExpired
) {
this.setSessionId(currentTime);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const fileDownloadTracking = (): EnrichmentPlugin => {
}

/* istanbul ignore if */
if (typeof document === 'undefined') {
if (typeof document === 'undefined' || !document.body) {
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const formInteractionTracking = (): EnrichmentPlugin => {
}

/* istanbul ignore if */
if (typeof document === 'undefined') {
if (typeof document === 'undefined' || !document.body) {
return;
}

Expand Down
156 changes: 153 additions & 3 deletions packages/analytics-browser/test/browser-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,17 @@ import { AmplitudeBrowser } from '../src/browser-client';
import * as core from '@amplitude/analytics-core';
import * as Config from '../src/config';
import * as CookieMigration from '../src/cookie-migration';
import { UserSession } from '@amplitude/analytics-types';
import {
DestinationPlugin,
IdentifyEvent,
Result,
SpecialEventType,
UserSession,
Event,
} from '@amplitude/analytics-types';
import {
BASE_CAMPAIGN,
CampaignParser,
CookieStorage,
FetchTransport,
getAnalyticsConnector,
Expand All @@ -13,6 +22,7 @@ import * as SnippetHelper from '../src/utils/snippet-helper';
import * as fileDownloadTracking from '../src/plugins/file-download-tracking';
import * as formInteractionTracking from '../src/plugins/form-interaction-tracking';
import * as webAttributionPlugin from '@amplitude/plugin-web-attribution-browser';
import * as helpers from '@amplitude/plugin-web-attribution-browser/src/helpers';

describe('browser-client', () => {
let apiKey = '';
Expand Down Expand Up @@ -57,7 +67,7 @@ describe('browser-client', () => {
expect(client.getUserId()).toBe(undefined);
});

test('should set initalize with undefined user id', async () => {
test('should set initialize with undefined user id', async () => {
client.setOptOut(true);
await client.init(apiKey, undefined).promise;
expect(client.getUserId()).toBe(undefined);
Expand Down Expand Up @@ -236,9 +246,10 @@ describe('browser-client', () => {
expect(formInteractionTrackingPlugin).toHaveBeenCalledTimes(0);
});

test('should add web attribution tracking plugin', async () => {
test('should add web attribution tracking plugin with valid session', async () => {
jest.spyOn(CookieMigration, 'parseLegacyCookies').mockResolvedValueOnce({
optOut: false,
sessionId: Date.now(),
lastEventTime: Date.now(),
});
const webAttributionPluginPlugin = jest.spyOn(webAttributionPlugin, 'webAttributionPlugin');
Expand All @@ -253,13 +264,102 @@ describe('browser-client', () => {
);
await client.init(apiKey, userId, {
optOut: false,
defaultTracking: {
...defaultTracking,
attribution: {
resetSessionOnNewCampaign: true,
},
},
}).promise;
expect(webAttributionPluginPlugin).toHaveBeenCalledTimes(1);
expect(webAttributionPluginPlugin).toHaveBeenNthCalledWith(1, {
resetSessionOnNewCampaign: true,
});
});

test('should add web attribution tracking plugin with expired session', async () => {
jest.spyOn(CookieMigration, 'parseLegacyCookies').mockResolvedValueOnce({
optOut: false,
sessionId: Date.now(),
lastEventTime: 1,
});
const webAttributionPluginPlugin = jest.spyOn(webAttributionPlugin, 'webAttributionPlugin');
jest.spyOn(client, 'dispatch').mockReturnValueOnce(
Promise.resolve({
code: 200,
message: '',
event: {
event_type: 'event_type',
},
}),
);
await client.init(apiKey, userId, {
optOut: false,
defaultTracking: {
...defaultTracking,
attribution: {
resetSessionOnNewCampaign: false,
},
},
}).promise;
expect(webAttributionPluginPlugin).toHaveBeenCalledTimes(1);
expect(webAttributionPluginPlugin).toHaveBeenNthCalledWith(1, {
resetSessionOnNewCampaign: false,
});
});

test('should add web attribution to session start event', async () => {
jest.spyOn(CookieMigration, 'parseLegacyCookies').mockResolvedValueOnce({
optOut: false,
lastEventTime: Date.now() - 1000,
});
const webAttributionPluginPlugin = jest.spyOn(webAttributionPlugin, 'webAttributionPlugin');
const track = jest.spyOn(client, 'track');
jest.spyOn(helpers, 'isNewCampaign').mockReturnValue(true);
jest.spyOn(CampaignParser.prototype, 'parse').mockResolvedValueOnce({
...BASE_CAMPAIGN,
utm_source: 'amp-test',
});
const setSessionId = jest.spyOn(client, 'setSessionId');
const testDestination: DestinationPlugin = {
name: 'test-destination',
type: 'destination',
execute(event: Event): Promise<Result> {
return Promise.resolve({
code: 200,
message: '',
event,
});
},
};
const testDestinationExecute = jest.spyOn(testDestination, 'execute');
client.add(testDestination);

await client.init(apiKey, userId, {
optOut: false,
sessionTimeout: 500,
flushQueueSize: 1,
defaultTracking: {
...defaultTracking,
attribution: {},
sessions: true,
},
sessionId: Date.now(),
}).promise;
client.remove('amplitude');

expect(webAttributionPluginPlugin).toHaveBeenCalledTimes(1);
expect(setSessionId).toHaveBeenCalledTimes(1);
expect(track).toHaveBeenCalledTimes(2);
expect(track.mock.calls[0][0]).toBe('session_start');

await client.flush().promise;

expect(testDestinationExecute).toHaveBeenCalledTimes(1);
expect(testDestinationExecute.mock.calls[0][0].event_type).toEqual('session_start');
expect(testDestinationExecute.mock.calls[0][0].user_properties).toEqual(
campaignEventWithUtmSource.user_properties,
);
});
});

Expand Down Expand Up @@ -838,3 +938,53 @@ describe('browser-client', () => {
});
});
});

const campaignEventWithUtmSource: IdentifyEvent = {
event_type: SpecialEventType.IDENTIFY,
user_properties: {
$set: {
utm_source: 'amp-test',
},
$setOnce: {
initial_dclid: 'EMPTY',
initial_fbclid: 'EMPTY',
initial_gbraid: 'EMPTY',
initial_gclid: 'EMPTY',
initial_ko_click_id: 'EMPTY',
initial_li_fat_id: 'EMPTY',
initial_msclkid: 'EMPTY',
initial_wbraid: 'EMPTY',
initial_referrer: 'EMPTY',
initial_referring_domain: 'EMPTY',
initial_rtd_cid: 'EMPTY',
initial_ttclid: 'EMPTY',
initial_twclid: 'EMPTY',
initial_utm_campaign: 'EMPTY',
initial_utm_content: 'EMPTY',
initial_utm_id: 'EMPTY',
initial_utm_medium: 'EMPTY',
initial_utm_source: 'amp-test',
initial_utm_term: 'EMPTY',
},
$unset: {
dclid: '-',
fbclid: '-',
gbraid: '-',
gclid: '-',
ko_click_id: '-',
li_fat_id: '-',
msclkid: '-',
wbraid: '-',
referrer: '-',
referring_domain: '-',
rtd_cid: '-',
ttclid: '-',
twclid: '-',
utm_campaign: '-',
utm_content: '-',
utm_id: '-',
utm_medium: '-',
utm_term: '-',
},
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Event, IdentifyOperation, IdentifyUserProperties } from '@amplitude/analytics-types';
import { BASE_CAMPAIGN } from './constants';

export const isCampaignEvent = (event: Event) => {
if (event.user_properties) {
const properties = event.user_properties as IdentifyUserProperties;
const $set = properties[IdentifyOperation.SET] || {};
const $unset = properties[IdentifyOperation.UNSET] || {};
const userProperties = [...Object.keys($set), ...Object.keys($unset)];
return Object.keys(BASE_CAMPAIGN).every((value) => userProperties.includes(value));
}
return false;
};
3 changes: 2 additions & 1 deletion packages/analytics-client-common/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export { CampaignParser } from './attribution/campaign-parser';
export { CampaignTracker } from './attribution/campaign-tracker';
export { getQueryParams } from './query-params';
export { isNewSession } from './session';
export { isNewSession, isSessionExpired } from './session';
export { getCookieName, getOldCookieName } from './cookie-name';
export { CookieStorage } from './storage/cookie';
export { FetchTransport } from './transports/fetch';
Expand All @@ -19,3 +19,4 @@ export {
isPageViewTrackingEnabled,
isSessionTrackingEnabled,
} from './default-tracking';
export { isCampaignEvent } from './attribution/campaign-helper';
8 changes: 7 additions & 1 deletion packages/analytics-client-common/src/session.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
export const isNewSession = (sessionTimeout: number, lastEventTime: number = Date.now()): boolean => {
/**
* @deprecated Function name is misleading. Use `isSessionExpired`.
*/
export const isNewSession = (sessionTimeout: number, lastEventTime: number = Date.now()): boolean =>
isSessionExpired(sessionTimeout, lastEventTime);

export const isSessionExpired = (sessionTimeout: number, lastEventTime: number = Date.now()): boolean => {
const currentTime = Date.now();
const timeSinceLastEvent = currentTime - lastEventTime;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Identify } from '@amplitude/analytics-core';
import { isCampaignEvent } from '../../src/attribution/campaign-helper';
import { BASE_CAMPAIGN } from '../../src/attribution/constants';

describe('isCampaignEvent', () => {
test('should return false with undefined user props', () => {
expect(
isCampaignEvent({
event_type: 'event_type',
}),
).toBe(false);
});

test('should return false with empty user props', () => {
expect(
isCampaignEvent({
event_type: 'event_type',
user_properties: {},
}),
).toBe(false);
});

test('should return true', () => {
const identifyEvent = Object.entries(BASE_CAMPAIGN).reduce((identify, [key, value]) => {
if (value) {
return identify.set(key, value);
}
return identify.unset(key);
}, new Identify());

expect(
isCampaignEvent({
event_type: 'event_type',
user_properties: identifyEvent.getUserProperties(),
}),
).toBe(true);
});
});
Loading

0 comments on commit bf45ca6

Please sign in to comment.