Skip to content

Commit

Permalink
Use Dynamic Measurement ID in Analytics (#2800)
Browse files Browse the repository at this point in the history
  • Loading branch information
hsubox76 committed Sep 8, 2020
1 parent d347c6c commit fb3b095
Show file tree
Hide file tree
Showing 32 changed files with 1,711 additions and 375 deletions.
6 changes: 6 additions & 0 deletions .changeset/swift-pillows-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@firebase/remote-config': patch
---

Moved `calculateBackoffMillis()` exponential backoff function to util and have remote-config
import it from util.
6 changes: 6 additions & 0 deletions .changeset/thin-rivers-relax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@firebase/util': patch
---

Moved `calculateBackoffMillis()` exponential backoff function from remote-config to util,
where it can be shared between packages.
8 changes: 8 additions & 0 deletions .changeset/witty-deers-study.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion config/ci.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
"databaseURL": "https://jscore-sandbox-141b5.firebaseio.com",
"projectId": "jscore-sandbox-141b5",
"storageBucket": "jscore-sandbox-141b5.appspot.com",
"messagingSenderId": "280127633210"
"messagingSenderId": "280127633210",
"appId": "1:280127633210:web:1eb2f7e8799c4d5a46c203"
}
31 changes: 31 additions & 0 deletions packages/analytics-types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
26 changes: 26 additions & 0 deletions packages/analytics/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -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]
}
]
}
};
186 changes: 141 additions & 45 deletions packages/analytics/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -34,51 +34,146 @@ import { GtagCommand, EventName } from './src/constants';
import { findGtagScriptOnPage } from './src/helpers';
import { removeGtagScript } from './testing/gtag-script-util';
import { Deferred } from '@firebase/util';
import { AnalyticsError } from './src/errors';

let analyticsInstance: FirebaseAnalytics = {} as FirebaseAnalytics;
const analyticsId = 'abcd-efgh';
const gtagStub: SinonStub = stub();
const fakeMeasurementId = 'abcd-efgh';
const fakeAppParams = { appId: 'abcdefgh12345:23405', apiKey: 'AAbbCCdd12345' };
let fetchStub: SinonStub = stub();
const customGtagName = 'customGtag';
const customDataLayerName = 'customDataLayer';
let clock: sinon.SinonFakeTimers;

describe('FirebaseAnalytics instance tests', () => {
it('Throws if no analyticsId in config', () => {
const app = getFakeApp();
const installations = getFakeInstallations();
expect(() => analyticsFactory(app, installations)).to.throw(
'field is empty'
);
function stubFetch(status: number, body: object): void {
fetchStub = stub(window, 'fetch');
const mockResponse = new Response(JSON.stringify(body), {
status
});
it('Throws if creating an instance with already-used analytics ID', () => {
const app = getFakeApp(analyticsId);
const installations = getFakeInstallations();
resetGlobalVars(false, { [analyticsId]: Promise.resolve() });
expect(() => analyticsFactory(app, installations)).to.throw(
'already exists'
);
fetchStub.returns(Promise.resolve(mockResponse));
}

describe('FirebaseAnalytics instance tests', () => {
describe('Initialization', () => {
beforeEach(() => resetGlobalVars());

it('Throws if no appId in config', () => {
const app = getFakeApp({ apiKey: fakeAppParams.apiKey });
const installations = getFakeInstallations();
expect(() => analyticsFactory(app, installations)).to.throw(
AnalyticsError.NO_APP_ID
);
});
it('Throws if no apiKey or measurementId in config', () => {
const app = getFakeApp({ appId: fakeAppParams.appId });
const installations = getFakeInstallations();
expect(() => analyticsFactory(app, installations)).to.throw(
AnalyticsError.NO_API_KEY
);
});
it('Warns if config has no apiKey but does have a measurementId', () => {
const warnStub = stub(console, 'warn');
const app = getFakeApp({
appId: fakeAppParams.appId,
measurementId: fakeMeasurementId
});
const installations = getFakeInstallations();
analyticsFactory(app, installations);
expect(warnStub.args[0][1]).to.include(
`Falling back to the measurement ID ${fakeMeasurementId}`
);
warnStub.restore();
});
it('Throws if cookies are not enabled', () => {
const cookieStub = stub(navigator, 'cookieEnabled').value(false);
const app = getFakeApp({
appId: fakeAppParams.appId,
apiKey: fakeAppParams.apiKey
});
const installations = getFakeInstallations();
expect(() => analyticsFactory(app, installations)).to.throw(
AnalyticsError.COOKIES_NOT_ENABLED
);
cookieStub.restore();
});
it('Throws if browser extension environment', () => {
window.chrome = { runtime: { id: 'blah' } };
const app = getFakeApp({
appId: fakeAppParams.appId,
apiKey: fakeAppParams.apiKey
});
const installations = getFakeInstallations();
expect(() => analyticsFactory(app, installations)).to.throw(
AnalyticsError.INVALID_ANALYTICS_CONTEXT
);
window.chrome = undefined;
});
it('Throws if indexedDB does not exist', () => {
const idbStub = stub(window, 'indexedDB').value(undefined);
const app = getFakeApp({
appId: fakeAppParams.appId,
apiKey: fakeAppParams.apiKey
});
const installations = getFakeInstallations();
expect(() => analyticsFactory(app, installations)).to.throw(
AnalyticsError.INDEXED_DB_UNSUPPORTED
);
idbStub.restore();
});
it('Warns eventually if indexedDB.open() does not work', async () => {
clock = useFakeTimers();
stubFetch(200, { measurementId: fakeMeasurementId });
const warnStub = stub(console, 'warn');
const idbOpenStub = stub(indexedDB, 'open').throws(
'idb open throw message'
);
const app = getFakeApp({
appId: fakeAppParams.appId,
apiKey: fakeAppParams.apiKey
});
const installations = getFakeInstallations();
analyticsFactory(app, installations);
await clock.runAllAsync();
expect(warnStub.args[0][1]).to.include(
AnalyticsError.INVALID_INDEXED_DB_CONTEXT
);
expect(warnStub.args[0][1]).to.include('idb open throw message');
warnStub.restore();
idbOpenStub.restore();
fetchStub.restore();
clock.restore();
});
it('Throws if creating an instance with already-used appId', () => {
const app = getFakeApp(fakeAppParams);
const installations = getFakeInstallations();
resetGlobalVars(false, { [fakeAppParams.appId]: Promise.resolve() });
expect(() => analyticsFactory(app, installations)).to.throw(
AnalyticsError.ALREADY_EXISTS
);
});
});
describe('Standard app, page already has user gtag script', () => {
let app: FirebaseApp = {} as FirebaseApp;
let fidDeferred: Deferred<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);
Expand All @@ -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',
Expand Down Expand Up @@ -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()
Expand All @@ -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',
Expand All @@ -179,23 +275,23 @@ describe('FirebaseAnalytics instance tests', () => {
});

describe('Page has no existing gtag script or dataLayer', () => {
before(() => {
it('Adds the script tag to the page', async () => {
resetGlobalVars();
const app = getFakeApp(analyticsId);
const app = getFakeApp(fakeAppParams);
const installations = getFakeInstallations();
stubFetch(200, {});
analyticsInstance = analyticsFactory(app, installations);
});
after(() => {
delete window['gtag'];
delete window['dataLayer'];
removeGtagScript();
});
it('Adds the script tag to the page', async () => {
const { initializedIdPromisesMap } = getGlobalVars();
await initializedIdPromisesMap[analyticsId];

const { initializationPromisesMap } = getGlobalVars();
await initializationPromisesMap[fakeAppParams.appId];
expect(findGtagScriptOnPage()).to.not.be.null;
expect(typeof window['gtag']).to.equal('function');
expect(Array.isArray(window['dataLayer'])).to.be.true;

delete window['gtag'];
delete window['dataLayer'];
removeGtagScript();
fetchStub.restore();
});
});
});
2 changes: 1 addition & 1 deletion packages/analytics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -61,6 +60,7 @@ export function registerAnalytics(instance: _FirebaseNamespace): void {
const installations = container
.getProvider('installations')
.getImmediate();

return factory(app, installations);
},
ComponentType.PUBLIC
Expand Down
Loading

0 comments on commit fb3b095

Please sign in to comment.