Skip to content

Commit 1d3a34d

Browse files
authored
Add function setConsent() to set end user consent state for web apps 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
1 parent 47fefc2 commit 1d3a34d

13 files changed

+241
-12
lines changed

.changeset/shiny-bats-reflect.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@firebase/analytics': minor
3+
---
4+
5+
Add function `setConsent()` to set the applicable end user "consent" state.

common/api-review/analytics.api.md

+17
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,20 @@ export interface AnalyticsSettings {
2121
config?: GtagConfigParams | EventParams;
2222
}
2323

24+
// @public
25+
export interface ConsentSettings {
26+
// (undocumented)
27+
[key: string]: unknown;
28+
ad_storage?: ConsentStatusString;
29+
analytics_storage?: ConsentStatusString;
30+
functionality_storage?: ConsentStatusString;
31+
personalization_storage?: ConsentStatusString;
32+
security_storage?: ConsentStatusString;
33+
}
34+
35+
// @public
36+
export type ConsentStatusString = 'granted' | 'denied';
37+
2438
// @public
2539
export interface ControlParams {
2640
// (undocumented)
@@ -388,6 +402,9 @@ export interface Promotion {
388402
// @public
389403
export function setAnalyticsCollectionEnabled(analyticsInstance: Analytics, enabled: boolean): void;
390404

405+
// @public
406+
export function setConsent(consentSettings: ConsentSettings): void;
407+
391408
// @public @deprecated
392409
export function setCurrentScreen(analyticsInstance: Analytics, screenName: string, options?: AnalyticsCallOptions): void;
393410

packages/analytics/src/api.test.ts

+32-1
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,19 @@ import { getFullApp } from '../testing/get-fake-firebase-services';
2222
import {
2323
getAnalytics,
2424
initializeAnalytics,
25+
setConsent,
2526
setDefaultEventParameters
2627
} from './api';
2728
import { FirebaseApp, deleteApp } from '@firebase/app';
2829
import { AnalyticsError } from './errors';
2930
import * as init from './initialize-analytics';
3031
const fakeAppParams = { appId: 'abcdefgh12345:23405', apiKey: 'AAbbCCdd12345' };
3132
import * as factory from './factory';
32-
import { defaultEventParametersForInit } from './functions';
33+
import {
34+
defaultConsentSettingsForInit,
35+
defaultEventParametersForInit
36+
} from './functions';
37+
import { ConsentSettings } from './public-types';
3338

3439
describe('FirebaseAnalytics API tests', () => {
3540
let initStub: SinonStub = stub();
@@ -123,4 +128,30 @@ describe('FirebaseAnalytics API tests', () => {
123128
eventParametersForInit
124129
);
125130
});
131+
it('setConsent() updates defaultConsentSettingsForInit if gtag does not exist ', () => {
132+
const consentParametersForInit: ConsentSettings = {
133+
'analytics_storage': 'granted',
134+
'functionality_storage': 'denied'
135+
};
136+
stub(factory, 'wrappedGtagFunction').get(() => undefined);
137+
app = getFullApp(fakeAppParams);
138+
setConsent(consentParametersForInit);
139+
expect(defaultConsentSettingsForInit).to.deep.equal(
140+
consentParametersForInit
141+
);
142+
});
143+
it('setConsent() calls gtag consent "update" if wrappedGtagFunction exists', () => {
144+
const consentParametersForInit: ConsentSettings = {
145+
'analytics_storage': 'granted',
146+
'functionality_storage': 'denied'
147+
};
148+
stub(factory, 'wrappedGtagFunction').get(() => wrappedGtag);
149+
app = getFullApp(fakeAppParams);
150+
setConsent(consentParametersForInit);
151+
expect(wrappedGtag).to.have.been.calledWithExactly(
152+
'consent',
153+
'update',
154+
consentParametersForInit
155+
);
156+
});
126157
});

packages/analytics/src/api.ts

+21-1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
Analytics,
2323
AnalyticsCallOptions,
2424
AnalyticsSettings,
25+
ConsentSettings,
2526
CustomParams,
2627
EventNameString,
2728
EventParams
@@ -48,6 +49,7 @@ import {
4849
setUserId as internalSetUserId,
4950
setUserProperties as internalSetUserProperties,
5051
setAnalyticsCollectionEnabled as internalSetAnalyticsCollectionEnabled,
52+
_setConsentDefaultForInit,
5153
_setDefaultEventParametersForInit
5254
} from './functions';
5355
import { ERROR_FACTORY, AnalyticsError } from './errors';
@@ -231,7 +233,7 @@ export function setAnalyticsCollectionEnabled(
231233
* With gtag's "set" command, the values passed persist on the current page and are passed with
232234
* all subsequent events.
233235
* @public
234-
* @param customParams Any custom params the user may pass to gtag.js.
236+
* @param customParams - Any custom params the user may pass to gtag.js.
235237
*/
236238
export function setDefaultEventParameters(customParams: CustomParams): void {
237239
// Check if reference to existing gtag function on window object exists
@@ -734,3 +736,21 @@ export function logEvent(
734736
* @public
735737
*/
736738
export type CustomEventName<T> = T extends EventNameString ? never : T;
739+
740+
/**
741+
* Sets the applicable end user consent state for this web app across all gtag references once
742+
* Firebase Analytics is initialized.
743+
*
744+
* Use the {@link ConsentSettings} to specify individual consent type values. By default consent
745+
* types are set to "granted".
746+
* @public
747+
* @param consentSettings - Maps the applicable end user consent state for gtag.js.
748+
*/
749+
export function setConsent(consentSettings: ConsentSettings): void {
750+
// Check if reference to existing gtag function on window object exists
751+
if (wrappedGtagFunction) {
752+
wrappedGtagFunction(GtagCommand.CONSENT, 'update', consentSettings);
753+
} else {
754+
_setConsentDefaultForInit(consentSettings);
755+
}
756+
}

packages/analytics/src/constants.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,6 @@ export const GTAG_URL = 'https://www.googletagmanager.com/gtag/js';
3434
export const enum GtagCommand {
3535
EVENT = 'event',
3636
SET = 'set',
37-
CONFIG = 'config'
37+
CONFIG = 'config',
38+
CONSENT = 'consent'
3839
}

packages/analytics/src/functions.test.ts

+26-1
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,12 @@ import {
2525
setUserProperties,
2626
setAnalyticsCollectionEnabled,
2727
defaultEventParametersForInit,
28-
_setDefaultEventParametersForInit
28+
_setDefaultEventParametersForInit,
29+
_setConsentDefaultForInit,
30+
defaultConsentSettingsForInit
2931
} from './functions';
3032
import { GtagCommand } from './constants';
33+
import { ConsentSettings } from './public-types';
3134

3235
const fakeMeasurementId = 'abcd-efgh-ijkl';
3336
const fakeInitializationPromise = Promise.resolve(fakeMeasurementId);
@@ -192,4 +195,26 @@ describe('FirebaseAnalytics methods', () => {
192195
...additionalParams
193196
});
194197
});
198+
it('_setConsentDefaultForInit() stores individual params correctly', async () => {
199+
const consentParametersForInit: ConsentSettings = {
200+
'analytics_storage': 'granted',
201+
'functionality_storage': 'denied'
202+
};
203+
_setConsentDefaultForInit(consentParametersForInit);
204+
expect(defaultConsentSettingsForInit).to.deep.equal(
205+
consentParametersForInit
206+
);
207+
});
208+
it('_setConsentDefaultForInit() replaces previous params with new params', async () => {
209+
const consentParametersForInit: ConsentSettings = {
210+
'analytics_storage': 'granted',
211+
'functionality_storage': 'denied'
212+
};
213+
const additionalParams = { 'wait_for_update': 500 };
214+
_setConsentDefaultForInit(consentParametersForInit);
215+
_setConsentDefaultForInit(additionalParams);
216+
expect(defaultConsentSettingsForInit).to.deep.equal({
217+
...additionalParams
218+
});
219+
});
195220
});

packages/analytics/src/functions.ts

+19-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ import {
1919
AnalyticsCallOptions,
2020
CustomParams,
2121
ControlParams,
22-
EventParams
22+
EventParams,
23+
ConsentSettings
2324
} from './public-types';
2425
import { Gtag } from './types';
2526
import { GtagCommand } from './constants';
@@ -149,6 +150,23 @@ export async function setAnalyticsCollectionEnabled(
149150
window[`ga-disable-${measurementId}`] = !enabled;
150151
}
151152

153+
/**
154+
* Consent parameters to default to during 'gtag' initialization.
155+
*/
156+
export let defaultConsentSettingsForInit: ConsentSettings | undefined;
157+
158+
/**
159+
* Sets the variable {@link defaultConsentSettingsForInit} for use in the initialization of
160+
* analytics.
161+
*
162+
* @param consentSettings Maps the applicable end user consent state for gtag.js.
163+
*/
164+
export function _setConsentDefaultForInit(
165+
consentSettings?: ConsentSettings
166+
): void {
167+
defaultConsentSettingsForInit = consentSettings;
168+
}
169+
152170
/**
153171
* Sets the variable `defaultEventParametersForInit` for use in the initialization of
154172
* analytics.

packages/analytics/src/helpers.test.ts

+22
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
} from './helpers';
2929
import { GtagCommand } from './constants';
3030
import { Deferred } from '@firebase/util';
31+
import { ConsentSettings } from './public-types';
3132

3233
const fakeMeasurementId = 'abcd-efgh-ijkl';
3334
const fakeAppId = 'my-test-app-1234';
@@ -226,6 +227,27 @@ describe('Gtag wrapping functions', () => {
226227
expect((window['dataLayer'] as DataLayer).length).to.equal(1);
227228
});
228229

230+
it('new window.gtag function does not wait when sending "consent" calls', async () => {
231+
const consentParameters: ConsentSettings = {
232+
'analytics_storage': 'granted',
233+
'functionality_storage': 'denied'
234+
};
235+
wrapOrCreateGtag(
236+
{ [fakeAppId]: Promise.resolve(fakeMeasurementId) },
237+
fakeDynamicConfigPromises,
238+
{},
239+
'dataLayer',
240+
'gtag'
241+
);
242+
window['dataLayer'] = [];
243+
(window['gtag'] as Gtag)(
244+
GtagCommand.CONSENT,
245+
'update',
246+
consentParameters
247+
);
248+
expect((window['dataLayer'] as DataLayer).length).to.equal(1);
249+
});
250+
229251
it('new window.gtag function waits for initialization promise when sending "config" calls', async () => {
230252
const initPromise1 = new Deferred<string>();
231253
wrapOrCreateGtag(

packages/analytics/src/helpers.ts

+16-5
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,19 @@
1515
* limitations under the License.
1616
*/
1717

18-
import { CustomParams, ControlParams, EventParams } from './public-types';
18+
import {
19+
CustomParams,
20+
ControlParams,
21+
EventParams,
22+
ConsentSettings
23+
} from './public-types';
1924
import { DynamicConfig, DataLayer, Gtag, MinimalDynamicConfig } from './types';
2025
import { GtagCommand, GTAG_URL } from './constants';
2126
import { logger } from './logger';
2227

28+
// Possible parameter types for gtag 'event' and 'config' commands
29+
type GtagConfigOrEventParams = ControlParams & EventParams & CustomParams;
30+
2331
/**
2432
* Makeshift polyfill for Promise.allSettled(). Resolves when all promises
2533
* have either resolved or rejected.
@@ -219,9 +227,9 @@ function wrapGtag(
219227
* @param gtagParams Params if event is EVENT/CONFIG.
220228
*/
221229
async function gtagWrapper(
222-
command: 'config' | 'set' | 'event',
230+
command: 'config' | 'set' | 'event' | 'consent',
223231
idOrNameOrParams: string | ControlParams,
224-
gtagParams?: ControlParams & EventParams & CustomParams
232+
gtagParams?: GtagConfigOrEventParams | ConsentSettings
225233
): Promise<void> {
226234
try {
227235
// If event, check that relevant initialization promises have completed.
@@ -232,7 +240,7 @@ function wrapGtag(
232240
initializationPromisesMap,
233241
dynamicConfigPromisesList,
234242
idOrNameOrParams as string,
235-
gtagParams
243+
gtagParams as GtagConfigOrEventParams
236244
);
237245
} else if (command === GtagCommand.CONFIG) {
238246
// If CONFIG, second arg must be measurementId.
@@ -242,8 +250,11 @@ function wrapGtag(
242250
dynamicConfigPromisesList,
243251
measurementIdToAppId,
244252
idOrNameOrParams as string,
245-
gtagParams
253+
gtagParams as GtagConfigOrEventParams
246254
);
255+
} else if (command === GtagCommand.CONSENT) {
256+
// If CONFIG, second arg must be measurementId.
257+
gtagCore(GtagCommand.CONSENT, 'update', gtagParams as ConsentSettings);
247258
} else {
248259
// If SET, second arg must be params.
249260
gtagCore(GtagCommand.SET, idOrNameOrParams as CustomParams);

packages/analytics/src/initialize-analytics.test.ts

+29-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,12 @@ import { Deferred } from '@firebase/util';
3030
import { _FirebaseInstallationsInternal } from '@firebase/installations';
3131
import { removeGtagScript } from '../testing/gtag-script-util';
3232
import { setDefaultEventParameters } from './api';
33-
import { defaultEventParametersForInit } from './functions';
33+
import {
34+
defaultConsentSettingsForInit,
35+
defaultEventParametersForInit,
36+
_setConsentDefaultForInit
37+
} from './functions';
38+
import { ConsentSettings } from './public-types';
3439

3540
const fakeMeasurementId = 'abcd-efgh-ijkl';
3641
const fakeFid = 'fid-1234-zyxw';
@@ -118,6 +123,29 @@ describe('initializeAnalytics()', () => {
118123
// defaultEventParametersForInit is reset after initialization.
119124
expect(defaultEventParametersForInit).to.equal(undefined);
120125
});
126+
it('calls gtag consent if there are default consent parameters', async () => {
127+
stubFetch();
128+
const consentParametersForInit: ConsentSettings = {
129+
'analytics_storage': 'granted',
130+
'functionality_storage': 'denied'
131+
};
132+
_setConsentDefaultForInit(consentParametersForInit);
133+
await _initializeAnalytics(
134+
app,
135+
dynamicPromisesList,
136+
measurementIdToAppId,
137+
fakeInstallations,
138+
gtagStub,
139+
'dataLayer'
140+
);
141+
expect(gtagStub).to.be.calledWith(
142+
GtagCommand.CONSENT,
143+
'default',
144+
consentParametersForInit
145+
);
146+
// defaultEventParametersForInit is reset after initialization.
147+
expect(defaultConsentSettingsForInit).to.equal(undefined);
148+
});
121149
it('puts dynamic fetch promise into dynamic promises list', async () => {
122150
stubFetch();
123151
await _initializeAnalytics(

packages/analytics/src/initialize-analytics.ts

+8
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import { ERROR_FACTORY, AnalyticsError } from './errors';
2929
import { findGtagScriptOnPage, insertScriptTag } from './helpers';
3030
import { AnalyticsSettings } from './public-types';
3131
import {
32+
defaultConsentSettingsForInit,
33+
_setConsentDefaultForInit,
3234
defaultEventParametersForInit,
3335
_setDefaultEventParametersForInit
3436
} from './functions';
@@ -122,6 +124,12 @@ export async function _initializeAnalytics(
122124
insertScriptTag(dataLayerName, dynamicConfig.measurementId);
123125
}
124126

127+
// Detects if there are consent settings that need to be configured.
128+
if (defaultConsentSettingsForInit) {
129+
gtagCore(GtagCommand.CONSENT, 'default', defaultConsentSettingsForInit);
130+
_setConsentDefaultForInit(undefined);
131+
}
132+
125133
// This command initializes gtag.js and only needs to be called once for the entire web app,
126134
// but since it is idempotent, we can call it multiple times.
127135
// We keep it together with other initialization logic for better code structure.

0 commit comments

Comments
 (0)