Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add function setConsent() to set end user consent state for web apps in Firebase Analytics #6376

Merged
merged 13 commits into from
Jun 29, 2022
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.
22 changes: 22 additions & 0 deletions common/api-review/analytics.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,25 @@ export interface AnalyticsSettings {
config?: GtagConfigParams | EventParams;
}

// @public
export interface ConsentSettings {
// (undocumented)
[key: string]: unknown;
// (undocumented)
ad_storage?: ConsentStatusString;
// (undocumented)
analytics_storage?: ConsentStatusString;
// (undocumented)
functionality_storage?: ConsentStatusString;
// (undocumented)
personalization_storage?: ConsentStatusString;
// (undocumented)
security_storage?: ConsentStatusString;
}

// @public
export type ConsentStatusString = 'granted' | 'denied';

// @public
export interface ControlParams {
// (undocumented)
Expand Down Expand Up @@ -388,6 +407,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
);
});
});
20 changes: 20 additions & 0 deletions 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 @@ -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.
dwyfrequency marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, I just ran build on this branch and noticed warnings from api-documenter that consentSettings needs to be followed by a hyphen here, and also customParams on line 236, which I think was from a previous PR.

*/
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(
dwyfrequency marked this conversation as resolved.
Show resolved Hide resolved
consentSettings?: ConsentSettings
): void {
defaultConsentSettingsForInit = consentSettings;
}

/**
* Sets the variable `defaultEventParametersForInit` for use in the initialization of
* analytics.
Expand Down
13 changes: 11 additions & 2 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 @@ -213,7 +214,11 @@ describe('Gtag wrapping functions', () => {
expect((window['dataLayer'] as DataLayer).length).to.equal(1);
});

it('new window.gtag function does not wait when sending "set" calls', async () => {
it('new window.gtag function does not wait when sending "consent" calls', async () => {
dwyfrequency marked this conversation as resolved.
Show resolved Hide resolved
const consentParameters: ConsentSettings = {
'analytics_storage': 'granted',
'functionality_storage': 'denied'
};
wrapOrCreateGtag(
{ [fakeAppId]: Promise.resolve(fakeMeasurementId) },
fakeDynamicConfigPromises,
Expand All @@ -222,7 +227,11 @@ describe('Gtag wrapping functions', () => {
'gtag'
);
window['dataLayer'] = [];
(window['gtag'] as Gtag)(GtagCommand.SET, { 'language': 'en' });
(window['gtag'] as Gtag)(
GtagCommand.CONSENT,
'update',
consentParameters
);
expect((window['dataLayer'] as DataLayer).length).to.equal(1);
});

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);
dwyfrequency marked this conversation as resolved.
Show resolved Hide resolved
_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