Skip to content

Commit fb3b095

Browse files
authored
Use Dynamic Measurement ID in Analytics (#2800)
1 parent d347c6c commit fb3b095

32 files changed

+1711
-375
lines changed

.changeset/swift-pillows-retire.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@firebase/remote-config': patch
3+
---
4+
5+
Moved `calculateBackoffMillis()` exponential backoff function to util and have remote-config
6+
import it from util.

.changeset/thin-rivers-relax.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@firebase/util': patch
3+
---
4+
5+
Moved `calculateBackoffMillis()` exponential backoff function from remote-config to util,
6+
where it can be shared between packages.

.changeset/witty-deers-study.md

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@firebase/analytics': minor
3+
'@firebase/analytics-types': minor
4+
---
5+
6+
Analytics now dynamically fetches the app's Measurement ID from the Dynamic Config backend
7+
instead of depending on the local Firebase config. It will fall back to any `measurementId`
8+
value found in the local config if the Dynamic Config fetch fails.

config/ci.config.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@
44
"databaseURL": "https://jscore-sandbox-141b5.firebaseio.com",
55
"projectId": "jscore-sandbox-141b5",
66
"storageBucket": "jscore-sandbox-141b5.appspot.com",
7-
"messagingSenderId": "280127633210"
7+
"messagingSenderId": "280127633210",
8+
"appId": "1:280127633210:web:1eb2f7e8799c4d5a46c203"
89
}

packages/analytics-types/index.d.ts

+31
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,37 @@ export interface Promotion {
235235
name?: string;
236236
}
237237

238+
/**
239+
* Dynamic configuration fetched from server.
240+
* See https://firebase.google.com/docs/projects/api/reference/rest/v1beta1/projects.webApps/getConfig
241+
*/
242+
interface DynamicConfig {
243+
projectId: string;
244+
appId: string;
245+
databaseURL: string;
246+
storageBucket: string;
247+
locationId: string;
248+
apiKey: string;
249+
authDomain: string;
250+
messagingSenderId: string;
251+
measurementId: string;
252+
}
253+
254+
interface MinimalDynamicConfig {
255+
appId: string;
256+
measurementId: string;
257+
}
258+
259+
/**
260+
* Encapsulates metadata concerning throttled fetch requests.
261+
*/
262+
export interface ThrottleMetadata {
263+
// The number of times fetch has backed off. Used for resuming backoff after a timeout.
264+
backoffCount: number;
265+
// The Unix timestamp in milliseconds when callers can retry a request.
266+
throttleEndTimeMillis: number;
267+
}
268+
238269
declare module '@firebase/component' {
239270
interface NameServiceMapping {
240271
'analytics': FirebaseAnalytics;

packages/analytics/.eslintrc.js

+26
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,33 @@
1+
/**
2+
* @license
3+
* Copyright 2020 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
const path = require('path');
18+
119
module.exports = {
220
'extends': '../../config/.eslintrc.js',
321
'parserOptions': {
422
'project': 'tsconfig.json',
523
'tsconfigRootDir': __dirname
24+
},
25+
rules: {
26+
'import/no-extraneous-dependencies': [
27+
'error',
28+
{
29+
'packageDir': [path.resolve(__dirname, '../../'), __dirname]
30+
}
31+
]
632
}
733
};

packages/analytics/index.test.ts

+141-45
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
import { expect } from 'chai';
1919
import { FirebaseAnalytics } from '@firebase/analytics-types';
20-
import { SinonStub, stub } from 'sinon';
20+
import { SinonStub, stub, useFakeTimers } from 'sinon';
2121
import './testing/setup';
2222
import {
2323
settings as analyticsSettings,
@@ -34,51 +34,146 @@ import { GtagCommand, EventName } from './src/constants';
3434
import { findGtagScriptOnPage } from './src/helpers';
3535
import { removeGtagScript } from './testing/gtag-script-util';
3636
import { Deferred } from '@firebase/util';
37+
import { AnalyticsError } from './src/errors';
3738

3839
let analyticsInstance: FirebaseAnalytics = {} as FirebaseAnalytics;
39-
const analyticsId = 'abcd-efgh';
40-
const gtagStub: SinonStub = stub();
40+
const fakeMeasurementId = 'abcd-efgh';
41+
const fakeAppParams = { appId: 'abcdefgh12345:23405', apiKey: 'AAbbCCdd12345' };
42+
let fetchStub: SinonStub = stub();
4143
const customGtagName = 'customGtag';
4244
const customDataLayerName = 'customDataLayer';
45+
let clock: sinon.SinonFakeTimers;
4346

44-
describe('FirebaseAnalytics instance tests', () => {
45-
it('Throws if no analyticsId in config', () => {
46-
const app = getFakeApp();
47-
const installations = getFakeInstallations();
48-
expect(() => analyticsFactory(app, installations)).to.throw(
49-
'field is empty'
50-
);
47+
function stubFetch(status: number, body: object): void {
48+
fetchStub = stub(window, 'fetch');
49+
const mockResponse = new Response(JSON.stringify(body), {
50+
status
5151
});
52-
it('Throws if creating an instance with already-used analytics ID', () => {
53-
const app = getFakeApp(analyticsId);
54-
const installations = getFakeInstallations();
55-
resetGlobalVars(false, { [analyticsId]: Promise.resolve() });
56-
expect(() => analyticsFactory(app, installations)).to.throw(
57-
'already exists'
58-
);
52+
fetchStub.returns(Promise.resolve(mockResponse));
53+
}
54+
55+
describe('FirebaseAnalytics instance tests', () => {
56+
describe('Initialization', () => {
57+
beforeEach(() => resetGlobalVars());
58+
59+
it('Throws if no appId in config', () => {
60+
const app = getFakeApp({ apiKey: fakeAppParams.apiKey });
61+
const installations = getFakeInstallations();
62+
expect(() => analyticsFactory(app, installations)).to.throw(
63+
AnalyticsError.NO_APP_ID
64+
);
65+
});
66+
it('Throws if no apiKey or measurementId in config', () => {
67+
const app = getFakeApp({ appId: fakeAppParams.appId });
68+
const installations = getFakeInstallations();
69+
expect(() => analyticsFactory(app, installations)).to.throw(
70+
AnalyticsError.NO_API_KEY
71+
);
72+
});
73+
it('Warns if config has no apiKey but does have a measurementId', () => {
74+
const warnStub = stub(console, 'warn');
75+
const app = getFakeApp({
76+
appId: fakeAppParams.appId,
77+
measurementId: fakeMeasurementId
78+
});
79+
const installations = getFakeInstallations();
80+
analyticsFactory(app, installations);
81+
expect(warnStub.args[0][1]).to.include(
82+
`Falling back to the measurement ID ${fakeMeasurementId}`
83+
);
84+
warnStub.restore();
85+
});
86+
it('Throws if cookies are not enabled', () => {
87+
const cookieStub = stub(navigator, 'cookieEnabled').value(false);
88+
const app = getFakeApp({
89+
appId: fakeAppParams.appId,
90+
apiKey: fakeAppParams.apiKey
91+
});
92+
const installations = getFakeInstallations();
93+
expect(() => analyticsFactory(app, installations)).to.throw(
94+
AnalyticsError.COOKIES_NOT_ENABLED
95+
);
96+
cookieStub.restore();
97+
});
98+
it('Throws if browser extension environment', () => {
99+
window.chrome = { runtime: { id: 'blah' } };
100+
const app = getFakeApp({
101+
appId: fakeAppParams.appId,
102+
apiKey: fakeAppParams.apiKey
103+
});
104+
const installations = getFakeInstallations();
105+
expect(() => analyticsFactory(app, installations)).to.throw(
106+
AnalyticsError.INVALID_ANALYTICS_CONTEXT
107+
);
108+
window.chrome = undefined;
109+
});
110+
it('Throws if indexedDB does not exist', () => {
111+
const idbStub = stub(window, 'indexedDB').value(undefined);
112+
const app = getFakeApp({
113+
appId: fakeAppParams.appId,
114+
apiKey: fakeAppParams.apiKey
115+
});
116+
const installations = getFakeInstallations();
117+
expect(() => analyticsFactory(app, installations)).to.throw(
118+
AnalyticsError.INDEXED_DB_UNSUPPORTED
119+
);
120+
idbStub.restore();
121+
});
122+
it('Warns eventually if indexedDB.open() does not work', async () => {
123+
clock = useFakeTimers();
124+
stubFetch(200, { measurementId: fakeMeasurementId });
125+
const warnStub = stub(console, 'warn');
126+
const idbOpenStub = stub(indexedDB, 'open').throws(
127+
'idb open throw message'
128+
);
129+
const app = getFakeApp({
130+
appId: fakeAppParams.appId,
131+
apiKey: fakeAppParams.apiKey
132+
});
133+
const installations = getFakeInstallations();
134+
analyticsFactory(app, installations);
135+
await clock.runAllAsync();
136+
expect(warnStub.args[0][1]).to.include(
137+
AnalyticsError.INVALID_INDEXED_DB_CONTEXT
138+
);
139+
expect(warnStub.args[0][1]).to.include('idb open throw message');
140+
warnStub.restore();
141+
idbOpenStub.restore();
142+
fetchStub.restore();
143+
clock.restore();
144+
});
145+
it('Throws if creating an instance with already-used appId', () => {
146+
const app = getFakeApp(fakeAppParams);
147+
const installations = getFakeInstallations();
148+
resetGlobalVars(false, { [fakeAppParams.appId]: Promise.resolve() });
149+
expect(() => analyticsFactory(app, installations)).to.throw(
150+
AnalyticsError.ALREADY_EXISTS
151+
);
152+
});
59153
});
60154
describe('Standard app, page already has user gtag script', () => {
61155
let app: FirebaseApp = {} as FirebaseApp;
62156
let fidDeferred: Deferred<void>;
157+
const gtagStub: SinonStub = stub();
63158
before(() => {
159+
clock = useFakeTimers();
64160
resetGlobalVars();
65-
app = getFakeApp(analyticsId);
161+
app = getFakeApp(fakeAppParams);
66162
fidDeferred = new Deferred<void>();
67163
const installations = getFakeInstallations('fid-1234', () =>
68164
fidDeferred.resolve()
69165
);
70-
71166
window['gtag'] = gtagStub;
72167
window['dataLayer'] = [];
168+
stubFetch(200, { measurementId: fakeMeasurementId });
73169
analyticsInstance = analyticsFactory(app, installations);
74170
});
75171
after(() => {
76172
delete window['gtag'];
77173
delete window['dataLayer'];
78174
removeGtagScript();
79-
});
80-
afterEach(() => {
81-
gtagStub.reset();
175+
fetchStub.restore();
176+
clock.restore();
82177
});
83178
it('Contains reference to parent app', () => {
84179
expect(analyticsInstance.app).to.equal(app);
@@ -87,13 +182,12 @@ describe('FirebaseAnalytics instance tests', () => {
87182
analyticsInstance.logEvent(EventName.ADD_PAYMENT_INFO, {
88183
currency: 'USD'
89184
});
90-
// Clear event stack of initialization promise.
91-
const { initializedIdPromisesMap } = getGlobalVars();
92-
await Promise.all(Object.values(initializedIdPromisesMap));
185+
// Clear promise chain started by logEvent.
186+
await clock.runAllAsync();
93187
expect(gtagStub).to.have.been.calledWith('js');
94188
expect(gtagStub).to.have.been.calledWith(
95189
GtagCommand.CONFIG,
96-
analyticsId,
190+
fakeMeasurementId,
97191
{
98192
'firebase_id': 'fid-1234',
99193
origin: 'firebase',
@@ -126,10 +220,13 @@ describe('FirebaseAnalytics instance tests', () => {
126220
});
127221

128222
describe('Page has user gtag script with custom gtag and dataLayer names', () => {
223+
let app: FirebaseApp = {} as FirebaseApp;
129224
let fidDeferred: Deferred<void>;
225+
const gtagStub: SinonStub = stub();
130226
before(() => {
227+
clock = useFakeTimers();
131228
resetGlobalVars();
132-
const app = getFakeApp(analyticsId);
229+
app = getFakeApp(fakeAppParams);
133230
fidDeferred = new Deferred<void>();
134231
const installations = getFakeInstallations('fid-1234', () =>
135232
fidDeferred.resolve()
@@ -140,27 +237,26 @@ describe('FirebaseAnalytics instance tests', () => {
140237
dataLayerName: customDataLayerName,
141238
gtagName: customGtagName
142239
});
240+
stubFetch(200, { measurementId: fakeMeasurementId });
143241
analyticsInstance = analyticsFactory(app, installations);
144242
});
145243
after(() => {
146244
delete window[customGtagName];
147245
delete window[customDataLayerName];
148246
removeGtagScript();
149-
});
150-
afterEach(() => {
151-
gtagStub.reset();
247+
fetchStub.restore();
248+
clock.restore();
152249
});
153250
it('Calls gtag correctly on logEvent (instance)', async () => {
154251
analyticsInstance.logEvent(EventName.ADD_PAYMENT_INFO, {
155252
currency: 'USD'
156253
});
157-
// Clear event stack of initialization promise.
158-
const { initializedIdPromisesMap } = getGlobalVars();
159-
await Promise.all(Object.values(initializedIdPromisesMap));
254+
// Clear promise chain started by logEvent.
255+
await clock.runAllAsync();
160256
expect(gtagStub).to.have.been.calledWith('js');
161257
expect(gtagStub).to.have.been.calledWith(
162258
GtagCommand.CONFIG,
163-
analyticsId,
259+
fakeMeasurementId,
164260
{
165261
'firebase_id': 'fid-1234',
166262
origin: 'firebase',
@@ -179,23 +275,23 @@ describe('FirebaseAnalytics instance tests', () => {
179275
});
180276

181277
describe('Page has no existing gtag script or dataLayer', () => {
182-
before(() => {
278+
it('Adds the script tag to the page', async () => {
183279
resetGlobalVars();
184-
const app = getFakeApp(analyticsId);
280+
const app = getFakeApp(fakeAppParams);
185281
const installations = getFakeInstallations();
282+
stubFetch(200, {});
186283
analyticsInstance = analyticsFactory(app, installations);
187-
});
188-
after(() => {
189-
delete window['gtag'];
190-
delete window['dataLayer'];
191-
removeGtagScript();
192-
});
193-
it('Adds the script tag to the page', async () => {
194-
const { initializedIdPromisesMap } = getGlobalVars();
195-
await initializedIdPromisesMap[analyticsId];
284+
285+
const { initializationPromisesMap } = getGlobalVars();
286+
await initializationPromisesMap[fakeAppParams.appId];
196287
expect(findGtagScriptOnPage()).to.not.be.null;
197288
expect(typeof window['gtag']).to.equal('function');
198289
expect(Array.isArray(window['dataLayer'])).to.be.true;
290+
291+
delete window['gtag'];
292+
delete window['dataLayer'];
293+
removeGtagScript();
294+
fetchStub.restore();
199295
});
200296
});
201297
});

packages/analytics/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ declare global {
5050
* Type constant for Firebase Analytics.
5151
*/
5252
const ANALYTICS_TYPE = 'analytics';
53-
5453
export function registerAnalytics(instance: _FirebaseNamespace): void {
5554
instance.INTERNAL.registerComponent(
5655
new Component(
@@ -61,6 +60,7 @@ export function registerAnalytics(instance: _FirebaseNamespace): void {
6160
const installations = container
6261
.getProvider('installations')
6362
.getImmediate();
63+
6464
return factory(app, installations);
6565
},
6666
ComponentType.PUBLIC

0 commit comments

Comments
 (0)