diff --git a/.changeset/quick-moons-play.md b/.changeset/quick-moons-play.md new file mode 100644 index 00000000000..8b5f493a337 --- /dev/null +++ b/.changeset/quick-moons-play.md @@ -0,0 +1,5 @@ +--- +'@firebase/app': patch +--- + +Add heartbeat controller for platform logging. diff --git a/packages/app/package.json b/packages/app/package.json index fc3b696b263..915aa7eed9b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -40,6 +40,7 @@ "@firebase/util": "1.4.3", "@firebase/logger": "0.3.2", "@firebase/component": "0.5.10", + "idb": "3.0.2", "tslib": "^2.1.0" }, "license": "Apache-2.0", diff --git a/packages/app/src/errors.ts b/packages/app/src/errors.ts index b8bbae5c1b8..8c9742e69de 100644 --- a/packages/app/src/errors.ts +++ b/packages/app/src/errors.ts @@ -23,7 +23,11 @@ export const enum AppError { DUPLICATE_APP = 'duplicate-app', APP_DELETED = 'app-deleted', INVALID_APP_ARGUMENT = 'invalid-app-argument', - INVALID_LOG_ARGUMENT = 'invalid-log-argument' + INVALID_LOG_ARGUMENT = 'invalid-log-argument', + STORAGE_OPEN = 'storage-open', + STORAGE_GET = 'storage-get', + STORAGE_WRITE = 'storage-set', + STORAGE_DELETE = 'storage-delete' } const ERRORS: ErrorMap = { @@ -38,7 +42,15 @@ const ERRORS: ErrorMap = { 'firebase.{$appName}() takes either no argument or a ' + 'Firebase App instance.', [AppError.INVALID_LOG_ARGUMENT]: - 'First argument to `onLog` must be null or a function.' + 'First argument to `onLog` must be null or a function.', + [AppError.STORAGE_OPEN]: + 'Error thrown when opening storage. Original error: {$originalErrorMessage}.', + [AppError.STORAGE_GET]: + 'Error thrown when reading from storage. Original error: {$originalErrorMessage}.', + [AppError.STORAGE_WRITE]: + 'Error thrown when writing to storage. Original error: {$originalErrorMessage}.', + [AppError.STORAGE_DELETE]: + 'Error thrown when deleting from storage. Original error: {$originalErrorMessage}.' }; interface ErrorParams { @@ -47,6 +59,10 @@ interface ErrorParams { [AppError.DUPLICATE_APP]: { appName: string }; [AppError.APP_DELETED]: { appName: string }; [AppError.INVALID_APP_ARGUMENT]: { appName: string }; + [AppError.STORAGE_OPEN]: { originalErrorMessage?: string }; + [AppError.STORAGE_GET]: { originalErrorMessage?: string }; + [AppError.STORAGE_WRITE]: { originalErrorMessage?: string }; + [AppError.STORAGE_DELETE]: { originalErrorMessage?: string }; } export const ERROR_FACTORY = new ErrorFactory( diff --git a/packages/app/src/heartbeatService.test.ts b/packages/app/src/heartbeatService.test.ts new file mode 100644 index 00000000000..ad0d453b706 --- /dev/null +++ b/packages/app/src/heartbeatService.test.ts @@ -0,0 +1,320 @@ +/** + * @license + * Copyright 2021 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 '../test/setup'; +import { + countBytes, + HeartbeatServiceImpl, + extractHeartbeatsForHeader +} from './heartbeatService'; +import { + Component, + ComponentType, + ComponentContainer +} from '@firebase/component'; +import { PlatformLoggerService } from './types'; +import { FirebaseApp } from './public-types'; +import * as firebaseUtil from '@firebase/util'; +import { SinonStub, stub, useFakeTimers } from 'sinon'; +import * as indexedDb from './indexeddb'; +import { base64Encode, isIndexedDBAvailable } from '@firebase/util'; + +declare module '@firebase/component' { + interface NameServiceMapping { + 'platform-logger': PlatformLoggerService; + } +} + +const USER_AGENT_STRING_1 = 'vs1/1.2.3 vs2/2.3.4'; +const USER_AGENT_STRING_2 = 'different/1.2.3'; + +function generateUserAgentString(pairs: number): string { + let uaString = ''; + for (let i = 0; i < pairs; i++) { + uaString += `test-platform/${i % 10}.${i % 10}.${i % 10}`; + } + return uaString; +} + +function generateDates(count: number): string[] { + let currentTimestamp = Date.now(); + const dates = []; + for (let i = 0; i < count; i++) { + dates.push(new Date(currentTimestamp).toISOString().slice(0, 10)); + currentTimestamp += 24 * 60 * 60 * 1000; + } + return dates; +} + +describe('HeartbeatServiceImpl', () => { + describe('If IndexedDB has no entries', () => { + let heartbeatService: HeartbeatServiceImpl; + let clock = useFakeTimers(); + let userAgentString = USER_AGENT_STRING_1; + let writeStub: SinonStub; + before(() => { + const container = new ComponentContainer('heartbeatTestContainer'); + container.addComponent( + new Component( + 'app', + () => + ({ + options: { appId: 'an-app-id' }, + name: 'an-app-name' + } as FirebaseApp), + ComponentType.VERSION + ) + ); + container.addComponent( + new Component( + 'platform-logger', + () => ({ getPlatformInfoString: () => userAgentString }), + ComponentType.VERSION + ) + ); + heartbeatService = new HeartbeatServiceImpl(container); + }); + beforeEach(() => { + clock = useFakeTimers(); + writeStub = stub(heartbeatService._storage, 'overwrite'); + }); + /** + * NOTE: The clock is being reset between each test because of the global + * restore() in test/setup.ts. Don't assume previous clock state. + */ + it(`triggerHeartbeat() stores a heartbeat`, async () => { + await heartbeatService.triggerHeartbeat(); + expect(heartbeatService._heartbeatsCache?.length).to.equal(1); + const heartbeat1 = heartbeatService._heartbeatsCache?.[0]; + expect(heartbeat1?.userAgent).to.equal(USER_AGENT_STRING_1); + expect(heartbeat1?.date).to.equal('1970-01-01'); + expect(writeStub).to.be.calledWith([heartbeat1]); + }); + it(`triggerHeartbeat() doesn't store another heartbeat on the same day`, async () => { + expect(heartbeatService._heartbeatsCache?.length).to.equal(1); + await heartbeatService.triggerHeartbeat(); + expect(heartbeatService._heartbeatsCache?.length).to.equal(1); + }); + it(`triggerHeartbeat() does store another heartbeat on a different day`, async () => { + expect(heartbeatService._heartbeatsCache?.length).to.equal(1); + clock.tick(24 * 60 * 60 * 1000); + await heartbeatService.triggerHeartbeat(); + expect(heartbeatService._heartbeatsCache?.length).to.equal(2); + expect(heartbeatService._heartbeatsCache?.[1].date).to.equal( + '1970-01-02' + ); + }); + it(`triggerHeartbeat() stores another entry for a different user agent`, async () => { + userAgentString = USER_AGENT_STRING_2; + expect(heartbeatService._heartbeatsCache?.length).to.equal(2); + clock.tick(2 * 24 * 60 * 60 * 1000); + await heartbeatService.triggerHeartbeat(); + expect(heartbeatService._heartbeatsCache?.length).to.equal(3); + expect(heartbeatService._heartbeatsCache?.[2].date).to.equal( + '1970-01-03' + ); + }); + it('getHeartbeatHeaders() gets stored heartbeats and clears heartbeats', async () => { + const deleteStub = stub(heartbeatService._storage, 'deleteAll'); + const heartbeatHeaders = firebaseUtil.base64Decode( + await heartbeatService.getHeartbeatsHeader() + ); + expect(heartbeatHeaders).to.include(USER_AGENT_STRING_1); + expect(heartbeatHeaders).to.include(USER_AGENT_STRING_2); + expect(heartbeatHeaders).to.include('1970-01-01'); + expect(heartbeatHeaders).to.include('1970-01-02'); + expect(heartbeatHeaders).to.include('1970-01-03'); + expect(heartbeatHeaders).to.include(`"version":2`); + expect(heartbeatService._heartbeatsCache).to.equal(null); + const emptyHeaders = await heartbeatService.getHeartbeatsHeader(); + expect(emptyHeaders).to.equal(''); + expect(deleteStub).to.be.called; + }); + }); + describe('If IndexedDB has entries', () => { + let heartbeatService: HeartbeatServiceImpl; + let clock = useFakeTimers(); + let writeStub: SinonStub; + let userAgentString = USER_AGENT_STRING_1; + const mockIndexedDBHeartbeats = [ + // Chosen so one will exceed 30 day limit and one will not. + { + userAgent: 'old-user-agent', + date: '1969-12-01' + }, + { + userAgent: 'old-user-agent', + date: '1969-12-31' + } + ]; + before(() => { + const container = new ComponentContainer('heartbeatTestContainer'); + container.addComponent( + new Component( + 'app', + () => + ({ + options: { appId: 'an-app-id' }, + name: 'an-app-name' + } as FirebaseApp), + ComponentType.VERSION + ) + ); + container.addComponent( + new Component( + 'platform-logger', + () => ({ getPlatformInfoString: () => userAgentString }), + ComponentType.VERSION + ) + ); + stub(indexedDb, 'readHeartbeatsFromIndexedDB').resolves({ + heartbeats: [...mockIndexedDBHeartbeats] + }); + heartbeatService = new HeartbeatServiceImpl(container); + }); + beforeEach(() => { + clock = useFakeTimers(); + writeStub = stub(heartbeatService._storage, 'overwrite'); + }); + /** + * NOTE: The clock is being reset between each test because of the global + * restore() in test/setup.ts. Don't assume previous clock state. + */ + it(`new heartbeat service reads from indexedDB cache`, async () => { + const promiseResult = await heartbeatService._heartbeatsCachePromise; + if (isIndexedDBAvailable()) { + expect(promiseResult).to.deep.equal(mockIndexedDBHeartbeats); + expect(heartbeatService._heartbeatsCache).to.deep.equal( + mockIndexedDBHeartbeats + ); + } else { + // In Node or other no-indexed-db environments it will fail the + // `canUseIndexedDb` check and return an empty array. + expect(promiseResult).to.deep.equal([]); + expect(heartbeatService._heartbeatsCache).to.deep.equal([]); + } + }); + it(`triggerHeartbeat() writes new heartbeats and retains old ones newer than 30 days`, async () => { + userAgentString = USER_AGENT_STRING_2; + clock.tick(3 * 24 * 60 * 60 * 1000); + await heartbeatService.triggerHeartbeat(); + if (isIndexedDBAvailable()) { + expect(writeStub).to.be.calledWith([ + // The first entry exceeds the 30 day retention limit. + mockIndexedDBHeartbeats[1], + { userAgent: USER_AGENT_STRING_2, date: '1970-01-04' } + ]); + } else { + expect(writeStub).to.be.calledWith([ + { userAgent: USER_AGENT_STRING_2, date: '1970-01-04' } + ]); + } + }); + it('getHeartbeatHeaders() gets stored heartbeats and clears heartbeats', async () => { + const deleteStub = stub(heartbeatService._storage, 'deleteAll'); + const heartbeatHeaders = firebaseUtil.base64Decode( + await heartbeatService.getHeartbeatsHeader() + ); + if (isIndexedDBAvailable()) { + expect(heartbeatHeaders).to.include('old-user-agent'); + expect(heartbeatHeaders).to.include('1969-12-31'); + } + expect(heartbeatHeaders).to.include(USER_AGENT_STRING_2); + expect(heartbeatHeaders).to.include('1970-01-04'); + expect(heartbeatHeaders).to.include(`"version":2`); + expect(heartbeatService._heartbeatsCache).to.equal(null); + const emptyHeaders = await heartbeatService.getHeartbeatsHeader(); + expect(emptyHeaders).to.equal(''); + expect(deleteStub).to.be.called; + }); + }); + + describe('countBytes()', () => { + it('counts how many bytes there will be in a stringified, encoded header', () => { + const heartbeats = [ + { userAgent: generateUserAgentString(1), dates: generateDates(1) }, + { userAgent: generateUserAgentString(3), dates: generateDates(2) } + ]; + let size: number = 0; + const headerString = base64Encode( + JSON.stringify({ version: 2, heartbeats }) + ); + // Use independent methods to validate our byte count method matches. + // We don't use this measurement method in the app because user + // environments are much more unpredictable while we know the + // tests will run in either a standard headless browser or Node. + if (typeof Blob !== 'undefined') { + const blob = new Blob([headerString]); + size = blob.size; + } else if (typeof Buffer !== 'undefined') { + const buffer = Buffer.from(headerString); + size = buffer.byteLength; + } + expect(countBytes(heartbeats)).to.equal(size); + }); + }); + + describe('_extractHeartbeatsForHeader()', () => { + it('returns empty heartbeatsToKeep if it cannot get under maxSize', () => { + const heartbeats = [ + { userAgent: generateUserAgentString(1), date: '2022-01-01' } + ]; + const { unsentEntries, heartbeatsToSend } = extractHeartbeatsForHeader( + heartbeats, + 5 + ); + expect(heartbeatsToSend.length).to.equal(0); + expect(unsentEntries).to.deep.equal(heartbeats); + }); + it('splits heartbeats array', () => { + const heartbeats = [ + { userAgent: generateUserAgentString(20), date: '2022-01-01' }, + { userAgent: generateUserAgentString(4), date: '2022-01-02' } + ]; + const sizeWithHeartbeat0Only = countBytes([ + { userAgent: heartbeats[0].userAgent, dates: [heartbeats[0].date] } + ]); + const { unsentEntries, heartbeatsToSend } = extractHeartbeatsForHeader( + heartbeats, + sizeWithHeartbeat0Only + 1 + ); + expect(heartbeatsToSend.length).to.equal(1); + expect(unsentEntries.length).to.equal(1); + }); + it('splits the first heartbeat if needed', () => { + const uaString = generateUserAgentString(20); + const heartbeats = [ + { userAgent: uaString, date: '2022-01-01' }, + { userAgent: uaString, date: '2022-01-02' }, + { userAgent: uaString, date: '2022-01-03' } + ]; + const sizeWithHeartbeat0Only = countBytes([ + { userAgent: heartbeats[0].userAgent, dates: [heartbeats[0].date] } + ]); + const { unsentEntries, heartbeatsToSend } = extractHeartbeatsForHeader( + heartbeats, + sizeWithHeartbeat0Only + 1 + ); + expect(heartbeatsToSend.length).to.equal(1); + expect(unsentEntries.length).to.equal(2); + expect(heartbeatsToSend[0].dates.length + unsentEntries.length).to.equal( + heartbeats.length + ); + }); + }); +}); diff --git a/packages/app/src/heartbeatService.ts b/packages/app/src/heartbeatService.ts new file mode 100644 index 00000000000..88ada8c9cf9 --- /dev/null +++ b/packages/app/src/heartbeatService.ts @@ -0,0 +1,290 @@ +/** + * @license + * Copyright 2021 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 { ComponentContainer } from '@firebase/component'; +import { + base64Encode, + isIndexedDBAvailable, + validateIndexedDBOpenable +} from '@firebase/util'; +import { + deleteHeartbeatsFromIndexedDB, + readHeartbeatsFromIndexedDB, + writeHeartbeatsToIndexedDB +} from './indexeddb'; +import { FirebaseApp } from './public-types'; +import { + HeartbeatsByUserAgent, + HeartbeatService, + HeartbeatStorage, + SingleDateHeartbeat +} from './types'; + +const MAX_HEADER_BYTES = 1024; +// 30 days +const STORED_HEARTBEAT_RETENTION_MAX_MILLIS = 30 * 24 * 60 * 60 * 1000; + +export class HeartbeatServiceImpl implements HeartbeatService { + /** + * The persistence layer for heartbeats + * Leave public for easier testing. + */ + _storage: HeartbeatStorageImpl; + + /** + * In-memory cache for heartbeats, used by getHeartbeatsHeader() to generate + * the header string. + * Stores one record per date. This will be consolidated into the standard + * format of one record per user agent string before being sent as a header. + * Populated from indexedDB when the controller is instantiated and should + * be kept in sync with indexedDB. + * Leave public for easier testing. + */ + _heartbeatsCache: SingleDateHeartbeat[] | null = null; + + /** + * the initialization promise for populating heartbeatCache. + * If getHeartbeatsHeader() is called before the promise resolves + * (hearbeatsCache == null), it should wait for this promise + * Leave public for easier testing. + */ + _heartbeatsCachePromise: Promise; + constructor(private readonly container: ComponentContainer) { + const app = this.container.getProvider('app').getImmediate(); + this._storage = new HeartbeatStorageImpl(app); + this._heartbeatsCachePromise = this._storage.read().then(result => { + this._heartbeatsCache = result; + return result; + }); + } + + /** + * Called to report a heartbeat. The function will generate + * a HeartbeatsByUserAgent object, update heartbeatsCache, and persist it + * to IndexedDB. + * Note that we only store one heartbeat per day. So if a heartbeat for today is + * already logged, subsequent calls to this function in the same day will be ignored. + */ + async triggerHeartbeat(): Promise { + const platformLogger = this.container + .getProvider('platform-logger') + .getImmediate(); + + // This is the "Firebase user agent" string from the platform logger + // service, not the browser user agent. + const userAgent = platformLogger.getPlatformInfoString(); + const date = getUTCDateString(); + if (this._heartbeatsCache === null) { + this._heartbeatsCache = await this._heartbeatsCachePromise; + } + if ( + this._heartbeatsCache.some( + singleDateHeartbeat => singleDateHeartbeat.date === date + ) + ) { + // Do not store a heartbeat if one is already stored for this day. + return; + } else { + // There is no entry for this date. Create one. + this._heartbeatsCache.push({ date, userAgent }); + } + // Remove entries older than 30 days. + this._heartbeatsCache = this._heartbeatsCache.filter( + singleDateHeartbeat => { + const hbTimestamp = new Date(singleDateHeartbeat.date).valueOf(); + const now = Date.now(); + return now - hbTimestamp <= STORED_HEARTBEAT_RETENTION_MAX_MILLIS; + } + ); + return this._storage.overwrite(this._heartbeatsCache); + } + + /** + * Returns a base64 encoded string which can be attached to the heartbeat-specific header directly. + * It also clears all heartbeats from memory as well as in IndexedDB. + * + * NOTE: It will read heartbeats from the heartbeatsCache, instead of from indexedDB to reduce latency + */ + async getHeartbeatsHeader(): Promise { + if (this._heartbeatsCache === null) { + await this._heartbeatsCachePromise; + } + // If it's still null, it's been cleared and has not been repopulated. + if (this._heartbeatsCache === null) { + return ''; + } + // Extract as many heartbeats from the cache as will fit under the size limit. + const { heartbeatsToSend, unsentEntries } = extractHeartbeatsForHeader( + this._heartbeatsCache + ); + const headerString = base64Encode( + JSON.stringify({ version: 2, heartbeats: heartbeatsToSend }) + ); + if (unsentEntries.length > 0) { + // Store any unsent entries if they exist. + this._heartbeatsCache = unsentEntries; + // This seems more likely than deleteAll (below) to lead to some odd state + // since the cache isn't empty and this will be called again on the next request, + // and is probably safest if we await it. + await this._storage.overwrite(this._heartbeatsCache); + } else { + this._heartbeatsCache = null; + // Do not wait for this, to reduce latency. + void this._storage.deleteAll(); + } + return headerString; + } +} + +function getUTCDateString(): string { + const today = new Date(); + // Returns date format 'YYYY-MM-DD' + return today.toISOString().substring(0, 10); +} + +export function extractHeartbeatsForHeader( + heartbeatsCache: SingleDateHeartbeat[], + maxSize = MAX_HEADER_BYTES +): { + heartbeatsToSend: HeartbeatsByUserAgent[]; + unsentEntries: SingleDateHeartbeat[]; +} { + // Heartbeats grouped by user agent in the standard format to be sent in + // the header. + const heartbeatsToSend: HeartbeatsByUserAgent[] = []; + // Single date format heartbeats that are not sent. + let unsentEntries = heartbeatsCache.slice(); + for (const singleDateHeartbeat of heartbeatsCache) { + // Look for an existing entry with the same user agent. + const heartbeatEntry = heartbeatsToSend.find( + hb => hb.userAgent === singleDateHeartbeat.userAgent + ); + if (!heartbeatEntry) { + // If no entry for this user agent exists, create one. + heartbeatsToSend.push({ + userAgent: singleDateHeartbeat.userAgent, + dates: [singleDateHeartbeat.date] + }); + if (countBytes(heartbeatsToSend) > maxSize) { + // If the header would exceed max size, remove the added heartbeat + // entry and stop adding to the header. + heartbeatsToSend.pop(); + break; + } + } else { + heartbeatEntry.dates.push(singleDateHeartbeat.date); + // If the header would exceed max size, remove the added date + // and stop adding to the header. + if (countBytes(heartbeatsToSend) > maxSize) { + heartbeatEntry.dates.pop(); + break; + } + } + // Pop unsent entry from queue. (Skipped if adding the entry exceeded + // quota and the loop breaks early.) + unsentEntries = unsentEntries.slice(1); + } + return { + heartbeatsToSend, + unsentEntries + }; +} + +export class HeartbeatStorageImpl implements HeartbeatStorage { + private _canUseIndexedDBPromise: Promise; + constructor(public app: FirebaseApp) { + this._canUseIndexedDBPromise = this.runIndexedDBEnvironmentCheck(); + } + async runIndexedDBEnvironmentCheck(): Promise { + if (!isIndexedDBAvailable()) { + return false; + } else { + return validateIndexedDBOpenable() + .then(() => true) + .catch(() => false); + } + } + /** + * Read all heartbeats. + */ + async read(): Promise { + const canUseIndexedDB = await this._canUseIndexedDBPromise; + if (!canUseIndexedDB) { + return []; + } else { + const idbHeartbeatObject = await readHeartbeatsFromIndexedDB(this.app); + return idbHeartbeatObject?.heartbeats || []; + } + } + // overwrite the storage with the provided heartbeats + async overwrite(heartbeats: SingleDateHeartbeat[]): Promise { + const canUseIndexedDB = await this._canUseIndexedDBPromise; + if (!canUseIndexedDB) { + return; + } else { + return writeHeartbeatsToIndexedDB(this.app, { heartbeats }); + } + } + // add heartbeats + async add(heartbeats: SingleDateHeartbeat[]): Promise { + const canUseIndexedDB = await this._canUseIndexedDBPromise; + if (!canUseIndexedDB) { + return; + } else { + const existingHeartbeats = await this.read(); + return writeHeartbeatsToIndexedDB(this.app, { + heartbeats: [...existingHeartbeats, ...heartbeats] + }); + } + } + // delete heartbeats + async delete(heartbeats: SingleDateHeartbeat[]): Promise { + const canUseIndexedDB = await this._canUseIndexedDBPromise; + if (!canUseIndexedDB) { + return; + } else { + const existingHeartbeats = await this.read(); + return writeHeartbeatsToIndexedDB(this.app, { + heartbeats: existingHeartbeats.filter( + existingHeartbeat => !heartbeats.includes(existingHeartbeat) + ) + }); + } + } + // delete all heartbeats + async deleteAll(): Promise { + const canUseIndexedDB = await this._canUseIndexedDBPromise; + if (!canUseIndexedDB) { + return; + } else { + return deleteHeartbeatsFromIndexedDB(this.app); + } + } +} + +/** + * Calculate bytes of a HeartbeatsByUserAgent array after being wrapped + * in a platform logging header JSON object, stringified, and converted + * to base 64. + */ +export function countBytes(heartbeatsCache: HeartbeatsByUserAgent[]): number { + // base64 has a restricted set of characters, all of which should be 1 byte. + return base64Encode( + // heartbeatsCache wrapper properties + JSON.stringify({ version: 2, heartbeats: heartbeatsCache }) + ).length; +} diff --git a/packages/app/src/indexeddb.ts b/packages/app/src/indexeddb.ts new file mode 100644 index 00000000000..5bdaad0b1b3 --- /dev/null +++ b/packages/app/src/indexeddb.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2021 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 { DB, openDb } from 'idb'; +import { AppError, ERROR_FACTORY } from './errors'; +import { FirebaseApp } from './public-types'; +import { HeartbeatsInIndexedDB } from './types'; +const DB_NAME = 'firebase-heartbeat-database'; +const DB_VERSION = 1; +const STORE_NAME = 'firebase-heartbeat-store'; + +let dbPromise: Promise | null = null; +function getDbPromise(): Promise { + if (!dbPromise) { + dbPromise = openDb(DB_NAME, DB_VERSION, upgradeDB => { + // We don't use 'break' in this switch statement, the fall-through + // behavior is what we want, because if there are multiple versions between + // the old version and the current version, we want ALL the migrations + // that correspond to those versions to run, not only the last one. + // eslint-disable-next-line default-case + switch (upgradeDB.oldVersion) { + case 0: + upgradeDB.createObjectStore(STORE_NAME); + } + }).catch(e => { + throw ERROR_FACTORY.create(AppError.STORAGE_OPEN, { + originalErrorMessage: e.message + }); + }); + } + return dbPromise; +} + +export async function readHeartbeatsFromIndexedDB( + app: FirebaseApp +): Promise { + try { + const db = await getDbPromise(); + return db + .transaction(STORE_NAME) + .objectStore(STORE_NAME) + .get(computeKey(app)); + } catch (e) { + throw ERROR_FACTORY.create(AppError.STORAGE_GET, { + originalErrorMessage: e.message + }); + } +} + +export async function writeHeartbeatsToIndexedDB( + app: FirebaseApp, + heartbeatObject: HeartbeatsInIndexedDB +): Promise { + try { + const db = await getDbPromise(); + const tx = db.transaction(STORE_NAME, 'readwrite'); + const objectStore = tx.objectStore(STORE_NAME); + await objectStore.put(heartbeatObject, computeKey(app)); + return tx.complete; + } catch (e) { + throw ERROR_FACTORY.create(AppError.STORAGE_WRITE, { + originalErrorMessage: e.message + }); + } +} + +export async function deleteHeartbeatsFromIndexedDB( + app: FirebaseApp +): Promise { + try { + const db = await getDbPromise(); + const tx = db.transaction(STORE_NAME, 'readwrite'); + await tx.objectStore(STORE_NAME).delete(computeKey(app)); + return tx.complete; + } catch (e) { + throw ERROR_FACTORY.create(AppError.STORAGE_DELETE, { + originalErrorMessage: e.message + }); + } +} + +function computeKey(app: FirebaseApp): string { + return `${app.name}!${app.options.appId}`; +} diff --git a/packages/app/src/internal.ts b/packages/app/src/internal.ts index d653521d535..9026a36b26a 100644 --- a/packages/app/src/internal.ts +++ b/packages/app/src/internal.ts @@ -106,6 +106,12 @@ export function _getProvider( app: FirebaseApp, name: T ): Provider { + const heartbeatController = (app as FirebaseAppImpl).container + .getProvider('heartbeat') + .getImmediate({ optional: true }); + if (heartbeatController) { + void heartbeatController.triggerHeartbeat(); + } return (app as FirebaseAppImpl).container.getProvider(name); } diff --git a/packages/app/src/public-types.ts b/packages/app/src/public-types.ts index 7bad697a464..f5b2ce33613 100644 --- a/packages/app/src/public-types.ts +++ b/packages/app/src/public-types.ts @@ -16,7 +16,11 @@ */ import { ComponentContainer } from '@firebase/component'; -import { PlatformLoggerService, VersionService } from './types'; +import { + PlatformLoggerService, + VersionService, + HeartbeatService +} from './types'; /** * A {@link @firebase/app#FirebaseApp} holds the initialization information for a collection of @@ -162,6 +166,7 @@ declare module '@firebase/component' { interface NameServiceMapping { 'app': FirebaseApp; 'app-version': VersionService; + 'heartbeat': HeartbeatService; 'platform-logger': PlatformLoggerService; } } diff --git a/packages/app/src/registerCoreComponents.ts b/packages/app/src/registerCoreComponents.ts index 29ecba01f8d..744b916e4c0 100644 --- a/packages/app/src/registerCoreComponents.ts +++ b/packages/app/src/registerCoreComponents.ts @@ -20,6 +20,7 @@ import { PlatformLoggerServiceImpl } from './platformLoggerService'; import { name, version } from '../package.json'; import { _registerComponent } from './internal'; import { registerVersion } from './api'; +import { HeartbeatServiceImpl } from './heartbeatService'; export function registerCoreComponents(variant?: string): void { _registerComponent( @@ -29,6 +30,13 @@ export function registerCoreComponents(variant?: string): void { ComponentType.PRIVATE ) ); + _registerComponent( + new Component( + 'heartbeat', + container => new HeartbeatServiceImpl(container), + ComponentType.PRIVATE + ) + ); // Register `app` package. registerVersion(name, version, variant); diff --git a/packages/app/src/types.ts b/packages/app/src/types.ts index 3bee24dd945..2b47c967382 100644 --- a/packages/app/src/types.ts +++ b/packages/app/src/types.ts @@ -23,3 +23,47 @@ export interface VersionService { export interface PlatformLoggerService { getPlatformInfoString(): string; } + +export interface HeartbeatService { + /** + * Called to report a heartbeat. The function will generate + * a HeartbeatsByUserAgent object, update heartbeatsCache, and persist it + * to IndexedDB. + * Note that we only store one heartbeat per day. So if a heartbeat for today is + * already logged, subsequent calls to this function in the same day will be ignored. + */ + triggerHeartbeat(): Promise; + /** + * Returns a base64 encoded string which can be attached to the heartbeat-specific header directly. + * It also clears all heartbeats from memory as well as in IndexedDB. + */ + getHeartbeatsHeader(): Promise; +} + +// Heartbeats grouped by the same user agent string +export interface HeartbeatsByUserAgent { + userAgent: string; + dates: string[]; +} + +export interface SingleDateHeartbeat { + userAgent: string; + date: string; +} + +export interface HeartbeatStorage { + // overwrite the storage with the provided heartbeats + overwrite(heartbeats: SingleDateHeartbeat[]): Promise; + // add heartbeats + add(heartbeats: SingleDateHeartbeat[]): Promise; + // delete heartbeats + delete(heartbeats: SingleDateHeartbeat[]): Promise; + // delete all heartbeats + deleteAll(): Promise; + // read all heartbeats + read(): Promise; +} + +export interface HeartbeatsInIndexedDB { + heartbeats: SingleDateHeartbeat[]; +}