diff --git a/.changeset/shiny-bats-reflect.md b/.changeset/shiny-bats-reflect.md new file mode 100644 index 00000000000..feb7a9a353f --- /dev/null +++ b/.changeset/shiny-bats-reflect.md @@ -0,0 +1,5 @@ +--- +'@firebase/analytics': minor +--- + +Add function `setConsent()` to set the applicable end user "consent" state. diff --git a/common/api-review/analytics.api.md b/common/api-review/analytics.api.md index 851e26c23cf..856815b4b5d 100644 --- a/common/api-review/analytics.api.md +++ b/common/api-review/analytics.api.md @@ -21,6 +21,20 @@ export interface AnalyticsSettings { config?: GtagConfigParams | EventParams; } +// @public +export interface ConsentSettings { + // (undocumented) + [key: string]: unknown; + ad_storage?: ConsentStatusString; + analytics_storage?: ConsentStatusString; + functionality_storage?: ConsentStatusString; + personalization_storage?: ConsentStatusString; + security_storage?: ConsentStatusString; +} + +// @public +export type ConsentStatusString = 'granted' | 'denied'; + // @public export interface ControlParams { // (undocumented) @@ -388,6 +402,9 @@ export interface Promotion { // @public export function setAnalyticsCollectionEnabled(analyticsInstance: Analytics, enabled: boolean): void; +// @public +export function setConsent(consentSettings: ConsentSettings): void; + // @public @deprecated export function setCurrentScreen(analyticsInstance: Analytics, screenName: string, options?: AnalyticsCallOptions): void; diff --git a/packages/analytics/src/api.test.ts b/packages/analytics/src/api.test.ts index a9a936ea373..eacaf3ee811 100644 --- a/packages/analytics/src/api.test.ts +++ b/packages/analytics/src/api.test.ts @@ -22,6 +22,7 @@ import { getFullApp } from '../testing/get-fake-firebase-services'; import { getAnalytics, initializeAnalytics, + setConsent, setDefaultEventParameters } from './api'; import { FirebaseApp, deleteApp } from '@firebase/app'; @@ -29,7 +30,11 @@ import { AnalyticsError } from './errors'; import * as init from './initialize-analytics'; const fakeAppParams = { appId: 'abcdefgh12345:23405', apiKey: 'AAbbCCdd12345' }; import * as factory from './factory'; -import { defaultEventParametersForInit } from './functions'; +import { + defaultConsentSettingsForInit, + defaultEventParametersForInit +} from './functions'; +import { ConsentSettings } from './public-types'; describe('FirebaseAnalytics API tests', () => { let initStub: SinonStub = stub(); @@ -123,4 +128,30 @@ describe('FirebaseAnalytics API tests', () => { eventParametersForInit ); }); + it('setConsent() updates defaultConsentSettingsForInit if gtag does not exist ', () => { + const consentParametersForInit: ConsentSettings = { + 'analytics_storage': 'granted', + 'functionality_storage': 'denied' + }; + stub(factory, 'wrappedGtagFunction').get(() => undefined); + app = getFullApp(fakeAppParams); + setConsent(consentParametersForInit); + expect(defaultConsentSettingsForInit).to.deep.equal( + consentParametersForInit + ); + }); + it('setConsent() calls gtag consent "update" if wrappedGtagFunction exists', () => { + const consentParametersForInit: ConsentSettings = { + 'analytics_storage': 'granted', + 'functionality_storage': 'denied' + }; + stub(factory, 'wrappedGtagFunction').get(() => wrappedGtag); + app = getFullApp(fakeAppParams); + setConsent(consentParametersForInit); + expect(wrappedGtag).to.have.been.calledWithExactly( + 'consent', + 'update', + consentParametersForInit + ); + }); }); diff --git a/packages/analytics/src/api.ts b/packages/analytics/src/api.ts index 4e3888bd724..423092361d9 100644 --- a/packages/analytics/src/api.ts +++ b/packages/analytics/src/api.ts @@ -22,6 +22,7 @@ import { Analytics, AnalyticsCallOptions, AnalyticsSettings, + ConsentSettings, CustomParams, EventNameString, EventParams @@ -48,6 +49,7 @@ import { setUserId as internalSetUserId, setUserProperties as internalSetUserProperties, setAnalyticsCollectionEnabled as internalSetAnalyticsCollectionEnabled, + _setConsentDefaultForInit, _setDefaultEventParametersForInit } from './functions'; import { ERROR_FACTORY, AnalyticsError } from './errors'; @@ -231,7 +233,7 @@ export function setAnalyticsCollectionEnabled( * With gtag's "set" command, the values passed persist on the current page and are passed with * all subsequent events. * @public - * @param customParams Any custom params the user may pass to gtag.js. + * @param customParams - Any custom params the user may pass to gtag.js. */ export function setDefaultEventParameters(customParams: CustomParams): void { // Check if reference to existing gtag function on window object exists @@ -734,3 +736,21 @@ export function logEvent( * @public */ export type CustomEventName = T extends EventNameString ? never : T; + +/** + * Sets the applicable end user consent state for this web app across all gtag references once + * Firebase Analytics is initialized. + * + * Use the {@link ConsentSettings} to specify individual consent type values. By default consent + * types are set to "granted". + * @public + * @param consentSettings - Maps the applicable end user consent state for gtag.js. + */ +export function setConsent(consentSettings: ConsentSettings): void { + // Check if reference to existing gtag function on window object exists + if (wrappedGtagFunction) { + wrappedGtagFunction(GtagCommand.CONSENT, 'update', consentSettings); + } else { + _setConsentDefaultForInit(consentSettings); + } +} diff --git a/packages/analytics/src/constants.ts b/packages/analytics/src/constants.ts index 6697466c8aa..7f3da181288 100644 --- a/packages/analytics/src/constants.ts +++ b/packages/analytics/src/constants.ts @@ -34,5 +34,6 @@ export const GTAG_URL = 'https://www.googletagmanager.com/gtag/js'; export const enum GtagCommand { EVENT = 'event', SET = 'set', - CONFIG = 'config' + CONFIG = 'config', + CONSENT = 'consent' } diff --git a/packages/analytics/src/functions.test.ts b/packages/analytics/src/functions.test.ts index 0f806849f72..efce7145ce6 100644 --- a/packages/analytics/src/functions.test.ts +++ b/packages/analytics/src/functions.test.ts @@ -25,9 +25,12 @@ import { setUserProperties, setAnalyticsCollectionEnabled, defaultEventParametersForInit, - _setDefaultEventParametersForInit + _setDefaultEventParametersForInit, + _setConsentDefaultForInit, + defaultConsentSettingsForInit } from './functions'; import { GtagCommand } from './constants'; +import { ConsentSettings } from './public-types'; const fakeMeasurementId = 'abcd-efgh-ijkl'; const fakeInitializationPromise = Promise.resolve(fakeMeasurementId); @@ -192,4 +195,26 @@ describe('FirebaseAnalytics methods', () => { ...additionalParams }); }); + it('_setConsentDefaultForInit() stores individual params correctly', async () => { + const consentParametersForInit: ConsentSettings = { + 'analytics_storage': 'granted', + 'functionality_storage': 'denied' + }; + _setConsentDefaultForInit(consentParametersForInit); + expect(defaultConsentSettingsForInit).to.deep.equal( + consentParametersForInit + ); + }); + it('_setConsentDefaultForInit() replaces previous params with new params', async () => { + const consentParametersForInit: ConsentSettings = { + 'analytics_storage': 'granted', + 'functionality_storage': 'denied' + }; + const additionalParams = { 'wait_for_update': 500 }; + _setConsentDefaultForInit(consentParametersForInit); + _setConsentDefaultForInit(additionalParams); + expect(defaultConsentSettingsForInit).to.deep.equal({ + ...additionalParams + }); + }); }); diff --git a/packages/analytics/src/functions.ts b/packages/analytics/src/functions.ts index 535b3bd530b..0b3b15f68c1 100644 --- a/packages/analytics/src/functions.ts +++ b/packages/analytics/src/functions.ts @@ -19,7 +19,8 @@ import { AnalyticsCallOptions, CustomParams, ControlParams, - EventParams + EventParams, + ConsentSettings } from './public-types'; import { Gtag } from './types'; import { GtagCommand } from './constants'; @@ -149,6 +150,23 @@ export async function setAnalyticsCollectionEnabled( window[`ga-disable-${measurementId}`] = !enabled; } +/** + * Consent parameters to default to during 'gtag' initialization. + */ +export let defaultConsentSettingsForInit: ConsentSettings | undefined; + +/** + * Sets the variable {@link defaultConsentSettingsForInit} for use in the initialization of + * analytics. + * + * @param consentSettings Maps the applicable end user consent state for gtag.js. + */ +export function _setConsentDefaultForInit( + consentSettings?: ConsentSettings +): void { + defaultConsentSettingsForInit = consentSettings; +} + /** * Sets the variable `defaultEventParametersForInit` for use in the initialization of * analytics. diff --git a/packages/analytics/src/helpers.test.ts b/packages/analytics/src/helpers.test.ts index 79614f9edf4..0a67fafcf60 100644 --- a/packages/analytics/src/helpers.test.ts +++ b/packages/analytics/src/helpers.test.ts @@ -28,6 +28,7 @@ import { } from './helpers'; import { GtagCommand } from './constants'; import { Deferred } from '@firebase/util'; +import { ConsentSettings } from './public-types'; const fakeMeasurementId = 'abcd-efgh-ijkl'; const fakeAppId = 'my-test-app-1234'; @@ -226,6 +227,27 @@ describe('Gtag wrapping functions', () => { expect((window['dataLayer'] as DataLayer).length).to.equal(1); }); + it('new window.gtag function does not wait when sending "consent" calls', async () => { + const consentParameters: ConsentSettings = { + 'analytics_storage': 'granted', + 'functionality_storage': 'denied' + }; + wrapOrCreateGtag( + { [fakeAppId]: Promise.resolve(fakeMeasurementId) }, + fakeDynamicConfigPromises, + {}, + 'dataLayer', + 'gtag' + ); + window['dataLayer'] = []; + (window['gtag'] as Gtag)( + GtagCommand.CONSENT, + 'update', + consentParameters + ); + expect((window['dataLayer'] as DataLayer).length).to.equal(1); + }); + it('new window.gtag function waits for initialization promise when sending "config" calls', async () => { const initPromise1 = new Deferred(); wrapOrCreateGtag( diff --git a/packages/analytics/src/helpers.ts b/packages/analytics/src/helpers.ts index d8171f9c0f5..be713c2c8a0 100644 --- a/packages/analytics/src/helpers.ts +++ b/packages/analytics/src/helpers.ts @@ -15,11 +15,19 @@ * limitations under the License. */ -import { CustomParams, ControlParams, EventParams } from './public-types'; +import { + CustomParams, + ControlParams, + EventParams, + ConsentSettings +} from './public-types'; import { DynamicConfig, DataLayer, Gtag, MinimalDynamicConfig } from './types'; import { GtagCommand, GTAG_URL } from './constants'; import { logger } from './logger'; +// Possible parameter types for gtag 'event' and 'config' commands +type GtagConfigOrEventParams = ControlParams & EventParams & CustomParams; + /** * Makeshift polyfill for Promise.allSettled(). Resolves when all promises * have either resolved or rejected. @@ -219,9 +227,9 @@ function wrapGtag( * @param gtagParams Params if event is EVENT/CONFIG. */ async function gtagWrapper( - command: 'config' | 'set' | 'event', + command: 'config' | 'set' | 'event' | 'consent', idOrNameOrParams: string | ControlParams, - gtagParams?: ControlParams & EventParams & CustomParams + gtagParams?: GtagConfigOrEventParams | ConsentSettings ): Promise { try { // If event, check that relevant initialization promises have completed. @@ -232,7 +240,7 @@ function wrapGtag( initializationPromisesMap, dynamicConfigPromisesList, idOrNameOrParams as string, - gtagParams + gtagParams as GtagConfigOrEventParams ); } else if (command === GtagCommand.CONFIG) { // If CONFIG, second arg must be measurementId. @@ -242,8 +250,11 @@ function wrapGtag( dynamicConfigPromisesList, measurementIdToAppId, idOrNameOrParams as string, - gtagParams + gtagParams as GtagConfigOrEventParams ); + } else if (command === GtagCommand.CONSENT) { + // If CONFIG, second arg must be measurementId. + gtagCore(GtagCommand.CONSENT, 'update', gtagParams as ConsentSettings); } else { // If SET, second arg must be params. gtagCore(GtagCommand.SET, idOrNameOrParams as CustomParams); diff --git a/packages/analytics/src/initialize-analytics.test.ts b/packages/analytics/src/initialize-analytics.test.ts index 2ed8053bdad..da2c8f964b6 100644 --- a/packages/analytics/src/initialize-analytics.test.ts +++ b/packages/analytics/src/initialize-analytics.test.ts @@ -30,7 +30,12 @@ import { Deferred } from '@firebase/util'; import { _FirebaseInstallationsInternal } from '@firebase/installations'; import { removeGtagScript } from '../testing/gtag-script-util'; import { setDefaultEventParameters } from './api'; -import { defaultEventParametersForInit } from './functions'; +import { + defaultConsentSettingsForInit, + defaultEventParametersForInit, + _setConsentDefaultForInit +} from './functions'; +import { ConsentSettings } from './public-types'; const fakeMeasurementId = 'abcd-efgh-ijkl'; const fakeFid = 'fid-1234-zyxw'; @@ -118,6 +123,29 @@ describe('initializeAnalytics()', () => { // defaultEventParametersForInit is reset after initialization. expect(defaultEventParametersForInit).to.equal(undefined); }); + it('calls gtag consent if there are default consent parameters', async () => { + stubFetch(); + const consentParametersForInit: ConsentSettings = { + 'analytics_storage': 'granted', + 'functionality_storage': 'denied' + }; + _setConsentDefaultForInit(consentParametersForInit); + await _initializeAnalytics( + app, + dynamicPromisesList, + measurementIdToAppId, + fakeInstallations, + gtagStub, + 'dataLayer' + ); + expect(gtagStub).to.be.calledWith( + GtagCommand.CONSENT, + 'default', + consentParametersForInit + ); + // defaultEventParametersForInit is reset after initialization. + expect(defaultConsentSettingsForInit).to.equal(undefined); + }); it('puts dynamic fetch promise into dynamic promises list', async () => { stubFetch(); await _initializeAnalytics( diff --git a/packages/analytics/src/initialize-analytics.ts b/packages/analytics/src/initialize-analytics.ts index cdd73378f55..f89fcf1ddf7 100644 --- a/packages/analytics/src/initialize-analytics.ts +++ b/packages/analytics/src/initialize-analytics.ts @@ -29,6 +29,8 @@ import { ERROR_FACTORY, AnalyticsError } from './errors'; import { findGtagScriptOnPage, insertScriptTag } from './helpers'; import { AnalyticsSettings } from './public-types'; import { + defaultConsentSettingsForInit, + _setConsentDefaultForInit, defaultEventParametersForInit, _setDefaultEventParametersForInit } from './functions'; @@ -122,6 +124,12 @@ export async function _initializeAnalytics( insertScriptTag(dataLayerName, dynamicConfig.measurementId); } + // Detects if there are consent settings that need to be configured. + if (defaultConsentSettingsForInit) { + gtagCore(GtagCommand.CONSENT, 'default', defaultConsentSettingsForInit); + _setConsentDefaultForInit(undefined); + } + // This command initializes gtag.js and only needs to be called once for the entire web app, // but since it is idempotent, we can call it multiple times. // We keep it together with other initialization logic for better code structure. diff --git a/packages/analytics/src/public-types.ts b/packages/analytics/src/public-types.ts index 9e0f479a3a9..930ba17b1eb 100644 --- a/packages/analytics/src/public-types.ts +++ b/packages/analytics/src/public-types.ts @@ -288,4 +288,37 @@ export interface EventParams { page_path?: string; [key: string]: unknown; } + +/** + * Consent status settings for each consent type. + * For more information, see + * {@link https://developers.google.com/tag-platform/tag-manager/templates/consent-apis + * | the GA4 reference documentation for consent state and consent types}. + * @public + */ +export interface ConsentSettings { + /** Enables storage, such as cookies, related to advertising */ + ad_storage?: ConsentStatusString; + /** Enables storage, such as cookies, related to analytics (for example, visit duration) */ + analytics_storage?: ConsentStatusString; + /** + * Enables storage that supports the functionality of the website or app such as language settings + */ + functionality_storage?: ConsentStatusString; + /** Enables storage related to personalization such as video recommendations */ + personalization_storage?: ConsentStatusString; + /** + * Enables storage related to security such as authentication functionality, fraud prevention, + * and other user protection. + */ + security_storage?: ConsentStatusString; + [key: string]: unknown; +} + /* eslint-enable camelcase */ + +/** + * Whether a particular consent type has been granted or denied. + * @public + */ +export type ConsentStatusString = 'granted' | 'denied'; diff --git a/packages/analytics/src/types.ts b/packages/analytics/src/types.ts index b3f6b8cf116..3b615c1c011 100644 --- a/packages/analytics/src/types.ts +++ b/packages/analytics/src/types.ts @@ -15,7 +15,12 @@ * limitations under the License. */ -import { ControlParams, EventParams, CustomParams } from './public-types'; +import { + ControlParams, + EventParams, + CustomParams, + ConsentSettings +} from './public-types'; /** * Encapsulates metadata concerning throttled fetch requests. @@ -63,6 +68,11 @@ export interface Gtag { eventName: string, eventParams?: ControlParams | EventParams | CustomParams ): void; + ( + command: 'consent', + subCommand: 'default' | 'update', + consentSettings: ConsentSettings + ): void; } export type DataLayer = IArguments[];