diff --git a/packages/actions-shared/src/amplitude/constants.ts b/packages/actions-shared/src/amplitude/constants.ts new file mode 100644 index 00000000000..4b53ee47f84 --- /dev/null +++ b/packages/actions-shared/src/amplitude/constants.ts @@ -0,0 +1,23 @@ +export const AMPLITUDE_ATTRIBUTION_KEYS = [ + 'referrer', + 'referring_domain', + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_term', + 'utm_content', + 'utm_id', + 'dclid', + 'fbclid', + 'gbraid', + 'wbraid', + 'gclid', + 'ko_clickid', + 'li_fat_id', + 'msclkid', + 'rtd_cid', + 'ttclid', + 'twclid' +] as const + +export const AMPLITUDE_ATTRIBUTION_STORAGE_KEY = 'amplitude-attribution-params' \ No newline at end of file diff --git a/packages/actions-shared/src/amplitude/types.ts b/packages/actions-shared/src/amplitude/types.ts new file mode 100644 index 00000000000..9f5ce56c932 --- /dev/null +++ b/packages/actions-shared/src/amplitude/types.ts @@ -0,0 +1,9 @@ +import { AMPLITUDE_ATTRIBUTION_KEYS } from './constants' + +export type AmplitudeAttributionKey = typeof AMPLITUDE_ATTRIBUTION_KEYS[number] + +export type AmplitudeSetOnceAttributionKey = `initial_${AmplitudeAttributionKey}` + +export type AmplitudeAttributionValues = Record + +export type AmplitudeSetOnceAttributionValues = Record \ No newline at end of file diff --git a/packages/actions-shared/src/index.ts b/packages/actions-shared/src/index.ts index 1705cf33342..dd42eac24a6 100644 --- a/packages/actions-shared/src/index.ts +++ b/packages/actions-shared/src/index.ts @@ -7,3 +7,5 @@ export * from './friendbuy/sharedPurchase' export * from './friendbuy/sharedSignUp' export * from './friendbuy/util' export * from './engage/utils' +export * from './amplitude/types' +export * from './amplitude/constants' diff --git a/packages/browser-destinations/destinations/amplitude-plugins/src/autocaptureAttribution/__tests__/index.test.ts b/packages/browser-destinations/destinations/amplitude-plugins/src/autocaptureAttribution/__tests__/index.test.ts new file mode 100644 index 00000000000..ccfe282eadd --- /dev/null +++ b/packages/browser-destinations/destinations/amplitude-plugins/src/autocaptureAttribution/__tests__/index.test.ts @@ -0,0 +1,472 @@ +import { Analytics, Context, Plugin } from '@segment/analytics-next' +import { Subscription } from '@segment/browser-destination-runtime/types' +import browserPluginsDestination from '../../' +import { DESTINATION_INTEGRATION_NAME } from '../../constants' + +describe('ajs-integration', () => { + + describe('autocapture works as expected', () => { + + const example: Subscription[] = [ + { + partnerAction: 'autocaptureAttribution', + name: 'Autocapture Attribution Plugin', + enabled: true, + subscribe: 'type = "track"', + mapping: {} + } + ] + + let browserActions: Plugin[] + let autocaptureAttributionPlugin: Plugin + let ajs: Analytics + + beforeAll(async () => { + browserActions = await browserPluginsDestination({ subscriptions: example }) + autocaptureAttributionPlugin = browserActions[0] + + ajs = new Analytics({ + writeKey: 'w_123' + }) + + // window.localStorage.clear() + + Object.defineProperty(window, 'location', { + value: { + search: '?utm_source=google&utm_medium=cpc&utm_campaign=spring_sale&utm_term=running+shoes&utm_content=ad1&gclid=gclid1234&gbraid=gbraid5678' + }, + writable: true + }) + }) + + test('updates the original event with with attributions values from the URL, caches the values, then updates when new values come along', async () => { + await autocaptureAttributionPlugin.load(Context.system(), ajs) + const ctx = new Context({ + type: 'track', + event: 'Test Event', + properties: { + greeting: 'Yo!' + } + }) + + /* + * First event on the page with attribution values will be transmitted with set and set_once values + */ + const updatedCtx = await autocaptureAttributionPlugin.track?.(ctx) + const ampIntegrationsObj = updatedCtx?.event?.integrations[DESTINATION_INTEGRATION_NAME] + expect(ampIntegrationsObj).toEqual({ + autocapture_attribution: { + enabled: true, + set: { + gbraid: "gbraid5678", + gclid: "gclid1234", + utm_campaign: "spring_sale", + utm_content: "ad1", + utm_medium: "cpc", + utm_source: "google", + utm_term: "running shoes", + }, + set_once: { + initial_dclid: "", + initial_fbclid: "", + initial_gbraid: "gbraid5678", + initial_gclid: "gclid1234", + initial_ko_clickid: "", + initial_li_fat_id: "", + initial_msclkid: "", + initial_referrer: "", + initial_referring_domain: "", + initial_rtd_cid: "", + initial_ttclid: "", + initial_twclid: "", + initial_utm_campaign: "spring_sale", + initial_utm_content: "ad1", + initial_utm_id: "", + initial_utm_medium: "cpc", + initial_utm_source: "google", + initial_utm_term: "running shoes", + initial_wbraid: "" + }, + unset: [ + "referrer", + "referring_domain", + "utm_id", + "dclid", + "fbclid", + "wbraid", + "ko_clickid", + "li_fat_id", + "msclkid", + "rtd_cid", + "ttclid", + "twclid" + ] + } + }) + + /* + * Second event on the same page with attribution values will be transmitted without set and set_once values + */ + const updatedCtx1 = await autocaptureAttributionPlugin.track?.(ctx) + const ampIntegrationsObj1 = updatedCtx1?.event?.integrations[DESTINATION_INTEGRATION_NAME] + expect(ampIntegrationsObj1).toEqual({ + autocapture_attribution: { + enabled: true, + set_once: {}, + set: {}, + unset: [] + } + }) + + + /* + * A new URL should result in updated set and unset values being sent in the payload + */ + Object.defineProperty(window, 'location', { + value: { + search: '?utm_source=email' + }, + writable: true + }) + + const updatedCtx2 = await autocaptureAttributionPlugin.track?.(ctx) + const ampIntegrationsObj2 = updatedCtx2?.event?.integrations[DESTINATION_INTEGRATION_NAME] + + expect(ampIntegrationsObj2).toEqual( + { + autocapture_attribution: { + enabled: true, + set: { + utm_source: "email", + }, + set_once: { + initial_dclid: "", + initial_fbclid: "", + initial_gbraid: "", + initial_gclid: "", + initial_ko_clickid: "", + initial_li_fat_id: "", + initial_msclkid: "", + initial_referrer:"", + initial_referring_domain: "", + initial_rtd_cid: "", + initial_ttclid: "", + initial_twclid: "", + initial_utm_campaign: "", + initial_utm_content: "", + initial_utm_id: "", + initial_utm_medium: "", + initial_utm_source: "email", + initial_utm_term: "", + initial_wbraid: "" + }, + unset: [ + 'referrer', + 'referring_domain', + "utm_medium", + "utm_campaign", + "utm_term", + "utm_content", + "utm_id", + "dclid", + "fbclid", + "gbraid", + "wbraid", + "gclid", + "ko_clickid", + "li_fat_id", + "msclkid", + "rtd_cid", + "ttclid", + "twclid" + ] + } + } + ) + + /* + * Next a new page load happens which does not have any valid attribution values. No attribution values should be sent in the payload + */ + Object.defineProperty(window, 'location', { + value: { + search: '?' + }, + writable: true + }) + + const updatedCtx3 = await autocaptureAttributionPlugin.track?.(ctx) + const ampIntegrationsObj3 = updatedCtx3?.event?.integrations[DESTINATION_INTEGRATION_NAME] + + expect(ampIntegrationsObj3).toEqual( + { + autocapture_attribution: { + enabled: true, + set: {}, + set_once: {}, + unset: [] + } + } + ) + + + /* + * Then we test when there are non attreibution URL params - the last cached attribution values are passed correctly in the payload + */ + Object.defineProperty(window, 'location', { + value: { + search: '?some_fake_non_attribution_param=12345' + }, + writable: true + }) + + const updatedCtx4 = await autocaptureAttributionPlugin.track?.(ctx) + const ampIntegrationsObj4 = updatedCtx4?.event?.integrations[DESTINATION_INTEGRATION_NAME] + + expect(ampIntegrationsObj4).toEqual( + { + autocapture_attribution: { + enabled: true, + set: {}, + set_once: {}, + unset: [] + } + } + ) + + /* + * Next we test with a completely new attribution parameter + */ + Object.defineProperty(window, 'location', { + value: { + search: '?ttclid=uyiuyiuy' + }, + writable: true + }) + + const updatedCtx5 = await autocaptureAttributionPlugin.track?.(ctx) + const ampIntegrationsObj5 = updatedCtx5?.event?.integrations[DESTINATION_INTEGRATION_NAME] + + expect(ampIntegrationsObj5).toEqual( + { + autocapture_attribution: { + enabled: true, + set: { + ttclid: "uyiuyiuy" + }, + set_once: { + initial_dclid: "", + initial_fbclid: "", + initial_gbraid: "", + initial_gclid: "", + initial_ko_clickid: "", + initial_li_fat_id: "", + initial_msclkid: "", + initial_referrer: "", + initial_referring_domain: "", + initial_rtd_cid: "", + initial_ttclid: "uyiuyiuy", + initial_twclid: "", + initial_utm_campaign: "", + initial_utm_content: "", + initial_utm_id: "", + initial_utm_medium: "", + initial_utm_source: "", + initial_utm_term: "", + initial_wbraid: "" + }, + unset: [ + "referrer", + "referring_domain", + "utm_source", + "utm_medium", + "utm_campaign", + "utm_term", + "utm_content", + "utm_id", + "dclid", + "fbclid", + "gbraid", + "wbraid", + "gclid", + "ko_clickid", + "li_fat_id", + "msclkid", + "rtd_cid", + "twclid" + ] + } + } + ) + + /* + * Next we test with a some attributes we've never seen before, referrer and referring_domain + */ + Object.defineProperty(window, 'location', { + value: { + href: 'https://blah.com/path/page/hello/', + search: '', + protocol: 'https:' + }, + writable: true + }) + + Object.defineProperty(document, 'referrer', { + writable: true, + value: 'https://blah.com/path/page/hello/', + }) + + const updatedCtx6 = await autocaptureAttributionPlugin.track?.(ctx) + const ampIntegrationsObj6 = updatedCtx6?.event?.integrations[DESTINATION_INTEGRATION_NAME] + + expect(ampIntegrationsObj6).toEqual( + { + autocapture_attribution: { + enabled: true, + set: { + referrer: "https://blah.com/path/page/hello/", + referring_domain: "blah.com" + }, + set_once: { + initial_dclid: "", + initial_fbclid: "", + initial_gbraid: "", + initial_gclid: "", + initial_ko_clickid: "", + initial_li_fat_id: "", + initial_msclkid: "", + initial_referrer: "https://blah.com/path/page/hello/", + initial_referring_domain: "blah.com", + initial_rtd_cid: "", + initial_ttclid: "", + initial_twclid: "", + initial_utm_campaign: "", + initial_utm_content: "", + initial_utm_id: "", + initial_utm_medium: "", + initial_utm_source: "", + initial_utm_term: "", + initial_wbraid: "" + }, + unset: [ + "utm_source", + "utm_medium", + "utm_campaign", + "utm_term", + "utm_content", + "utm_id", + "dclid", + "fbclid", + "gbraid", + "wbraid", + "gclid", + "ko_clickid", + "li_fat_id", + "msclkid", + "rtd_cid", + "ttclid", + "twclid" + ] + } + } + ) + }) + }) + + describe('autocapture can be blocked as expected', () => { + + const example: Subscription[] = [ + { + partnerAction: 'autocaptureAttribution', + name: 'Autocapture Attribution Plugin', + enabled: true, + subscribe: 'type = "track"', + mapping: { + excludeReferrers: ["test.com", "sub.blah.com", "meh.blah.com"] + } + } + ] + + let browserActions: Plugin[] + let autocaptureAttributionPlugin: Plugin + let ajs: Analytics + + beforeAll(async () => { + browserActions = await browserPluginsDestination({ subscriptions: example }) + autocaptureAttributionPlugin = browserActions[0] + + ajs = new Analytics({ + writeKey: 'w_123' + }) + }) + + test('should prevent attribution from blocked domain', async () => { + await autocaptureAttributionPlugin.load(Context.system(), ajs) + const ctx = new Context({ + type: 'track', + event: 'Test Event', + properties: { + greeting: 'Yo!' + } + }) + + /* + * Page referrer is in the exclude list - no autocaptured attribution values should be sent. + */ + Object.defineProperty(window, 'location', { + value: { + href: 'https://test.com/path/page/hello/', + search: '', + protocol: 'https:' + }, + writable: true + }) + + Object.defineProperty(document, 'referrer', { + writable: true, + value: 'https://test.com/path/page/hello/', + }) + + const updatedCtx = await autocaptureAttributionPlugin.track?.(ctx) + const ampIntegrationsObj = updatedCtx?.event?.integrations[DESTINATION_INTEGRATION_NAME] + expect(ampIntegrationsObj).toEqual({ + autocapture_attribution: { + enabled: true, + set: {}, + set_once: {}, + unset: [] + } + }) + + /* + * Same test as before but this time with a sub domain + */ + Object.defineProperty(window, 'location', { + value: { + href: 'https://sub.blah.com/path/page/hello/', + search: '', + protocol: 'https:' + }, + writable: true + }) + + Object.defineProperty(document, 'referrer', { + writable: true, + value: 'https://sub.blah.com/path/page/hello/', + }) + + const updatedCtx1 = await autocaptureAttributionPlugin.track?.(ctx) + const ampIntegrationsObj1 = updatedCtx1?.event?.integrations[DESTINATION_INTEGRATION_NAME] + expect(ampIntegrationsObj1).toEqual({ + autocapture_attribution: { + enabled: true, + set: {}, + set_once: {}, + unset: [] + } + }) + + }) + }) + +}) + diff --git a/packages/browser-destinations/destinations/amplitude-plugins/src/autocaptureAttribution/functions.ts b/packages/browser-destinations/destinations/amplitude-plugins/src/autocaptureAttribution/functions.ts new file mode 100644 index 00000000000..38b1bd16db0 --- /dev/null +++ b/packages/browser-destinations/destinations/amplitude-plugins/src/autocaptureAttribution/functions.ts @@ -0,0 +1,27 @@ + +import { UniversalStorage } from '@segment/analytics-next' +import type { AmplitudeAttributionValues } from '@segment/actions-shared/src/amplitude/types' +import { AMPLITUDE_ATTRIBUTION_KEYS, AMPLITUDE_ATTRIBUTION_STORAGE_KEY } from '@segment/actions-shared' + +export function getAttributionsFromURL(queryString: string): Partial { + if (!queryString){ + return {} + } + + const urlParams = new URLSearchParams(queryString) + + return Object.fromEntries( + AMPLITUDE_ATTRIBUTION_KEYS + .map(key => [key, urlParams.get(key)] as const) + .filter(([, value]) => value !== null) + ) as Partial +} + +export function getAttributionsFromStorage(storage: UniversalStorage>>): Partial { + const values = storage.get(AMPLITUDE_ATTRIBUTION_STORAGE_KEY) + return values ?? {} +} + +export function setAttributionsInStorage(storage: UniversalStorage>>, attributions: Partial): void { + storage.set(AMPLITUDE_ATTRIBUTION_STORAGE_KEY, attributions) +} \ No newline at end of file diff --git a/packages/browser-destinations/destinations/amplitude-plugins/src/autocaptureAttribution/generated-types.ts b/packages/browser-destinations/destinations/amplitude-plugins/src/autocaptureAttribution/generated-types.ts new file mode 100644 index 00000000000..29e6cfb34b1 --- /dev/null +++ b/packages/browser-destinations/destinations/amplitude-plugins/src/autocaptureAttribution/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * A list of hostnames to ignore when capturing attribution data. If the current page referrer matches any of these hostnames, no attribution data will be captured from the URL. + */ + excludeReferrers?: string[] +} diff --git a/packages/browser-destinations/destinations/amplitude-plugins/src/autocaptureAttribution/index.ts b/packages/browser-destinations/destinations/amplitude-plugins/src/autocaptureAttribution/index.ts new file mode 100644 index 00000000000..f82cb697fb5 --- /dev/null +++ b/packages/browser-destinations/destinations/amplitude-plugins/src/autocaptureAttribution/index.ts @@ -0,0 +1,66 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +import { UniversalStorage } from '@segment/analytics-next' +import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { getAttributionsFromURL, getAttributionsFromStorage, setAttributionsInStorage } from './functions' +import { AmplitudeAttributionValues, AMPLITUDE_ATTRIBUTION_KEYS, AmplitudeAttributionKey, AmplitudeSetOnceAttributionValues } from '@segment/actions-shared' +import { DESTINATION_INTEGRATION_NAME } from '../constants' +import isEqual from 'lodash/isEqual' + +const action: BrowserActionDefinition = { + title: 'Autocapture Attribution Plugin', + description: 'Captures attribution details from the URL and attaches them to every Amplitude browser based event. Use with the Log Event V2 action to automate the collection of attribution data.', + platform: 'web', + defaultSubscription: 'type = "track"', + fields: { + excludeReferrers: { + label: 'Exclude Referrers', + description: 'A list of hostnames to ignore when capturing attribution data. If the current page referrer matches any of these hostnames, no attribution data will be captured from the URL.', + type: 'string', + required: false, + multiple: true + } + }, + lifecycleHook: 'enrichment', + perform: (_, { context, payload, analytics }) => { + const referrer = document.referrer + const referringDomain = referrer ? new URL(referrer).hostname : '' + const { excludeReferrers } = payload + const isExcluded = excludeReferrers?.includes(referringDomain) + const current: Partial = isExcluded ? {} : {...getAttributionsFromURL(window.location.search), referrer, referring_domain: referringDomain} + const previous = getAttributionsFromStorage(analytics.storage as UniversalStorage>>) + const setOnce: Partial = {} + const set: Partial = {} + const unset: AmplitudeAttributionKey[] = [] + const currentPageHasAttribution = current && Object.values(current).some(v => typeof v === 'string' && v.length > 0) + + if (currentPageHasAttribution && !isEqual(current, previous)){ + AMPLITUDE_ATTRIBUTION_KEYS.forEach(key => { + setOnce[`initial_${key}`] = current[key] ?? "" + if(current[key]){ + set[key] = current[key] + } + else{ + unset.push(key) + } + }) + if(Object.entries(current).length >0) { + setAttributionsInStorage(analytics.storage as UniversalStorage>>, current) + } + } + + if (context.event.integrations?.All !== false || context.event.integrations[DESTINATION_INTEGRATION_NAME]) { + context.updateEvent(`integrations.${DESTINATION_INTEGRATION_NAME}`, {}) + context.updateEvent(`integrations.${DESTINATION_INTEGRATION_NAME}.autocapture_attribution`, { + enabled: true, + set_once: setOnce, + set: set, + unset: unset + }) + } + + return + } +} +export default action \ No newline at end of file diff --git a/packages/browser-destinations/destinations/amplitude-plugins/src/constants.ts b/packages/browser-destinations/destinations/amplitude-plugins/src/constants.ts new file mode 100644 index 00000000000..a1a2643db79 --- /dev/null +++ b/packages/browser-destinations/destinations/amplitude-plugins/src/constants.ts @@ -0,0 +1 @@ +export const DESTINATION_INTEGRATION_NAME = 'Actions Amplitude' \ No newline at end of file diff --git a/packages/browser-destinations/destinations/amplitude-plugins/src/index.ts b/packages/browser-destinations/destinations/amplitude-plugins/src/index.ts index 9227d87747b..9a19c414f9d 100644 --- a/packages/browser-destinations/destinations/amplitude-plugins/src/index.ts +++ b/packages/browser-destinations/destinations/amplitude-plugins/src/index.ts @@ -2,12 +2,14 @@ import type { Settings } from './generated-types' import type { BrowserDestinationDefinition } from '@segment/browser-destination-runtime/types' import { browserDestination } from '@segment/browser-destination-runtime/shim' import sessionId from './sessionId' +import autocaptureAttribution from './autocaptureAttribution' export const destination: BrowserDestinationDefinition = { name: 'Amplitude (Actions)', mode: 'device', actions: { - sessionId + sessionId, + autocaptureAttribution }, initialize: async () => { return {} diff --git a/packages/browser-destinations/destinations/amplitude-plugins/src/sessionId/index.ts b/packages/browser-destinations/destinations/amplitude-plugins/src/sessionId/index.ts index 8079657ed66..98dfda94423 100644 --- a/packages/browser-destinations/destinations/amplitude-plugins/src/sessionId/index.ts +++ b/packages/browser-destinations/destinations/amplitude-plugins/src/sessionId/index.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/no-unsafe-call */ -import { UniversalStorage } from '@segment/analytics-next' +import { UniversalStorage, Analytics } from '@segment/analytics-next' import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { Analytics } from '@segment/analytics-next' +import { DESTINATION_INTEGRATION_NAME } from '../constants' function newSessionId(): number { return now() @@ -144,13 +144,13 @@ const action: BrowserActionDefinition = { storage.set('analytics_session_id.last_access', newSession) - if (context.event.integrations?.All !== false || context.event.integrations['Actions Amplitude']) { - context.updateEvent('integrations.Actions Amplitude', {}) - context.updateEvent('integrations.Actions Amplitude.session_id', id) + if (context.event.integrations?.All !== false || context.event.integrations[DESTINATION_INTEGRATION_NAME]) { + context.updateEvent(`integrations.${DESTINATION_INTEGRATION_NAME}`, {}) + context.updateEvent(`integrations.${DESTINATION_INTEGRATION_NAME}.session_id`, id) } return } } -export default action +export default action \ No newline at end of file diff --git a/packages/core/src/segment-event.ts b/packages/core/src/segment-event.ts index dc92d03075b..7862ec7603d 100644 --- a/packages/core/src/segment-event.ts +++ b/packages/core/src/segment-event.ts @@ -1,4 +1,4 @@ -import { JSONValue } from './json-object' +import { JSONObject, JSONValue } from './json-object' export type ID = string | null | undefined @@ -14,7 +14,7 @@ interface CompactMetric { export type Integrations = { All?: boolean - [integration: string]: boolean | undefined + [integration: string]: boolean | undefined | JSONObject } export type Options = { diff --git a/packages/destination-actions/src/destinations/amplitude/__tests__/autocapture-attribution.test.ts b/packages/destination-actions/src/destinations/amplitude/__tests__/autocapture-attribution.test.ts new file mode 100644 index 00000000000..ceda377f32f --- /dev/null +++ b/packages/destination-actions/src/destinations/amplitude/__tests__/autocapture-attribution.test.ts @@ -0,0 +1,399 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Amplitude from '../index' +import {AmplitudeAttributionValues, AmplitudeSetOnceAttributionValues, AmplitudeAttributionKey} from '@segment/actions-shared' + +const testDestination = createTestIntegration(Amplitude) +const timestamp = '2021-08-17T15:21:15.449Z' + +describe('Amplitude', () => { + describe('logEvent V2', () => { + it('correctly handles autocapture attribution values passed in integrations object', async () => { + nock('https://api2.amplitude.com/2').post('/httpapi').reply(200, {}) + + const set_once: AmplitudeSetOnceAttributionValues = { + initial_referrer: 'initial-referrer-from-integrations-object', + initial_utm_campaign: 'initial-utm-campaign-from-integrations-object', + initial_utm_content: 'initial-utm-content-from-integrations-object', + initial_utm_medium: '', + initial_utm_source: 'initial-utm-source-from-integrations-object', + initial_utm_term: 'initial-utm-term-from-integrations-object', + initial_gclid: 'initial-gclid-from-integrations-object', + initial_fbclid: '', + initial_dclid: '', + initial_gbraid: '', + initial_wbraid: '', + initial_ko_clickid: '', + initial_li_fat_id: '', + initial_msclkid: '', + initial_referring_domain: 'initial-referring-domain-from-integrations-object', + initial_rtd_cid: '', + initial_ttclid: '', + initial_twclid: '', + initial_utm_id: '' + } + + const set: Partial = { + referrer: 'referrer-from-integrations-object', + utm_campaign: 'utm-campaign-from-integrations-object', + utm_content: 'utm-content-from-integrations-object', + utm_source: 'utm-source-from-integrations-object', + utm_term: 'utm-term-from-integrations-object', + gclid: 'gclid-from-integrations-object', + referring_domain: 'referring-domain-from-integrations-object' + } + + const unset: AmplitudeAttributionKey[] = [ + 'utm_medium', + 'fbclid', + 'dclid', + 'gbraid', + 'wbraid', + 'ko_clickid', + 'li_fat_id', + 'msclkid', + 'rtd_cid', + 'ttclid', + 'twclid', + 'utm_id' + ] + + const event = createTestEvent({ + timestamp, + event: 'Test Event', + traits: { + otherTraits: {'some-trait-key': 'some-trait-value'}, + setTraits: { + interests: ['music', 'sports'] // should get sent as normal set + }, + setOnceTraits: { + first_name: "Billybob" // should get sent as normal setOnce + } + }, + integrations: { + 'Actions Amplitude': { + autocapture_attribution: { + enabled: true, + set_once, + set, + unset + } + } + }, + context: { + + page: { + referrer: 'referrer-from-page-context' // should get dropped + }, + campaign: { + name: 'campaign-name-from-campaign-context', // should get dropped + source: 'campaign-source-from-campaign-context', // should get dropped + medium: 'campaign-medium-from-campaign-context',// should get dropped + term: 'campaign-term-from-campaign-context',// should get dropped + content: 'campaign-content-from-campaign-context'// should get dropped + } + } + }) + + const responses = await testDestination.testAction( + 'logEventV2', + { + event, + useDefaultMappings: true, + mapping: { + user_properties: { '@path': '$.traits.otherTraits' }, + setOnce: { + initial_referrer: { '@path': '$.context.page.referrer' }, + initial_utm_source: { '@path': '$.context.campaign.source' }, + initial_utm_medium: { '@path': '$.context.campaign.medium' }, + initial_utm_campaign: { '@path': '$.context.campaign.name' }, + initial_utm_term: { '@path': '$.context.campaign.term' }, + initial_utm_content: { '@path': '$.context.campaign.content' }, + first_name: { '@path': '$.traits.setOnceTraits.first_name' } + }, + setAlways: { + referrer: { '@path': '$.context.page.referrer' }, + utm_source: { '@path': '$.context.campaign.source' }, + utm_medium: { '@path': '$.context.campaign.medium' }, + utm_campaign: { '@path': '$.context.campaign.name' }, + utm_term: { '@path': '$.context.campaign.term' }, + utm_content: { '@path': '$.context.campaign.content' }, + interests: { '@path': '$.traits.setTraits.interests' } + } + } + } + ) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toEqual({ + api_key: undefined, + events: [ + { + device_id: "anonId1234", + event_properties: {}, + event_type: "Test Event", + library: "segment", + time: 1629213675449, + use_batch_endpoint: false, + user_id: "user1234", + user_properties: { + $set: { + interests: ["music", "sports"], // carried over from the setAlways mapping + gclid: "gclid-from-integrations-object", + referrer: "referrer-from-integrations-object", + referring_domain: "referring-domain-from-integrations-object", + utm_campaign: "utm-campaign-from-integrations-object", + utm_content: "utm-content-from-integrations-object", + utm_source: "utm-source-from-integrations-object", + utm_term: "utm-term-from-integrations-object", + }, + $setOnce: { + first_name: "Billybob", // carried over from the setOnce mapping + initial_dclid: "", + initial_fbclid: "", + initial_gbraid: "", + initial_gclid: "initial-gclid-from-integrations-object", + initial_ko_clickid: "", + initial_li_fat_id: "", + initial_msclkid: "", + initial_referrer: "initial-referrer-from-integrations-object", + initial_referring_domain: "initial-referring-domain-from-integrations-object", + initial_rtd_cid: "", + initial_ttclid: "", + initial_twclid: "", + initial_utm_campaign: "initial-utm-campaign-from-integrations-object", + initial_utm_content: "initial-utm-content-from-integrations-object", + initial_utm_id: "", + initial_utm_medium: "", + initial_utm_source: "initial-utm-source-from-integrations-object", + initial_utm_term: "initial-utm-term-from-integrations-object", + initial_wbraid: "", + }, + $unset: [ + "utm_medium", + "fbclid", + "dclid", + "gbraid", + "wbraid", + "ko_clickid", + "li_fat_id", + "msclkid", + "rtd_cid", + "ttclid", + "twclid", + "utm_id", + ], + "some-trait-key": "some-trait-value", + }, + }, + ], + options: undefined, + }) + }) + + it('Blocks utm and referrer data if autocapture attribution is enabled', async () => { + nock('https://api2.amplitude.com/2').post('/httpapi').reply(200, {}) + + const event = createTestEvent({ + timestamp, + event: 'Test Event', + traits: { + otherTraits: {'some-trait-key': 'some-trait-value'}, + setTraits: { + interests: ['music', 'sports'] // should get sent as normal set + }, + setOnceTraits: { + first_name: "Billybob" // should get sent as normal setOnce + } + }, + integrations: { + 'Actions Amplitude': { + autocapture_attribution: { + enabled: true, + set_once: {}, // no attribution values provided - should still block mapped values + set: {}, + unset: [] + } + } + }, + context: { + page: { + referrer: 'referrer-from-page-context' // should get ignored + }, + campaign: { + name: 'campaign-name-from-campaign-context', // should get ignored + source: 'campaign-source-from-campaign-context', // should get ignored + medium: 'campaign-medium-from-campaign-context',// should get ignored + term: 'campaign-term-from-campaign-context',// should get ignored + content: 'campaign-content-from-campaign-context'// should get ignored + } + } + }) + + const responses = await testDestination.testAction( + 'logEventV2', + { + event, + useDefaultMappings: true, + mapping: { + user_properties: { '@path': '$.traits.otherTraits' }, + setOnce: { + initial_referrer: { '@path': '$.context.page.referrer' }, + initial_utm_source: { '@path': '$.context.campaign.source' }, + initial_utm_medium: { '@path': '$.context.campaign.medium' }, + initial_utm_campaign: { '@path': '$.context.campaign.name' }, + initial_utm_term: { '@path': '$.context.campaign.term' }, + initial_utm_content: { '@path': '$.context.campaign.content' }, + first_name: { '@path': '$.traits.setOnceTraits.first_name' } + }, + setAlways: { + referrer: { '@path': '$.context.page.referrer' }, + utm_source: { '@path': '$.context.campaign.source' }, + utm_medium: { '@path': '$.context.campaign.medium' }, + utm_campaign: { '@path': '$.context.campaign.name' }, + utm_term: { '@path': '$.context.campaign.term' }, + utm_content: { '@path': '$.context.campaign.content' }, + interests: { '@path': '$.traits.setTraits.interests' } + } + } + } + ) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toEqual({ + api_key: undefined, + events: [ + { + device_id: "anonId1234", + event_properties: {}, + event_type: "Test Event", + library: "segment", + time: 1629213675449, + use_batch_endpoint: false, + user_id: "user1234", + user_properties: { + $set: { + interests: ["music", "sports"], // carried over from the setAlways mapping + }, + $setOnce: { + first_name: "Billybob", // carried over from the setOnce mapping + }, + "some-trait-key": "some-trait-value", + }, + }, + ], + options: undefined, + }) + }) + + it('regular utm and referrer data is sent when autocapture attribution is disabled', async () => { + nock('https://api2.amplitude.com/2').post('/httpapi').reply(200, {}) + + const event = createTestEvent({ + timestamp, + event: 'Test Event', + traits: { + otherTraits: {'some-trait-key': 'some-trait-value'}, + setTraits: { + interests: ['music', 'sports'] // should get sent as normal set + }, + setOnceTraits: { + first_name: "Billybob" // should get sent as normal setOnce + } + }, + integrations: { + 'Actions Amplitude': { + autocapture_attribution: { + // enabled: true, // Disabled autocapture attribution + set_once: {}, + set: {}, + unset: [] + } + } + }, + context: { + page: { + referrer: 'referrer-from-page-context' // should get handled normally + }, + campaign: { + name: 'campaign-name-from-campaign-context', // should get handled normally + source: 'campaign-source-from-campaign-context', // should get handled normally + medium: 'campaign-medium-from-campaign-context',// should get handled normally + term: 'campaign-term-from-campaign-context',// should get handled normally + content: 'campaign-content-from-campaign-context'// should get handled normally + } + } + }) + + const responses = await testDestination.testAction( + 'logEventV2', + { + event, + useDefaultMappings: true, + mapping: { + user_properties: { '@path': '$.traits.otherTraits' }, + setOnce: { + initial_referrer: { '@path': '$.context.page.referrer' }, + initial_utm_source: { '@path': '$.context.campaign.source' }, + initial_utm_medium: { '@path': '$.context.campaign.medium' }, + initial_utm_campaign: { '@path': '$.context.campaign.name' }, + initial_utm_term: { '@path': '$.context.campaign.term' }, + initial_utm_content: { '@path': '$.context.campaign.content' }, + first_name: { '@path': '$.traits.setOnceTraits.first_name' } + }, + setAlways: { + referrer: { '@path': '$.context.page.referrer' }, + utm_source: { '@path': '$.context.campaign.source' }, + utm_medium: { '@path': '$.context.campaign.medium' }, + utm_campaign: { '@path': '$.context.campaign.name' }, + utm_term: { '@path': '$.context.campaign.term' }, + utm_content: { '@path': '$.context.campaign.content' }, + interests: { '@path': '$.traits.setTraits.interests' } + } + } + } + ) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toEqual({ + api_key: undefined, + events: [ + { + device_id: "anonId1234", + event_properties: {}, + event_type: "Test Event", + library: "segment", + time: 1629213675449, + use_batch_endpoint: false, + user_id: "user1234", + user_properties: { + $set: { + interests: ["music", "sports"], + referrer: "referrer-from-page-context", + utm_campaign: "campaign-name-from-campaign-context", + utm_content: "campaign-content-from-campaign-context", + utm_medium: "campaign-medium-from-campaign-context", + utm_source: "campaign-source-from-campaign-context", + utm_term: "campaign-term-from-campaign-context" + }, + $setOnce: { + first_name: "Billybob", + initial_referrer: "referrer-from-page-context", + initial_utm_campaign: "campaign-name-from-campaign-context", + initial_utm_content: "campaign-content-from-campaign-context", + initial_utm_medium: "campaign-medium-from-campaign-context", + initial_utm_source: "campaign-source-from-campaign-context", + initial_utm_term: "campaign-term-from-campaign-context" + }, + "some-trait-key": "some-trait-value" + } + } + ], + options: undefined + }) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/amplitude/compact.ts b/packages/destination-actions/src/destinations/amplitude/compact.ts deleted file mode 100644 index 34186e5c573..00000000000 --- a/packages/destination-actions/src/destinations/amplitude/compact.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Payload as LogV2Payload } from './logEventV2/generated-types' - -/** - * Takes an object and removes all keys with a "falsey" value. Then, checks if the object is empty or not. - * - * @param object the setAlways, setOnce, or add object from the LogEvent payload - * @returns a boolean signifying whether the resulting object is empty or not - */ - -export default function compact( - object: LogV2Payload['setOnce'] | LogV2Payload['setAlways'] | LogV2Payload['add'] -): boolean { - return Object.keys(Object.fromEntries(Object.entries(object ?? {}).filter(([_, v]) => v !== ''))).length > 0 -} diff --git a/packages/destination-actions/src/destinations/amplitude/logEventV2/autocapture-attribution.ts b/packages/destination-actions/src/destinations/amplitude/logEventV2/autocapture-attribution.ts new file mode 100644 index 00000000000..c21e258c4ba --- /dev/null +++ b/packages/destination-actions/src/destinations/amplitude/logEventV2/autocapture-attribution.ts @@ -0,0 +1,58 @@ +import { AMPLITUDE_ATTRIBUTION_KEYS } from '@segment/actions-shared' +import { Payload } from './generated-types' + +export const DESTINATION_INTEGRATION_NAME = 'Actions Amplitude' + +function compact(object: { [k: string]: unknown } | undefined): boolean { + return Object.keys(Object.fromEntries(Object.entries(object ?? {}).filter(([_, v]) => v !== ''))).length > 0 +} + +export function getUserProperties(payload: Payload): { [k: string]: unknown } { + const { + setOnce, + setAlways, + add, + autocaptureAttributionEnabled, + autocaptureAttributionSet, + autocaptureAttributionSetOnce, + autocaptureAttributionUnset, + user_properties + } = payload + + if (autocaptureAttributionEnabled) { + // If autocapture attribution is enabled, we need to make sure that attribution keys are not sent from the setAlways and setOnce fields + for (const key of AMPLITUDE_ATTRIBUTION_KEYS) { + if( typeof setAlways === "object" && setAlways !== null){ + delete setAlways[key] + } + if(typeof setOnce === "object" && setOnce !== null){ + delete setOnce[`initial_${key}`] + } + } + } + + const userProperties = { + ...user_properties, + ...(compact(autocaptureAttributionEnabled ? { ...setOnce, ...autocaptureAttributionSetOnce } : setOnce) + ? { $setOnce: autocaptureAttributionEnabled ? { ...setOnce, ...autocaptureAttributionSetOnce } : setOnce } + : {}), + ...(compact(autocaptureAttributionEnabled ? { ...setAlways, ...autocaptureAttributionSet } : setAlways) + ? { $set: autocaptureAttributionEnabled ? { ...setAlways, ...autocaptureAttributionSet } : setAlways } + : {}), + ...(compact(add) ? { $add: add } : {}), + ...(autocaptureAttributionUnset && autocaptureAttributionUnset.length > 0 + ? { $unset: autocaptureAttributionUnset } + : {}) + } + + return userProperties +} + + + + + + + + + \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/amplitude/logEventV2/autocapture-fields.ts b/packages/destination-actions/src/destinations/amplitude/logEventV2/autocapture-fields.ts new file mode 100644 index 00000000000..3807eb8d414 --- /dev/null +++ b/packages/destination-actions/src/destinations/amplitude/logEventV2/autocapture-fields.ts @@ -0,0 +1,34 @@ +import type { InputField } from '@segment/actions-core' +import { DESTINATION_INTEGRATION_NAME } from './autocapture-attribution' + +export const autocaptureFields: Record = { + autocaptureAttributionEnabled: { + label: 'Autocapture Attribution Enabled', + description: 'Utility field used to detect if Autocapture Attribution Plugin is enabled.', + type: 'boolean', + default: { '@path': `$.integrations.${DESTINATION_INTEGRATION_NAME}.autocapture_attribution.enabled` }, + readOnly: true + }, + autocaptureAttributionSet: { + label: 'Autocapture Attribution Set', + description: 'Utility field used to detect if any attribution values need to be set.', + type: 'object', + default: { '@path': `$.integrations.${DESTINATION_INTEGRATION_NAME}.autocapture_attribution.set` }, + readOnly: true + }, + autocaptureAttributionSetOnce: { + label: 'Autocapture Attribution Set Once', + description: 'Utility field used to detect if any attribution values need to be set_once.', + type: 'object', + default: { '@path': `$.integrations.${DESTINATION_INTEGRATION_NAME}.autocapture_attribution.set_once` }, + readOnly: true + }, + autocaptureAttributionUnset: { + label: 'Autocapture Attribution Unset', + description: 'Utility field used to detect if any attribution values need to be unset.', + type: 'string', + multiple: true, + default: { '@path': `$.integrations.${DESTINATION_INTEGRATION_NAME}.autocapture_attribution.unset` }, + readOnly: true + } +} \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/amplitude/logEventV2/generated-types.ts b/packages/destination-actions/src/destinations/amplitude/logEventV2/generated-types.ts index 787b698616a..56f342c51d4 100644 --- a/packages/destination-actions/src/destinations/amplitude/logEventV2/generated-types.ts +++ b/packages/destination-actions/src/destinations/amplitude/logEventV2/generated-types.ts @@ -178,7 +178,27 @@ export interface Payload { [k: string]: unknown }[] /** - * The following fields will only be set as user properties if they do not already have a value. + * Utility field used to detect if Autocapture Attribution Plugin is enabled. + */ + autocaptureAttributionEnabled?: boolean + /** + * Utility field used to detect if any attribution values need to be set. + */ + autocaptureAttributionSet?: { + [k: string]: unknown + } + /** + * Utility field used to detect if any attribution values need to be set_once. + */ + autocaptureAttributionSetOnce?: { + [k: string]: unknown + } + /** + * Utility field used to detect if any attribution values need to be unset. + */ + autocaptureAttributionUnset?: string[] + /** + * The following fields will only be set as user properties if they do not already have a value. If 'Autocapture Attribution' is enabled, UTM and attribution values in this field will be ignored. */ setOnce?: { /** @@ -193,7 +213,7 @@ export interface Payload { [k: string]: unknown } /** - * The following fields will be set as user properties for every event. + * The following fields will be set as user properties for every event. If 'Autocapture Attribution' is enabled, UTM and attribution values in this field will be ignored. */ setAlways?: { referrer?: string diff --git a/packages/destination-actions/src/destinations/amplitude/logEventV2/index.ts b/packages/destination-actions/src/destinations/amplitude/logEventV2/index.ts index ef20b455231..e05273db7cf 100644 --- a/packages/destination-actions/src/destinations/amplitude/logEventV2/index.ts +++ b/packages/destination-actions/src/destinations/amplitude/logEventV2/index.ts @@ -1,6 +1,5 @@ import { ActionDefinition, omit, removeUndefined } from '@segment/actions-core' import dayjs from 'dayjs' -import compact from '../compact' import { eventSchema } from '../event-schema' import type { Settings } from '../generated-types' import { getEndpointByRegion } from '../regional-endpoints' @@ -8,6 +7,8 @@ import { parseUserAgentProperties } from '../user-agent' import type { Payload } from './generated-types' import { formatSessionId } from '../convert-timestamp' import { userAgentData } from '../properties' +import { autocaptureFields } from './autocapture-fields' +import { getUserProperties } from './autocapture-attribution' export interface AmplitudeEvent extends Omit { library?: string @@ -19,7 +20,8 @@ export interface AmplitudeEvent extends Omit = { title: 'Log Event V2', description: 'Send an event to Amplitude', @@ -86,9 +88,10 @@ const action: ActionDefinition = { ] } }, + ...autocaptureFields, setOnce: { label: 'Set Once', - description: 'The following fields will only be set as user properties if they do not already have a value.', + description: "The following fields will only be set as user properties if they do not already have a value. If 'Autocapture Attribution' is enabled, UTM and attribution values in this field will be ignored.", type: 'object', additionalProperties: true, properties: { @@ -129,7 +132,7 @@ const action: ActionDefinition = { }, setAlways: { label: 'Set Always', - description: 'The following fields will be set as user properties for every event.', + description: "The following fields will be set as user properties for every event. If 'Autocapture Attribution' is enabled, UTM and attribution values in this field will be ignored.", type: 'object', additionalProperties: true, properties: { @@ -223,11 +226,8 @@ const action: ActionDefinition = { userAgentData, min_id_length, library, - setOnce, - setAlways, - add, ...rest - } = omit(payload, revenueKeys) + } = omit(payload, keysToOmit) const properties = rest as AmplitudeEvent let options @@ -251,18 +251,7 @@ const action: ActionDefinition = { options = { min_id_length } } - const setUserProperties = ( - name: '$setOnce' | '$set' | '$add', - obj: Payload['setOnce'] | Payload['setAlways'] | Payload['add'] - ) => { - if (compact(obj)) { - properties.user_properties = { ...properties.user_properties, [name]: obj } - } - } - - setUserProperties('$setOnce', setOnce) - setUserProperties('$set', setAlways) - setUserProperties('$add', add) + properties.user_properties = getUserProperties(payload) const events: AmplitudeEvent[] = [ {