diff --git a/common/api-review/analytics-exp.api.md b/common/api-review/analytics-exp.api.md index cbdf8be9b83..35172fa0f8f 100644 --- a/common/api-review/analytics-exp.api.md +++ b/common/api-review/analytics-exp.api.md @@ -16,6 +16,11 @@ export interface AnalyticsCallOptions { global: boolean; } +// @public +export interface AnalyticsOptions { + config?: GtagConfigParams | EventParams; +} + // @public export interface ControlParams { // (undocumented) @@ -45,6 +50,8 @@ export type EventNameString = 'add_payment_info' | 'add_shipping_info' | 'add_to // @public export interface EventParams { + // (undocumented) + [key: string]: unknown; // (undocumented) affiliation?: string; // (undocumented) @@ -110,6 +117,31 @@ export interface EventParams { // @public export function getAnalytics(app?: FirebaseApp): Analytics; +// @public +export interface GtagConfigParams { + 'allow_google_signals?': boolean; + // (undocumented) + [key: string]: unknown; + 'allow_ad_personalization_signals'?: boolean; + 'anonymize_ip'?: boolean; + 'cookie_domain'?: string; + 'cookie_expires'?: number; + 'cookie_flags'?: string; + 'cookie_prefix'?: string; + 'cookie_update'?: boolean; + 'custom_map'?: { + [key: string]: unknown; + }; + 'link_attribution'?: boolean; + 'page_location'?: string; + 'page_path'?: string; + 'page_title'?: string; + 'send_page_view'?: boolean; +} + +// @public +export function initializeAnalytics(app: FirebaseApp, options?: AnalyticsOptions): Analytics; + // @public export function isSupported(): Promise; diff --git a/packages-exp/analytics-exp/src/api.ts b/packages-exp/analytics-exp/src/api.ts index e99332aacae..c0ff7f337fc 100644 --- a/packages-exp/analytics-exp/src/api.ts +++ b/packages-exp/analytics-exp/src/api.ts @@ -21,6 +21,7 @@ import { _getProvider, FirebaseApp, getApp } from '@firebase/app-exp'; import { Analytics, AnalyticsCallOptions, + AnalyticsOptions, CustomParams, EventNameString, EventParams @@ -47,6 +48,7 @@ import { setUserProperties as internalSetUserProperties, setAnalyticsCollectionEnabled as internalSetAnalyticsCollectionEnabled } from './functions'; +import { ERROR_FACTORY, AnalyticsError } from './errors'; export { settings } from './factory'; @@ -70,7 +72,34 @@ export function getAnalytics(app: FirebaseApp = getApp()): Analytics { app, ANALYTICS_TYPE ); - const analyticsInstance = analyticsProvider.getImmediate(); + + if (analyticsProvider.isInitialized()) { + return analyticsProvider.getImmediate(); + } + + return initializeAnalytics(app); +} + +/** + * Returns a Firebase Analytics instance for the given app. + * + * @public + * + * @param app - The FirebaseApp to use. + */ +export function initializeAnalytics( + app: FirebaseApp, + options: AnalyticsOptions = {} +): Analytics { + // Dependencies + const analyticsProvider: Provider<'analytics-exp'> = _getProvider( + app, + ANALYTICS_TYPE + ); + if (analyticsProvider.isInitialized()) { + throw ERROR_FACTORY.create(AnalyticsError.ALREADY_INITIALIZED); + } + const analyticsInstance = analyticsProvider.initialize({ options }); return analyticsInstance; } diff --git a/packages-exp/analytics-exp/src/errors.ts b/packages-exp/analytics-exp/src/errors.ts index 6381eac4871..b500f88d013 100644 --- a/packages-exp/analytics-exp/src/errors.ts +++ b/packages-exp/analytics-exp/src/errors.ts @@ -20,6 +20,7 @@ import { ErrorFactory, ErrorMap } from '@firebase/util'; export const enum AnalyticsError { ALREADY_EXISTS = 'already-exists', ALREADY_INITIALIZED = 'already-initialized', + ALREADY_INITIALIZED_SETTINGS = 'already-initialized-settings', INTEROP_COMPONENT_REG_FAILED = 'interop-component-reg-failed', INVALID_ANALYTICS_CONTEXT = 'invalid-analytics-context', INDEXEDDB_UNAVAILABLE = 'indexeddb-unavailable', @@ -35,6 +36,10 @@ const ERRORS: ErrorMap = { ' already exists. ' + 'Only one Firebase Analytics instance can be created for each appId.', [AnalyticsError.ALREADY_INITIALIZED]: + 'Firebase Analytics has already been initialized. ' + + 'initializeAnalytics() must only be called once. getAnalytics() can be used ' + + 'to get a reference to the already-intialized instance.', + [AnalyticsError.ALREADY_INITIALIZED_SETTINGS]: 'Firebase Analytics has already been initialized.' + 'settings() must be called before initializing any Analytics instance' + 'or it will have no effect.', diff --git a/packages-exp/analytics-exp/src/factory.ts b/packages-exp/analytics-exp/src/factory.ts index 4d12ad5c5be..e43535a3558 100644 --- a/packages-exp/analytics-exp/src/factory.ts +++ b/packages-exp/analytics-exp/src/factory.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { SettingsOptions, Analytics } from './public-types'; +import { SettingsOptions, Analytics, AnalyticsOptions } from './public-types'; import { Gtag, DynamicConfig, MinimalDynamicConfig } from './types'; import { getOrCreateDataLayer, wrapOrCreateGtag } from './helpers'; import { AnalyticsError, ERROR_FACTORY } from './errors'; @@ -176,7 +176,8 @@ function warnOnBrowserContextMismatch(): void { */ export function factory( app: FirebaseApp, - installations: _FirebaseInstallationsInternal + installations: _FirebaseInstallationsInternal, + options?: AnalyticsOptions ): AnalyticsService { warnOnBrowserContextMismatch(); const appId = app.options.appId; @@ -226,7 +227,8 @@ export function factory( measurementIdToAppId, installations, gtagCoreFunction, - dataLayerName + dataLayerName, + options ); const analyticsInstance: AnalyticsService = new AnalyticsService(app); diff --git a/packages-exp/analytics-exp/src/index.ts b/packages-exp/analytics-exp/src/index.ts index 0ec40773f81..d4050799802 100644 --- a/packages-exp/analytics-exp/src/index.ts +++ b/packages-exp/analytics-exp/src/index.ts @@ -28,7 +28,8 @@ import { ANALYTICS_TYPE } from './constants'; import { Component, ComponentType, - ComponentContainer + ComponentContainer, + InstanceFactoryOptions } from '@firebase/component'; import { ERROR_FACTORY, AnalyticsError } from './errors'; import { logEvent } from './api'; @@ -46,14 +47,14 @@ function registerAnalytics(): void { _registerComponent( new Component( ANALYTICS_TYPE, - container => { + (container, { options: analyticsOptions }: InstanceFactoryOptions) => { // getImmediate for FirebaseApp will always succeed const app = container.getProvider('app-exp').getImmediate(); const installations = container .getProvider('installations-exp-internal') .getImmediate(); - return factory(app, installations); + return factory(app, installations, analyticsOptions); }, ComponentType.PUBLIC ) diff --git a/packages-exp/analytics-exp/src/initialize-analytics.test.ts b/packages-exp/analytics-exp/src/initialize-analytics.test.ts index a4bea39eb3c..93d2bc8d4b9 100644 --- a/packages-exp/analytics-exp/src/initialize-analytics.test.ts +++ b/packages-exp/analytics-exp/src/initialize-analytics.test.ts @@ -48,7 +48,7 @@ function stubFetch(): void { fetchStub.returns(Promise.resolve(mockResponse)); } -describe('initializeIds()', () => { +describe('initializeAnalytics()', () => { const gtagStub: SinonStub = stub(); const dynamicPromisesList: Array> = []; const measurementIdToAppId: { [key: string]: string } = {}; @@ -79,6 +79,24 @@ describe('initializeIds()', () => { update: true }); }); + it('calls gtag config with options if provided', async () => { + stubFetch(); + await initializeAnalytics( + app, + dynamicPromisesList, + measurementIdToAppId, + fakeInstallations, + gtagStub, + 'dataLayer', + { config: { 'send_page_view': false } } + ); + expect(gtagStub).to.be.calledWith(GtagCommand.CONFIG, fakeMeasurementId, { + 'firebase_id': fakeFid, + 'origin': 'firebase', + update: true, + 'send_page_view': false + }); + }); it('puts dynamic fetch promise into dynamic promises list', async () => { stubFetch(); await initializeAnalytics( diff --git a/packages-exp/analytics-exp/src/initialize-analytics.ts b/packages-exp/analytics-exp/src/initialize-analytics.ts index c9577735b48..3eab2f3da9b 100644 --- a/packages-exp/analytics-exp/src/initialize-analytics.ts +++ b/packages-exp/analytics-exp/src/initialize-analytics.ts @@ -27,6 +27,7 @@ import { } from '@firebase/util'; import { ERROR_FACTORY, AnalyticsError } from './errors'; import { findGtagScriptOnPage, insertScriptTag } from './helpers'; +import { AnalyticsOptions } from './public-types'; async function validateIndexedDB(): Promise { if (!isIndexedDBAvailable()) { @@ -72,7 +73,8 @@ export async function initializeAnalytics( measurementIdToAppId: { [key: string]: string }, installations: _FirebaseInstallationsInternal, gtagCore: Gtag, - dataLayerName: string + dataLayerName: string, + options?: AnalyticsOptions ): Promise { const dynamicConfigPromise = fetchDynamicConfigWithRetry(app); // Once fetched, map measurementIds to appId, for ease of lookup in wrapped gtag function. @@ -121,12 +123,13 @@ export async function initializeAnalytics( // We keep it together with other initialization logic for better code structure. // eslint-disable-next-line @typescript-eslint/no-explicit-any (gtagCore as any)('js', new Date()); + // User config added first. We don't want users to accidentally overwrite + // base Firebase config properties. + const configProperties: Record = options?.config ?? {}; - const configProperties: { [key: string]: string | boolean } = { - // guard against developers accidentally setting properties with prefix `firebase_` - [ORIGIN_KEY]: 'firebase', - update: true - }; + // guard against developers accidentally setting properties with prefix `firebase_` + configProperties[ORIGIN_KEY] = 'firebase'; + configProperties.update = true; if (fid != null) { configProperties[GA_FID_KEY] = fid; diff --git a/packages-exp/analytics-exp/src/public-types.ts b/packages-exp/analytics-exp/src/public-types.ts index fe7213af164..13076472589 100644 --- a/packages-exp/analytics-exp/src/public-types.ts +++ b/packages-exp/analytics-exp/src/public-types.ts @@ -17,6 +17,98 @@ import { FirebaseApp } from '@firebase/app-exp'; +/** + * A set of common Analytics config settings recognized by + * gtag. + * @public + */ +export interface GtagConfigParams { + /** + * Whether or not a page view should be sent. + * If set to true (default), a page view is automatically sent upon initialization + * of analytics. + * See https://developers.google.com/analytics/devguides/collection/gtagjs/pages + */ + 'send_page_view'?: boolean; + /** + * The title of the page. + * See https://developers.google.com/analytics/devguides/collection/gtagjs/pages + */ + 'page_title'?: string; + /** + * The path to the page. If overridden, this value must start with a / character. + * See https://developers.google.com/analytics/devguides/collection/gtagjs/pages + */ + 'page_path'?: string; + /** + * The URL of the page. + * See https://developers.google.com/analytics/devguides/collection/gtagjs/pages + */ + 'page_location'?: string; + /** + * Defaults to `auto`. + * See https://developers.google.com/analytics/devguides/collection/gtagjs/cookies-user-id + */ + 'cookie_domain'?: string; + /** + * Defaults to 63072000 (two years, in seconds). + * See https://developers.google.com/analytics/devguides/collection/gtagjs/cookies-user-id + */ + 'cookie_expires'?: number; + /** + * Defaults to `_ga`. + * See https://developers.google.com/analytics/devguides/collection/gtagjs/cookies-user-id + */ + 'cookie_prefix'?: string; + /** + * If set to true, will update cookies on each page load. + * Defaults to true. + * See https://developers.google.com/analytics/devguides/collection/gtagjs/cookies-user-id + */ + 'cookie_update'?: boolean; + /** + * Appends additional flags to the cookie when set. + * See https://developers.google.com/analytics/devguides/collection/gtagjs/cookies-user-id + */ + 'cookie_flags'?: string; + /** + * If set to false, disables all advertising features with gtag.js. + * See https://developers.google.com/analytics/devguides/collection/gtagjs/display-features + */ + 'allow_google_signals?': boolean; + /** + * If set to false, disables all advertising personalization with gtag.js. + * See https://developers.google.com/analytics/devguides/collection/gtagjs/display-features + */ + 'allow_ad_personalization_signals'?: boolean; + /** + * See https://developers.google.com/analytics/devguides/collection/gtagjs/enhanced-link-attribution + */ + 'link_attribution'?: boolean; + /** + * If set to true, anonymizes IP addresses for all events. + * See https://developers.google.com/analytics/devguides/collection/gtagjs/ip-anonymization + */ + 'anonymize_ip'?: boolean; + /** + * Custom dimensions and metrics. + * See https://developers.google.com/analytics/devguides/collection/gtagjs/custom-dims-mets + */ + 'custom_map'?: { [key: string]: unknown }; + [key: string]: unknown; +} + +/** + * Analytics initialization options. + * @public + */ +export interface AnalyticsOptions { + /** + * Params to be passed in the initial gtag config call during analytics initialization. + */ + config?: GtagConfigParams | EventParams; +} + /** * Additional options that can be passed to Firebase Analytics method * calls such as `logEvent`, `setCurrentScreen`, etc. @@ -31,13 +123,12 @@ export interface AnalyticsCallOptions { } /** - * The Firebase Analytics service interface. - * + * An instance of Firebase Analytics. * @public */ export interface Analytics { /** - * The FirebaseApp this Functions instance is associated with. + * The FirebaseApp this Analytics instance is associated with. */ app: FirebaseApp; } @@ -61,6 +152,7 @@ export interface SettingsOptions { export interface CustomParams { [key: string]: unknown; } + /** * Type for standard gtag.js event names. `logEvent` also accepts any * custom string and interprets it as a custom event name. @@ -96,14 +188,14 @@ export type EventNameString = | 'view_search_results'; /** - * Currency field used by some Analytics events. + * Standard analytics currency type. * @public */ export type Currency = string | number; /* eslint-disable camelcase */ /** - * Item field used by some Analytics events. + * Standard analytics `Item` type. * @public */ export interface Item { @@ -204,5 +296,6 @@ export interface EventParams { page_title?: string; page_location?: string; page_path?: string; + [key: string]: unknown; } /* eslint-enable camelcase */ diff --git a/packages-exp/analytics-exp/testing/integration-tests/integration.ts b/packages-exp/analytics-exp/testing/integration-tests/integration.ts index 9ea64a1b65a..e95a709b919 100644 --- a/packages-exp/analytics-exp/testing/integration-tests/integration.ts +++ b/packages-exp/analytics-exp/testing/integration-tests/integration.ts @@ -17,10 +17,11 @@ import { initializeApp, deleteApp, FirebaseApp } from '@firebase/app-exp'; import '@firebase/installations-exp'; -import { getAnalytics, logEvent } from '../../src/index'; +import { getAnalytics, initializeAnalytics, logEvent } from '../../src/index'; import '../setup'; import { expect } from 'chai'; import { stub } from 'sinon'; +import { AnalyticsError } from '../../src/errors'; let config: Record; try { @@ -33,39 +34,67 @@ try { } const RETRY_INTERVAL = 1000; +const TIMEOUT_MILLIS = 20000; + +async function checkForEventCalls(retryCount = 0): Promise { + if (retryCount > TIMEOUT_MILLIS / RETRY_INTERVAL) { + return Promise.resolve([]); + } + await new Promise(resolve => setTimeout(resolve, RETRY_INTERVAL)); + const resources = performance.getEntriesByType('resource'); + performance.clearResourceTimings(); + const callsWithEvent = resources.filter( + resource => + resource.name.includes('google-analytics.com') && + resource.name.includes('en=login') + ); + if (callsWithEvent.length === 0) { + return checkForEventCalls(retryCount + 1); + } else { + return callsWithEvent; + } +} describe('FirebaseAnalytics Integration Smoke Tests', () => { let app: FirebaseApp; - afterEach(() => deleteApp(app)); - it('logEvent() sends correct network request.', async () => { - app = initializeApp(config); - logEvent(getAnalytics(app), 'login', { method: 'email' }); - async function checkForEventCalls(): Promise { - await new Promise(resolve => setTimeout(resolve, RETRY_INTERVAL)); - const resources = performance.getEntriesByType('resource'); - const callsWithEvent = resources.filter( - resource => - resource.name.includes('google-analytics.com') && - resource.name.includes('en=login') - ); - if (callsWithEvent.length === 0) { - return checkForEventCalls(); - } else { - return callsWithEvent.length; - } - } - const eventCallCount = await checkForEventCalls(); - expect(eventCallCount).to.equal(1); + describe('Using getAnalytics()', () => { + afterEach(() => deleteApp(app)); + it('logEvent() sends correct network request.', async () => { + app = initializeApp(config); + logEvent(getAnalytics(app), 'login', { method: 'phone' }); + const eventCalls = await checkForEventCalls(); + expect(eventCalls.length).to.equal(1); + expect(eventCalls[0].name).to.include('method=phone'); + }); + it("Warns if measurement ID doesn't match.", done => { + const warnStub = stub(console, 'warn').callsFake(() => { + expect(warnStub.args[0][1]).to.include('does not match'); + done(); + }); + app = initializeApp({ + ...config, + measurementId: 'wrong-id' + }); + getAnalytics(app); + }); }); - it("Warns if measurement ID doesn't match.", done => { - const warnStub = stub(console, 'warn').callsFake(() => { - expect(warnStub.args[0][1]).to.include('does not match'); - done(); + describe('Using initializeAnalytics()', () => { + it('logEvent() sends correct network request.', async () => { + app = initializeApp(config); + logEvent(initializeAnalytics(app), 'login', { method: 'email' }); + const eventCalls = await checkForEventCalls(); + expect(eventCalls.length).to.equal(1); + expect(eventCalls[0].name).to.include('method=email'); }); - app = initializeApp({ - ...config, - measurementId: 'wrong-id' + it('getAnalytics() does not throw if called after initializeAnalytics().', async () => { + const analyticsInstance = getAnalytics(app); + expect(analyticsInstance.app).to.equal(app); + }); + it('initializeAnalytics() throws if called more than once.', async () => { + expect(() => initializeAnalytics(app)).to.throw( + AnalyticsError.ALREADY_INITIALIZED + ); + await deleteApp(app); }); - getAnalytics(app); }); });