From fb3b095e4b7c8f57fdb3172bc039c84576abf290 Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Tue, 8 Sep 2020 10:55:40 -0700 Subject: [PATCH] Use Dynamic Measurement ID in Analytics (#2800) --- .changeset/swift-pillows-retire.md | 6 + .changeset/thin-rivers-relax.md | 6 + .changeset/witty-deers-study.md | 8 + config/ci.config.json | 3 +- packages/analytics-types/index.d.ts | 31 ++ packages/analytics/.eslintrc.js | 26 ++ packages/analytics/index.test.ts | 186 +++++++--- packages/analytics/index.ts | 2 +- packages/analytics/karma.integration.conf.js | 32 ++ packages/analytics/package.json | 12 +- packages/analytics/src/constants.ts | 7 +- packages/analytics/src/errors.ts | 46 ++- packages/analytics/src/factory.ts | 149 ++++++-- packages/analytics/src/functions.test.ts | 107 +++--- packages/analytics/src/functions.ts | 62 ++-- packages/analytics/src/get-config.test.ts | 259 ++++++++++++++ packages/analytics/src/get-config.ts | 322 ++++++++++++++++++ packages/analytics/src/helpers.test.ts | 191 +++++++---- packages/analytics/src/helpers.ts | 309 +++++++++++------ packages/analytics/src/initialize-ids.test.ts | 118 +++++++ packages/analytics/src/initialize-ids.ts | 93 +++++ .../testing/get-fake-firebase-services.ts | 12 +- .../testing/integration-tests/integration.ts | 62 ++++ packages/analytics/testing/setup.ts | 5 - .../src/client/retrying_client.ts | 3 +- .../remote-config/test/remote_config.test.ts | 6 +- packages/util/index.node.ts | 1 + packages/util/index.ts | 1 + packages/util/karma.conf.js | 4 +- packages/util/src/environment.ts | 2 +- .../src}/exponential_backoff.ts | 13 +- .../test}/exponential_backoff.test.ts | 2 +- 32 files changed, 1711 insertions(+), 375 deletions(-) create mode 100644 .changeset/swift-pillows-retire.md create mode 100644 .changeset/thin-rivers-relax.md create mode 100644 .changeset/witty-deers-study.md create mode 100644 packages/analytics/karma.integration.conf.js create mode 100644 packages/analytics/src/get-config.test.ts create mode 100644 packages/analytics/src/get-config.ts create mode 100644 packages/analytics/src/initialize-ids.test.ts create mode 100644 packages/analytics/src/initialize-ids.ts create mode 100644 packages/analytics/testing/integration-tests/integration.ts rename packages/{remote-config/src/client => util/src}/exponential_backoff.ts (86%) rename packages/{remote-config/test/client => util/test}/exponential_backoff.test.ts (97%) diff --git a/.changeset/swift-pillows-retire.md b/.changeset/swift-pillows-retire.md new file mode 100644 index 00000000000..3be0be9ef9e --- /dev/null +++ b/.changeset/swift-pillows-retire.md @@ -0,0 +1,6 @@ +--- +'@firebase/remote-config': patch +--- + +Moved `calculateBackoffMillis()` exponential backoff function to util and have remote-config +import it from util. diff --git a/.changeset/thin-rivers-relax.md b/.changeset/thin-rivers-relax.md new file mode 100644 index 00000000000..7602a74aa48 --- /dev/null +++ b/.changeset/thin-rivers-relax.md @@ -0,0 +1,6 @@ +--- +'@firebase/util': patch +--- + +Moved `calculateBackoffMillis()` exponential backoff function from remote-config to util, +where it can be shared between packages. diff --git a/.changeset/witty-deers-study.md b/.changeset/witty-deers-study.md new file mode 100644 index 00000000000..12c55eb9c49 --- /dev/null +++ b/.changeset/witty-deers-study.md @@ -0,0 +1,8 @@ +--- +'@firebase/analytics': minor +'@firebase/analytics-types': minor +--- + +Analytics now dynamically fetches the app's Measurement ID from the Dynamic Config backend +instead of depending on the local Firebase config. It will fall back to any `measurementId` +value found in the local config if the Dynamic Config fetch fails. diff --git a/config/ci.config.json b/config/ci.config.json index 5b838cc9bdb..75d94ee8281 100644 --- a/config/ci.config.json +++ b/config/ci.config.json @@ -4,5 +4,6 @@ "databaseURL": "https://jscore-sandbox-141b5.firebaseio.com", "projectId": "jscore-sandbox-141b5", "storageBucket": "jscore-sandbox-141b5.appspot.com", - "messagingSenderId": "280127633210" + "messagingSenderId": "280127633210", + "appId": "1:280127633210:web:1eb2f7e8799c4d5a46c203" } diff --git a/packages/analytics-types/index.d.ts b/packages/analytics-types/index.d.ts index b49cc55f86c..09708902516 100644 --- a/packages/analytics-types/index.d.ts +++ b/packages/analytics-types/index.d.ts @@ -235,6 +235,37 @@ export interface Promotion { name?: string; } +/** + * Dynamic configuration fetched from server. + * See https://firebase.google.com/docs/projects/api/reference/rest/v1beta1/projects.webApps/getConfig + */ +interface DynamicConfig { + projectId: string; + appId: string; + databaseURL: string; + storageBucket: string; + locationId: string; + apiKey: string; + authDomain: string; + messagingSenderId: string; + measurementId: string; +} + +interface MinimalDynamicConfig { + appId: string; + measurementId: string; +} + +/** + * Encapsulates metadata concerning throttled fetch requests. + */ +export interface ThrottleMetadata { + // The number of times fetch has backed off. Used for resuming backoff after a timeout. + backoffCount: number; + // The Unix timestamp in milliseconds when callers can retry a request. + throttleEndTimeMillis: number; +} + declare module '@firebase/component' { interface NameServiceMapping { 'analytics': FirebaseAnalytics; diff --git a/packages/analytics/.eslintrc.js b/packages/analytics/.eslintrc.js index ad9f22b5838..85388055d9d 100644 --- a/packages/analytics/.eslintrc.js +++ b/packages/analytics/.eslintrc.js @@ -1,7 +1,33 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const path = require('path'); + module.exports = { 'extends': '../../config/.eslintrc.js', 'parserOptions': { 'project': 'tsconfig.json', 'tsconfigRootDir': __dirname + }, + rules: { + 'import/no-extraneous-dependencies': [ + 'error', + { + 'packageDir': [path.resolve(__dirname, '../../'), __dirname] + } + ] } }; diff --git a/packages/analytics/index.test.ts b/packages/analytics/index.test.ts index 290bcce71d6..25208792772 100644 --- a/packages/analytics/index.test.ts +++ b/packages/analytics/index.test.ts @@ -17,7 +17,7 @@ import { expect } from 'chai'; import { FirebaseAnalytics } from '@firebase/analytics-types'; -import { SinonStub, stub } from 'sinon'; +import { SinonStub, stub, useFakeTimers } from 'sinon'; import './testing/setup'; import { settings as analyticsSettings, @@ -34,51 +34,146 @@ import { GtagCommand, EventName } from './src/constants'; import { findGtagScriptOnPage } from './src/helpers'; import { removeGtagScript } from './testing/gtag-script-util'; import { Deferred } from '@firebase/util'; +import { AnalyticsError } from './src/errors'; let analyticsInstance: FirebaseAnalytics = {} as FirebaseAnalytics; -const analyticsId = 'abcd-efgh'; -const gtagStub: SinonStub = stub(); +const fakeMeasurementId = 'abcd-efgh'; +const fakeAppParams = { appId: 'abcdefgh12345:23405', apiKey: 'AAbbCCdd12345' }; +let fetchStub: SinonStub = stub(); const customGtagName = 'customGtag'; const customDataLayerName = 'customDataLayer'; +let clock: sinon.SinonFakeTimers; -describe('FirebaseAnalytics instance tests', () => { - it('Throws if no analyticsId in config', () => { - const app = getFakeApp(); - const installations = getFakeInstallations(); - expect(() => analyticsFactory(app, installations)).to.throw( - 'field is empty' - ); +function stubFetch(status: number, body: object): void { + fetchStub = stub(window, 'fetch'); + const mockResponse = new Response(JSON.stringify(body), { + status }); - it('Throws if creating an instance with already-used analytics ID', () => { - const app = getFakeApp(analyticsId); - const installations = getFakeInstallations(); - resetGlobalVars(false, { [analyticsId]: Promise.resolve() }); - expect(() => analyticsFactory(app, installations)).to.throw( - 'already exists' - ); + fetchStub.returns(Promise.resolve(mockResponse)); +} + +describe('FirebaseAnalytics instance tests', () => { + describe('Initialization', () => { + beforeEach(() => resetGlobalVars()); + + it('Throws if no appId in config', () => { + const app = getFakeApp({ apiKey: fakeAppParams.apiKey }); + const installations = getFakeInstallations(); + expect(() => analyticsFactory(app, installations)).to.throw( + AnalyticsError.NO_APP_ID + ); + }); + it('Throws if no apiKey or measurementId in config', () => { + const app = getFakeApp({ appId: fakeAppParams.appId }); + const installations = getFakeInstallations(); + expect(() => analyticsFactory(app, installations)).to.throw( + AnalyticsError.NO_API_KEY + ); + }); + it('Warns if config has no apiKey but does have a measurementId', () => { + const warnStub = stub(console, 'warn'); + const app = getFakeApp({ + appId: fakeAppParams.appId, + measurementId: fakeMeasurementId + }); + const installations = getFakeInstallations(); + analyticsFactory(app, installations); + expect(warnStub.args[0][1]).to.include( + `Falling back to the measurement ID ${fakeMeasurementId}` + ); + warnStub.restore(); + }); + it('Throws if cookies are not enabled', () => { + const cookieStub = stub(navigator, 'cookieEnabled').value(false); + const app = getFakeApp({ + appId: fakeAppParams.appId, + apiKey: fakeAppParams.apiKey + }); + const installations = getFakeInstallations(); + expect(() => analyticsFactory(app, installations)).to.throw( + AnalyticsError.COOKIES_NOT_ENABLED + ); + cookieStub.restore(); + }); + it('Throws if browser extension environment', () => { + window.chrome = { runtime: { id: 'blah' } }; + const app = getFakeApp({ + appId: fakeAppParams.appId, + apiKey: fakeAppParams.apiKey + }); + const installations = getFakeInstallations(); + expect(() => analyticsFactory(app, installations)).to.throw( + AnalyticsError.INVALID_ANALYTICS_CONTEXT + ); + window.chrome = undefined; + }); + it('Throws if indexedDB does not exist', () => { + const idbStub = stub(window, 'indexedDB').value(undefined); + const app = getFakeApp({ + appId: fakeAppParams.appId, + apiKey: fakeAppParams.apiKey + }); + const installations = getFakeInstallations(); + expect(() => analyticsFactory(app, installations)).to.throw( + AnalyticsError.INDEXED_DB_UNSUPPORTED + ); + idbStub.restore(); + }); + it('Warns eventually if indexedDB.open() does not work', async () => { + clock = useFakeTimers(); + stubFetch(200, { measurementId: fakeMeasurementId }); + const warnStub = stub(console, 'warn'); + const idbOpenStub = stub(indexedDB, 'open').throws( + 'idb open throw message' + ); + const app = getFakeApp({ + appId: fakeAppParams.appId, + apiKey: fakeAppParams.apiKey + }); + const installations = getFakeInstallations(); + analyticsFactory(app, installations); + await clock.runAllAsync(); + expect(warnStub.args[0][1]).to.include( + AnalyticsError.INVALID_INDEXED_DB_CONTEXT + ); + expect(warnStub.args[0][1]).to.include('idb open throw message'); + warnStub.restore(); + idbOpenStub.restore(); + fetchStub.restore(); + clock.restore(); + }); + it('Throws if creating an instance with already-used appId', () => { + const app = getFakeApp(fakeAppParams); + const installations = getFakeInstallations(); + resetGlobalVars(false, { [fakeAppParams.appId]: Promise.resolve() }); + expect(() => analyticsFactory(app, installations)).to.throw( + AnalyticsError.ALREADY_EXISTS + ); + }); }); describe('Standard app, page already has user gtag script', () => { let app: FirebaseApp = {} as FirebaseApp; let fidDeferred: Deferred; + const gtagStub: SinonStub = stub(); before(() => { + clock = useFakeTimers(); resetGlobalVars(); - app = getFakeApp(analyticsId); + app = getFakeApp(fakeAppParams); fidDeferred = new Deferred(); const installations = getFakeInstallations('fid-1234', () => fidDeferred.resolve() ); - window['gtag'] = gtagStub; window['dataLayer'] = []; + stubFetch(200, { measurementId: fakeMeasurementId }); analyticsInstance = analyticsFactory(app, installations); }); after(() => { delete window['gtag']; delete window['dataLayer']; removeGtagScript(); - }); - afterEach(() => { - gtagStub.reset(); + fetchStub.restore(); + clock.restore(); }); it('Contains reference to parent app', () => { expect(analyticsInstance.app).to.equal(app); @@ -87,13 +182,12 @@ describe('FirebaseAnalytics instance tests', () => { analyticsInstance.logEvent(EventName.ADD_PAYMENT_INFO, { currency: 'USD' }); - // Clear event stack of initialization promise. - const { initializedIdPromisesMap } = getGlobalVars(); - await Promise.all(Object.values(initializedIdPromisesMap)); + // Clear promise chain started by logEvent. + await clock.runAllAsync(); expect(gtagStub).to.have.been.calledWith('js'); expect(gtagStub).to.have.been.calledWith( GtagCommand.CONFIG, - analyticsId, + fakeMeasurementId, { 'firebase_id': 'fid-1234', origin: 'firebase', @@ -126,10 +220,13 @@ describe('FirebaseAnalytics instance tests', () => { }); describe('Page has user gtag script with custom gtag and dataLayer names', () => { + let app: FirebaseApp = {} as FirebaseApp; let fidDeferred: Deferred; + const gtagStub: SinonStub = stub(); before(() => { + clock = useFakeTimers(); resetGlobalVars(); - const app = getFakeApp(analyticsId); + app = getFakeApp(fakeAppParams); fidDeferred = new Deferred(); const installations = getFakeInstallations('fid-1234', () => fidDeferred.resolve() @@ -140,27 +237,26 @@ describe('FirebaseAnalytics instance tests', () => { dataLayerName: customDataLayerName, gtagName: customGtagName }); + stubFetch(200, { measurementId: fakeMeasurementId }); analyticsInstance = analyticsFactory(app, installations); }); after(() => { delete window[customGtagName]; delete window[customDataLayerName]; removeGtagScript(); - }); - afterEach(() => { - gtagStub.reset(); + fetchStub.restore(); + clock.restore(); }); it('Calls gtag correctly on logEvent (instance)', async () => { analyticsInstance.logEvent(EventName.ADD_PAYMENT_INFO, { currency: 'USD' }); - // Clear event stack of initialization promise. - const { initializedIdPromisesMap } = getGlobalVars(); - await Promise.all(Object.values(initializedIdPromisesMap)); + // Clear promise chain started by logEvent. + await clock.runAllAsync(); expect(gtagStub).to.have.been.calledWith('js'); expect(gtagStub).to.have.been.calledWith( GtagCommand.CONFIG, - analyticsId, + fakeMeasurementId, { 'firebase_id': 'fid-1234', origin: 'firebase', @@ -179,23 +275,23 @@ describe('FirebaseAnalytics instance tests', () => { }); describe('Page has no existing gtag script or dataLayer', () => { - before(() => { + it('Adds the script tag to the page', async () => { resetGlobalVars(); - const app = getFakeApp(analyticsId); + const app = getFakeApp(fakeAppParams); const installations = getFakeInstallations(); + stubFetch(200, {}); analyticsInstance = analyticsFactory(app, installations); - }); - after(() => { - delete window['gtag']; - delete window['dataLayer']; - removeGtagScript(); - }); - it('Adds the script tag to the page', async () => { - const { initializedIdPromisesMap } = getGlobalVars(); - await initializedIdPromisesMap[analyticsId]; + + const { initializationPromisesMap } = getGlobalVars(); + await initializationPromisesMap[fakeAppParams.appId]; expect(findGtagScriptOnPage()).to.not.be.null; expect(typeof window['gtag']).to.equal('function'); expect(Array.isArray(window['dataLayer'])).to.be.true; + + delete window['gtag']; + delete window['dataLayer']; + removeGtagScript(); + fetchStub.restore(); }); }); }); diff --git a/packages/analytics/index.ts b/packages/analytics/index.ts index b3544c68462..1d5ecd01969 100644 --- a/packages/analytics/index.ts +++ b/packages/analytics/index.ts @@ -50,7 +50,6 @@ declare global { * Type constant for Firebase Analytics. */ const ANALYTICS_TYPE = 'analytics'; - export function registerAnalytics(instance: _FirebaseNamespace): void { instance.INTERNAL.registerComponent( new Component( @@ -61,6 +60,7 @@ export function registerAnalytics(instance: _FirebaseNamespace): void { const installations = container .getProvider('installations') .getImmediate(); + return factory(app, installations); }, ComponentType.PUBLIC diff --git a/packages/analytics/karma.integration.conf.js b/packages/analytics/karma.integration.conf.js new file mode 100644 index 00000000000..94215252451 --- /dev/null +++ b/packages/analytics/karma.integration.conf.js @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const karmaBase = require('../../config/karma.base'); + +const files = [`./testing/integration-tests/integration.ts`]; + +module.exports = function (config) { + config.set({ + ...karmaBase, + files, + preprocessors: { '**/*.ts': ['webpack', 'sourcemap'] }, + frameworks: ['mocha'] + }); +}; + +module.exports.files = files; diff --git a/packages/analytics/package.json b/packages/analytics/package.json index afc58f91ce2..94e41ce63a0 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -6,16 +6,20 @@ "main": "dist/index.cjs.js", "module": "dist/index.esm.js", "esm2017": "dist/index.esm2017.js", - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "lint": "eslint -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", "lint:fix": "eslint --fix -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", "build": "rollup -c", "build:deps": "lerna run --scope @firebase/analytics --include-dependencies build", "dev": "rollup -c -w", - "test": "run-p lint test:browser", - "test:ci": "node ../../scripts/run_tests_in_ci.js -s test:browser", + "test": "run-p lint test:all", + "test:all": "run-p test:browser test:integration", + "test:ci": "node ../../scripts/run_tests_in_ci.js -s test:all", "test:browser": "karma start --single-run --nocache", + "test:integration": "karma start ./karma.integration.conf.js --single-run --nocache", "prepare": "yarn build" }, "peerDependencies": { @@ -56,4 +60,4 @@ ], "reportDir": "./coverage/node" } -} +} \ No newline at end of file diff --git a/packages/analytics/src/constants.ts b/packages/analytics/src/constants.ts index a58914ce205..2b24c0f59ec 100644 --- a/packages/analytics/src/constants.ts +++ b/packages/analytics/src/constants.ts @@ -15,12 +15,15 @@ * limitations under the License. */ -export const ANALYTICS_ID_FIELD = 'measurementId'; - // Key to attach FID to in gtag params. export const GA_FID_KEY = 'firebase_id'; export const ORIGIN_KEY = 'origin'; +export const FETCH_TIMEOUT_MILLIS = 60 * 1000; + +export const DYNAMIC_CONFIG_URL = + 'https://firebase.googleapis.com/v1alpha/projects/-/apps/{app-id}/webConfig'; + export const GTAG_URL = 'https://www.googletagmanager.com/gtag/js'; export enum GtagCommand { diff --git a/packages/analytics/src/errors.ts b/packages/analytics/src/errors.ts index eccc437c362..ce8012b8383 100644 --- a/packages/analytics/src/errors.ts +++ b/packages/analytics/src/errors.ts @@ -16,34 +16,32 @@ */ import { ErrorFactory, ErrorMap } from '@firebase/util'; -import { ANALYTICS_ID_FIELD } from './constants'; export const enum AnalyticsError { - NO_GA_ID = 'no-ga-id', ALREADY_EXISTS = 'already-exists', ALREADY_INITIALIZED = 'already-initialized', INTEROP_COMPONENT_REG_FAILED = 'interop-component-reg-failed', + INVALID_ANALYTICS_CONTEXT = 'invalid-analytics-context', + FETCH_THROTTLE = 'fetch-throttle', + CONFIG_FETCH_FAILED = 'config-fetch-failed', + NO_API_KEY = 'no-api-key', + NO_APP_ID = 'no-app-id', INDEXED_DB_UNSUPPORTED = 'indexedDB-unsupported', INVALID_INDEXED_DB_CONTEXT = 'invalid-indexedDB-context', - COOKIES_NOT_ENABLED = 'cookies-not-enabled', - INVALID_ANALYTICS_CONTEXT = 'invalid-analytics-context' + COOKIES_NOT_ENABLED = 'cookies-not-enabled' } const ERRORS: ErrorMap = { - [AnalyticsError.NO_GA_ID]: - `"${ANALYTICS_ID_FIELD}" field is empty in ` + - 'Firebase config. Firebase Analytics ' + - 'requires this field to contain a valid measurement ID.', [AnalyticsError.ALREADY_EXISTS]: - 'A Firebase Analytics instance with the measurement ID ${id} ' + + 'A Firebase Analytics instance with the appId {$id} ' + ' already exists. ' + - 'Only one Firebase Analytics instance can be created for each measurement ID.', + 'Only one Firebase Analytics instance can be created for each appId.', [AnalyticsError.ALREADY_INITIALIZED]: 'Firebase Analytics has already been initialized.' + 'settings() must be called before initializing any Analytics instance' + 'or it will have no effect.', [AnalyticsError.INTEROP_COMPONENT_REG_FAILED]: - 'Firebase Analytics Interop Component failed to instantiate', + 'Firebase Analytics Interop Component failed to instantiate: {$reason}', [AnalyticsError.INDEXED_DB_UNSUPPORTED]: 'IndexedDB is not supported by current browswer', [AnalyticsError.INVALID_INDEXED_DB_CONTEXT]: @@ -53,12 +51,36 @@ const ERRORS: ErrorMap = { [AnalyticsError.COOKIES_NOT_ENABLED]: 'Cookies are not enabled in this browser environment. Analytics requires cookies to be enabled.', [AnalyticsError.INVALID_ANALYTICS_CONTEXT]: - 'Firebase Analytics is not supported in browser extensions.' + 'Firebase Analytics is not supported in browser extensions.', + [AnalyticsError.FETCH_THROTTLE]: + 'The config fetch request timed out while in an exponential backoff state.' + + ' Unix timestamp in milliseconds when fetch request throttling ends: {$throttleEndTimeMillis}.', + [AnalyticsError.CONFIG_FETCH_FAILED]: + 'Dynamic config fetch failed: [{$httpStatus}] {$responseMessage}', + [AnalyticsError.NO_API_KEY]: + 'The "apiKey" field is empty in the local Firebase config. Firebase Analytics requires this field to' + + 'contain a valid API key.', + [AnalyticsError.NO_APP_ID]: + 'The "appId" field is empty in the local Firebase config. Firebase Analytics requires this field to' + + 'contain a valid app ID.', + [AnalyticsError.INDEXED_DB_UNSUPPORTED]: + 'IndexedDB is not supported by current browswer', + [AnalyticsError.INVALID_INDEXED_DB_CONTEXT]: + "Environment doesn't support IndexedDB: {$errorInfo}. " + + 'Wrap initialization of analytics in analytics.isSupported() ' + + 'to prevent initialization in unsupported environments', + [AnalyticsError.COOKIES_NOT_ENABLED]: + 'Cookies are not enabled in this browser environment. Analytics requires cookies to be enabled.' }; interface ErrorParams { [AnalyticsError.ALREADY_EXISTS]: { id: string }; [AnalyticsError.INTEROP_COMPONENT_REG_FAILED]: { reason: Error }; + [AnalyticsError.FETCH_THROTTLE]: { throttleEndTimeMillis: number }; + [AnalyticsError.CONFIG_FETCH_FAILED]: { + httpStatus: number; + responseMessage: string; + }; [AnalyticsError.INVALID_INDEXED_DB_CONTEXT]: { errorInfo: string }; } diff --git a/packages/analytics/src/factory.ts b/packages/analytics/src/factory.ts index 3335f9b03c7..f45d6301eba 100644 --- a/packages/analytics/src/factory.ts +++ b/packages/analytics/src/factory.ts @@ -18,7 +18,9 @@ import { FirebaseAnalytics, Gtag, - SettingsOptions + SettingsOptions, + DynamicConfig, + MinimalDynamicConfig } from '@firebase/analytics-types'; import { logEvent, @@ -28,13 +30,11 @@ import { setAnalyticsCollectionEnabled } from './functions'; import { - initializeGAId, insertScriptTag, getOrCreateDataLayer, wrapOrCreateGtag, findGtagScriptOnPage } from './helpers'; -import { ANALYTICS_ID_FIELD } from './constants'; import { AnalyticsError, ERROR_FACTORY } from './errors'; import { FirebaseApp } from '@firebase/app-types'; import { FirebaseInstallations } from '@firebase/installations-types'; @@ -44,11 +44,39 @@ import { areCookiesEnabled, isBrowserExtension } from '@firebase/util'; +import { initializeIds } from './initialize-ids'; +import { logger } from './logger'; +import { FirebaseService } from '@firebase/app-types/private'; + +interface FirebaseAnalyticsInternal + extends FirebaseAnalytics, + FirebaseService {} + +/** + * Maps appId to full initialization promise. Wrapped gtag calls must wait on + * all or some of these, depending on the call's `send_to` param and the status + * of the dynamic config fetches (see below). + */ +let initializationPromisesMap: { + [appId: string]: Promise; // Promise contains measurement ID string. +} = {}; + +/** + * List of dynamic config fetch promises. In certain cases, wrapped gtag calls + * wait on all these to be complete in order to determine if it can selectively + * wait for only certain initialization (FID) promises or if it must wait for all. + */ +let dynamicConfigPromisesList: Array> = []; /** - * Maps gaId to FID fetch promises. + * Maps fetched measurementIds to appId. Populated when the app's dynamic config + * fetch completes. If already populated, gtag config calls can use this to + * selectively wait for only this app's initialization promise (FID) instead of all + * initialization promises. */ -let initializedIdPromisesMap: { [gaId: string]: Promise } = {}; +const measurementIdToAppId: { [measurementId: string]: string } = {}; /** * Name for window global data layer array used by GA: defaults to 'dataLayer'. @@ -83,10 +111,12 @@ let globalInitDone: boolean = false; */ export function resetGlobalVars( newGlobalInitDone = false, - newGaInitializedPromise = {} + newInitializationPromisesMap = {}, + newDynamicPromises = [] ): void { globalInitDone = newGlobalInitDone; - initializedIdPromisesMap = newGaInitializedPromise; + initializationPromisesMap = newInitializationPromisesMap; + dynamicConfigPromisesList = newDynamicPromises; dataLayerName = 'dataLayer'; gtagName = 'gtag'; } @@ -95,10 +125,14 @@ export function resetGlobalVars( * For testing */ export function getGlobalVars(): { - initializedIdPromisesMap: { [gaId: string]: Promise }; + initializationPromisesMap: { [appId: string]: Promise }; + dynamicConfigPromisesList: Array< + Promise + >; } { return { - initializedIdPromisesMap + initializationPromisesMap, + dynamicConfigPromisesList }; } @@ -134,19 +168,32 @@ export function factory( } // Async but non-blocking. validateIndexedDBOpenable().catch(error => { - throw ERROR_FACTORY.create(AnalyticsError.INVALID_INDEXED_DB_CONTEXT, { - errorInfo: error - }); + const analyticsError = ERROR_FACTORY.create( + AnalyticsError.INVALID_INDEXED_DB_CONTEXT, + { + errorInfo: error + } + ); + logger.warn(analyticsError.message); }); - - const analyticsId = app.options[ANALYTICS_ID_FIELD]; - if (!analyticsId) { - throw ERROR_FACTORY.create(AnalyticsError.NO_GA_ID); + const appId = app.options.appId; + if (!appId) { + throw ERROR_FACTORY.create(AnalyticsError.NO_APP_ID); } - - if (initializedIdPromisesMap[analyticsId] != null) { + if (!app.options.apiKey) { + if (app.options.measurementId) { + logger.warn( + `The "apiKey" field is empty in the local Firebase config. This is needed to fetch the latest` + + ` measurement ID for this Firebase app. Falling back to the measurement ID ${app.options.measurementId}` + + ` provided in the "measurementId" field in the local Firebase config.` + ); + } else { + throw ERROR_FACTORY.create(AnalyticsError.NO_API_KEY); + } + } + if (initializationPromisesMap[appId] != null) { throw ERROR_FACTORY.create(AnalyticsError.ALREADY_EXISTS, { - id: analyticsId + id: appId }); } @@ -161,7 +208,9 @@ export function factory( getOrCreateDataLayer(dataLayerName); const { wrappedGtag, gtagCore } = wrapOrCreateGtag( - initializedIdPromisesMap, + initializationPromisesMap, + dynamicConfigPromisesList, + measurementIdToAppId, dataLayerName, gtagName ); @@ -171,30 +220,64 @@ export function factory( globalInitDone = true; } // Async but non-blocking. - initializedIdPromisesMap[analyticsId] = initializeGAId( + // This map reflects the completion state of all promises for each appId. + initializationPromisesMap[appId] = initializeIds( app, + dynamicConfigPromisesList, + measurementIdToAppId, installations, gtagCoreFunction ); - const analyticsInstance: FirebaseAnalytics = { + const analyticsInstance: FirebaseAnalyticsInternal = { app, - logEvent: (eventName, eventParams, options) => + // Public methods return void for API simplicity and to better match gtag, + // while internal implementations return promises. + logEvent: (eventName, eventParams, options) => { logEvent( wrappedGtagFunction, - analyticsId, + initializationPromisesMap[appId], eventName, eventParams, options - ), - setCurrentScreen: (screenName, options) => - setCurrentScreen(wrappedGtagFunction, analyticsId, screenName, options), - setUserId: (id, options) => - setUserId(wrappedGtagFunction, analyticsId, id, options), - setUserProperties: (properties, options) => - setUserProperties(wrappedGtagFunction, analyticsId, properties, options), - setAnalyticsCollectionEnabled: enabled => - setAnalyticsCollectionEnabled(analyticsId, enabled) + ).catch(e => logger.error(e)); + }, + setCurrentScreen: (screenName, options) => { + setCurrentScreen( + wrappedGtagFunction, + initializationPromisesMap[appId], + screenName, + options + ).catch(e => logger.error(e)); + }, + setUserId: (id, options) => { + setUserId( + wrappedGtagFunction, + initializationPromisesMap[appId], + id, + options + ).catch(e => logger.error(e)); + }, + setUserProperties: (properties, options) => { + setUserProperties( + wrappedGtagFunction, + initializationPromisesMap[appId], + properties, + options + ).catch(e => logger.error(e)); + }, + setAnalyticsCollectionEnabled: enabled => { + setAnalyticsCollectionEnabled( + initializationPromisesMap[appId], + enabled + ).catch(e => logger.error(e)); + }, + INTERNAL: { + delete: (): Promise => { + delete initializationPromisesMap[appId]; + return Promise.resolve(); + } + } }; return analyticsInstance; diff --git a/packages/analytics/src/functions.test.ts b/packages/analytics/src/functions.test.ts index ab7a3a233b2..531bb6faa10 100644 --- a/packages/analytics/src/functions.test.ts +++ b/packages/analytics/src/functions.test.ts @@ -27,7 +27,8 @@ import { } from './functions'; import { GtagCommand, EventName } from './constants'; -const analyticsId = 'abcd-efgh-ijkl'; +const fakeMeasurementId = 'abcd-efgh-ijkl'; +const fakeInitializationPromise = Promise.resolve(fakeMeasurementId); describe('FirebaseAnalytics methods', () => { const gtagStub: SinonStub = stub(); @@ -36,8 +37,8 @@ describe('FirebaseAnalytics methods', () => { gtagStub.reset(); }); - it('logEvent() calls gtag function correctly', () => { - logEvent(gtagStub, analyticsId, EventName.ADD_TO_CART, { + it('logEvent() calls gtag function correctly', async () => { + await logEvent(gtagStub, fakeInitializationPromise, EventName.ADD_TO_CART, { currency: 'USD' }); @@ -45,28 +46,28 @@ describe('FirebaseAnalytics methods', () => { GtagCommand.EVENT, EventName.ADD_TO_CART, { - 'send_to': analyticsId, + 'send_to': fakeMeasurementId, currency: 'USD' } ); }); - it('logEvent() with no event params calls gtag function correctly', () => { - logEvent(gtagStub, analyticsId, EventName.VIEW_ITEM); + it('logEvent() with no event params calls gtag function correctly', async () => { + await logEvent(gtagStub, fakeInitializationPromise, EventName.VIEW_ITEM); expect(gtagStub).to.have.been.calledWith( GtagCommand.EVENT, EventName.VIEW_ITEM, { - 'send_to': analyticsId + 'send_to': fakeMeasurementId } ); }); - it('logEvent() globally calls gtag function correctly', () => { - logEvent( + it('logEvent() globally calls gtag function correctly', async () => { + await logEvent( gtagStub, - analyticsId, + fakeInitializationPromise, EventName.ADD_TO_CART, { currency: 'USD' @@ -83,66 +84,88 @@ describe('FirebaseAnalytics methods', () => { ); }); - it('logEvent() with no event params globally calls gtag function correctly', () => { - logEvent(gtagStub, analyticsId, EventName.ADD_TO_CART, undefined, { - global: true - }); + it('logEvent() with no event params globally calls gtag function correctly', async () => { + await logEvent( + gtagStub, + fakeInitializationPromise, + EventName.ADD_TO_CART, + undefined, + { + global: true + } + ); expect(gtagStub).to.have.been.calledWith( GtagCommand.EVENT, EventName.ADD_TO_CART, - {} + undefined ); }); it('setCurrentScreen() calls gtag correctly (instance)', async () => { - setCurrentScreen(gtagStub, analyticsId, 'home'); - expect(gtagStub).to.have.been.calledWith(GtagCommand.CONFIG, analyticsId, { - 'screen_name': 'home', - update: true - }); + await setCurrentScreen(gtagStub, fakeInitializationPromise, 'home'); + expect(gtagStub).to.have.been.calledWith( + GtagCommand.CONFIG, + fakeMeasurementId, + { + 'screen_name': 'home', + update: true + } + ); }); it('setCurrentScreen() calls gtag correctly (global)', async () => { - setCurrentScreen(gtagStub, analyticsId, 'home', { global: true }); + await setCurrentScreen(gtagStub, fakeInitializationPromise, 'home', { + global: true + }); expect(gtagStub).to.be.calledWith(GtagCommand.SET, { 'screen_name': 'home' }); }); it('setUserId() calls gtag correctly (instance)', async () => { - setUserId(gtagStub, analyticsId, 'user123'); - expect(gtagStub).to.have.been.calledWith(GtagCommand.CONFIG, analyticsId, { - 'user_id': 'user123', - update: true - }); + await setUserId(gtagStub, fakeInitializationPromise, 'user123'); + expect(gtagStub).to.have.been.calledWith( + GtagCommand.CONFIG, + fakeMeasurementId, + { + 'user_id': 'user123', + update: true + } + ); }); it('setUserId() calls gtag correctly (global)', async () => { - setUserId(gtagStub, analyticsId, 'user123', { global: true }); + await setUserId(gtagStub, fakeInitializationPromise, 'user123', { + global: true + }); expect(gtagStub).to.be.calledWith(GtagCommand.SET, { 'user_id': 'user123' }); }); it('setUserProperties() calls gtag correctly (instance)', async () => { - setUserProperties(gtagStub, analyticsId, { + await setUserProperties(gtagStub, fakeInitializationPromise, { 'currency': 'USD', 'language': 'en' }); - expect(gtagStub).to.have.been.calledWith(GtagCommand.CONFIG, analyticsId, { - 'user_properties': { - 'currency': 'USD', - 'language': 'en' - }, - update: true - }); + expect(gtagStub).to.have.been.calledWith( + GtagCommand.CONFIG, + fakeMeasurementId, + { + 'user_properties': { + 'currency': 'USD', + 'language': 'en' + }, + update: true + } + ); }); it('setUserProperties() calls gtag correctly (global)', async () => { - setUserProperties( + await setUserProperties( gtagStub, - analyticsId, + fakeInitializationPromise, { 'currency': 'USD', 'language': 'en' }, { global: true } ); @@ -153,10 +176,10 @@ describe('FirebaseAnalytics methods', () => { }); it('setAnalyticsCollectionEnabled() calls gtag correctly', async () => { - setAnalyticsCollectionEnabled(analyticsId, true); - expect(window[`ga-disable-${analyticsId}`]).to.be.false; - setAnalyticsCollectionEnabled(analyticsId, false); - expect(window[`ga-disable-${analyticsId}`]).to.be.true; - delete window[`ga-disable-${analyticsId}`]; + await setAnalyticsCollectionEnabled(fakeInitializationPromise, true); + expect(window[`ga-disable-${fakeMeasurementId}`]).to.be.false; + await setAnalyticsCollectionEnabled(fakeInitializationPromise, false); + expect(window[`ga-disable-${fakeMeasurementId}`]).to.be.true; + delete window[`ga-disable-${fakeMeasurementId}`]; }); }); diff --git a/packages/analytics/src/functions.ts b/packages/analytics/src/functions.ts index 547a12eb773..0ef739460c8 100644 --- a/packages/analytics/src/functions.ts +++ b/packages/analytics/src/functions.ts @@ -30,39 +30,44 @@ import { GtagCommand } from './constants'; * @param eventName Google Analytics event name, choose from standard list or use a custom string. * @param eventParams Analytics event parameters. */ -export function logEvent( +export async function logEvent( gtagFunction: Gtag, - analyticsId: string, + initializationPromise: Promise, eventName: string, eventParams?: EventParams, options?: AnalyticsCallOptions -): void { - let params: EventParams | ControlParams = eventParams || {}; - if (!options || !options.global) { - params = { ...eventParams, 'send_to': analyticsId }; +): Promise { + if (options && options.global) { + gtagFunction(GtagCommand.EVENT, eventName, eventParams); + return; + } else { + const measurementId = await initializationPromise; + const params: EventParams | ControlParams = { + ...eventParams, + 'send_to': measurementId + }; + gtagFunction(GtagCommand.EVENT, eventName, params); } - // Workaround for http://b/141370449 - third argument cannot be undefined. - gtagFunction(GtagCommand.EVENT, eventName, params || {}); } -// TODO: Brad is going to add `screen_name` to GA Gold config parameter schema - /** * Set screen_name parameter for this Google Analytics ID. * * @param gtagFunction Wrapped gtag function that waits for fid to be set before sending an event * @param screenName Screen name string to set. */ -export function setCurrentScreen( +export async function setCurrentScreen( gtagFunction: Gtag, - analyticsId: string, + initializationPromise: Promise, screenName: string | null, options?: AnalyticsCallOptions -): void { +): Promise { if (options && options.global) { gtagFunction(GtagCommand.SET, { 'screen_name': screenName }); + return Promise.resolve(); } else { - gtagFunction(GtagCommand.CONFIG, analyticsId, { + const measurementId = await initializationPromise; + gtagFunction(GtagCommand.CONFIG, measurementId, { update: true, 'screen_name': screenName }); @@ -75,16 +80,18 @@ export function setCurrentScreen( * @param gtagFunction Wrapped gtag function that waits for fid to be set before sending an event * @param id User ID string to set */ -export function setUserId( +export async function setUserId( gtagFunction: Gtag, - analyticsId: string, + initializationPromise: Promise, id: string | null, options?: AnalyticsCallOptions -): void { +): Promise { if (options && options.global) { gtagFunction(GtagCommand.SET, { 'user_id': id }); + return Promise.resolve(); } else { - gtagFunction(GtagCommand.CONFIG, analyticsId, { + const measurementId = await initializationPromise; + gtagFunction(GtagCommand.CONFIG, measurementId, { update: true, 'user_id': id }); @@ -97,12 +104,12 @@ export function setUserId( * @param gtagFunction Wrapped gtag function that waits for fid to be set before sending an event * @param properties Map of user properties to set */ -export function setUserProperties( +export async function setUserProperties( gtagFunction: Gtag, - analyticsId: string, + initializationPromise: Promise, properties: CustomParams, options?: AnalyticsCallOptions -): void { +): Promise { if (options && options.global) { const flatProperties: { [key: string]: unknown } = {}; for (const key of Object.keys(properties)) { @@ -110,8 +117,10 @@ export function setUserProperties( flatProperties[`user_properties.${key}`] = properties[key]; } gtagFunction(GtagCommand.SET, flatProperties); + return Promise.resolve(); } else { - gtagFunction(GtagCommand.CONFIG, analyticsId, { + const measurementId = await initializationPromise; + gtagFunction(GtagCommand.CONFIG, measurementId, { update: true, 'user_properties': properties }); @@ -123,9 +132,10 @@ export function setUserProperties( * * @param enabled If true, collection is enabled for this ID. */ -export function setAnalyticsCollectionEnabled( - analyticsId: string, +export async function setAnalyticsCollectionEnabled( + initializationPromise: Promise, enabled: boolean -): void { - window[`ga-disable-${analyticsId}`] = !enabled; +): Promise { + const measurementId = await initializationPromise; + window[`ga-disable-${measurementId}`] = !enabled; } diff --git a/packages/analytics/src/get-config.test.ts b/packages/analytics/src/get-config.test.ts new file mode 100644 index 00000000000..430f07b66fc --- /dev/null +++ b/packages/analytics/src/get-config.test.ts @@ -0,0 +1,259 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { SinonStub, stub, useFakeTimers, restore } from 'sinon'; +import '../testing/setup'; +import { + fetchDynamicConfig, + fetchDynamicConfigWithRetry, + AppFields, + LONG_RETRY_FACTOR +} from './get-config'; +import { DYNAMIC_CONFIG_URL } from './constants'; +import { getFakeApp } from '../testing/get-fake-firebase-services'; +import { DynamicConfig, MinimalDynamicConfig } from '@firebase/analytics-types'; +import { AnalyticsError } from './errors'; + +const fakeMeasurementId = 'abcd-efgh-ijkl'; +const fakeAppId = 'abcdefgh12345:23405'; +const fakeAppParams = { appId: fakeAppId, apiKey: 'AAbbCCdd12345' }; +const fakeUrl = DYNAMIC_CONFIG_URL.replace('{app-id}', fakeAppId); +const successObject = { measurementId: fakeMeasurementId, appId: fakeAppId }; +let fetchStub: SinonStub; + +function stubFetch(status: number, body: { [key: string]: any }): void { + fetchStub = stub(window, 'fetch'); + const mockResponse = new window.Response(JSON.stringify(body), { + status + }); + fetchStub.returns(Promise.resolve(mockResponse)); +} + +describe('Dynamic Config Fetch Functions', () => { + afterEach(restore); + describe('fetchDynamicConfig() - no retry', () => { + it('successfully request and receives dynamic config JSON data', async () => { + stubFetch(200, successObject); + const config: DynamicConfig = await fetchDynamicConfig(fakeAppParams); + expect(fetchStub.args[0][0]).to.equal(fakeUrl); + expect(fetchStub.args[0][1].headers.get('x-goog-api-key')).to.equal( + fakeAppParams.apiKey + ); + expect(config.appId).to.equal(fakeAppId); + expect(config.measurementId).to.equal(fakeMeasurementId); + }); + it('throws error on failed response', async () => { + stubFetch(500, { + error: { + /* no message */ + } + }); + const app = getFakeApp(fakeAppParams); + await expect( + fetchDynamicConfig(app.options as AppFields) + ).to.be.rejectedWith(AnalyticsError.CONFIG_FETCH_FAILED); + }); + it('throws error on failed response, includes server error message if provided', async () => { + stubFetch(500, { error: { message: 'Oops' } }); + const app = getFakeApp(fakeAppParams); + await expect( + fetchDynamicConfig(app.options as AppFields) + ).to.be.rejectedWith( + new RegExp(`Oops.+${AnalyticsError.CONFIG_FETCH_FAILED}`) + ); + }); + }); + describe('fetchDynamicConfigWithRetry()', () => { + it('successfully request and receives dynamic config JSON data', async () => { + stubFetch(200, successObject); + const app = getFakeApp(fakeAppParams); + const config: + | DynamicConfig + | MinimalDynamicConfig = await fetchDynamicConfigWithRetry(app); + expect(fetchStub.args[0][0]).to.equal(fakeUrl); + expect(fetchStub.args[0][1].headers.get('x-goog-api-key')).to.equal( + fakeAppParams.apiKey + ); + expect(config.appId).to.equal(fakeAppId); + expect(config.measurementId).to.equal(fakeMeasurementId); + }); + it('throws error on non-retriable failed response', async () => { + stubFetch(404, { + error: { + /* no message */ + } + }); + const app = getFakeApp(fakeAppParams); + await expect(fetchDynamicConfigWithRetry(app)).to.be.rejectedWith( + AnalyticsError.CONFIG_FETCH_FAILED + ); + }); + it('warns on non-retriable failed response if local measurementId available', async () => { + stubFetch(404, { + error: { + /* no message */ + } + }); + const consoleStub = stub(console, 'warn'); + const app = getFakeApp({ + ...fakeAppParams, + measurementId: fakeMeasurementId + }); + await fetchDynamicConfigWithRetry(app); + expect(consoleStub.args[0][1]).to.include(fakeMeasurementId); + consoleStub.restore(); + }); + it('retries on retriable error until success', async () => { + // Configures Date.now() to advance clock from zero in 20ms increments, enabling + // tests to assert a known throttle end time and allow setTimeout to work. + const clock = useFakeTimers({ shouldAdvanceTime: true }); + + // Ensures backoff is always zero, which simplifies reasoning about timer. + const powSpy = stub(Math, 'pow').returns(0); + const randomSpy = stub(Math, 'random').returns(0.5); + const fakeRetryData = { + throttleMetadata: {}, + getThrottleMetadata: stub(), + setThrottleMetadata: stub(), + deleteThrottleMetadata: stub(), + intervalMillis: 5 + }; + + // Returns responses with each of 4 retriable statuses, then a success response. + const retriableStatuses = [429, 500, 503, 504]; + fetchStub = stub(window, 'fetch'); + retriableStatuses.forEach((status, index) => { + const failResponse = new window.Response(JSON.stringify({}), { + status + }); + fetchStub.onCall(index).resolves(failResponse); + }); + const successResponse = new window.Response( + JSON.stringify(successObject), + { + status: 200 + } + ); + fetchStub.onCall(retriableStatuses.length).resolves(successResponse); + + const app = getFakeApp(fakeAppParams); + const config: + | DynamicConfig + | MinimalDynamicConfig = await fetchDynamicConfigWithRetry( + app, + fakeRetryData + ); + + // Verify retryData.setThrottleMetadata() was called on each retry. + for (let i = 0; i < retriableStatuses.length; i++) { + retriableStatuses[i]; + expect(fakeRetryData.setThrottleMetadata.args[i][1]).to.deep.equal({ + backoffCount: i + 1, + throttleEndTimeMillis: (i + 1) * 20 + }); + } + + expect(fetchStub.args[0][0]).to.equal(fakeUrl); + expect(fetchStub.args[0][1].headers.get('x-goog-api-key')).to.equal( + fakeAppParams.apiKey + ); + expect(config.appId).to.equal(fakeAppId); + expect(config.measurementId).to.equal(fakeMeasurementId); + + powSpy.restore(); + randomSpy.restore(); + clock.restore(); + }); + it('retries on retriable error until aborted by timeout', async () => { + const fakeRetryData = { + throttleMetadata: {}, + getThrottleMetadata: stub(), + setThrottleMetadata: stub(), + deleteThrottleMetadata: stub(), + intervalMillis: 10 + }; + + // Always returns retriable server error. + stubFetch(500, {}); + + const app = getFakeApp(fakeAppParams); + // Set fetch timeout to 50 ms. + const fetchPromise = fetchDynamicConfigWithRetry(app, fakeRetryData, 50); + await expect(fetchPromise).to.be.rejectedWith( + AnalyticsError.FETCH_THROTTLE + ); + // Should be enough time for at least 2 retries, including fuzzing. + expect(fakeRetryData.setThrottleMetadata.callCount).to.be.greaterThan(1); + }); + it('retries on 503 error until aborted by timeout', async () => { + const fakeRetryData = { + throttleMetadata: {}, + getThrottleMetadata: stub(), + setThrottleMetadata: stub(), + deleteThrottleMetadata: stub(), + intervalMillis: 10 + }; + + // Always returns retriable server error. + stubFetch(503, {}); + + const app = getFakeApp(fakeAppParams); + // Set fetch timeout to 50 ms. + const fetchPromise = fetchDynamicConfigWithRetry(app, fakeRetryData, 50); + await expect(fetchPromise).to.be.rejectedWith( + AnalyticsError.FETCH_THROTTLE + ); + const retryTime1 = + fakeRetryData.setThrottleMetadata.args[0][1].throttleEndTimeMillis; + const retryTime2 = + fakeRetryData.setThrottleMetadata.args[1][1].throttleEndTimeMillis; + expect(fakeRetryData.setThrottleMetadata).to.be.called; + // Interval between first and second retry should be greater than lowest fuzzable + // value of LONG_RETRY_FACTOR. + expect(retryTime2 - retryTime1).to.be.at.least( + Math.floor(LONG_RETRY_FACTOR / 2) * fakeRetryData.intervalMillis + ); + }); + it( + 'retries on retriable error until aborted by timeout,' + + ' then uses local measurementId if available', + async () => { + const fakeRetryData = { + throttleMetadata: {}, + getThrottleMetadata: stub(), + setThrottleMetadata: stub(), + deleteThrottleMetadata: stub(), + intervalMillis: 10 + }; + + // Always returns retriable server error. + stubFetch(500, {}); + const consoleStub = stub(console, 'warn'); + + const app = getFakeApp({ + ...fakeAppParams, + measurementId: fakeMeasurementId + }); + // Set fetch timeout to 50 ms. + await fetchDynamicConfigWithRetry(app, fakeRetryData, 50); + expect(consoleStub.args[0][1]).to.include(fakeMeasurementId); + consoleStub.restore(); + } + ); + }); +}); diff --git a/packages/analytics/src/get-config.ts b/packages/analytics/src/get-config.ts new file mode 100644 index 00000000000..9a9024d058b --- /dev/null +++ b/packages/analytics/src/get-config.ts @@ -0,0 +1,322 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview Most logic is copied from packages/remote-config/src/client/retrying_client.ts + */ + +import { FirebaseApp } from '@firebase/app-types'; +import { + DynamicConfig, + ThrottleMetadata, + MinimalDynamicConfig +} from '@firebase/analytics-types'; +import { FirebaseError, calculateBackoffMillis } from '@firebase/util'; +import { AnalyticsError, ERROR_FACTORY } from './errors'; +import { DYNAMIC_CONFIG_URL, FETCH_TIMEOUT_MILLIS } from './constants'; +import { logger } from './logger'; + +// App config fields needed by analytics. +export interface AppFields { + appId: string; + apiKey: string; + measurementId?: string; +} + +/** + * Backoff factor for 503 errors, which we want to be conservative about + * to avoid overloading servers. Each retry interval will be + * BASE_INTERVAL_MILLIS * LONG_RETRY_FACTOR ^ retryCount, so the second one + * will be ~30 seconds (with fuzzing). + */ +export const LONG_RETRY_FACTOR = 30; + +/** + * Base wait interval to multiplied by backoffFactor^backoffCount. + */ +const BASE_INTERVAL_MILLIS = 1000; + +/** + * Stubbable retry data storage class. + */ +class RetryData { + constructor( + public throttleMetadata: { [appId: string]: ThrottleMetadata } = {}, + public intervalMillis: number = BASE_INTERVAL_MILLIS + ) {} + + getThrottleMetadata(appId: string): ThrottleMetadata { + return this.throttleMetadata[appId]; + } + + setThrottleMetadata(appId: string, metadata: ThrottleMetadata): void { + this.throttleMetadata[appId] = metadata; + } + + deleteThrottleMetadata(appId: string): void { + delete this.throttleMetadata[appId]; + } +} + +const defaultRetryData = new RetryData(); + +/** + * Set GET request headers. + * @param apiKey App API key. + */ +function getHeaders(apiKey: string): Headers { + return new Headers({ + Accept: 'application/json', + 'x-goog-api-key': apiKey + }); +} + +/** + * Fetches dynamic config from backend. + * @param app Firebase app to fetch config for. + */ +export async function fetchDynamicConfig( + appFields: AppFields +): Promise { + const { appId, apiKey } = appFields; + const request: RequestInit = { + method: 'GET', + headers: getHeaders(apiKey) + }; + const appUrl = DYNAMIC_CONFIG_URL.replace('{app-id}', appId); + const response = await fetch(appUrl, request); + if (response.status !== 200 && response.status !== 304) { + let errorMessage = ''; + try { + // Try to get any error message text from server response. + const jsonResponse = (await response.json()) as { + error?: { message?: string }; + }; + if (jsonResponse.error?.message) { + errorMessage = jsonResponse.error.message; + } + } catch (_ignored) {} + throw ERROR_FACTORY.create(AnalyticsError.CONFIG_FETCH_FAILED, { + httpStatus: response.status, + responseMessage: errorMessage + }); + } + return response.json(); +} + +/** + * Fetches dynamic config from backend, retrying if failed. + * @param app Firebase app to fetch config for. + */ +export async function fetchDynamicConfigWithRetry( + app: FirebaseApp, + // retryData and timeoutMillis are parameterized to allow passing a different value for testing. + retryData: RetryData = defaultRetryData, + timeoutMillis?: number +): Promise { + const { appId, apiKey, measurementId } = app.options; + + if (!appId) { + throw ERROR_FACTORY.create(AnalyticsError.NO_APP_ID); + } + + if (!apiKey) { + if (measurementId) { + return { + measurementId, + appId + }; + } + throw ERROR_FACTORY.create(AnalyticsError.NO_API_KEY); + } + + const throttleMetadata: ThrottleMetadata = retryData.getThrottleMetadata( + appId + ) || { + backoffCount: 0, + throttleEndTimeMillis: Date.now() + }; + + const signal = new AnalyticsAbortSignal(); + + setTimeout( + async () => { + // Note a very low delay, eg < 10ms, can elapse before listeners are initialized. + signal.abort(); + }, + timeoutMillis !== undefined ? timeoutMillis : FETCH_TIMEOUT_MILLIS + ); + + return attemptFetchDynamicConfigWithRetry( + { appId, apiKey, measurementId }, + throttleMetadata, + signal, + retryData + ); +} + +/** + * Runs one retry attempt. + * @param appFields Necessary app config fields. + * @param throttleMetadata Ongoing metadata to determine throttling times. + * @param signal Abort signal. + */ +async function attemptFetchDynamicConfigWithRetry( + appFields: AppFields, + { throttleEndTimeMillis, backoffCount }: ThrottleMetadata, + signal: AnalyticsAbortSignal, + retryData: RetryData = defaultRetryData // for testing +): Promise { + const { appId, measurementId } = appFields; + // Starts with a (potentially zero) timeout to support resumption from stored state. + // Ensures the throttle end time is honored if the last attempt timed out. + // Note the SDK will never make a request if the fetch timeout expires at this point. + try { + await setAbortableTimeout(signal, throttleEndTimeMillis); + } catch (e) { + if (measurementId) { + logger.warn( + `Timed out fetching this Firebase app's measurement ID from the server.` + + ` Falling back to the measurement ID ${measurementId}` + + ` provided in the "measurementId" field in the local Firebase config. [${e.message}]` + ); + return { appId, measurementId }; + } + throw e; + } + + try { + const response = await fetchDynamicConfig(appFields); + + // Note the SDK only clears throttle state if response is success or non-retriable. + retryData.deleteThrottleMetadata(appId); + + return response; + } catch (e) { + if (!isRetriableError(e)) { + retryData.deleteThrottleMetadata(appId); + if (measurementId) { + logger.warn( + `Failed to fetch this Firebase app's measurement ID from the server.` + + ` Falling back to the measurement ID ${measurementId}` + + ` provided in the "measurementId" field in the local Firebase config. [${e.message}]` + ); + return { appId, measurementId }; + } else { + throw e; + } + } + + const backoffMillis = + Number(e.httpStatus) === 503 + ? calculateBackoffMillis( + backoffCount, + retryData.intervalMillis, + LONG_RETRY_FACTOR + ) + : calculateBackoffMillis(backoffCount, retryData.intervalMillis); + + // Increments backoff state. + const throttleMetadata = { + throttleEndTimeMillis: Date.now() + backoffMillis, + backoffCount: backoffCount + 1 + }; + + // Persists state. + retryData.setThrottleMetadata(appId, throttleMetadata); + logger.debug(`Calling attemptFetch again in ${backoffMillis} millis`); + + return attemptFetchDynamicConfigWithRetry( + appFields, + throttleMetadata, + signal, + retryData + ); + } +} + +/** + * Supports waiting on a backoff by: + * + *
    + *
  • Promisifying setTimeout, so we can set a timeout in our Promise chain
  • + *
  • Listening on a signal bus for abort events, just like the Fetch API
  • + *
  • Failing in the same way the Fetch API fails, so timing out a live request and a throttled + * request appear the same.
  • + *
+ * + *

Visible for testing. + */ +function setAbortableTimeout( + signal: AnalyticsAbortSignal, + throttleEndTimeMillis: number +): Promise { + return new Promise((resolve, reject) => { + // Derives backoff from given end time, normalizing negative numbers to zero. + const backoffMillis = Math.max(throttleEndTimeMillis - Date.now(), 0); + + const timeout = setTimeout(resolve, backoffMillis); + + // Adds listener, rather than sets onabort, because signal is a shared object. + signal.addEventListener(() => { + clearTimeout(timeout); + // If the request completes before this timeout, the rejection has no effect. + reject( + ERROR_FACTORY.create(AnalyticsError.FETCH_THROTTLE, { + throttleEndTimeMillis + }) + ); + }); + }); +} + +/** + * Returns true if the {@link Error} indicates a fetch request may succeed later. + */ +function isRetriableError(e: Error): boolean { + if (!(e instanceof FirebaseError)) { + return false; + } + + // Uses string index defined by ErrorData, which FirebaseError implements. + const httpStatus = Number(e['httpStatus']); + + return ( + httpStatus === 429 || + httpStatus === 500 || + httpStatus === 503 || + httpStatus === 504 + ); +} + +/** + * Shims a minimal AbortSignal (copied from Remote Config). + * + *

AbortController's AbortSignal conveniently decouples fetch timeout logic from other aspects + * of networking, such as retries. Firebase doesn't use AbortController enough to justify a + * polyfill recommendation, like we do with the Fetch API, but this minimal shim can easily be + * swapped out if/when we do. + */ +export class AnalyticsAbortSignal { + listeners: Array<() => void> = []; + addEventListener(listener: () => void): void { + this.listeners.push(listener); + } + abort(): void { + this.listeners.forEach(listener => listener()); + } +} diff --git a/packages/analytics/src/helpers.test.ts b/packages/analytics/src/helpers.test.ts index ed1b82c29f8..dc800fe6083 100644 --- a/packages/analytics/src/helpers.test.ts +++ b/packages/analytics/src/helpers.test.ts @@ -18,37 +18,32 @@ import { expect } from 'chai'; import { SinonStub, stub } from 'sinon'; import '../testing/setup'; -import { DataLayer, Gtag } from '@firebase/analytics-types'; +import { DataLayer, Gtag, DynamicConfig } from '@firebase/analytics-types'; import { - initializeGAId, getOrCreateDataLayer, insertScriptTag, wrapOrCreateGtag, findGtagScriptOnPage } from './helpers'; -import { - getFakeApp, - getFakeInstallations -} from '../testing/get-fake-firebase-services'; import { GtagCommand } from './constants'; import { Deferred } from '@firebase/util'; -const mockAnalyticsId = 'abcd-efgh-ijkl'; -const mockFid = 'fid-1234-zyxw'; - -describe('FirebaseAnalytics methods', () => { - it('initializeGAId gets FID from installations and calls gtag config with it', async () => { - const gtagStub: SinonStub = stub(); - const app = getFakeApp(mockAnalyticsId); - const installations = getFakeInstallations(mockFid); - await initializeGAId(app, installations, gtagStub); - expect(gtagStub).to.be.calledWith(GtagCommand.CONFIG, mockAnalyticsId, { - 'firebase_id': mockFid, - 'origin': 'firebase', - update: true - }); - }); - +const fakeMeasurementId = 'abcd-efgh-ijkl'; +const fakeAppId = 'my-test-app-1234'; +const fakeDynamicConfig: DynamicConfig = { + projectId: '---', + appId: fakeAppId, + databaseURL: '---', + storageBucket: '---', + locationId: '---', + apiKey: '---', + authDomain: '---', + messagingSenderId: '---', + measurementId: fakeMeasurementId +}; +const fakeDynamicConfigPromises = [Promise.resolve(fakeDynamicConfig)]; + +describe('Gtag wrapping functions', () => { it('getOrCreateDataLayer is able to create a new data layer if none exists', () => { delete window['dataLayer']; expect(getOrCreateDataLayer('dataLayer')).to.deep.equal([]); @@ -75,18 +70,20 @@ describe('FirebaseAnalytics methods', () => { it('wrapOrCreateGtag creates new gtag function if needed', () => { expect(window['gtag']).to.not.exist; - wrapOrCreateGtag({}, 'dataLayer', 'gtag'); + wrapOrCreateGtag({}, fakeDynamicConfigPromises, {}, 'dataLayer', 'gtag'); expect(window['gtag']).to.exist; }); it('new window.gtag function waits for all initialization promises before sending group events', async () => { - const initPromise1 = new Deferred(); - const initPromise2 = new Deferred(); + const initPromise1 = new Deferred(); + const initPromise2 = new Deferred(); wrapOrCreateGtag( { - [mockAnalyticsId]: initPromise1.promise, + [fakeAppId]: initPromise1.promise, otherId: initPromise2.promise }, + fakeDynamicConfigPromises, + {}, 'dataLayer', 'gtag' ); @@ -97,11 +94,12 @@ describe('FirebaseAnalytics methods', () => { }); expect((window['dataLayer'] as DataLayer).length).to.equal(0); - initPromise1.resolve(); // Resolves first initialization promise. + initPromise1.resolve(fakeMeasurementId); // Resolves first initialization promise. expect((window['dataLayer'] as DataLayer).length).to.equal(0); - initPromise2.resolve(); // Resolves second initialization promise. + initPromise2.resolve('other-measurement-id'); // Resolves second initialization promise. await Promise.all([initPromise1, initPromise2]); // Wait for resolution of Promise.all() + await Promise.all(fakeDynamicConfigPromises); expect((window['dataLayer'] as DataLayer).length).to.equal(1); }); @@ -110,20 +108,22 @@ describe('FirebaseAnalytics methods', () => { 'new window.gtag function waits for all initialization promises before sending ' + 'event with at least one unknown send_to ID', async () => { - const initPromise1 = new Deferred(); - const initPromise2 = new Deferred(); + const initPromise1 = new Deferred(); + const initPromise2 = new Deferred(); wrapOrCreateGtag( { - [mockAnalyticsId]: initPromise1.promise, + [fakeAppId]: initPromise1.promise, otherId: initPromise2.promise }, + fakeDynamicConfigPromises, + { [fakeMeasurementId]: fakeAppId }, 'dataLayer', 'gtag' ); window['dataLayer'] = []; (window['gtag'] as Gtag)(GtagCommand.EVENT, 'purchase', { 'transaction_id': 'abcd123', - 'send_to': [mockAnalyticsId, 'some_group'] + 'send_to': [fakeMeasurementId, 'some_group'] }); expect((window['dataLayer'] as DataLayer).length).to.equal(0); @@ -132,6 +132,7 @@ describe('FirebaseAnalytics methods', () => { initPromise2.resolve(); // Resolves second initialization promise. await Promise.all([initPromise1, initPromise2]); // Wait for resolution of Promise.all() + await Promise.all(fakeDynamicConfigPromises); expect((window['dataLayer'] as DataLayer).length).to.equal(1); } @@ -141,13 +142,15 @@ describe('FirebaseAnalytics methods', () => { 'new window.gtag function waits for all initialization promises before sending ' + 'events with no send_to field', async () => { - const initPromise1 = new Deferred(); - const initPromise2 = new Deferred(); + const initPromise1 = new Deferred(); + const initPromise2 = new Deferred(); wrapOrCreateGtag( { - [mockAnalyticsId]: initPromise1.promise, + [fakeAppId]: initPromise1.promise, otherId: initPromise2.promise }, + fakeDynamicConfigPromises, + { [fakeMeasurementId]: fakeAppId }, 'dataLayer', 'gtag' ); @@ -171,24 +174,27 @@ describe('FirebaseAnalytics methods', () => { 'new window.gtag function only waits for firebase initialization promise ' + 'before sending event only targeted to Firebase instance GA ID', async () => { - const initPromise1 = new Deferred(); - const initPromise2 = new Deferred(); + const initPromise1 = new Deferred(); + const initPromise2 = new Deferred(); wrapOrCreateGtag( { - [mockAnalyticsId]: initPromise1.promise, + [fakeAppId]: initPromise1.promise, otherId: initPromise2.promise }, + fakeDynamicConfigPromises, + { [fakeMeasurementId]: fakeAppId }, 'dataLayer', 'gtag' ); window['dataLayer'] = []; (window['gtag'] as Gtag)(GtagCommand.EVENT, 'purchase', { 'transaction_id': 'abcd123', - 'send_to': mockAnalyticsId + 'send_to': fakeMeasurementId }); expect((window['dataLayer'] as DataLayer).length).to.equal(0); initPromise1.resolve(); // Resolves first initialization promise. + await Promise.all(fakeDynamicConfigPromises); await Promise.all([initPromise1]); // Wait for resolution of Promise.all() expect((window['dataLayer'] as DataLayer).length).to.equal(1); @@ -196,7 +202,7 @@ describe('FirebaseAnalytics methods', () => { ); it('new window.gtag function does not wait before sending events if there are no pending initialization promises', async () => { - wrapOrCreateGtag({}, 'dataLayer', 'gtag'); + wrapOrCreateGtag({}, fakeDynamicConfigPromises, {}, 'dataLayer', 'gtag'); window['dataLayer'] = []; (window['gtag'] as Gtag)(GtagCommand.EVENT, 'purchase', { 'transaction_id': 'abcd123' @@ -207,7 +213,9 @@ describe('FirebaseAnalytics methods', () => { it('new window.gtag function does not wait when sending "set" calls', async () => { wrapOrCreateGtag( - { [mockAnalyticsId]: Promise.resolve() }, + { [fakeAppId]: Promise.resolve(fakeMeasurementId) }, + fakeDynamicConfigPromises, + {}, 'dataLayer', 'gtag' ); @@ -217,31 +225,37 @@ describe('FirebaseAnalytics methods', () => { }); it('new window.gtag function waits for initialization promise when sending "config" calls', async () => { - const initPromise1 = new Deferred(); + const initPromise1 = new Deferred(); wrapOrCreateGtag( - { [mockAnalyticsId]: initPromise1.promise }, + { [fakeAppId]: initPromise1.promise }, + fakeDynamicConfigPromises, + {}, 'dataLayer', 'gtag' ); window['dataLayer'] = []; - (window['gtag'] as Gtag)(GtagCommand.CONFIG, mockAnalyticsId, { + (window['gtag'] as Gtag)(GtagCommand.CONFIG, fakeMeasurementId, { 'language': 'en' }); expect((window['dataLayer'] as DataLayer).length).to.equal(0); - initPromise1.resolve(); + initPromise1.resolve(fakeMeasurementId); + await Promise.all(fakeDynamicConfigPromises); // Resolves dynamic config fetches. + expect((window['dataLayer'] as DataLayer).length).to.equal(0); + await Promise.all([initPromise1]); // Wait for resolution of Promise.all() expect((window['dataLayer'] as DataLayer).length).to.equal(1); }); it('new window.gtag function does not wait when sending "config" calls if there are no pending initialization promises', async () => { - wrapOrCreateGtag({}, 'dataLayer', 'gtag'); + wrapOrCreateGtag({}, fakeDynamicConfigPromises, {}, 'dataLayer', 'gtag'); window['dataLayer'] = []; - (window['gtag'] as Gtag)(GtagCommand.CONFIG, mockAnalyticsId, { + (window['gtag'] as Gtag)(GtagCommand.CONFIG, fakeMeasurementId, { 'transaction_id': 'abcd123' }); - await Promise.resolve(); // Config call is always chained onto a promise, even if empty. + await Promise.all(fakeDynamicConfigPromises); + await Promise.resolve(); // Config call is always chained onto initialization promise list, even if empty. expect((window['dataLayer'] as DataLayer).length).to.equal(1); }); }); @@ -258,13 +272,15 @@ describe('FirebaseAnalytics methods', () => { }); it('new window.gtag function waits for all initialization promises before sending group events', async () => { - const initPromise1 = new Deferred(); - const initPromise2 = new Deferred(); + const initPromise1 = new Deferred(); + const initPromise2 = new Deferred(); wrapOrCreateGtag( { - [mockAnalyticsId]: initPromise1.promise, + [fakeAppId]: initPromise1.promise, otherId: initPromise2.promise }, + fakeDynamicConfigPromises, + { [fakeMeasurementId]: fakeAppId }, 'dataLayer', 'gtag' ); @@ -278,6 +294,9 @@ describe('FirebaseAnalytics methods', () => { expect(existingGtagStub).to.not.be.called; initPromise2.resolve(); // Resolves second initialization promise. + await Promise.all(fakeDynamicConfigPromises); // Resolves dynamic config fetches. + expect(existingGtagStub).to.not.be.called; + await Promise.all([initPromise1, initPromise2]); // Wait for resolution of Promise.all() expect(existingGtagStub).to.be.calledWith(GtagCommand.EVENT, 'purchase', { @@ -290,19 +309,21 @@ describe('FirebaseAnalytics methods', () => { 'new window.gtag function waits for all initialization promises before sending ' + 'event with at least one unknown send_to ID', async () => { - const initPromise1 = new Deferred(); - const initPromise2 = new Deferred(); + const initPromise1 = new Deferred(); + const initPromise2 = new Deferred(); wrapOrCreateGtag( { - [mockAnalyticsId]: initPromise1.promise, + [fakeAppId]: initPromise1.promise, otherId: initPromise2.promise }, + fakeDynamicConfigPromises, + { [fakeMeasurementId]: fakeAppId }, 'dataLayer', 'gtag' ); (window['gtag'] as Gtag)(GtagCommand.EVENT, 'purchase', { 'transaction_id': 'abcd123', - 'send_to': [mockAnalyticsId, 'some_group'] + 'send_to': [fakeMeasurementId, 'some_group'] }); expect(existingGtagStub).to.not.be.called; @@ -310,13 +331,16 @@ describe('FirebaseAnalytics methods', () => { expect(existingGtagStub).to.not.be.called; initPromise2.resolve(); // Resolves second initialization promise. + await Promise.all(fakeDynamicConfigPromises); // Resolves dynamic config fetches. + expect(existingGtagStub).to.not.be.called; + await Promise.all([initPromise1, initPromise2]); // Wait for resolution of Promise.all() expect(existingGtagStub).to.be.calledWith( GtagCommand.EVENT, 'purchase', { - 'send_to': [mockAnalyticsId, 'some_group'], + 'send_to': [fakeMeasurementId, 'some_group'], 'transaction_id': 'abcd123' } ); @@ -327,13 +351,15 @@ describe('FirebaseAnalytics methods', () => { 'new window.gtag function waits for all initialization promises before sending ' + 'events with no send_to field', async () => { - const initPromise1 = new Deferred(); - const initPromise2 = new Deferred(); + const initPromise1 = new Deferred(); + const initPromise2 = new Deferred(); wrapOrCreateGtag( { - [mockAnalyticsId]: initPromise1.promise, + [fakeAppId]: initPromise1.promise, otherId: initPromise2.promise }, + fakeDynamicConfigPromises, + { [fakeMeasurementId]: fakeAppId }, 'dataLayer', 'gtag' ); @@ -346,6 +372,7 @@ describe('FirebaseAnalytics methods', () => { expect(existingGtagStub).to.not.be.called; initPromise2.resolve(); // Resolves second initialization promise. + await Promise.all([initPromise1, initPromise2]); // Wait for resolution of Promise.all() expect(existingGtagStub).to.be.calledWith( @@ -360,35 +387,40 @@ describe('FirebaseAnalytics methods', () => { 'new window.gtag function only waits for firebase initialization promise ' + 'before sending event only targeted to Firebase instance GA ID', async () => { - const initPromise1 = new Deferred(); - const initPromise2 = new Deferred(); + const initPromise1 = new Deferred(); + const initPromise2 = new Deferred(); wrapOrCreateGtag( { - [mockAnalyticsId]: initPromise1.promise, + [fakeAppId]: initPromise1.promise, otherId: initPromise2.promise }, + fakeDynamicConfigPromises, + { [fakeMeasurementId]: fakeAppId }, 'dataLayer', 'gtag' ); (window['gtag'] as Gtag)(GtagCommand.EVENT, 'purchase', { 'transaction_id': 'abcd123', - 'send_to': mockAnalyticsId + 'send_to': fakeMeasurementId }); expect(existingGtagStub).to.not.be.called; initPromise1.resolve(); // Resolves first initialization promise. + await Promise.all(fakeDynamicConfigPromises); // Resolves dynamic config fetches. + expect(existingGtagStub).to.not.be.called; + await Promise.all([initPromise1]); // Wait for resolution of Promise.all() expect(existingGtagStub).to.be.calledWith( GtagCommand.EVENT, 'purchase', - { 'send_to': mockAnalyticsId, 'transaction_id': 'abcd123' } + { 'send_to': fakeMeasurementId, 'transaction_id': 'abcd123' } ); } ); it('wrapped window.gtag function does not wait if there are no pending initialization promises', async () => { - wrapOrCreateGtag({}, 'dataLayer', 'gtag'); + wrapOrCreateGtag({}, fakeDynamicConfigPromises, {}, 'dataLayer', 'gtag'); window['dataLayer'] = []; (window['gtag'] as Gtag)(GtagCommand.EVENT, 'purchase', { 'transaction_id': 'abcd321' @@ -401,7 +433,9 @@ describe('FirebaseAnalytics methods', () => { it('wrapped window.gtag function does not wait when sending "set" calls', async () => { wrapOrCreateGtag( - { [mockAnalyticsId]: Promise.resolve() }, + { [fakeAppId]: Promise.resolve(fakeMeasurementId) }, + fakeDynamicConfigPromises, + {}, 'dataLayer', 'gtag' ); @@ -413,24 +447,29 @@ describe('FirebaseAnalytics methods', () => { }); it('new window.gtag function waits for initialization promise when sending "config" calls', async () => { - const initPromise1 = new Deferred(); + const initPromise1 = new Deferred(); wrapOrCreateGtag( - { [mockAnalyticsId]: initPromise1.promise }, + { [fakeAppId]: initPromise1.promise }, + fakeDynamicConfigPromises, + {}, 'dataLayer', 'gtag' ); window['dataLayer'] = []; - (window['gtag'] as Gtag)(GtagCommand.CONFIG, mockAnalyticsId, { + (window['gtag'] as Gtag)(GtagCommand.CONFIG, fakeMeasurementId, { 'language': 'en' }); expect(existingGtagStub).to.not.be.called; - initPromise1.resolve(); + initPromise1.resolve(fakeMeasurementId); + await Promise.all(fakeDynamicConfigPromises); // Resolves dynamic config fetches. + expect(existingGtagStub).to.not.be.called; + await Promise.all([initPromise1]); // Wait for resolution of Promise.all() expect(existingGtagStub).to.be.calledWith( GtagCommand.CONFIG, - mockAnalyticsId, + fakeMeasurementId, { 'language': 'en' } @@ -438,15 +477,17 @@ describe('FirebaseAnalytics methods', () => { }); it('new window.gtag function does not wait when sending "config" calls if there are no pending initialization promises', async () => { - wrapOrCreateGtag({}, 'dataLayer', 'gtag'); + wrapOrCreateGtag({}, fakeDynamicConfigPromises, {}, 'dataLayer', 'gtag'); window['dataLayer'] = []; - (window['gtag'] as Gtag)(GtagCommand.CONFIG, mockAnalyticsId, { + (window['gtag'] as Gtag)(GtagCommand.CONFIG, fakeMeasurementId, { 'transaction_id': 'abcd123' }); - await Promise.resolve(); // Config call is always chained onto a promise, even if empty. + await Promise.all(fakeDynamicConfigPromises); // Resolves dynamic config fetches. + expect(existingGtagStub).to.not.be.called; + await Promise.resolve(); // Config call is always chained onto initialization promise list, even if empty. expect(existingGtagStub).to.be.calledWith( GtagCommand.CONFIG, - mockAnalyticsId, + fakeMeasurementId, { 'transaction_id': 'abcd123' } diff --git a/packages/analytics/src/helpers.ts b/packages/analytics/src/helpers.ts index 106d8c4abdc..fb98bd16a7e 100644 --- a/packages/analytics/src/helpers.ts +++ b/packages/analytics/src/helpers.ts @@ -15,54 +15,22 @@ * limitations under the License. */ -import { FirebaseApp } from '@firebase/app-types'; import { + DynamicConfig, DataLayer, Gtag, CustomParams, ControlParams, - EventParams + EventParams, + MinimalDynamicConfig } from '@firebase/analytics-types'; -import { - GtagCommand, - ANALYTICS_ID_FIELD, - GA_FID_KEY, - ORIGIN_KEY, - GTAG_URL -} from './constants'; -import { FirebaseInstallations } from '@firebase/installations-types'; +import { GtagCommand, GTAG_URL } from './constants'; import { logger } from './logger'; + /** - * Initialize the analytics instance in gtag.js by calling config command with fid. - * - * NOTE: We combine analytics initialization and setting fid together because we want fid to be - * part of the `page_view` event that's sent during the initialization - * @param app Firebase app - * @param gtagCore The gtag function that's not wrapped. + * Inserts gtag script tag into the page to asynchronously download gtag. + * @param dataLayerName Name of datalayer (most often the default, "_dataLayer"). */ -export async function initializeGAId( - app: FirebaseApp, - installations: FirebaseInstallations, - gtagCore: Gtag -): Promise { - const fid = await installations.getId(); - - // 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. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - gtagCore('js' as any, new Date()); - - // It should be the first config command called on this GA-ID - // Initialize this GA-ID and set FID on it using the gtag config API. - gtagCore(GtagCommand.CONFIG, app.options[ANALYTICS_ID_FIELD]!, { - [GA_FID_KEY]: fid, - // guard against developers accidentally setting properties with prefix `firebase_` - [ORIGIN_KEY]: 'firebase', - update: true - }); -} - export function insertScriptTag(dataLayerName: string): void { const script = document.createElement('script'); // We are not providing an analyticsId in the URL because it would trigger a `page_view` @@ -72,8 +40,9 @@ export function insertScriptTag(dataLayerName: string): void { document.head.appendChild(script); } -/** Get reference to, or create, global datalayer. - * @param dataLayerName Name of datalayer (most often the default, "_dataLayer") +/** + * Get reference to, or create, global datalayer. + * @param dataLayerName Name of datalayer (most often the default, "_dataLayer"). */ export function getOrCreateDataLayer(dataLayerName: string): DataLayer { // Check for existing dataLayer and create if needed. @@ -85,84 +54,193 @@ export function getOrCreateDataLayer(dataLayerName: string): DataLayer { } return dataLayer; } + +/** + * Wrapped gtag logic when gtag is called with 'config' command. + * + * @param gtagCore Basic gtag function that just appends to dataLayer. + * @param initializationPromisesMap Map of appIds to their initialization promises. + * @param dynamicConfigPromisesList Array of dynamic config fetch promises. + * @param measurementIdToAppId Map of GA measurementIDs to corresponding Firebase appId. + * @param measurementId GA Measurement ID to set config for. + * @param gtagParams Gtag config params to set. + */ +async function gtagOnConfig( + gtagCore: Gtag, + initializationPromisesMap: { [appId: string]: Promise }, + dynamicConfigPromisesList: Array< + Promise + >, + measurementIdToAppId: { [measurementId: string]: string }, + measurementId: string, + gtagParams?: ControlParams & EventParams & CustomParams +): Promise { + // If config is already fetched, we know the appId and can use it to look up what FID promise we + /// are waiting for, and wait only on that one. + const correspondingAppId = measurementIdToAppId[measurementId as string]; + try { + if (correspondingAppId) { + await initializationPromisesMap[correspondingAppId]; + } else { + // If config is not fetched yet, wait for all configs (we don't know which one we need) and + // find the appId (if any) corresponding to this measurementId. If there is one, wait on + // that appId's initialization promise. If there is none, promise resolves and gtag + // call goes through. + const dynamicConfigResults = await Promise.all(dynamicConfigPromisesList); + const foundConfig = dynamicConfigResults.find( + config => config.measurementId === measurementId + ); + if (foundConfig) { + await initializationPromisesMap[foundConfig.appId]; + } + } + } catch (e) { + logger.error(e); + } + gtagCore(GtagCommand.CONFIG, measurementId, gtagParams); +} + +/** + * Wrapped gtag logic when gtag is called with 'event' command. + * + * @param gtagCore Basic gtag function that just appends to dataLayer. + * @param initializationPromisesMap Map of appIds to their initialization promises. + * @param dynamicConfigPromisesList Array of dynamic config fetch promises. + * @param measurementId GA Measurement ID to log event to. + * @param gtagParams Params to log with this event. + */ +async function gtagOnEvent( + gtagCore: Gtag, + initializationPromisesMap: { [appId: string]: Promise }, + dynamicConfigPromisesList: Array< + Promise + >, + measurementId: string, + gtagParams?: ControlParams & EventParams & CustomParams +): Promise { + try { + let initializationPromisesToWaitFor: Array> = []; + + // If there's a 'send_to' param, check if any ID specified matches + // an initializeIds() promise we are waiting for. + if (gtagParams && gtagParams['send_to']) { + let gaSendToList: string | string[] = gtagParams['send_to']; + // Make it an array if is isn't, so it can be dealt with the same way. + if (!Array.isArray(gaSendToList)) { + gaSendToList = [gaSendToList]; + } + // Checking 'send_to' fields requires having all measurement ID results back from + // the dynamic config fetch. + const dynamicConfigResults = await Promise.all(dynamicConfigPromisesList); + for (const sendToId of gaSendToList) { + // Any fetched dynamic measurement ID that matches this 'send_to' ID + const foundConfig = dynamicConfigResults.find( + config => config.measurementId === sendToId + ); + const initializationPromise = + foundConfig && initializationPromisesMap[foundConfig.appId]; + if (initializationPromise) { + initializationPromisesToWaitFor.push(initializationPromise); + } else { + // Found an item in 'send_to' that is not associated + // directly with an FID, possibly a group. Empty this array, + // exit the loop early, and let it get populated below. + initializationPromisesToWaitFor = []; + break; + } + } + } + + // This will be unpopulated if there was no 'send_to' field , or + // if not all entries in the 'send_to' field could be mapped to + // a FID. In these cases, wait on all pending initialization promises. + if (initializationPromisesToWaitFor.length === 0) { + initializationPromisesToWaitFor = Object.values( + initializationPromisesMap + ); + } + + // Run core gtag function with args after all relevant initialization + // promises have been resolved. + await Promise.all(initializationPromisesToWaitFor); + // Workaround for http://b/141370449 - third argument cannot be undefined. + gtagCore(GtagCommand.EVENT, measurementId, gtagParams || {}); + } catch (e) { + logger.error(e); + } +} + /** * Wraps a standard gtag function with extra code to wait for completion of * relevant initialization promises before sending requests. * - * @param gtagCore Basic gtag function that just appends to dataLayer - * @param initializedIdPromisesMap Map of gaIds to their initialization promises + * @param gtagCore Basic gtag function that just appends to dataLayer. + * @param initializationPromisesMap Map of appIds to their initialization promises. + * @param dynamicConfigPromisesList Array of dynamic config fetch promises. + * @param measurementIdToAppId Map of GA measurementIDs to corresponding Firebase appId. */ function wrapGtag( gtagCore: Gtag, - initializedIdPromisesMap: { [gaId: string]: Promise } + /** + * Allows wrapped gtag calls to wait on whichever intialization promises are required, + * depending on the contents of the gtag params' `send_to` field, if any. + */ + initializationPromisesMap: { [appId: string]: Promise }, + /** + * Wrapped gtag calls sometimes require all dynamic config fetches to have returned + * before determining what initialization promises (which include FIDs) to wait for. + */ + dynamicConfigPromisesList: Array< + Promise + >, + /** + * Wrapped gtag config calls can narrow down which initialization promise (with FID) + * to wait for if the measurementId is already fetched, by getting the corresponding appId, + * which is the key for the initialization promises map. + */ + measurementIdToAppId: { [measurementId: string]: string } ): Gtag { - return ( + /** + * Wrapper around gtag that ensures FID is sent with gtag calls. + * @param command Gtag command type. + * @param idOrNameOrParams Measurement ID if command is EVENT/CONFIG, params if command is SET. + * @param gtagParams Params if event is EVENT/CONFIG. + */ + async function gtagWrapper( command: 'config' | 'set' | 'event', idOrNameOrParams: string | ControlParams, gtagParams?: ControlParams & EventParams & CustomParams - ) => { - // If event, check that relevant initialization promises have completed. - if (command === GtagCommand.EVENT) { - let initializationPromisesToWaitFor: Array> = []; - // If there's a 'send_to' param, check if any ID specified matches - // a FID we have begun a fetch on. - if (gtagParams && gtagParams['send_to']) { - let gaSendToList: string | string[] = gtagParams['send_to']; - // Make it an array if is isn't, so it can be dealt with the same way. - if (!Array.isArray(gaSendToList)) { - gaSendToList = [gaSendToList]; - } - for (const sendToId of gaSendToList) { - const initializationPromise = initializedIdPromisesMap[sendToId]; - // Groups will not be in the map. - if (initializationPromise) { - initializationPromisesToWaitFor.push(initializationPromise); - } else { - // There is an item in 'send_to' that is not associated - // directly with an FID, possibly a group. Empty this array - // and let it get populated below. - initializationPromisesToWaitFor = []; - break; - } - } + ): Promise { + try { + // If event, check that relevant initialization promises have completed. + if (command === GtagCommand.EVENT) { + // If EVENT, second arg must be measurementId. + await gtagOnEvent( + gtagCore, + initializationPromisesMap, + dynamicConfigPromisesList, + idOrNameOrParams as string, + gtagParams + ); + } else if (command === GtagCommand.CONFIG) { + // If CONFIG, second arg must be measurementId. + await gtagOnConfig( + gtagCore, + initializationPromisesMap, + dynamicConfigPromisesList, + measurementIdToAppId, + idOrNameOrParams as string, + gtagParams + ); + } else { + // If SET, second arg must be params. + gtagCore(GtagCommand.SET, idOrNameOrParams as CustomParams); } - - // This will be unpopulated if there was no 'send_to' field , or - // if not all entries in the 'send_to' field could be mapped to - // a FID. In these cases, wait on all pending initialization promises. - if (initializationPromisesToWaitFor.length === 0) { - for (const idPromise of Object.values(initializedIdPromisesMap)) { - initializationPromisesToWaitFor.push(idPromise); - } - } - // Run core gtag function with args after all relevant initialization - // promises have been resolved. - Promise.all(initializationPromisesToWaitFor) - // Workaround for http://b/141370449 - third argument cannot be undefined. - .then(() => - gtagCore( - GtagCommand.EVENT, - idOrNameOrParams as string, - gtagParams || {} - ) - ) - .catch(e => logger.error(e)); - } else if (command === GtagCommand.CONFIG) { - const initializationPromiseToWait = - initializedIdPromisesMap[idOrNameOrParams as string] || - Promise.resolve(); - initializationPromiseToWait - .then(() => { - gtagCore(GtagCommand.CONFIG, idOrNameOrParams as string, gtagParams); - }) - .catch(e => logger.error(e)); - } else { - // SET command. - // Splitting calls for CONFIG and SET to make it clear which signature - // Typescript is checking. - gtagCore(GtagCommand.SET, idOrNameOrParams as CustomParams); + } catch (e) { + logger.error(e); } - }; + } + return gtagWrapper; } /** @@ -170,12 +248,18 @@ function wrapGtag( * This wrapped function attaches Firebase instance ID (FID) to gtag 'config' and * 'event' calls that belong to the GAID associated with this Firebase instance. * - * @param initializedIdPromisesMap Map of gaId to initialization promises. + * @param initializationPromisesMap Map of appIds to their initialization promises. + * @param dynamicConfigPromisesList Array of dynamic config fetch promises. + * @param measurementIdToAppId Map of GA measurementIDs to corresponding Firebase appId. * @param dataLayerName Name of global GA datalayer array. - * @param gtagFunctionName Name of global gtag function ("gtag" if not user-specified) + * @param gtagFunctionName Name of global gtag function ("gtag" if not user-specified). */ export function wrapOrCreateGtag( - initializedIdPromisesMap: { [gaId: string]: Promise }, + initializationPromisesMap: { [appId: string]: Promise }, + dynamicConfigPromisesList: Array< + Promise + >, + measurementIdToAppId: { [measurementId: string]: string }, dataLayerName: string, gtagFunctionName: string ): { @@ -197,7 +281,12 @@ export function wrapOrCreateGtag( gtagCore = window[gtagFunctionName]; } - window[gtagFunctionName] = wrapGtag(gtagCore, initializedIdPromisesMap); + window[gtagFunctionName] = wrapGtag( + gtagCore, + initializationPromisesMap, + dynamicConfigPromisesList, + measurementIdToAppId + ); return { gtagCore, diff --git a/packages/analytics/src/initialize-ids.test.ts b/packages/analytics/src/initialize-ids.test.ts new file mode 100644 index 00000000000..d89abc45be5 --- /dev/null +++ b/packages/analytics/src/initialize-ids.test.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { SinonStub, stub } from 'sinon'; +import '../testing/setup'; +import { initializeIds } from './initialize-ids'; +import { + getFakeApp, + getFakeInstallations +} from '../testing/get-fake-firebase-services'; +import { GtagCommand } from './constants'; +import { DynamicConfig } from '@firebase/analytics-types'; +import { FirebaseInstallations } from '@firebase/installations-types'; +import { FirebaseApp } from '@firebase/app-types'; +import { Deferred } from '@firebase/util'; + +const fakeMeasurementId = 'abcd-efgh-ijkl'; +const fakeFid = 'fid-1234-zyxw'; +const fakeAppId = 'abcdefgh12345:23405'; +const fakeAppParams = { appId: fakeAppId, apiKey: 'AAbbCCdd12345' }; +let fetchStub: SinonStub; + +function stubFetch(): void { + fetchStub = stub(window, 'fetch'); + const mockResponse = new window.Response( + JSON.stringify({ measurementId: fakeMeasurementId, appId: fakeAppId }), + { + status: 200 + } + ); + fetchStub.returns(Promise.resolve(mockResponse)); +} + +describe('initializeIds()', () => { + const gtagStub: SinonStub = stub(); + const dynamicPromisesList: Array> = []; + const measurementIdToAppId: { [key: string]: string } = {}; + let app: FirebaseApp; + let installations: FirebaseInstallations; + let fidDeferred: Deferred; + beforeEach(() => { + fidDeferred = new Deferred(); + app = getFakeApp(fakeAppParams); + installations = getFakeInstallations(fakeFid, fidDeferred.resolve); + }); + afterEach(() => { + fetchStub.restore(); + }); + it('gets FID and measurement ID and calls gtag config with them', async () => { + stubFetch(); + await initializeIds( + app, + dynamicPromisesList, + measurementIdToAppId, + installations, + gtagStub + ); + expect(gtagStub).to.be.calledWith(GtagCommand.CONFIG, fakeMeasurementId, { + 'firebase_id': fakeFid, + 'origin': 'firebase', + update: true + }); + }); + it('puts dynamic fetch promise into dynamic promises list', async () => { + stubFetch(); + await initializeIds( + app, + dynamicPromisesList, + measurementIdToAppId, + installations, + gtagStub + ); + const dynamicPromiseResult = await dynamicPromisesList[0]; + expect(dynamicPromiseResult.measurementId).to.equal(fakeMeasurementId); + expect(dynamicPromiseResult.appId).to.equal(fakeAppId); + }); + it('puts dynamically fetched measurementId into lookup table', async () => { + stubFetch(); + await initializeIds( + app, + dynamicPromisesList, + measurementIdToAppId, + installations, + gtagStub + ); + expect(measurementIdToAppId[fakeMeasurementId]).to.equal(fakeAppId); + }); + it('warns on local/fetched measurement ID mismatch', async () => { + stubFetch(); + const consoleStub = stub(console, 'warn'); + await initializeIds( + getFakeApp({ ...fakeAppParams, measurementId: 'old-measurement-id' }), + dynamicPromisesList, + measurementIdToAppId, + installations, + gtagStub + ); + expect(consoleStub.args[0][1]).to.include(fakeMeasurementId); + expect(consoleStub.args[0][1]).to.include('old-measurement-id'); + expect(consoleStub.args[0][1]).to.include('does not match'); + consoleStub.restore(); + }); +}); diff --git a/packages/analytics/src/initialize-ids.ts b/packages/analytics/src/initialize-ids.ts new file mode 100644 index 00000000000..9c30df9375f --- /dev/null +++ b/packages/analytics/src/initialize-ids.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + DynamicConfig, + Gtag, + MinimalDynamicConfig +} from '@firebase/analytics-types'; +import { GtagCommand, GA_FID_KEY, ORIGIN_KEY } from './constants'; +import { FirebaseInstallations } from '@firebase/installations-types'; +import { fetchDynamicConfigWithRetry } from './get-config'; +import { logger } from './logger'; +import { FirebaseApp } from '@firebase/app-types'; + +/** + * Initialize the analytics instance in gtag.js by calling config command with fid. + * + * NOTE: We combine analytics initialization and setting fid together because we want fid to be + * part of the `page_view` event that's sent during the initialization + * @param app Firebase app + * @param gtagCore The gtag function that's not wrapped. + * @param dynamicConfigPromisesList Array of all dynamic config promises. + * @param measurementIdToAppId Maps measurementID to appID. + * @param installations FirebaseInstallations instance. + * + * @returns Measurement ID. + */ +export async function initializeIds( + app: FirebaseApp, + dynamicConfigPromisesList: Array< + Promise + >, + measurementIdToAppId: { [key: string]: string }, + installations: FirebaseInstallations, + gtagCore: Gtag +): Promise { + const dynamicConfigPromise = fetchDynamicConfigWithRetry(app); + // Once fetched, map measurementIds to appId, for ease of lookup in wrapped gtag function. + dynamicConfigPromise + .then(config => { + measurementIdToAppId[config.measurementId] = config.appId; + if ( + app.options.measurementId && + config.measurementId !== app.options.measurementId + ) { + logger.warn( + `The measurement ID in the local Firebase config (${app.options.measurementId})` + + ` does not match the measurement ID fetched from the server (${config.measurementId}).` + + ` To ensure analytics events are always sent to the correct Analytics property,` + + ` update the` + + ` measurement ID field in the local config or remove it from the local config.` + ); + } + }) + .catch(e => logger.error(e)); + // Add to list to track state of all dynamic config promises. + dynamicConfigPromisesList.push(dynamicConfigPromise); + + const [dynamicConfig, fid] = await Promise.all([ + dynamicConfigPromise, + installations.getId() + ]); + + // 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. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + gtagCore('js' as any, new Date()); + + // It should be the first config command called on this GA-ID + // Initialize this GA-ID and set FID on it using the gtag config API. + gtagCore(GtagCommand.CONFIG, dynamicConfig.measurementId, { + [GA_FID_KEY]: fid, + // guard against developers accidentally setting properties with prefix `firebase_` + [ORIGIN_KEY]: 'firebase', + update: true + }); + return dynamicConfig.measurementId; +} diff --git a/packages/analytics/testing/get-fake-firebase-services.ts b/packages/analytics/testing/get-fake-firebase-services.ts index 5d4bc4305f2..413955dfc59 100644 --- a/packages/analytics/testing/get-fake-firebase-services.ts +++ b/packages/analytics/testing/get-fake-firebase-services.ts @@ -18,18 +18,22 @@ import { FirebaseApp } from '@firebase/app-types'; import { FirebaseInstallations } from '@firebase/installations-types'; -export function getFakeApp(measurementId?: string): FirebaseApp { +export function getFakeApp(fakeAppParams?: { + appId?: string; + apiKey?: string; + measurementId?: string; +}): FirebaseApp { return { name: 'appName', options: { - apiKey: 'apiKey', + apiKey: fakeAppParams?.apiKey, projectId: 'projectId', authDomain: 'authDomain', messagingSenderId: 'messagingSenderId', databaseURL: 'databaseUrl', storageBucket: 'storageBucket', - appId: '1:777777777777:web:d93b5ca1475efe57', - measurementId + appId: fakeAppParams?.appId, + measurementId: fakeAppParams?.measurementId }, automaticDataCollectionEnabled: true, delete: async () => {}, diff --git a/packages/analytics/testing/integration-tests/integration.ts b/packages/analytics/testing/integration-tests/integration.ts new file mode 100644 index 00000000000..5c2a2084b4d --- /dev/null +++ b/packages/analytics/testing/integration-tests/integration.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import firebase from '@firebase/app'; +import '../../index.ts'; +import '../setup'; +import { expect } from 'chai'; +import { stub } from 'sinon'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const config = require('../../../../config/project.json'); + +const RETRY_INTERVAL = 1000; + +describe('FirebaseAnalytics Integration Smoke Tests', () => { + afterEach(() => firebase.app().delete()); + it('logEvent() sends correct network request.', async () => { + firebase.initializeApp(config); + firebase.analytics().logEvent('login'); + 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); + }); + 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(); + }); + firebase.initializeApp({ + ...config, + measurementId: 'wrong-id' + }); + firebase.analytics(); + }); +}); diff --git a/packages/analytics/testing/setup.ts b/packages/analytics/testing/setup.ts index 61ec63709ff..3165aab4e7e 100644 --- a/packages/analytics/testing/setup.ts +++ b/packages/analytics/testing/setup.ts @@ -17,12 +17,7 @@ import { use } from 'chai'; import * as chaiAsPromised from 'chai-as-promised'; -import { restore } from 'sinon'; import * as sinonChai from 'sinon-chai'; use(chaiAsPromised); use(sinonChai); - -afterEach(() => { - restore(); -}); diff --git a/packages/remote-config/src/client/retrying_client.ts b/packages/remote-config/src/client/retrying_client.ts index 3a69d7ec535..0364d2db1c2 100644 --- a/packages/remote-config/src/client/retrying_client.ts +++ b/packages/remote-config/src/client/retrying_client.ts @@ -22,9 +22,8 @@ import { FetchRequest } from './remote_config_fetch_client'; import { ThrottleMetadata, Storage } from '../storage/storage'; -import { calculateBackoffMillis } from './exponential_backoff'; import { ErrorCode, ERROR_FACTORY } from '../errors'; -import { FirebaseError } from '@firebase/util'; +import { FirebaseError, calculateBackoffMillis } from '@firebase/util'; /** * Supports waiting on a backoff by: diff --git a/packages/remote-config/test/remote_config.test.ts b/packages/remote-config/test/remote_config.test.ts index 5294bbf0bfa..5cd8a05b474 100644 --- a/packages/remote-config/test/remote_config.test.ts +++ b/packages/remote-config/test/remote_config.test.ts @@ -58,7 +58,7 @@ describe('RemoteConfig', () => { let getActiveConfigStub: sinon.SinonStub; let loggerDebugSpy: sinon.SinonSpy; - let loggerLogLevelSpy: sinon.SinonSpy; + let loggerLogLevelSpy: any; beforeEach(() => { // Clears stubbed behavior between each test. @@ -85,7 +85,7 @@ describe('RemoteConfig', () => { rc.setLogLevel('debug'); // Casts spy to any because property setters aren't defined on the SinonSpy type. - expect((loggerLogLevelSpy as any).set).to.have.been.calledWith( + expect(loggerLogLevelSpy.set).to.have.been.calledWith( FirebaseLogLevel.DEBUG ); }); @@ -95,7 +95,7 @@ describe('RemoteConfig', () => { rc.setLogLevel(logLevel as RemoteConfigLogLevel); // Casts spy to any because property setters aren't defined on the SinonSpy type. - expect((loggerLogLevelSpy as any).set).to.have.been.calledWith( + expect(loggerLogLevelSpy.set).to.have.been.calledWith( FirebaseLogLevel.ERROR ); } diff --git a/packages/util/index.node.ts b/packages/util/index.node.ts index 46c32c5ca9a..27a66023d75 100644 --- a/packages/util/index.node.ts +++ b/packages/util/index.node.ts @@ -35,3 +35,4 @@ export * from './src/sha1'; export * from './src/subscribe'; export * from './src/validation'; export * from './src/utf8'; +export * from './src/exponential_backoff'; diff --git a/packages/util/index.ts b/packages/util/index.ts index 446b58c97d0..0ca3fed2725 100644 --- a/packages/util/index.ts +++ b/packages/util/index.ts @@ -30,3 +30,4 @@ export * from './src/sha1'; export * from './src/subscribe'; export * from './src/validation'; export * from './src/utf8'; +export * from './src/exponential_backoff'; diff --git a/packages/util/karma.conf.js b/packages/util/karma.conf.js index aa8fac4fdf2..3d42695ced9 100644 --- a/packages/util/karma.conf.js +++ b/packages/util/karma.conf.js @@ -15,8 +15,6 @@ * limitations under the License. */ -const karma = require('karma'); -const path = require('path'); const karmaBase = require('../../config/karma.base'); const files = [`test/**/*`]; @@ -24,7 +22,7 @@ const files = [`test/**/*`]; module.exports = function (config) { const karmaConfig = Object.assign({}, karmaBase, { // files to load into karma - files: files, + files, // frameworks to use // available frameworks: https://npmjs.org/browse/keyword/karma-adapter frameworks: ['mocha'] diff --git a/packages/util/src/environment.ts b/packages/util/src/environment.ts index bc35bca5eb6..8e903010564 100644 --- a/packages/util/src/environment.ts +++ b/packages/util/src/environment.ts @@ -140,7 +140,7 @@ export function isSafari(): boolean { * @return true if indexedDB is supported by current browser/service worker context */ export function isIndexedDBAvailable(): boolean { - return 'indexedDB' in self && indexedDB !== null; + return 'indexedDB' in self && indexedDB != null; } /** diff --git a/packages/remote-config/src/client/exponential_backoff.ts b/packages/util/src/exponential_backoff.ts similarity index 86% rename from packages/remote-config/src/client/exponential_backoff.ts rename to packages/util/src/exponential_backoff.ts index bb5581eed0b..5b75ee02c69 100644 --- a/packages/remote-config/src/client/exponential_backoff.ts +++ b/packages/util/src/exponential_backoff.ts @@ -18,13 +18,13 @@ /** * The amount of milliseconds to exponentially increase. */ -const INTERVAL_MILLIS = 1000; +const DEFAULT_INTERVAL_MILLIS = 1000; /** * The factor to backoff by. * Should be a number greater than 1. */ -const BACKOFF_FACTOR = 2; +const DEFAULT_BACKOFF_FACTOR = 2; /** * The maximum milliseconds to increase to. @@ -48,12 +48,15 @@ export const RANDOM_FACTOR = 0.5; * https://github.com/google/closure-library/blob/master/closure/goog/math/exponentialbackoff.js. * Extracted here so we don't need to pass metadata and a stateful ExponentialBackoff object around. */ -export function calculateBackoffMillis(backoffCount: number): number { +export function calculateBackoffMillis( + backoffCount: number, + intervalMillis: number = DEFAULT_INTERVAL_MILLIS, + backoffFactor: number = DEFAULT_BACKOFF_FACTOR +): number { // Calculates an exponentially increasing value. // Deviation: calculates value from count and a constant interval, so we only need to save value // and count to restore state. - const currBaseValue = - INTERVAL_MILLIS * Math.pow(BACKOFF_FACTOR, backoffCount); + const currBaseValue = intervalMillis * Math.pow(backoffFactor, backoffCount); // A random "fuzz" to avoid waves of retries. // Deviation: randomFactor is required. diff --git a/packages/remote-config/test/client/exponential_backoff.test.ts b/packages/util/test/exponential_backoff.test.ts similarity index 97% rename from packages/remote-config/test/client/exponential_backoff.test.ts rename to packages/util/test/exponential_backoff.test.ts index 04849ded295..5e6d636a0e6 100644 --- a/packages/remote-config/test/client/exponential_backoff.test.ts +++ b/packages/util/test/exponential_backoff.test.ts @@ -20,7 +20,7 @@ import { calculateBackoffMillis, MAX_VALUE_MILLIS, RANDOM_FACTOR -} from '../../src/client/exponential_backoff'; +} from '../src/exponential_backoff'; describe('ExponentialBackoff', () => { // Based on