diff --git a/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/README.md b/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/README.md new file mode 100644 index 00000000000..95f9436178a --- /dev/null +++ b/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/README.md @@ -0,0 +1,31 @@ +# @segment/analytics-browser-actions-google-enhanced-conversions-plugins + +The Google Enhanced Conversions Plugins browser action destination for use with @segment/analytics-next. + +## License + +MIT License + +Copyright (c) 2025 Segment + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## Contributing + +All third party contributors acknowledge that any contributions they provide will be made under the same open source license that the open source project is provided under. diff --git a/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/package.json b/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/package.json new file mode 100644 index 00000000000..9d0478ed6c7 --- /dev/null +++ b/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/package.json @@ -0,0 +1,23 @@ +{ + "name": "@segment/analytics-browser-actions-google-enhanced-conversions-plugins", + "version": "1.0.0", + "license": "MIT", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "main": "./dist/cjs", + "module": "./dist/esm", + "scripts": { + "build": "yarn build:esm && yarn build:cjs", + "build:cjs": "tsc --module commonjs --outDir ./dist/cjs", + "build:esm": "tsc --outDir ./dist/esm" + }, + "typings": "./dist/esm", + "dependencies": { + "@segment/browser-destination-runtime": "^1.90.0" + }, + "peerDependencies": { + "@segment/analytics-next": ">=1.55.0" + } +} diff --git a/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/src/__tests__/index.test.ts b/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/src/__tests__/index.test.ts new file mode 100644 index 00000000000..838109f7410 --- /dev/null +++ b/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/src/__tests__/index.test.ts @@ -0,0 +1,123 @@ +import { Analytics, Context, JSONValue, Plugin } from '@segment/analytics-next' +import { Subscription } from '@segment/browser-destination-runtime/types' +import browserPluginsDestination from '../' +import { DESTINATION_NAME } from '../constants' + +const example: Subscription[] = [ + { + partnerAction: 'sessionAttributesEncoded', + name: 'Session Attributes Encoded Enrichment Plugin', + enabled: true, + subscribe: 'type = "track"', + mapping: {} + } +] + +let browserActions: Plugin[] +let sessionAttributesEncodedPlugin: Plugin +let ajs: Analytics + +beforeAll(async () => { + browserActions = await browserPluginsDestination({ subscriptions: example } as unknown as JSONValue) + sessionAttributesEncodedPlugin = browserActions[0] + + ajs = new Analytics({ + writeKey: 'w_123' + }) + + jest.useFakeTimers().setSystemTime(new Date('2023-01-01T00:00:00Z')) + + Object.defineProperty(window, 'location', { + value: { + href: 'https://example.com?gad_click_id=123&gad_user_id=456&gclid=gclid1234&gbraid=gbraid5678', + search: '?gad_click_id=123&gad_user_id=456&gclid=gclid1234&gbraid=gbraid5678' + }, + writable: true, + configurable: true + }) + + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + writable: true, + configurable: true + }) + + Object.defineProperty(document, 'referrer', { + value: 'https://referrer.com', + writable: true, + configurable: true + }) +}) + +afterAll(() => { + jest.useRealTimers() +}) + +describe('ajs-integration', () => { + test('updates the original event with a Google Enhanced Conversions querystring values', async () => { + await sessionAttributesEncodedPlugin.load(Context.system(), ajs) + const ctx = new Context({ + type: 'track', + event: 'Test Event', + properties: { + greeting: 'Yo!' + } + }) + const updatedCtx = await sessionAttributesEncodedPlugin.track?.(ctx) + const gecIntegrationsObj = updatedCtx?.event?.integrations[DESTINATION_NAME] + expect(gecIntegrationsObj).toEqual( + {"session_attributes_encoded": "eyJnYWRfY2xpY2tfaWQiOiIxMjMiLCJnYWRfdXNlcl9pZCI6IjQ1NiIsInNlc3Npb25fc3RhcnRfdGltZV91c2VjIjoiMTY3MjUzMTIwMDAwMDAwMCIsImxhbmRpbmdfcGFnZV91cmwiOiJodHRwczovL2V4YW1wbGUuY29tP2dhZF9jbGlja19pZD0xMjMmZ2FkX3VzZXJfaWQ9NDU2JmdjbGlkPWdjbGlkMTIzNCZnYnJhaWQ9Z2JyYWlkNTY3OCIsImxhbmRpbmdfcGFnZV9yZWZlcnJlciI6Imh0dHBzOi8vcmVmZXJyZXIuY29tIiwibGFuZGluZ19wYWdlX3VzZXJfYWdlbnQiOiJNb3ppbGxhLzUuMCAoV2luZG93cyBOVCAxMC4wOyBXaW42NDsgeDY0KSBBcHBsZVdlYktpdC81MzcuMzYgKEtIVE1MLCBsaWtlIEdlY2tvKSBDaHJvbWUvOTEuMC40NDcyLjEyNCBTYWZhcmkvNTM3LjM2In0"} + ) + }) + + test('New URL with no querystring should result in cashed session_attributes_encoded value being used', async () => { + // Note that this test will fail if executed on its own. It requires the previous test to first be executed. + Object.defineProperty(window, 'location', { + value: { + href: 'https://example.com', + search: '' // Empty querystring + }, + writable: true, + configurable: true + }) + + await sessionAttributesEncodedPlugin.load(Context.system(), ajs) + const ctx = new Context({ + type: 'track', + event: 'Test Event', + properties: { + greeting: 'Yo!' + } + }) + const updatedCtx = await sessionAttributesEncodedPlugin.track?.(ctx) + const gecIntegrationsObj = updatedCtx?.event?.integrations[DESTINATION_NAME] + expect(gecIntegrationsObj).toEqual( + {"session_attributes_encoded": "eyJnYWRfY2xpY2tfaWQiOiIxMjMiLCJnYWRfdXNlcl9pZCI6IjQ1NiIsInNlc3Npb25fc3RhcnRfdGltZV91c2VjIjoiMTY3MjUzMTIwMDAwMDAwMCIsImxhbmRpbmdfcGFnZV91cmwiOiJodHRwczovL2V4YW1wbGUuY29tP2dhZF9jbGlja19pZD0xMjMmZ2FkX3VzZXJfaWQ9NDU2JmdjbGlkPWdjbGlkMTIzNCZnYnJhaWQ9Z2JyYWlkNTY3OCIsImxhbmRpbmdfcGFnZV9yZWZlcnJlciI6Imh0dHBzOi8vcmVmZXJyZXIuY29tIiwibGFuZGluZ19wYWdlX3VzZXJfYWdlbnQiOiJNb3ppbGxhLzUuMCAoV2luZG93cyBOVCAxMC4wOyBXaW42NDsgeDY0KSBBcHBsZVdlYktpdC81MzcuMzYgKEtIVE1MLCBsaWtlIEdlY2tvKSBDaHJvbWUvOTEuMC40NDcyLjEyNCBTYWZhcmkvNTM3LjM2In0"} + ) + }) + + test('New querystring values result in a new session_attributes_encoded value', async () => { + Object.defineProperty(window, 'location', { + value: { + href: 'https://example.com?gad_click_id=999&gad_user_id=4567&gclid=gclid19876543567&gbraid=gbraid567dfsfsdfd', + search: '?gad_click_id=999&gad_user_id=4567&gclid=gclid19876543567&gbraid=gbraid567dfsfsdfd' + }, + writable: true, + configurable: true + }) + + await sessionAttributesEncodedPlugin.load(Context.system(), ajs) + const ctx = new Context({ + type: 'track', + event: 'Test Event', + properties: { + greeting: 'Yo!' + } + }) + const updatedCtx = await sessionAttributesEncodedPlugin.track?.(ctx) + const gecIntegrationsObj = updatedCtx?.event?.integrations[DESTINATION_NAME] + expect(gecIntegrationsObj).toEqual( + { "session_attributes_encoded": "eyJnYWRfY2xpY2tfaWQiOiIxMjMiLCJnYWRfdXNlcl9pZCI6IjQ1NiIsInNlc3Npb25fc3RhcnRfdGltZV91c2VjIjoiMTY3MjUzMTIwMDAwMDAwMCIsImxhbmRpbmdfcGFnZV91cmwiOiJodHRwczovL2V4YW1wbGUuY29tP2dhZF9jbGlja19pZD0xMjMmZ2FkX3VzZXJfaWQ9NDU2JmdjbGlkPWdjbGlkMTIzNCZnYnJhaWQ9Z2JyYWlkNTY3OCIsImxhbmRpbmdfcGFnZV9yZWZlcnJlciI6Imh0dHBzOi8vcmVmZXJyZXIuY29tIiwibGFuZGluZ19wYWdlX3VzZXJfYWdlbnQiOiJNb3ppbGxhLzUuMCAoV2luZG93cyBOVCAxMC4wOyBXaW42NDsgeDY0KSBBcHBsZVdlYktpdC81MzcuMzYgKEtIVE1MLCBsaWtlIEdlY2tvKSBDaHJvbWUvOTEuMC40NDcyLjEyNCBTYWZhcmkvNTM3LjM2In0"} + ) + }) +}) diff --git a/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/src/constants.ts b/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/src/constants.ts new file mode 100644 index 00000000000..6d09f858ade --- /dev/null +++ b/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/src/constants.ts @@ -0,0 +1,6 @@ +// The name of the storage location where we'll cache the Encoded Session Attributes value +export const STORAGE_LOCATION = 'google_session_attributes_encoded' + +export const INTEGRATION_FIELD_NAME = 'session_attributes_encoded' + +export const DESTINATION_NAME = 'Google Ads Conversions' \ No newline at end of file diff --git a/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/src/generated-types.ts b/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/src/generated-types.ts new file mode 100644 index 00000000000..4ab2786ec60 --- /dev/null +++ b/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/src/generated-types.ts @@ -0,0 +1,3 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings {} diff --git a/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/src/index.ts b/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/src/index.ts new file mode 100644 index 00000000000..c6969fa3e38 --- /dev/null +++ b/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/src/index.ts @@ -0,0 +1,49 @@ +import type { Settings } from './generated-types' +import type { BrowserDestinationDefinition } from '@segment/browser-destination-runtime/types' +import { browserDestination } from '@segment/browser-destination-runtime/shim' +import { storageFallback } from './utils' +import { STORAGE_LOCATION } from './constants' +import { UniversalStorage } from '@segment/analytics-next' +import sessionAttributesEncoded from './sessionAttributesEncoded' +import btoa from 'btoa-lite' + +export const destination: BrowserDestinationDefinition = { + name: 'Google Enhanced Conversions Browser Plugins', + mode: 'device', + description: 'Browser plugin to enrich Segment event payloads with data specifically for Google Enhanced Conversions.', + initialize: async ({ analytics }) => { + const storage = (analytics.storage as UniversalStorage>) ?? storageFallback + const urlParams = new URLSearchParams(window.location.search) || [] + const params: Record = {} + + urlParams.forEach((value, key) => { + if (key.startsWith("gad_")) { + params[key] = value + } + }) + + if (Object.keys(params).length > 0 || urlParams.has("gclid") || urlParams.has("gbraid")) { + params["session_start_time_usec"] = ( + new Date().getTime() * 1000 + ).toString() + + params["landing_page_url"] = window.location.href + params["landing_page_referrer"] = document.referrer + params["landing_page_user_agent"] = navigator.userAgent + + const sessionAttributesEncoded = btoa(JSON.stringify(params)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, "") + + storage.set(STORAGE_LOCATION, sessionAttributesEncoded) + } + return {} + }, + settings: {}, + actions: { + sessionAttributesEncoded + } +} + +export default browserDestination(destination) diff --git a/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/src/sessionAttributesEncoded/generated-types.ts b/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/src/sessionAttributesEncoded/generated-types.ts new file mode 100644 index 00000000000..944d22b0857 --- /dev/null +++ b/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/src/sessionAttributesEncoded/generated-types.ts @@ -0,0 +1,3 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload {} diff --git a/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/src/sessionAttributesEncoded/index.ts b/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/src/sessionAttributesEncoded/index.ts new file mode 100644 index 00000000000..3fc2406a7cb --- /dev/null +++ b/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/src/sessionAttributesEncoded/index.ts @@ -0,0 +1,30 @@ +import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { storageFallback } from '../utils' +import { STORAGE_LOCATION, INTEGRATION_FIELD_NAME, DESTINATION_NAME } from '../constants' +import { UniversalStorage } from '@segment/analytics-next' + +const action: BrowserActionDefinition = { + title: 'Session Attributes Encoded Plugin', + description: 'Enriches Segment payloads with Session Attributes Encoded values from the page URL.', + platform: 'web', + hidden: false, + defaultSubscription: 'type = "track" or type = "identify" or type = "page" or type = "group" or type = "alias"', + fields: {}, + lifecycleHook: 'enrichment', + perform: (_, { context, analytics }) => { + const storage = (analytics.storage as UniversalStorage>) ?? storageFallback + const sessionAttributesEncoded: string | null = storage.get(STORAGE_LOCATION) + if (sessionAttributesEncoded) { + const integrationsData: Record = {} + integrationsData[INTEGRATION_FIELD_NAME] = sessionAttributesEncoded + if (context.event.integrations?.All !== false || context.event.integrations[DESTINATION_NAME]) { + context.updateEvent(`integrations.${DESTINATION_NAME}`, integrationsData) + } + } + return + } +} + +export default action diff --git a/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/src/utils.ts b/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/src/utils.ts new file mode 100644 index 00000000000..128384cc520 --- /dev/null +++ b/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/src/utils.ts @@ -0,0 +1,9 @@ +export const storageFallback = { + get: (key: string) => { + const data = window.localStorage.getItem(key) + return data + }, + set: (key: string, value: string) => { + return window.localStorage.setItem(key, value) + } +} diff --git a/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/tsconfig.json b/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/tsconfig.json new file mode 100644 index 00000000000..62a51ed25d1 --- /dev/null +++ b/packages/browser-destinations/destinations/google-enhanced-conversions-plugins/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "rootDir": "./src", + "baseUrl": "." + }, + "include": ["src", "src/constants/.ts"], + "exclude": ["dist", "**/__tests__"] +} diff --git a/packages/browser-destinations/destinations/snap-plugins/src/__tests__/index.test.ts b/packages/browser-destinations/destinations/snap-plugins/src/__tests__/index.test.ts index 255d3d62206..22ff818d31a 100644 --- a/packages/browser-destinations/destinations/snap-plugins/src/__tests__/index.test.ts +++ b/packages/browser-destinations/destinations/snap-plugins/src/__tests__/index.test.ts @@ -55,7 +55,7 @@ describe('ajs-integration', () => { }) const updatedCtx = await snapPlugin.track?.(ctx) - + const snapIntegrationsObj = updatedCtx?.event?.integrations['Snap Conversions Api'] expect(snapIntegrationsObj[clickIdIntegrationFieldName]).toEqual('dummyQuerystringValue') }) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/index.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/index.ts index eea3e370500..de4a27599e8 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/index.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/index.ts @@ -193,6 +193,13 @@ const destination: AudienceDestinationDefinition = { userList }, presets: [ + { + name: 'Session Attributes Encoded Plugin', + subscribe: 'type = "track" or type = "identify" or type = "group" or type = "page" or type = "alias"', + partnerAction: 'sessionAttributesEncoded', + mapping: {}, + type: 'automatic' + }, { name: 'Entities Audience Membership Changed', partnerAction: 'userList', diff --git a/packages/destinations-manifest/package.json b/packages/destinations-manifest/package.json index 54669cbf52d..a0bd72aa82b 100644 --- a/packages/destinations-manifest/package.json +++ b/packages/destinations-manifest/package.json @@ -54,6 +54,7 @@ "@segment/analytics-browser-actions-vwo": "^1.94.0", "@segment/analytics-browser-actions-wiseops": "^1.92.0", "@segment/analytics-browser-hubble-web": "^1.77.0", + "@segment/analytics-browser-actions-google-enhanced-conversions-plugins": "^1.0.0", "@segment/browser-destination-runtime": "^1.90.0" } } diff --git a/packages/destinations-manifest/src/index.ts b/packages/destinations-manifest/src/index.ts index 5650829df50..119c2bba1f3 100644 --- a/packages/destinations-manifest/src/index.ts +++ b/packages/destinations-manifest/src/index.ts @@ -75,4 +75,5 @@ register('68383577d2c19626da376944', '@segment/analytics-browser-actions-reddit- register('686b6e058e93b64b2fe576a3', '@segment/analytics-browser-actions-facebook-conversions-api-web') register('68b82be249b48bae343517c7', '@segment/analytics-browser-actions-ms-bing-capi') register('68c14f4eefb643527597844c', '@segment/analytics-browser-actions-fullsession') -register('68c9611ea98b898fa893cffc', '@segment/analytics-browser-actions-cj') \ No newline at end of file +register('68c9611ea98b898fa893cffc', '@segment/analytics-browser-actions-cj') +register('60ae8b97dcb6cc52d5d0d5ab', '@segment/analytics-browser-actions-google-enhanced-conversions-plugins') \ No newline at end of file