mirror of
https://github.com/hanzoai/firebase-js-sdk.git
synced 2026-09-24 16:59:10 +00:00
Use Dynamic Measurement ID in Analytics (#2800)
This commit is contained in:
committed by
GitHub
parent
e0fe5646d2
commit
e97086a33e
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@firebase/remote-config': patch
|
||||
---
|
||||
|
||||
Moved `calculateBackoffMillis()` exponential backoff function to util and have remote-config
|
||||
import it from util.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@firebase/util': patch
|
||||
---
|
||||
|
||||
Moved `calculateBackoffMillis()` exponential backoff function from remote-config to util,
|
||||
where it can be shared between packages.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Vendored
+31
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`];
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+8
-5
@@ -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.
|
||||
+1
-1
@@ -20,7 +20,7 @@ import {
|
||||
calculateBackoffMillis,
|
||||
MAX_VALUE_MILLIS,
|
||||
RANDOM_FACTOR
|
||||
} from '../../src/client/exponential_backoff';
|
||||
} from '../src/exponential_backoff';
|
||||
|
||||
describe('ExponentialBackoff', () => {
|
||||
// Based on
|
||||
Reference in New Issue
Block a user