From b90025b31e23b96f428585b5515065e3a43d38a6 Mon Sep 17 00:00:00 2001 From: Alexander Sapountzis Date: Thu, 22 Feb 2024 17:37:51 -0500 Subject: [PATCH] feat: Add support for Consent State --- packages/GA4Client/src/common.js | 14 +- packages/GA4Client/src/consent.js | 107 +++++ packages/GA4Client/src/event-handler.js | 23 + packages/GA4Client/src/initialization.js | 29 ++ packages/GA4Client/test/src/tests.js | 547 +++++++++++++++++++++++ 5 files changed, 719 insertions(+), 1 deletion(-) create mode 100644 packages/GA4Client/src/consent.js diff --git a/packages/GA4Client/src/common.js b/packages/GA4Client/src/common.js index ad3b236..f00ad4b 100644 --- a/packages/GA4Client/src/common.js +++ b/packages/GA4Client/src/common.js @@ -2,6 +2,8 @@ // in place when sending to data layer. // https://support.google.com/analytics/answer/11202874?sjid=7958830619827381593-NA +var ConsentHandler = require('./consent'); + var EVENT_NAME_MAX_LENGTH = 40; var EVENT_ATTRIBUTE_KEY_MAX_LENGTH = 40; var EVENT_ATTRIBUTE_VAL_MAX_LENGTH = 100; @@ -33,7 +35,13 @@ function isEmpty(value) { return value == null || !(Object.keys(value) || value).length; } -function Common() {} +function Common() { + this.consentMappings = {}; + this.consentPayloadDefaults = {}; + this.consentPayloadAsString = ''; + + this.consentHandler = new ConsentHandler(this); +} Common.prototype.forwarderSettings = null; @@ -305,4 +313,8 @@ Common.prototype.getUserId = function ( } }; +Common.prototype.cloneObject = function (obj) { + return JSON.parse(JSON.stringify(obj)); +}; + module.exports = Common; diff --git a/packages/GA4Client/src/consent.js b/packages/GA4Client/src/consent.js new file mode 100644 index 0000000..96cdb45 --- /dev/null +++ b/packages/GA4Client/src/consent.js @@ -0,0 +1,107 @@ +var googleConsentValues = { + // Server Integration uses 'Unspecified' as a value when the setting is 'not set'. + // However, this is not used by Google's Web SDK. We are referencing it here as a comment + // as a record of this distinction and for posterity. + // If Google ever adds this for web, the line can just be uncommented to support this. + // + // Docs: + // Web: https://developers.google.com/tag-platform/gtagjs/reference#consent + // S2S: https://developers.google.com/google-ads/api/reference/rpc/v15/ConsentStatusEnum.ConsentStatus + // + // Unspecified: 'unspecified', + Denied: 'denied', + Granted: 'granted', +}; + +var googleConsentProperties = [ + 'ad_storage', + 'ad_user_data', + 'ad_personalization', + 'analytics_storage', +]; + +function ConsentHandler(common) { + this.common = common || {}; +} + +ConsentHandler.prototype.getUserConsentState = function () { + var userConsentState = null; + + if (mParticle.Identity && mParticle.Identity.getCurrentUser) { + var currentUser = mParticle.Identity.getCurrentUser(); + + if (!currentUser) { + return null; + } + + var consentState = + mParticle.Identity.getCurrentUser().getConsentState(); + + if (consentState && consentState.getGDPRConsentState) { + userConsentState = consentState.getGDPRConsentState(); + } + } + + return userConsentState; +}; + +ConsentHandler.prototype.getEventConsentState = function (eventConsentState) { + return eventConsentState && eventConsentState.getGDPRConsentState + ? eventConsentState.getGDPRConsentState() + : null; +}; + +ConsentHandler.prototype.getConsentSettings = function () { + var consentSettings = {}; + + var googleToMpConsentSettingsMapping = { + ad_storage: 'adStorageConsentWeb', + ad_user_data: 'adUserDataConsentWeb', + ad_personalization: 'adPersonalizationConsentWeb', + analytics_storage: 'analyticsStorageConsentWeb', + }; + + var forwarderSettings = this.common.forwarderSettings; + + Object.keys(googleToMpConsentSettingsMapping).forEach(function ( + googleConsentKey + ) { + var mpConsentSettingKey = + googleToMpConsentSettingsMapping[googleConsentKey]; + var googleConsentValuesKey = forwarderSettings[mpConsentSettingKey]; + + if (googleConsentValuesKey && mpConsentSettingKey) { + consentSettings[googleConsentKey] = + googleConsentValues[googleConsentValuesKey]; + } + }); + + return consentSettings; +}; + +ConsentHandler.prototype.generateConsentStatePayloadFromMappings = function ( + consentState, + mappings +) { + var payload = this.common.cloneObject(this.common.consentPayloadDefaults); + + for (var i = 0; i <= mappings.length - 1; i++) { + var mappingEntry = mappings[i]; + var mpMappedConsentName = mappingEntry.map; + var googleMappedConsentName = mappingEntry.value; + + if ( + consentState[mpMappedConsentName] && + googleConsentProperties.indexOf(googleMappedConsentName) !== -1 + ) { + payload[googleMappedConsentName] = consentState[mpMappedConsentName] + .Consented + ? googleConsentValues.Granted + : googleConsentValues.Denied; + } + } + + return payload; +}; + +module.exports = ConsentHandler; diff --git a/packages/GA4Client/src/event-handler.js b/packages/GA4Client/src/event-handler.js index dfabebf..bcedd26 100644 --- a/packages/GA4Client/src/event-handler.js +++ b/packages/GA4Client/src/event-handler.js @@ -2,6 +2,27 @@ function EventHandler(common) { this.common = common || {}; } +EventHandler.prototype.maybeSendConsentUpdateToGa4 = function (event) { + var eventConsentState = this.common.consentHandler.getEventConsentState( + event.ConsentState + ); + + if (eventConsentState) { + var updatedConsentPayload = + this.common.consentHandler.generateConsentStatePayloadFromMappings( + eventConsentState, + this.common.consentMappings + ); + + var eventConsentAsString = JSON.stringify(updatedConsentPayload); + + if (eventConsentAsString !== this.common.consentPayloadAsString) { + gtag('consent', 'update', updatedConsentPayload); + this.common.consentPayloadAsString = eventConsentAsString; + } + } +}; + // TODO: https://mparticle-eng.atlassian.net/browse/SQDSDKS-5715 EventHandler.prototype.sendEventToGA4 = function (eventName, eventAttributes) { var standardizedEventName; @@ -27,6 +48,7 @@ EventHandler.prototype.sendEventToGA4 = function (eventName, eventAttributes) { }; EventHandler.prototype.logEvent = function (event) { + this.maybeSendConsentUpdateToGa4(event); this.sendEventToGA4(event.EventName, event.EventAttributes); }; @@ -67,6 +89,7 @@ EventHandler.prototype.logPageView = function (event) { event.EventAttributes ); + this.maybeSendConsentUpdateToGa4(event); this.sendEventToGA4('page_view', eventAttributes); return true; diff --git a/packages/GA4Client/src/initialization.js b/packages/GA4Client/src/initialization.js index 0b764e8..b68126d 100644 --- a/packages/GA4Client/src/initialization.js +++ b/packages/GA4Client/src/initialization.js @@ -40,6 +40,13 @@ var initialization = { var configSettings = { send_page_view: forwarderSettings.enablePageView === 'True', }; + + if (forwarderSettings.consentMappingWeb) { + common.consentMappings = parseSettingsString( + forwarderSettings.consentMappingWeb + ); + } + window.dataLayer = window.dataLayer || []; window.gtag = function () { @@ -89,6 +96,24 @@ var initialization = { } else { isInitialized = true; } + + common.consentPayloadDefaults = + common.consentHandler.getConsentSettings(); + var initialConsentState = common.consentHandler.getUserConsentState(); + + if (common.consentPayloadDefaults && initialConsentState) { + var defaultConsentPayload = + common.consentHandler.generateConsentStatePayloadFromMappings( + initialConsentState, + common.consentMappings + ); + common.consentPayloadAsString = JSON.stringify( + defaultConsentPayload + ); + + gtag('consent', 'default', defaultConsentPayload); + } + return isInitialized; }, }; @@ -104,4 +129,8 @@ function setClientId(clientId, moduleId) { window.mParticle._setIntegrationDelay(moduleId, false); } +function parseSettingsString(settingsString) { + return JSON.parse(settingsString.replace(/"/g, '"')); +} + module.exports = initialization; diff --git a/packages/GA4Client/test/src/tests.js b/packages/GA4Client/test/src/tests.js index dc87cda..29b6e85 100644 --- a/packages/GA4Client/test/src/tests.js +++ b/packages/GA4Client/test/src/tests.js @@ -112,6 +112,24 @@ describe('Google Analytics 4 Event', function () { }, }; }, + getConsentState: function () { + return { + getGDPRConsentState: function () { + return { + some_consent: { + Consented: false, + Timestamp: 1, + Document: 'some_consent', + }, + test_consent: { + Consented: false, + Timestamp: 1, + Document: 'test_consent', + }, + }; + }, + }; + }, }; }, }; @@ -2285,4 +2303,533 @@ describe('Google Analytics 4 Event', function () { }); }); }); + + describe('Consent State', () => { + var consentMap = [ + { + jsmap: null, + map: 'some_consent', + maptype: 'ConsentPurposes', + value: 'ad_user_data', + }, + { + jsmap: null, + map: 'storage_consent', + maptype: 'ConsentPurposes', + value: 'analytics_storage', + }, + { + jsmap: null, + map: 'other_test_consent', + maptype: 'ConsentPurposes', + value: 'ad_storage', + }, + { + jsmap: null, + map: 'test_consent', + maptype: 'ConsentPurposes', + value: 'ad_personalization', + }, + ]; + + beforeEach(function () { + window.dataLayer = []; + }); + + it('should construct a Default Consent State Payload from Mappings', (done) => { + mParticle.forwarder.init( + { + conversionId: 'AW-123123123', + consentMappingWeb: + '[{"jsmap":null,"map":"some_consent","maptype":"ConsentPurposes","value":"ad_user_data"},{"jsmap":null,"map":"storage_consent","maptype":"ConsentPurposes","value":"analytics_storage"},{"jsmap":null,"map":"other_test_consent","maptype":"ConsentPurposes","value":"ad_storage"},{"jsmap":null,"map":"test_consent","maptype":"ConsentPurposes","value":"ad_personalization"}]', + }, + reportService.cb, + true + ); + + var expectedDataLayer = [ + 'consent', + 'default', + { + ad_user_data: 'denied', + ad_personalization: 'denied', + }, + ]; + + // Initial elements of Data Layer are setup for gtag. + // Consent state should be on the bottom + window.dataLayer.length.should.eql(4); + window.dataLayer[3][0].should.equal('consent'); + window.dataLayer[3][1].should.equal('default'); + window.dataLayer[3][2].should.deepEqual(expectedDataLayer[2]); + done(); + }); + + it('should merge Consent Setting Defaults with User Consent State to construct a Default Consent State', (done) => { + mParticle.forwarder.init( + { + conversionId: 'AW-123123123', + enableGtag: 'True', + consentMappingWeb: JSON.stringify(consentMap), + adPersonalizationConsentWeb: 'Granted', // Will be overriden by User Consent State + adUserDataConsentWeb: 'Granted', // Will be overriden by User Consent State + adStorageConsentWeb: 'Granted', + analyticsStorageConsentWeb: 'Granted', + }, + reportService.cb, + true + ); + + var expectedDataLayer = [ + 'consent', + 'default', + { + ad_personalization: 'denied', // From User Consent State + ad_user_data: 'denied', // From User Consent State + ad_storage: 'granted', // From Consent Settings + analytics_storage: 'granted', // From Consent Settings + }, + ]; + + // Initial elements of Data Layer are setup for gtag. + // Consent state should be on the bottom + window.dataLayer.length.should.eql(4); + window.dataLayer[3][0].should.equal('consent'); + window.dataLayer[3][1].should.equal('default'); + window.dataLayer[3][2].should.deepEqual(expectedDataLayer[2]); + + done(); + }); + + it('should ignore Unspecified Consent Settings if NOT explicitely defined in Consent State', (done) => { + mParticle.forwarder.init( + { + conversionId: 'AW-123123123', + enableGtag: 'True', + consentMappingWeb: JSON.stringify(consentMap), + adStorageConsentWeb: 'Unspecified', // Will be overriden by User Consent State + adUserDataConsentWeb: 'Unspecified', // Will be overriden by User Consent State + adPersonalizationConsentWeb: 'Unspecified', + analyticsStorageConsentWeb: 'Unspecified', + }, + reportService.cb, + true + ); + + var expectedDataLayer = [ + 'consent', + 'default', + { + ad_personalization: 'denied', // From User Consent State + ad_user_data: 'denied', // From User Consent State + }, + ]; + + // Initial elements of Data Layer are setup for gtag. + // Consent state should be on the bottom + window.dataLayer.length.should.eql(4); + window.dataLayer[3][0].should.equal('consent'); + window.dataLayer[3][1].should.equal('default'); + window.dataLayer[3][2].should.deepEqual(expectedDataLayer[2]); + + done(); + }); + + it('should construct a Consent State Update Payload when consent changes', (done) => { + mParticle.forwarder.init( + { + conversionId: 'AW-123123123', + enableGtag: 'True', + consentMappingWeb: JSON.stringify(consentMap), + }, + reportService.cb, + true + ); + + var expectedDataLayerBefore = [ + 'consent', + 'update', + { + ad_user_data: 'denied', + ad_personalization: 'denied', + }, + ]; + + // Initial elements of Data Layer are setup for gtag. + // Consent state should be on the bottom + window.dataLayer.length.should.eql(4); + window.dataLayer[3][0].should.equal('consent'); + window.dataLayer[3][1].should.equal('default'); + window.dataLayer[3][2].should.deepEqual(expectedDataLayerBefore[2]); + + mParticle.forwarder.process({ + EventName: 'Homepage', + EventDataType: MessageType.PageEvent, + EventCategory: EventType.Navigation, + EventAttributes: { + showcase: 'something', + test: 'thisoneshouldgetmapped', + mp: 'rock', + }, + ConsentState: { + getGDPRConsentState: function () { + return { + some_consent: { + Consented: true, + Timestamp: Date.now(), + Document: 'some_consent', + }, + ignored_consent: { + Consented: false, + Timestamp: Date.now(), + Document: 'ignored_consent', + }, + test_consent: { + Consented: true, + Timestamp: Date.now(), + Document: 'test_consent', + }, + }; + }, + + getCCPAConsentState: function () { + return { + data_sale_opt_out: { + Consented: false, + Timestamp: Date.now(), + Document: 'some_consent', + }, + }; + }, + }, + }); + + var expectedDataLayerAfter = [ + 'consent', + 'update', + { + ad_user_data: 'granted', + ad_personalization: 'granted', + }, + ]; + + // Initial elements of Data Layer are setup for gtag. + // Consent Default is index 3 + // Consent Update is index 4 + // Event is index 5 + window.dataLayer.length.should.eql(6); + window.dataLayer[4][0].should.equal('consent'); + window.dataLayer[4][1].should.equal('update'); + window.dataLayer[4][2].should.deepEqual(expectedDataLayerAfter[2]); + + mParticle.forwarder.process({ + EventName: 'Homepage', + EventDataType: MessageType.PageEvent, + EventCategory: EventType.Navigation, + EventAttributes: { + showcase: 'something', + test: 'thisoneshouldgetmapped', + mp: 'rock', + }, + ConsentState: { + getGDPRConsentState: function () { + return { + some_consent: { + Consented: true, + Timestamp: Date.now(), + Document: 'some_consent', + }, + ignored_consent: { + Consented: false, + Timestamp: Date.now(), + Document: 'ignored_consent', + }, + test_consent: { + Consented: true, + Timestamp: Date.now(), + Document: 'test_consent', + }, + other_test_consent: { + Consented: true, + Timestamp: Date.now(), + Document: 'other_test_consent', + }, + storage_consent: { + Consented: false, + Timestamp: Date.now(), + Document: 'storage_consent', + }, + }; + }, + + getCCPAConsentState: function () { + return { + data_sale_opt_out: { + Consented: false, + Timestamp: Date.now(), + Document: 'data_sale_opt_out', + }, + }; + }, + }, + }); + + var expectedDataLayerFinal = [ + 'consent', + 'update', + { + ad_personalization: 'granted', + ad_storage: 'granted', + ad_user_data: 'granted', + analytics_storage: 'denied', + }, + ]; + + // Initial elements of Data Layer are setup for gtag. + // Consent Default is index 3 + // Consent Update is index 4 + // Event is index 5 + // Consent Update #2 is index 6 + // Event #2 is index 7 + window.dataLayer.length.should.eql(8); + window.dataLayer[6][0].should.equal('consent'); + window.dataLayer[6][1].should.equal('update'); + window.dataLayer[6][2].should.deepEqual(expectedDataLayerFinal[2]); + + done(); + }); + + it('should construct a Consent State Update Payload with Consent Setting Defaults when consent changes', (done) => { + mParticle.forwarder.init( + { + conversionId: 'AW-123123123', + enableGtag: 'True', + consentMappingWeb: JSON.stringify(consentMap), + adPersonalizationConsentWeb: 'Granted', // Will be overriden by User Consent State + adUserDataConsentWeb: 'Granted', // Will be overriden by User Consent State + adStorageConsentWeb: 'Granted', + analyticsStorageConsentWeb: 'Granted', + }, + reportService.cb, + true + ); + + var expectedDataLayerBefore = [ + 'consent', + 'update', + { + ad_personalization: 'denied', // From User Consent State + ad_user_data: 'denied', // From User Consent State + ad_storage: 'granted', // From Consent Settings + analytics_storage: 'granted', // From Consent Settings + }, + ]; + + // Initial elements of Data Layer are setup for gtag. + // Consent state should be on the bottom + window.dataLayer.length.should.eql(4); + window.dataLayer[3][0].should.equal('consent'); + window.dataLayer[3][1].should.equal('default'); + window.dataLayer[3][2].should.deepEqual(expectedDataLayerBefore[2]); + + mParticle.forwarder.process({ + EventName: 'Homepage', + EventDataType: MessageType.PageEvent, + EventCategory: EventType.Navigation, + EventAttributes: { + showcase: 'something', + test: 'thisoneshouldgetmapped', + mp: 'rock', + }, + ConsentState: { + getGDPRConsentState: function () { + return { + some_consent: { + Consented: true, + Timestamp: Date.now(), + Document: 'some_consent', + }, + ignored_consent: { + Consented: false, + Timestamp: Date.now(), + Document: 'ignored_consent', + }, + test_consent: { + Consented: true, + Timestamp: Date.now(), + Document: 'test_consent', + }, + }; + }, + + getCCPAConsentState: function () { + return { + data_sale_opt_out: { + Consented: false, + Timestamp: Date.now(), + Document: 'data_sale_opt_out', + }, + }; + }, + }, + }); + + var expectedDataLayerAfter = [ + 'consent', + 'update', + { + ad_personalization: 'granted', // From Event Consent State Change + ad_user_data: 'granted', // From Event Consent State Change + ad_storage: 'granted', // From Consent Settings + analytics_storage: 'granted', // From Consent Settings + }, + ]; + + // Initial elements of Data Layer are setup for gtag. + // Consent Default is index 3 + // Consent Update is index 4 + // Event is index 5 + window.dataLayer.length.should.eql(6); + window.dataLayer[4][0].should.equal('consent'); + window.dataLayer[4][1].should.equal('update'); + window.dataLayer[4][2].should.deepEqual(expectedDataLayerAfter[2]); + + mParticle.forwarder.process({ + EventName: 'Homepage', + EventDataType: MessageType.PageEvent, + EventCategory: EventType.Navigation, + EventAttributes: { + showcase: 'something', + test: 'thisoneshouldgetmapped', + mp: 'rock', + }, + ConsentState: { + getGDPRConsentState: function () { + return { + some_consent: { + Consented: true, + Timestamp: Date.now(), + Document: 'some_consent', + }, + ignored_consent: { + Consented: false, + Timestamp: Date.now(), + Document: 'ignored_consent', + }, + test_consent: { + Consented: true, + Timestamp: Date.now(), + Document: 'test_consent', + }, + other_test_consent: { + Consented: true, + Timestamp: Date.now(), + Document: 'other_test_consent', + }, + storage_consent: { + Consented: false, + Timestamp: Date.now(), + Document: 'storage_consent', + }, + }; + }, + + getCCPAConsentState: function () { + return { + data_sale_opt_out: { + Consented: false, + Timestamp: Date.now(), + Document: 'data_sale_opt_out', + }, + }; + }, + }, + }); + + var expectedDataLayerFinal = [ + 'consent', + 'update', + { + ad_personalization: 'granted', // From Previous Event State Change + ad_storage: 'granted', // From Previous Event State Change + ad_user_data: 'granted', // From Consent Settings + analytics_storage: 'denied', // From FinalEvent Consent State Change + }, + ]; + + // Initial elements of Data Layer are setup for gtag. + // Consent Default is index 3 + // Consent Update is index 4 + // Event is index 5 + // Consent Update #2 is index 6 + // Event #2 is index 7 + window.dataLayer.length.should.eql(8); + window.dataLayer[6][0].should.equal('consent'); + window.dataLayer[6][1].should.equal('update'); + window.dataLayer[6][2].should.deepEqual(expectedDataLayerFinal[2]); + done(); + }); + + it('should NOT construct a Consent State Update Payload if consent DOES NOT change', (done) => { + mParticle.forwarder.init( + { + conversionId: 'AW-123123123', + enableGtag: 'True', + consentMappingWeb: JSON.stringify(consentMap), + }, + reportService.cb, + true + ); + + var expectedDataLayerBefore = [ + 'consent', + 'update', + { + ad_user_data: 'denied', + ad_personalization: 'denied', + }, + ]; + + // Initial elements of Data Layer are setup for gtag. + // Consent state should be on the bottom + window.dataLayer.length.should.eql(4); + window.dataLayer[3][0].should.equal('consent'); + window.dataLayer[3][1].should.equal('default'); + window.dataLayer[3][2].should.deepEqual(expectedDataLayerBefore[2]); + + mParticle.forwarder.process({ + EventName: 'Homepage', + EventDataType: MessageType.PageEvent, + EventCategory: EventType.Navigation, + EventAttributes: { + showcase: 'something', + test: 'thisoneshouldgetmapped', + mp: 'rock', + }, + ConsentState: { + getGDPRConsentState: function () { + return { + some_consent: { + Consented: false, + Timestamp: Date.now(), + Document: 'some_consent', + }, + test_consent: { + Consented: false, + Timestamp: Date.now(), + Document: 'test_consent', + }, + }; + }, + }, + }); + + // Last Element of data layer should only contain actual event + window.dataLayer.length.should.eql(5); + window.dataLayer[4][0].should.equal('event'); + window.dataLayer[4][1].should.equal('Homepage'); + + done(); + }); + }); });