Skip to content

Make Analytics environment checks warnings instead of errors #3836

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Sep 30, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lucky-squids-explode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@firebase/analytics': minor
---

Analytics now warns instead of throwing if it detects a browser environment where analytics does not work.
181 changes: 122 additions & 59 deletions packages/analytics/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { findGtagScriptOnPage } from './src/helpers';
import { removeGtagScript } from './testing/gtag-script-util';
import { Deferred } from '@firebase/util';
import { AnalyticsError } from './src/errors';
import { FirebaseInstallations } from '@firebase/installations-types';

let analyticsInstance: FirebaseAnalytics = {} as FirebaseAnalytics;
const fakeMeasurementId = 'abcd-efgh';
Expand All @@ -44,6 +45,15 @@ const customGtagName = 'customGtag';
const customDataLayerName = 'customDataLayer';
let clock: sinon.SinonFakeTimers;

// Fake indexedDB.open() request
let fakeRequest = {
onsuccess: () => {},
result: {
close: () => {}
}
};
let idbOpenStub = stub();

function stubFetch(status: number, body: object): void {
fetchStub = stub(window, 'fetch');
const mockResponse = new Response(JSON.stringify(body), {
Expand All @@ -52,6 +62,18 @@ function stubFetch(status: number, body: object): void {
fetchStub.returns(Promise.resolve(mockResponse));
}

// Stub indexedDB.open() because sinon's clock does not know
// how to wait for the real indexedDB callbacks to resolve.
function stubIdbOpen(): void {
(fakeRequest = {
onsuccess: () => {},
result: {
close: () => {}
}
}),
(idbOpenStub = stub(indexedDB, 'open').returns(fakeRequest as any));
}

describe('FirebaseAnalytics instance tests', () => {
describe('Initialization', () => {
beforeEach(() => resetGlobalVars());
Expand Down Expand Up @@ -83,65 +105,6 @@ describe('FirebaseAnalytics instance tests', () => {
);
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();
Expand All @@ -166,6 +129,7 @@ describe('FirebaseAnalytics instance tests', () => {
window['gtag'] = gtagStub;
window['dataLayer'] = [];
stubFetch(200, { measurementId: fakeMeasurementId });
stubIdbOpen();
analyticsInstance = analyticsFactory(app, installations);
});
after(() => {
Expand All @@ -174,6 +138,7 @@ describe('FirebaseAnalytics instance tests', () => {
removeGtagScript();
fetchStub.restore();
clock.restore();
idbOpenStub.restore();
});
it('Contains reference to parent app', () => {
expect(analyticsInstance.app).to.equal(app);
Expand All @@ -182,6 +147,8 @@ describe('FirebaseAnalytics instance tests', () => {
analyticsInstance.logEvent(EventName.ADD_PAYMENT_INFO, {
currency: 'USD'
});
// Successfully resolves fake IDB open request.
fakeRequest.onsuccess();
// Clear promise chain started by logEvent.
await clock.runAllAsync();
expect(gtagStub).to.have.been.calledWith('js');
Expand Down Expand Up @@ -219,6 +186,94 @@ describe('FirebaseAnalytics instance tests', () => {
});
});

describe('Standard app, mismatched environment', () => {
let app: FirebaseApp = {} as FirebaseApp;
let installations: FirebaseInstallations = {} as FirebaseInstallations;
const gtagStub: SinonStub = stub();
let fidDeferred: Deferred<void>;
let warnStub: SinonStub;
let cookieStub: SinonStub;
beforeEach(() => {
clock = useFakeTimers();
resetGlobalVars();
app = getFakeApp(fakeAppParams);
fidDeferred = new Deferred<void>();
installations = getFakeInstallations('fid-1234', () =>
fidDeferred.resolve()
);
window['gtag'] = gtagStub;
window['dataLayer'] = [];
stubFetch(200, { measurementId: fakeMeasurementId });
warnStub = stub(console, 'warn');
stubIdbOpen();
});
afterEach(() => {
delete window['gtag'];
delete window['dataLayer'];
fetchStub.restore();
clock.restore();
warnStub.restore();
idbOpenStub.restore();
gtagStub.resetHistory();
});
it('Warns on initialization if cookies not available', async () => {
cookieStub = stub(navigator, 'cookieEnabled').value(false);
analyticsInstance = analyticsFactory(app, installations);
expect(warnStub.args[0][1]).to.include(
AnalyticsError.INVALID_ANALYTICS_CONTEXT
);
expect(warnStub.args[0][1]).to.include('Cookies');
cookieStub.restore();
});
it('Warns on initialization if in browser extension', async () => {
window.chrome = { runtime: { id: 'blah' } };
analyticsInstance = analyticsFactory(app, installations);
expect(warnStub.args[0][1]).to.include(
AnalyticsError.INVALID_ANALYTICS_CONTEXT
);
expect(warnStub.args[0][1]).to.include('browser extension');
window.chrome = undefined;
});
it('Warns on logEvent if indexedDB API not available', async () => {
const idbStub = stub(window, 'indexedDB').value(undefined);
analyticsInstance = analyticsFactory(app, installations);
analyticsInstance.logEvent(EventName.ADD_PAYMENT_INFO, {
currency: 'USD'
});
// Clear promise chain started by logEvent.
await clock.runAllAsync();
// gtag config call omits FID
expect(gtagStub).to.be.calledWith('config', 'abcd-efgh', {
update: true,
origin: 'firebase'
});
expect(warnStub.args[0][1]).to.include(
AnalyticsError.INDEXEDDB_UNAVAILABLE
);
expect(warnStub.args[0][1]).to.include('IndexedDB is not available');
idbStub.restore();
});
it('Warns on logEvent if indexedDB.open() not allowed', async () => {
idbOpenStub.restore();
idbOpenStub = stub(indexedDB, 'open').throws('idb open error test');
analyticsInstance = analyticsFactory(app, installations);
analyticsInstance.logEvent(EventName.ADD_PAYMENT_INFO, {
currency: 'USD'
});
// Clear promise chain started by logEvent.
await clock.runAllAsync();
// gtag config call omits FID
expect(gtagStub).to.be.calledWith('config', 'abcd-efgh', {
update: true,
origin: 'firebase'
});
expect(warnStub.args[0][1]).to.include(
AnalyticsError.INDEXEDDB_UNAVAILABLE
);
expect(warnStub.args[0][1]).to.include('idb open error test');
});
});

describe('Page has user gtag script with custom gtag and dataLayer names', () => {
let app: FirebaseApp = {} as FirebaseApp;
let fidDeferred: Deferred<void>;
Expand All @@ -237,6 +292,7 @@ describe('FirebaseAnalytics instance tests', () => {
dataLayerName: customDataLayerName,
gtagName: customGtagName
});
stubIdbOpen();
stubFetch(200, { measurementId: fakeMeasurementId });
analyticsInstance = analyticsFactory(app, installations);
});
Expand All @@ -246,11 +302,14 @@ describe('FirebaseAnalytics instance tests', () => {
removeGtagScript();
fetchStub.restore();
clock.restore();
idbOpenStub.restore();
});
it('Calls gtag correctly on logEvent (instance)', async () => {
analyticsInstance.logEvent(EventName.ADD_PAYMENT_INFO, {
currency: 'USD'
});
// Successfully resolves fake IDB open request.
fakeRequest.onsuccess();
// Clear promise chain started by logEvent.
await clock.runAllAsync();
expect(gtagStub).to.have.been.calledWith('js');
Expand Down Expand Up @@ -280,9 +339,12 @@ describe('FirebaseAnalytics instance tests', () => {
const app = getFakeApp(fakeAppParams);
const installations = getFakeInstallations();
stubFetch(200, {});
stubIdbOpen();
analyticsInstance = analyticsFactory(app, installations);

const { initializationPromisesMap } = getGlobalVars();
// Successfully resolves fake IDB open request.
fakeRequest.onsuccess();
await initializationPromisesMap[fakeAppParams.appId];
expect(findGtagScriptOnPage()).to.not.be.null;
expect(typeof window['gtag']).to.equal('function');
Expand All @@ -292,6 +354,7 @@ describe('FirebaseAnalytics instance tests', () => {
delete window['dataLayer'];
removeGtagScript();
fetchStub.restore();
idbOpenStub.restore();
});
});
});
1 change: 1 addition & 0 deletions packages/analytics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ declare global {
* Type constant for Firebase Analytics.
*/
const ANALYTICS_TYPE = 'analytics';

export function registerAnalytics(instance: _FirebaseNamespace): void {
instance.INTERNAL.registerComponent(
new Component(
Expand Down
35 changes: 12 additions & 23 deletions packages/analytics/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,11 @@ export const enum AnalyticsError {
ALREADY_INITIALIZED = 'already-initialized',
INTEROP_COMPONENT_REG_FAILED = 'interop-component-reg-failed',
INVALID_ANALYTICS_CONTEXT = 'invalid-analytics-context',
INDEXEDDB_UNAVAILABLE = 'indexeddb-unavailable',
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'
NO_APP_ID = 'no-app-id'
}

const ERRORS: ErrorMap<AnalyticsError> = {
Expand All @@ -42,16 +40,14 @@ const ERRORS: ErrorMap<AnalyticsError> = {
'or it will have no effect.',
[AnalyticsError.INTEROP_COMPONENT_REG_FAILED]:
'Firebase Analytics Interop Component failed to instantiate: {$reason}',
[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.',
[AnalyticsError.INVALID_ANALYTICS_CONTEXT]:
'Firebase Analytics is not supported in browser extensions.',
'Firebase Analytics is not supported in this environment. ' +
'Wrap initialization of analytics in analytics.isSupported() ' +
'to prevent initialization in unsupported environments. Details: {$errorInfo}',
[AnalyticsError.INDEXEDDB_UNAVAILABLE]:
'IndexedDB unavailable or restricted in this environment. ' +
'Wrap initialization of analytics in analytics.isSupported() ' +
'to prevent initialization in unsupported environments. Details: {$errorInfo}',
[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}.',
Expand All @@ -62,15 +58,7 @@ const ERRORS: ErrorMap<AnalyticsError> = {
'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.'
'contain a valid app ID.'
};

interface ErrorParams {
Expand All @@ -81,7 +69,8 @@ interface ErrorParams {
httpStatus: number;
responseMessage: string;
};
[AnalyticsError.INVALID_INDEXED_DB_CONTEXT]: { errorInfo: string };
[AnalyticsError.INVALID_ANALYTICS_CONTEXT]: { errorInfo: string };
[AnalyticsError.INDEXEDDB_UNAVAILABLE]: { errorInfo: string };
}

export const ERROR_FACTORY = new ErrorFactory<AnalyticsError, ErrorParams>(
Expand Down
Loading