Use Dynamic Measurement ID in Analytics (#2800)

This commit is contained in:
Christina Holland
2020-09-08 10:55:40 -07:00
committed by GitHub
parent e0fe5646d2
commit e97086a33e
32 changed files with 1712 additions and 376 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@firebase/remote-config': patch
---
Moved `calculateBackoffMillis()` exponential backoff function to util and have remote-config
import it from util.
+6
View File
@@ -0,0 +1,6 @@
---
'@firebase/util': patch
---
Moved `calculateBackoffMillis()` exponential backoff function from remote-config to util,
where it can be shared between packages.
+8
View File
@@ -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.
+2 -1
View File
@@ -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"
}
+31
View File
@@ -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;
+26
View File
@@ -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]
}
]
}
};
+143 -47
View File
@@ -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;
function stubFetch(status: number, body: object): void {
fetchStub = stub(window, 'fetch');
const mockResponse = new Response(JSON.stringify(body), {
status
});
fetchStub.returns(Promise.resolve(mockResponse));
}
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'
);
});
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'
);
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<void>;
const gtagStub: SinonStub = stub();
before(() => {
clock = useFakeTimers();
resetGlobalVars();
app = getFakeApp(analyticsId);
app = getFakeApp(fakeAppParams);
fidDeferred = new Deferred<void>();
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<void>;
const gtagStub: SinonStub = stub();
before(() => {
clock = useFakeTimers();
resetGlobalVars();
const app = getFakeApp(analyticsId);
app = getFakeApp(fakeAppParams);
fidDeferred = new Deferred<void>();
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(() => {
resetGlobalVars();
const app = getFakeApp(analyticsId);
const installations = getFakeInstallations();
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];
resetGlobalVars();
const app = getFakeApp(fakeAppParams);
const installations = getFakeInstallations();
stubFetch(200, {});
analyticsInstance = analyticsFactory(app, installations);
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();
});
});
});
+1 -1
View File
@@ -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
@@ -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;
+8 -4
View File
@@ -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"
}
}
}
+5 -2
View File
@@ -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 {
+34 -12
View File
@@ -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> = {
[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> = {
[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 };
}
+116 -33
View File
@@ -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 gaId to FID fetch promises.
* 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 initializedIdPromisesMap: { [gaId: string]: Promise<void> } = {};
let initializationPromisesMap: {
[appId: string]: Promise<string>; // 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<Promise<
DynamicConfig | MinimalDynamicConfig
>> = [];
/**
* 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.
*/
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<void> };
initializationPromisesMap: { [appId: string]: Promise<string> };
dynamicConfigPromisesList: Array<
Promise<DynamicConfig | MinimalDynamicConfig>
>;
} {
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<void> => {
delete initializationPromisesMap[appId];
return Promise.resolve();
}
}
};
return analyticsInstance;
+65 -42
View File
@@ -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}`];
});
});
+36 -26
View File
@@ -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<string>,
eventName: string,
eventParams?: EventParams,
options?: AnalyticsCallOptions
): void {
let params: EventParams | ControlParams = eventParams || {};
if (!options || !options.global) {
params = { ...eventParams, 'send_to': analyticsId };
): Promise<void> {
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<string>,
screenName: string | null,
options?: AnalyticsCallOptions
): void {
): Promise<void> {
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<string>,
id: string | null,
options?: AnalyticsCallOptions
): void {
): Promise<void> {
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<string>,
properties: CustomParams,
options?: AnalyticsCallOptions
): void {
): Promise<void> {
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<string>,
enabled: boolean
): void {
window[`ga-disable-${analyticsId}`] = !enabled;
): Promise<void> {
const measurementId = await initializationPromise;
window[`ga-disable-${measurementId}`] = !enabled;
}
+259
View File
@@ -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();
}
);
});
});
+322
View File
@@ -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<DynamicConfig> {
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<DynamicConfig | MinimalDynamicConfig> {
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<DynamicConfig | MinimalDynamicConfig> {
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:
*
* <ul>
* <li>Promisifying setTimeout, so we can set a timeout in our Promise chain</li>
* <li>Listening on a signal bus for abort events, just like the Fetch API</li>
* <li>Failing in the same way the Fetch API fails, so timing out a live request and a throttled
* request appear the same.</li>
* </ul>
*
* <p>Visible for testing.
*/
function setAbortableTimeout(
signal: AnalyticsAbortSignal,
throttleEndTimeMillis: number
): Promise<void> {
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).
*
* <p>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());
}
}
+115 -74
View File
@@ -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<void>();
const initPromise2 = new Deferred<void>();
const initPromise1 = new Deferred<string>();
const initPromise2 = new Deferred<string>();
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<void>();
const initPromise2 = new Deferred<void>();
const initPromise1 = new Deferred<string>();
const initPromise2 = new Deferred<string>();
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<void>();
const initPromise2 = new Deferred<void>();
const initPromise1 = new Deferred<string>();
const initPromise2 = new Deferred<string>();
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<void>();
const initPromise2 = new Deferred<void>();
const initPromise1 = new Deferred<string>();
const initPromise2 = new Deferred<string>();
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<void>();
const initPromise1 = new Deferred<string>();
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<void>();
const initPromise2 = new Deferred<void>();
const initPromise1 = new Deferred<string>();
const initPromise2 = new Deferred<string>();
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<void>();
const initPromise2 = new Deferred<void>();
const initPromise1 = new Deferred<string>();
const initPromise2 = new Deferred<string>();
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<void>();
const initPromise2 = new Deferred<void>();
const initPromise1 = new Deferred<string>();
const initPromise2 = new Deferred<string>();
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<void>();
const initPromise2 = new Deferred<void>();
const initPromise1 = new Deferred<string>();
const initPromise2 = new Deferred<string>();
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<void>();
const initPromise1 = new Deferred<string>();
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'
}
+199 -110
View File
@@ -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<void> {
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<string> },
dynamicConfigPromisesList: Array<
Promise<DynamicConfig | MinimalDynamicConfig>
>,
measurementIdToAppId: { [measurementId: string]: string },
measurementId: string,
gtagParams?: ControlParams & EventParams & CustomParams
): Promise<void> {
// 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<string> },
dynamicConfigPromisesList: Array<
Promise<DynamicConfig | MinimalDynamicConfig>
>,
measurementId: string,
gtagParams?: ControlParams & EventParams & CustomParams
): Promise<void> {
try {
let initializationPromisesToWaitFor: Array<Promise<string>> = [];
// 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<void> }
/**
* 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<string> },
/**
* 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<DynamicConfig | MinimalDynamicConfig>
>,
/**
* 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<Promise<void>> = [];
// 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<void> {
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<void> },
initializationPromisesMap: { [appId: string]: Promise<string> },
dynamicConfigPromisesList: Array<
Promise<DynamicConfig | MinimalDynamicConfig>
>,
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,
@@ -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<Promise<DynamicConfig>> = [];
const measurementIdToAppId: { [key: string]: string } = {};
let app: FirebaseApp;
let installations: FirebaseInstallations;
let fidDeferred: Deferred<string>;
beforeEach(() => {
fidDeferred = new Deferred<string>();
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();
});
});
+93
View File
@@ -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<DynamicConfig | MinimalDynamicConfig>
>,
measurementIdToAppId: { [key: string]: string },
installations: FirebaseInstallations,
gtagCore: Gtag
): Promise<string> {
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;
}
@@ -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 () => {},
@@ -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<number> {
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();
});
});
-5
View File
@@ -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();
});
@@ -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:
@@ -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
);
}
+1
View File
@@ -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';
+1
View File
@@ -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';
+1 -3
View File
@@ -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']
+1 -1
View File
@@ -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;
}
/**
@@ -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.
@@ -20,7 +20,7 @@ import {
calculateBackoffMillis,
MAX_VALUE_MILLIS,
RANDOM_FACTOR
} from '../../src/client/exponential_backoff';
} from '../src/exponential_backoff';
describe('ExponentialBackoff', () => {
// Based on