Skip to content

Commit

Permalink
Add function setConsent() to set end user consent state for web apps …
Browse files Browse the repository at this point in the history
…in Firebase Analytics (#6376)

* Add initial draft of setConsent logic

* Update gtag wrappers to accommodate consent command

* Update API reports

* Add changeset

* Update types, documentation and functionality

* Update API reports

* Update comments and rename type

* Add tests and update public type docs.

* Add back 'set' test

* Update API reports

* Add hyphen after param name in jsdoc
  • Loading branch information
dwyfrequency authored Jun 29, 2022
1 parent 47fefc2 commit 1d3a34d
Show file tree
Hide file tree
Showing 13 changed files with 241 additions and 12 deletions.
5 changes: 5 additions & 0 deletions .changeset/shiny-bats-reflect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@firebase/analytics': minor
---

Add function `setConsent()` to set the applicable end user "consent" state.
17 changes: 17 additions & 0 deletions common/api-review/analytics.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;

Expand Down
33 changes: 32 additions & 1 deletion packages/analytics/src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,19 @@ import { getFullApp } from '../testing/get-fake-firebase-services';
import {
getAnalytics,
initializeAnalytics,
setConsent,
setDefaultEventParameters
} from './api';
import { FirebaseApp, deleteApp } from '@firebase/app';
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();
Expand Down Expand Up @@ -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
);
});
});
22 changes: 21 additions & 1 deletion packages/analytics/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
Analytics,
AnalyticsCallOptions,
AnalyticsSettings,
ConsentSettings,
CustomParams,
EventNameString,
EventParams
Expand All @@ -48,6 +49,7 @@ import {
setUserId as internalSetUserId,
setUserProperties as internalSetUserProperties,
setAnalyticsCollectionEnabled as internalSetAnalyticsCollectionEnabled,
_setConsentDefaultForInit,
_setDefaultEventParametersForInit
} from './functions';
import { ERROR_FACTORY, AnalyticsError } from './errors';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -734,3 +736,21 @@ export function logEvent(
* @public
*/
export type CustomEventName<T> = 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);
}
}
3 changes: 2 additions & 1 deletion packages/analytics/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
27 changes: 26 additions & 1 deletion packages/analytics/src/functions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
});
});
});
20 changes: 19 additions & 1 deletion packages/analytics/src/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import {
AnalyticsCallOptions,
CustomParams,
ControlParams,
EventParams
EventParams,
ConsentSettings
} from './public-types';
import { Gtag } from './types';
import { GtagCommand } from './constants';
Expand Down Expand Up @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions packages/analytics/src/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string>();
wrapOrCreateGtag(
Expand Down
21 changes: 16 additions & 5 deletions packages/analytics/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<void> {
try {
// If event, check that relevant initialization promises have completed.
Expand All @@ -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.
Expand All @@ -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);
Expand Down
30 changes: 29 additions & 1 deletion packages/analytics/src/initialize-analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 8 additions & 0 deletions packages/analytics/src/initialize-analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down
Loading

0 comments on commit 1d3a34d

Please sign in to comment.