diff --git a/packages/analytics-browser-test/test/index.test.ts b/packages/analytics-browser-test/test/index.test.ts index 92fba4625..55c465d38 100644 --- a/packages/analytics-browser-test/test/index.test.ts +++ b/packages/analytics-browser-test/test/index.test.ts @@ -89,7 +89,7 @@ describe('integration', () => { void client.track('Event Before Init').promise.then((response) => { expect(response.event).toEqual({ device_id: deviceId, // NOTE: Device ID was set before init - event_id: 0, + event_id: 1, event_type: 'Event Before Init', insert_id: uuid, ip: '$remote', @@ -212,7 +212,7 @@ describe('integration', () => { wbraid: '-', }, }, - event_id: 0, + event_id: 1, library: library, }, { @@ -225,7 +225,7 @@ describe('integration', () => { ip: '$remote', insert_id: uuid, event_type: 'Event Before Init', - event_id: 1, + event_id: 2, library: library, user_agent: userAgent, }, @@ -257,7 +257,7 @@ describe('integration', () => { insert_id: uuid, partner_id: undefined, event_type: 'test event', - event_id: 0, + event_id: 1, event_properties: { mode: 'test', }, @@ -289,7 +289,7 @@ describe('integration', () => { insert_id: uuid, partner_id: undefined, event_type: 'test event', - event_id: 0, + event_id: 1, library: library, user_agent: userAgent, }); @@ -320,7 +320,7 @@ describe('integration', () => { insert_id: uuid, partner_id: undefined, event_type: 'test event', - event_id: 0, + event_id: 1, library: library, user_agent: userAgent, }); @@ -357,7 +357,7 @@ describe('integration', () => { insert_id: uuid, partner_id: undefined, event_type: 'test event', - event_id: 0, + event_id: 1, library: library, ingestion_metadata: { source_name: sourceName, @@ -401,7 +401,7 @@ describe('integration', () => { insert_id: uuid, partner_id: undefined, event_type: 'test event', - event_id: 0, + event_id: 1, library: library, groups: { org: '15', @@ -446,7 +446,7 @@ describe('integration', () => { insert_id: uuid, partner_id: undefined, event_type: 'test event 1', - event_id: 0, + event_id: 1, library: library, user_agent: userAgent, }); @@ -463,7 +463,7 @@ describe('integration', () => { insert_id: uuid, partner_id: undefined, event_type: 'test event 2', - event_id: 1, + event_id: 2, library: library, user_agent: userAgent, }); @@ -497,7 +497,7 @@ describe('integration', () => { insert_id: uuid, partner_id: undefined, event_type: 'test event 1', - event_id: 0, + event_id: 1, library: library, user_agent: userAgent, }); @@ -514,7 +514,7 @@ describe('integration', () => { insert_id: uuid, partner_id: undefined, event_type: 'test event 2', - event_id: 1, + event_id: 2, library: library, user_agent: userAgent, }); @@ -557,7 +557,7 @@ describe('integration', () => { insert_id: uuid, partner_id: undefined, event_type: 'test event 1', - event_id: 0, + event_id: 1, library: library, user_agent: userAgent, }); @@ -574,7 +574,7 @@ describe('integration', () => { insert_id: uuid, partner_id: undefined, event_type: 'test event 2', - event_id: 1, + event_id: 2, library: library, user_agent: userAgent, }); @@ -606,7 +606,7 @@ describe('integration', () => { insert_id: uuid, partner_id: undefined, event_type: 'test event 1', - event_id: 0, + event_id: 1, library: library, user_agent: userAgent, }); @@ -623,7 +623,7 @@ describe('integration', () => { insert_id: uuid, partner_id: undefined, event_type: 'test event 2', - event_id: 1, + event_id: 2, library: library, user_agent: userAgent, }); @@ -655,7 +655,7 @@ describe('integration', () => { insert_id: uuid, partner_id: undefined, event_type: 'test event', - event_id: 0, + event_id: 1, library: library, user_agent: userAgent, }); @@ -715,7 +715,7 @@ describe('integration', () => { insert_id: uuid, partner_id: undefined, event_type: '$identify', - event_id: 0, + event_id: 1, library: library, user_agent: userAgent, user_properties: { @@ -767,7 +767,7 @@ describe('integration', () => { const response = await client.revenue(rev).promise; expect(response.event).toEqual({ device_id: uuid, - event_id: 0, + event_id: 1, event_properties: { $price: 100, $productId: '1', @@ -803,7 +803,7 @@ describe('integration', () => { const response = await client.setGroup('org', 'engineering').promise; expect(response.event).toEqual({ device_id: uuid, - event_id: 0, + event_id: 1, event_type: '$identify', groups: { org: 'engineering', @@ -918,22 +918,6 @@ describe('integration', () => { time: number, user_agent: userAgent, user_id: 'user1@amplitude.com', - }, - { - device_id: uuid, - event_id: 102, - event_type: '$identify', - insert_id: uuid, - ip: '$remote', - language: 'en-US', - library, - partner_id: undefined, - plan: undefined, - platform: 'Web', - session_id: number, - time: number, - user_agent: userAgent, - user_id: 'user1@amplitude.com', user_properties: { $setOnce: { initial_dclid: 'EMPTY', @@ -1054,7 +1038,7 @@ describe('integration', () => { }); }); - test('should send session events and replace with unknown user', () => { + test('should send session events and replace with unknown user with resetSessionOnNewCampaign:true', () => { // Reset previous session cookies document.cookie = `amp_${apiKey.substring(0, 6)}=null; expires=1 Jan 1970 00:00:00 GMT`; @@ -1068,7 +1052,9 @@ describe('integration', () => { client.init(apiKey, 'user1@amplitude.com', { defaultTracking: { ...defaultTracking, - attribution: true, + attribution: { + resetSessionOnNewCampaign: true, + }, sessions: true, }, sessionTimeout: 500, @@ -1095,25 +1081,114 @@ describe('integration', () => { payload.events[2].device_id, payload.events[3].device_id, payload.events[4].device_id, - payload.events[5].device_id, ].forEach((deviceId) => { expect(deviceId).toEqual(deviceId0); }); const sessionId0 = payload.events[0].session_id; - [payload.events[1].session_id, payload.events[2].session_id, payload.events[3].session_id].forEach( - (sessionId) => { - expect(sessionId).toEqual(sessionId0); - }, - ); - expect(sessionId0).not.toEqual(payload.events[4].session_id); - expect(payload.events[4].session_id).toEqual(payload.events[5].session_id); + [payload.events[1].session_id, payload.events[2].session_id].forEach((sessionId) => { + expect(sessionId).toEqual(sessionId0); + }); + expect(sessionId0).not.toEqual(payload.events[3].session_id); + expect(payload.events[3].session_id).toEqual(payload.events[4].session_id); expect(payload).toEqual({ api_key: apiKey, client_upload_time: event_upload_time, events: [ { device_id: uuid, - event_id: 0, + event_id: 1, + event_type: 'session_start', + insert_id: uuid, + ip: '$remote', + language: 'en-US', + library, + partner_id: undefined, + plan: undefined, + platform: 'Web', + session_id: number, + time: number, + user_agent: userAgent, + user_id: 'user1@amplitude.com', + user_properties: { + $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_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: 'EMPTY', + initial_utm_term: 'EMPTY', + initial_wbraid: 'EMPTY', + }, + $unset: { + dclid: '-', + fbclid: '-', + gbraid: '-', + gclid: '-', + ko_click_id: '-', + li_fat_id: '-', + msclkid: '-', + referrer: '-', + referring_domain: '-', + rtd_cid: '-', + ttclid: '-', + twclid: '-', + utm_campaign: '-', + utm_content: '-', + utm_id: '-', + utm_medium: '-', + utm_source: '-', + utm_term: '-', + wbraid: '-', + }, + }, + }, + { + device_id: uuid, + event_id: 3, + event_type: 'Event in first session', + insert_id: uuid, + ip: '$remote', + language: 'en-US', + library, + partner_id: undefined, + plan: undefined, + platform: 'Web', + session_id: number, + time: number, + user_agent: userAgent, + user_id: 'user1@amplitude.com', + }, + { + device_id: uuid, + event_id: 4, + event_type: 'session_end', + insert_id: uuid, + ip: '$remote', + language: 'en-US', + library, + partner_id: undefined, + plan: undefined, + platform: 'Web', + session_id: number, + time: number, + user_agent: userAgent, + user_id: 'user1@amplitude.com', + }, + { + device_id: uuid, + event_id: 5, event_type: 'session_start', insert_id: uuid, ip: '$remote', @@ -1127,10 +1202,93 @@ describe('integration', () => { user_agent: userAgent, user_id: 'user1@amplitude.com', }, + { + device_id: uuid, + event_id: 6, + event_type: 'Event in next session', + insert_id: uuid, + ip: '$remote', + language: 'en-US', + library, + partner_id: undefined, + plan: undefined, + platform: 'Web', + session_id: number, + time: number, + user_agent: userAgent, + user_id: 'user1@amplitude.com', + }, + ], + options: { + min_id_length: undefined, + }, + }); + scope.done(); + resolve(); + }, 4000); + }); + }); + + test('should send session events and replace with unknown user with resetSessionOnNewCampaign:false', () => { + // Reset previous session cookies + document.cookie = `amp_${apiKey.substring(0, 6)}=null; expires=1 Jan 1970 00:00:00 GMT`; + + let payload: any = undefined; + const scope = nock(url) + .post(path, (body: Record) => { + payload = body; + return true; + }) + .reply(200, success); + client.init(apiKey, 'user1@amplitude.com', { + defaultTracking: { + ...defaultTracking, + attribution: { + resetSessionOnNewCampaign: true, + }, + sessions: true, + }, + sessionTimeout: 500, + flushIntervalMillis: 3000, + }); + // Sends `session_start` event + client.track('Event in first session'); + + setTimeout(() => { + client.track('Event in next session'); + // Sends `session_end` event for previous session + // Sends `session_start` event for next session + }, 1000); + + setTimeout(() => { + client.reset(); + }, 2000); + + return new Promise((resolve) => { + setTimeout(() => { + const deviceId0 = payload.events[0].device_id; + [ + payload.events[1].device_id, + payload.events[2].device_id, + payload.events[3].device_id, + payload.events[4].device_id, + ].forEach((deviceId) => { + expect(deviceId).toEqual(deviceId0); + }); + const sessionId0 = payload.events[0].session_id; + [payload.events[1].session_id, payload.events[2].session_id].forEach((sessionId) => { + expect(sessionId).toEqual(sessionId0); + }); + expect(sessionId0).not.toEqual(payload.events[3].session_id); + expect(payload.events[3].session_id).toEqual(payload.events[4].session_id); + expect(payload).toEqual({ + api_key: apiKey, + client_upload_time: event_upload_time, + events: [ { device_id: uuid, event_id: 1, - event_type: '$identify', + event_type: 'session_start', insert_id: uuid, ip: '$remote', language: 'en-US', @@ -1189,7 +1347,7 @@ describe('integration', () => { }, { device_id: uuid, - event_id: 2, + event_id: 3, event_type: 'Event in first session', insert_id: uuid, ip: '$remote', @@ -1205,7 +1363,7 @@ describe('integration', () => { }, { device_id: uuid, - event_id: 3, + event_id: 4, event_type: 'session_end', insert_id: uuid, ip: '$remote', @@ -1221,7 +1379,7 @@ describe('integration', () => { }, { device_id: uuid, - event_id: 4, + event_id: 5, event_type: 'session_start', insert_id: uuid, ip: '$remote', @@ -1237,7 +1395,7 @@ describe('integration', () => { }, { device_id: uuid, - event_id: 5, + event_id: 6, event_type: 'Event in next session', insert_id: uuid, ip: '$remote', @@ -1485,7 +1643,7 @@ describe('integration', () => { events: [ { device_id: uuid, - event_id: 0, + event_id: 1, event_properties: { '[Amplitude] Page Domain': '', '[Amplitude] Page Location': '', @@ -1584,7 +1742,7 @@ describe('integration', () => { events: [ { device_id: uuid, - event_id: 0, + event_id: 1, event_properties: { '[Amplitude] Page Domain': '', '[Amplitude] Page Location': '', @@ -1727,7 +1885,7 @@ describe('integration', () => { insert_id: uuid, partner_id: undefined, event_type: 'test event', - event_id: 0, + event_id: 1, library: library, user_agent: userAgent, }); @@ -1767,7 +1925,7 @@ describe('integration', () => { insert_id: uuid, partner_id: undefined, event_type: 'test event', - event_id: 0, + event_id: 1, library: library, user_agent: userAgent, }); diff --git a/packages/analytics-browser/src/browser-client.ts b/packages/analytics-browser/src/browser-client.ts index 8c7c2b3c0..6e114e4c4 100644 --- a/packages/analytics-browser/src/browser-client.ts +++ b/packages/analytics-browser/src/browser-client.ts @@ -10,7 +10,7 @@ import { isFormInteractionTrackingEnabled, setConnectorDeviceId, setConnectorUserId, - isNewSession, + isSessionExpired, } from '@amplitude/analytics-client-common'; import { BrowserClient, @@ -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; @@ -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; } @@ -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; @@ -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; @@ -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); } diff --git a/packages/analytics-browser/src/plugins/file-download-tracking.ts b/packages/analytics-browser/src/plugins/file-download-tracking.ts index af6e39704..b5e8bc08a 100644 --- a/packages/analytics-browser/src/plugins/file-download-tracking.ts +++ b/packages/analytics-browser/src/plugins/file-download-tracking.ts @@ -40,7 +40,7 @@ export const fileDownloadTracking = (): EnrichmentPlugin => { } /* istanbul ignore if */ - if (typeof document === 'undefined') { + if (typeof document === 'undefined' || !document.body) { return; } diff --git a/packages/analytics-browser/src/plugins/form-interaction-tracking.ts b/packages/analytics-browser/src/plugins/form-interaction-tracking.ts index 1b45558c2..8f2bd857a 100644 --- a/packages/analytics-browser/src/plugins/form-interaction-tracking.ts +++ b/packages/analytics-browser/src/plugins/form-interaction-tracking.ts @@ -48,7 +48,7 @@ export const formInteractionTracking = (): EnrichmentPlugin => { } /* istanbul ignore if */ - if (typeof document === 'undefined') { + if (typeof document === 'undefined' || !document.body) { return; } diff --git a/packages/analytics-browser/test/browser-client.test.ts b/packages/analytics-browser/test/browser-client.test.ts index 72c858bd3..6aeb01ca7 100644 --- a/packages/analytics-browser/test/browser-client.test.ts +++ b/packages/analytics-browser/test/browser-client.test.ts @@ -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, @@ -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 = ''; @@ -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); @@ -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'); @@ -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 { + 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, + ); }); }); @@ -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: '-', + }, + }, +}; diff --git a/packages/analytics-client-common/src/attribution/campaign-helper.ts b/packages/analytics-client-common/src/attribution/campaign-helper.ts new file mode 100644 index 000000000..d67fed5b7 --- /dev/null +++ b/packages/analytics-client-common/src/attribution/campaign-helper.ts @@ -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; +}; diff --git a/packages/analytics-client-common/src/index.ts b/packages/analytics-client-common/src/index.ts index 65e3e19d4..8b0433b93 100644 --- a/packages/analytics-client-common/src/index.ts +++ b/packages/analytics-client-common/src/index.ts @@ -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'; @@ -19,3 +19,4 @@ export { isPageViewTrackingEnabled, isSessionTrackingEnabled, } from './default-tracking'; +export { isCampaignEvent } from './attribution/campaign-helper'; diff --git a/packages/analytics-client-common/src/session.ts b/packages/analytics-client-common/src/session.ts index 575544914..ed5bcb726 100644 --- a/packages/analytics-client-common/src/session.ts +++ b/packages/analytics-client-common/src/session.ts @@ -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; diff --git a/packages/analytics-client-common/test/attribution/campaign-helper.test.ts b/packages/analytics-client-common/test/attribution/campaign-helper.test.ts new file mode 100644 index 000000000..90779cffc --- /dev/null +++ b/packages/analytics-client-common/test/attribution/campaign-helper.test.ts @@ -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); + }); +}); diff --git a/packages/analytics-client-common/test/session.test.ts b/packages/analytics-client-common/test/session.test.ts index 0eed0b79d..2fdfcdd32 100644 --- a/packages/analytics-client-common/test/session.test.ts +++ b/packages/analytics-client-common/test/session.test.ts @@ -1,8 +1,8 @@ -import { isNewSession } from '../src/session'; +import { isNewSession, isSessionExpired } from '../src/session'; -describe('session', () => { - const sessionTimeout: number = 30 * 60 * 1000; +const sessionTimeout: number = 30 * 60 * 1000; +describe('session', () => { test('should be in a same session for undefined lastEventTime', () => { const isEventInNewSession = isNewSession(sessionTimeout, undefined); @@ -23,3 +23,23 @@ describe('session', () => { expect(isEventInNewSession).toBe(false); }); }); + +test('should be in a same session for undefined lastEventTime', () => { + const isEventInNewSession = isSessionExpired(sessionTimeout, undefined); + + expect(isEventInNewSession).toBe(false); +}); + +test('should be a new session', () => { + const lastEventTime = Date.now() - sessionTimeout * 2; + const isEventInNewSession = isSessionExpired(sessionTimeout, lastEventTime); + + expect(isEventInNewSession).toBe(true); +}); + +test('should be in a same session', () => { + const lastEventTime = Date.now(); + const isEventInNewSession = isSessionExpired(sessionTimeout, lastEventTime); + + expect(isEventInNewSession).toBe(false); +}); diff --git a/packages/analytics-core/src/timeline.ts b/packages/analytics-core/src/timeline.ts index f8487ee7b..e39866d2e 100644 --- a/packages/analytics-core/src/timeline.ts +++ b/packages/analytics-core/src/timeline.ts @@ -84,7 +84,7 @@ export class Timeline { } const e = await plugin.execute({ ...event }); if (e === null) { - resolve({ event, code: 0, message: '' }); + resolve({ event, code: 100, message: `Event was dropped by a plugin` }); return; } else { event = e; @@ -103,7 +103,7 @@ export class Timeline { } const e = await plugin.execute({ ...event }); if (e === null) { - resolve({ event, code: 0, message: '' }); + resolve({ event, code: 100, message: `Event was dropped by a plugin` }); return; } else { event = e; diff --git a/packages/plugin-page-view-tracking-browser/src/page-view-tracking.ts b/packages/plugin-page-view-tracking-browser/src/page-view-tracking.ts index 545d64034..42fc9d1be 100644 --- a/packages/plugin-page-view-tracking-browser/src/page-view-tracking.ts +++ b/packages/plugin-page-view-tracking-browser/src/page-view-tracking.ts @@ -1,14 +1,5 @@ -import { CampaignParser, getGlobalScope } from '@amplitude/analytics-client-common'; -import { - BrowserClient, - BrowserConfig, - EnrichmentPlugin, - Event, - IdentifyOperation, - IdentifyUserProperties, - Logger, -} from '@amplitude/analytics-types'; -import { BASE_CAMPAIGN } from '@amplitude/analytics-client-common'; +import { CampaignParser, getGlobalScope, isCampaignEvent } from '@amplitude/analytics-client-common'; +import { BrowserClient, BrowserConfig, EnrichmentPlugin, Event, Logger } from '@amplitude/analytics-types'; import { CreatePageViewTrackingPlugin, Options } from './typings/page-view-tracking'; import { omitUndefined } from './utils'; @@ -103,14 +94,22 @@ export const pageViewTrackingPlugin: CreatePageViewTrackingPlugin = (options: Op execute: async (event: Event) => { if (options.trackOn === 'attribution' && isCampaignEvent(event)) { - /* istanbul ignore next */ // loggerProvider should be defined by the time execute is invoked - loggerProvider?.log('Enriching campaign event to page view event with campaign parameters'); const pageViewEvent = await createPageViewEvent(); - event.event_type = pageViewEvent.event_type; - event.event_properties = { - ...event.event_properties, - ...pageViewEvent.event_properties, - }; + + if (event.event_type === '$identify') { + /* istanbul ignore next */ // loggerProvider should be defined by the time execute is invoked + loggerProvider?.log('Enriching campaign event to page view event with campaign parameters'); + event.event_type = pageViewEvent.event_type; + event.event_properties = { + ...event.event_properties, + ...pageViewEvent.event_properties, + }; + } else { + /* istanbul ignore else */ + if (amplitude) { + amplitude.track(pageViewEvent); + } + } } return event; }, @@ -129,17 +128,6 @@ export const pageViewTrackingPlugin: CreatePageViewTrackingPlugin = (options: Op const getCampaignParams = async () => omitUndefined(await new CampaignParser().parse()); -const isCampaignEvent = (event: Event) => { - if (event.event_type === '$identify' && 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; -}; - export const shouldTrackHistoryPageView = ( trackingOption: Options['trackHistoryChanges'], newURL: string, diff --git a/packages/plugin-page-view-tracking-browser/test/page-view-tracking.test.ts b/packages/plugin-page-view-tracking-browser/test/page-view-tracking.test.ts index 9570fddfd..52c623186 100644 --- a/packages/plugin-page-view-tracking-browser/test/page-view-tracking.test.ts +++ b/packages/plugin-page-view-tracking-browser/test/page-view-tracking.test.ts @@ -197,7 +197,78 @@ describe('pageViewTrackingPlugin', () => { }); describe('execute', () => { - test('should track page view on attribution', async () => { + test('should track a separate page view event on attribution', async () => { + const plugin = pageViewTrackingPlugin({ + trackOn: 'attribution', + }); + const amplitude = createInstance(); + const track = jest.spyOn(amplitude, 'track'); + await plugin.setup?.(mockConfig, amplitude); + + const event = await plugin.execute?.({ + event_type: 'session_start', + 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: '-', + }, + }, + }); + expect(event?.event_type).toBe('session_start'); + expect(track).toHaveBeenCalledTimes(1); + expect(track).toHaveBeenNthCalledWith(1, { + event_type: '[Amplitude] Page Viewed', + event_properties: { + '[Amplitude] Page Domain': '', + '[Amplitude] Page Location': '', + '[Amplitude] Page Path': '', + '[Amplitude] Page Title': '', + '[Amplitude] Page URL': '', + }, + }); + }); + + test('should enrich page view track on attribution user props', async () => { const plugin = pageViewTrackingPlugin({ trackOn: 'attribution', }); diff --git a/packages/plugin-web-attribution-browser/src/web-attribution.ts b/packages/plugin-web-attribution-browser/src/web-attribution.ts index 30c55608d..8fd9e9809 100644 --- a/packages/plugin-web-attribution-browser/src/web-attribution.ts +++ b/packages/plugin-web-attribution-browser/src/web-attribution.ts @@ -1,10 +1,19 @@ -import { CampaignParser } from '@amplitude/analytics-client-common'; -import { BeforePlugin, BrowserClient, BrowserConfig, Campaign, Event, Storage } from '@amplitude/analytics-types'; +import { CampaignParser, isCampaignEvent, isSessionExpired } from '@amplitude/analytics-client-common'; +import { + BeforePlugin, + BrowserClient, + BrowserConfig, + Campaign, + Event, + IdentifyEvent, + Storage, +} from '@amplitude/analytics-types'; import { createCampaignEvent, getDefaultExcludedReferrers, getStorageKey, isNewCampaign } from './helpers'; import { CreateWebAttributionPlugin, Options } from './typings/web-attribution'; -import { isNewSession } from '@amplitude/analytics-client-common'; export const webAttributionPlugin: CreateWebAttributionPlugin = function (options: Options = {}) { + const campaignPerSession: { [sessionId: number]: IdentifyEvent | undefined } = {}; + const plugin: BeforePlugin = { name: '@amplitude/plugin-web-attribution-browser', type: 'before', @@ -27,22 +36,91 @@ export const webAttributionPlugin: CreateWebAttributionPlugin = function (option storage.get(storageKey), ]); - const isEventInNewSession = isNewSession(config.sessionTimeout, config.lastEventTime); + // Check if the most recent session ID is expired + const isMostRecentSessionExpired = isSessionExpired(config.sessionTimeout, config.lastEventTime); + + // Check if attribution event will be tracked + if (isNewCampaign(currentCampaign, previousCampaign, pluginConfig, isMostRecentSessionExpired)) { + // Set default session ID to be the most recent session ID + let sessionId = config.sessionId ?? -1; - if (isNewCampaign(currentCampaign, previousCampaign, pluginConfig, isEventInNewSession)) { - if (pluginConfig.resetSessionOnNewCampaign) { - amplitude.setSessionId(Date.now()); - config.loggerProvider.log('Created a new session for new campaign.'); + // Check if most recent session ID is expired OR if `resetSessionOnNewCampaign` set to true + // If yes, set a new session ID + if (isMostRecentSessionExpired || pluginConfig.resetSessionOnNewCampaign) { + sessionId = Date.now(); + amplitude.setSessionId(sessionId); + + if (pluginConfig.resetSessionOnNewCampaign) { + config.loggerProvider.log('Created a new session for new campaign.'); + } } - config.loggerProvider.log('Tracking attribution.'); + + // Create campaign event const campaignEvent = createCampaignEvent(currentCampaign, pluginConfig); + campaignEvent.session_id = sessionId; + // Cache campaign event with its associated session ID as key + campaignPerSession[sessionId] = campaignEvent; + // Additionally, track the same event amplitude.track(campaignEvent); + void storage.set(storageKey, currentCampaign); } }, - execute: async (event: Event) => event, + execute: async (event: Event) => { + if (event.session_id) { + // Check a campaign event cache exists for session ID + const campaignEvent = campaignPerSession[event.session_id]; + if (campaignEvent) { + // If yes, merge first seen event with with campaign event. + // It is possible that `event` is equal to `campaignEvent` + // when `campaignEvent` is what is first passed to `execute`. + event.user_properties = mergeDeep( + campaignEvent.user_properties, + event.user_properties, + ); + event.event_properties = { + ...event.event_properties, + ...campaignEvent.event_properties, + }; + if (Object.keys(event.event_properties).length === 0) { + delete event.event_properties; + } + + // Remove cached campaign event + delete campaignPerSession[event.session_id]; + } else if (isCampaignEvent(event)) { + // If no campaign event event cache for session ID exists, + // then campaign event has been merged with another event + // and this is a dupe that must be dropped. + return null; + } + } + + return event; + }, }; return plugin; }; + +const isObject = (item: any): item is Record => + Boolean(item) && typeof item === 'object' && !Array.isArray(item); + +const mergeDeep = (target: any, source: any): T => { + if (isObject(target) && isObject(source)) { + for (const key in source) { + if (isObject(source[key])) { + if (!target[key]) { + Object.assign(target, { [key]: {} }); + } + mergeDeep(target[key], source[key]); + } else { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + Object.assign(target, { [key]: source[key] }); + } + } + } + + return target as T; +}; diff --git a/packages/plugin-web-attribution-browser/test/web-attribution.test.ts b/packages/plugin-web-attribution-browser/test/web-attribution.test.ts index 6ba4cfc86..14d8bb523 100644 --- a/packages/plugin-web-attribution-browser/test/web-attribution.test.ts +++ b/packages/plugin-web-attribution-browser/test/web-attribution.test.ts @@ -1,10 +1,62 @@ import { createInstance } from '@amplitude/analytics-browser'; import { BASE_CAMPAIGN, CampaignParser, CookieStorage, FetchTransport } from '@amplitude/analytics-client-common'; -import { webAttributionPlugin } from '../src/web-attribution'; +import * as webAttributionModule from '../src/web-attribution'; import * as helpers from '../src/helpers'; -import { BrowserConfig, LogLevel } from '@amplitude/analytics-types'; +import { BrowserConfig, IdentifyEvent, LogLevel, SpecialEventType } from '@amplitude/analytics-types'; import { Logger, UUID } from '@amplitude/analytics-core'; +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: '-', + }, + }, +}; + +const webAttributionPlugin = webAttributionModule.webAttributionPlugin; + describe('webAttributionPlugin', () => { const mockConfig: BrowserConfig = { apiKey: UUID(), @@ -39,15 +91,6 @@ describe('webAttributionPlugin', () => { test('when a campaign changes', async () => { const amplitude = createInstance(); const setSessionId = jest.spyOn(amplitude, 'setSessionId'); - const track = jest.spyOn(amplitude, 'track').mockReturnValueOnce({ - promise: Promise.resolve({ - code: 200, - message: '', - event: { - event_type: '$identify', - }, - }), - }); jest.spyOn(helpers, 'isNewCampaign').mockReturnValue(true); jest.spyOn(CampaignParser.prototype, 'parse').mockResolvedValueOnce({ ...BASE_CAMPAIGN, @@ -57,71 +100,27 @@ describe('webAttributionPlugin', () => { const plugin = webAttributionPlugin(); const overrideMockConfig = { ...mockConfig, + sessionId: 1, cookieOptions: undefined, }; await plugin.setup?.(overrideMockConfig, amplitude); - expect(track).toHaveBeenCalledWith({ - event_type: '$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: '-', - }, - }, - }); - expect(track).toHaveBeenCalledTimes(1); expect(setSessionId).toHaveBeenCalledTimes(0); + const newEvent = await plugin.execute?.({ + event_type: 'event_type', + session_id: 1, + }); + expect(newEvent?.user_properties).toEqual(campaignEventWithUtmSource.user_properties); }); - test('when a campaign changes and reset session id', async () => { + test('when a campaign changes and reset session id, without session events', async () => { const amplitude = createInstance(); const setSessionId = jest.spyOn(amplitude, 'setSessionId'); - const track = jest.spyOn(amplitude, 'track').mockReturnValueOnce({ + const track = jest.spyOn(amplitude, 'track').mockReturnValue({ promise: Promise.resolve({ code: 200, message: '', event: { - event_type: '$identify', + event_type: 'event_type', }, }), }); @@ -131,61 +130,53 @@ describe('webAttributionPlugin', () => { utm_source: 'amp-test', }); + const overrideMockConfig: BrowserConfig = { + ...mockConfig, + + // mocks a valid session to help assert + // session restart + sessionTimeout: 1000, + lastEventTime: Date.now() - 100, + }; const plugin = webAttributionPlugin({ resetSessionOnNewCampaign: true, }); - await plugin.setup?.(mockConfig, amplitude); - expect(track).toHaveBeenCalledWith({ + + await plugin.setup?.(overrideMockConfig, amplitude); + + // assert that session was restarted + expect(setSessionId).toHaveBeenCalledTimes(1); + const newSessionId = setSessionId.mock.calls[0][0]; + + // assert that campaign event ws tracked + expect(track).toHaveBeenCalledTimes(1); + expect(track).toHaveBeenNthCalledWith(1, { event_type: '$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: '-', + ...campaignEventWithUtmSource.user_properties, + }, + session_id: newSessionId, + }); + + const newEvent = await plugin.execute?.({ + event_type: 'event_type', + session_id: newSessionId, + user_properties: { + // adding other user properties to test merge logic + $add: { + a: 1, }, }, }); - expect(track).toHaveBeenCalledTimes(1); - expect(setSessionId).toHaveBeenCalledTimes(1); + + // assert next event seen was enriched with campaign event's user properties + expect(newEvent?.event_type).toEqual('event_type'); + expect(newEvent?.user_properties).toEqual({ + ...campaignEventWithUtmSource.user_properties, + $add: { + a: 1, + }, + }); }); }); @@ -264,5 +255,73 @@ describe('webAttributionPlugin', () => { expect(result).toBe(event); }); + + test('should enrich session_start event', async () => { + const amplitude = createInstance(); + jest.spyOn(Date, 'now').mockReturnValue(Date.now()); + const sessionId = Date.now(); + const overrideMockConfig: BrowserConfig = { + ...mockConfig, + sessionId, + defaultTracking: { + sessions: true, + }, + }; + jest.spyOn(helpers, 'isNewCampaign').mockReturnValue(true); + jest.spyOn(CampaignParser.prototype, 'parse').mockResolvedValueOnce({ + ...BASE_CAMPAIGN, + utm_source: 'amp-test', + }); + const plugin = webAttributionPlugin({ + resetSessionOnNewCampaign: true, + }); + const event = { + event_type: 'session_start', + session_id: sessionId, + }; + await plugin.setup?.(overrideMockConfig, amplitude); + const result = await plugin.execute?.(event); + + expect(result).toBe(event); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(result?.user_properties?.['$set']?.['utm_source']).toBe('amp-test'); + + const duplicateCampaignEvent = { + ...helpers.createCampaignEvent(BASE_CAMPAIGN, {}), + session_id: sessionId, + }; + const duplicateResult = await plugin.execute?.(duplicateCampaignEvent); + expect(duplicateResult).toBe(null); + }); + + test('should not enrich session_start event if session_id is not present', async () => { + const amplitude = createInstance(); + jest.spyOn(Date, 'now').mockReturnValue(Date.now()); + const sessionId = Date.now(); + const overrideMockConfig: BrowserConfig = { + ...mockConfig, + sessionId, + defaultTracking: { + sessions: true, + }, + }; + jest.spyOn(helpers, 'isNewCampaign').mockReturnValue(true); + jest.spyOn(CampaignParser.prototype, 'parse').mockResolvedValueOnce({ + ...BASE_CAMPAIGN, + utm_source: 'amp-test', + }); + const plugin = webAttributionPlugin({ + resetSessionOnNewCampaign: true, + }); + const event = { + event_type: 'session_start', + }; + await plugin.setup?.(overrideMockConfig, amplitude); + const result = await plugin.execute?.(event); + + expect(result).toBe(event); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(result?.user_properties?.['$set']?.['utm_source']).toBe(undefined); + }); }); });