From 9e9858c7d54fbe23968a75d03ae5581e36c7ff6e Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Tue, 9 Nov 2021 16:07:33 -0800 Subject: [PATCH 01/15] initial implementation --- packages/app/src/errors.ts | 20 ++- packages/app/src/heartbeatService.ts | 161 +++++++++++++++++++ packages/app/src/indexeddb.ts | 170 +++++++++++++++++++++ packages/app/src/internal.ts | 4 + packages/app/src/public-types.ts | 2 + packages/app/src/registerCoreComponents.ts | 8 + packages/app/src/types.ts | 58 +++++++ 7 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 packages/app/src/heartbeatService.ts create mode 100644 packages/app/src/indexeddb.ts 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.ts b/packages/app/src/heartbeatService.ts new file mode 100644 index 00000000000..6e8daf6296e --- /dev/null +++ b/packages/app/src/heartbeatService.ts @@ -0,0 +1,161 @@ +/** + * @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 { FirebaseApp } from '../dist/app/src'; +import { + deleteHeartbeatsFromIndexedDB, + readHeartbeatsFromIndexedDB, + writeHeartbeatsToIndexedDB +} from './indexeddb'; +import { + HeartbeatsByUserAgent, + HeartbeatService, + HeartbeatStorage +} from './types'; + +export class HeartbeatServiceImpl implements HeartbeatService { + storage: HeartbeatStorageImpl; + heartbeatsCache: HeartbeatsByUserAgent[] | null = null; + 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)); + } + async triggerHeartbeat(): Promise { + const platformLogger = this.container + .getProvider('platform-logger') + .getImmediate(); + const userAgent = platformLogger.getPlatformInfoString(); + const date = getDateString(); + if (!this.heartbeatsCache) { + await this.heartbeatsCachePromise; + } + let heartbeatsEntry = this.heartbeatsCache!.find( + heartbeats => heartbeats.userAgent === userAgent + ); + if (heartbeatsEntry) { + if (heartbeatsEntry.dates.includes(date)) { + return; + } else { + heartbeatsEntry.dates.push(date); + } + } else { + heartbeatsEntry = { + userAgent, + dates: [date] + }; + } + return this.storage.overwrite([]); + } + async getHeartbeatsHeader(): Promise { + if (!this.heartbeatsCache) { + await this.heartbeatsCachePromise; + } + return base64Encode(JSON.stringify(this.heartbeatsCache!)); + } +} + +function getDateString(): string { + const today = new Date(); + const yearString = today.getFullYear().toString(); + const month = today.getMonth() + 1; + const monthString = month < 10 ? '0' + month : month.toString(); + const date = today.getDate(); + const dayString = date < 10 ? '0' + date : date.toString(); + return `${yearString}-${monthString}-${dayString}`; +} + +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: HeartbeatsByUserAgent[]): Promise { + const canUseIndexedDB = await this._canUseIndexedDBPromise; + if (!canUseIndexedDB) { + return; + } else { + return writeHeartbeatsToIndexedDB(this.app, { heartbeats }); + } + } + // add heartbeats + async add(heartbeats: HeartbeatsByUserAgent[]): 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: HeartbeatsByUserAgent[]): 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); + } + } +} diff --git a/packages/app/src/indexeddb.ts b/packages/app/src/indexeddb.ts new file mode 100644 index 00000000000..e6038dad5da --- /dev/null +++ b/packages/app/src/indexeddb.ts @@ -0,0 +1,170 @@ +/** + * @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 { FirebaseApp } from '@firebase/app'; +import { AppError, ERROR_FACTORY } from './errors'; +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) { + return dbPromise; + } + + dbPromise = new Promise((resolve, reject) => { + try { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onsuccess = event => { + resolve((event.target as IDBOpenDBRequest).result); + }; + + request.onerror = event => { + reject( + ERROR_FACTORY.create(AppError.STORAGE_OPEN, { + originalErrorMessage: (event.target as IDBRequest).error?.message + }) + ); + }; + + request.onupgradeneeded = event => { + const db = (event.target as IDBOpenDBRequest).result; + + // 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 (event.oldVersion) { + case 0: + db.createObjectStore(STORE_NAME, { + keyPath: 'compositeKey' + }); + } + }; + } catch (e) { + reject( + ERROR_FACTORY.create(AppError.STORAGE_OPEN, { + originalErrorMessage: e.message + }) + ); + } + }); + + return dbPromise; +} + +export function readHeartbeatsFromIndexedDB( + app: FirebaseApp +): Promise { + return read(computeKey(app)) as Promise; +} + +export function writeHeartbeatsToIndexedDB( + app: FirebaseApp, + heartbeatObject: HeartbeatsInIndexedDB +): Promise { + return write(computeKey(app), heartbeatObject); +} + +export function deleteHeartbeatsFromIndexedDB( + app: FirebaseApp +): Promise { + return deleteEntry(computeKey(app)); +} + +async function write(key: string, value: unknown): Promise { + const db = await getDBPromise(); + + const transaction = db.transaction(STORE_NAME, 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + const request = store.put({ + compositeKey: key, + value + }); + + return new Promise((resolve, reject) => { + request.onsuccess = _event => { + resolve(); + }; + + transaction.onerror = event => { + reject( + ERROR_FACTORY.create(AppError.STORAGE_WRITE, { + originalErrorMessage: (event.target as IDBRequest).error?.message + }) + ); + }; + }); +} + +async function read(key: string): Promise { + const db = await getDBPromise(); + + const transaction = db.transaction(STORE_NAME, 'readonly'); + const store = transaction.objectStore(STORE_NAME); + const request = store.get(key); + + return new Promise((resolve, reject) => { + request.onsuccess = event => { + const result = (event.target as IDBRequest).result; + + if (result) { + resolve(result.value); + } else { + resolve(undefined); + } + }; + + transaction.onerror = event => { + reject( + ERROR_FACTORY.create(AppError.STORAGE_GET, { + originalErrorMessage: (event.target as IDBRequest).error?.message + }) + ); + }; + }); +} + +async function deleteEntry(key: string): Promise { + const db = await getDBPromise(); + + const transaction = db.transaction(STORE_NAME, 'readonly'); + const store = transaction.objectStore(STORE_NAME); + const request = store.delete(key); + + return new Promise((resolve, reject) => { + request.onsuccess = () => { + resolve(); + }; + + transaction.onerror = event => { + reject( + ERROR_FACTORY.create(AppError.STORAGE_DELETE, { + originalErrorMessage: (event.target as IDBRequest).error?.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..1ab44e0701e 100644 --- a/packages/app/src/internal.ts +++ b/packages/app/src/internal.ts @@ -106,6 +106,10 @@ export function _getProvider( app: FirebaseApp, name: T ): Provider { + const heartbeatController = (app as FirebaseAppImpl).container + .getProvider('heartbeat') + .getImmediate(); + 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..672b5cfdb8a 100644 --- a/packages/app/src/public-types.ts +++ b/packages/app/src/public-types.ts @@ -16,6 +16,7 @@ */ import { ComponentContainer } from '@firebase/component'; +import { HeartbeatServiceImpl } from './heartbeatService'; import { PlatformLoggerService, VersionService } from './types'; /** @@ -162,6 +163,7 @@ declare module '@firebase/component' { interface NameServiceMapping { 'app': FirebaseApp; 'app-version': VersionService; + 'heartbeat': HeartbeatServiceImpl; '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..07088772e28 100644 --- a/packages/app/src/types.ts +++ b/packages/app/src/types.ts @@ -23,3 +23,61 @@ export interface VersionService { export interface PlatformLoggerService { getPlatformInfoString(): string; } + +export interface HeartbeatService { + // The persistence layer for heartbeats + storage: HeartbeatStorage; + /** + * in-memory cache for heartbeats, used by getHeartbeatsHeader() to generate the header string. + * Populated from indexedDB when the controller is instantiated and should be kept in sync with indexedDB + */ + heartbeatsCache: HeartbeatsByUserAgent[] | null; + + /** + * the initialization promise for populating heartbeatCache. + * If getHeartbeatsHeader() is called before the promise resolves (hearbeatsCache == null), it should wait for this promise + */ + heartbeatsCachePromise: Promise; + + + /** + * 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, the subsequent calls to this function in the same day will be ignored. + */ + triggerHeartbeat(): Promise + + /** + * Returns a based64 encoded string which can be attached to the X-firebase-client(TBD) 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 the indexedDB to reduce latency + */ + getHeartbeatsHeader(): Promise; + +} + +// Heartbeats grouped by the same user agent string +export interface HeartbeatsByUserAgent { + userAgent: string; + dates: string[]; +} + +export interface HeartbeatStorage { + // overwrite the storage with the provided heartbeats + overwrite(heartbeats: HeartbeatsByUserAgent[]): Promise + // add heartbeats + add(heartbeats: HeartbeatsByUserAgent[]): Promise + // delete heartbeats + delete(heartbeats: HeartbeatsByUserAgent[]): Promise + // delete all heartbeats + deleteAll(): Promise + // read all heartbeats + read(): Promise +} + +export interface HeartbeatsInIndexedDB { + heartbeats: HeartbeatsByUserAgent[] +} \ No newline at end of file From f7b15270118d304abc209b9c23ee7ee1c3d5a5d8 Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Thu, 11 Nov 2021 09:39:29 -0800 Subject: [PATCH 02/15] Fix so it will build --- packages/app/src/heartbeatService.ts | 2 +- packages/app/src/indexeddb.ts | 4 +--- packages/app/src/public-types.ts | 9 +++++--- packages/app/src/types.ts | 34 +++++++++++++--------------- 4 files changed, 24 insertions(+), 25 deletions(-) diff --git a/packages/app/src/heartbeatService.ts b/packages/app/src/heartbeatService.ts index 6e8daf6296e..a192f98ab06 100644 --- a/packages/app/src/heartbeatService.ts +++ b/packages/app/src/heartbeatService.ts @@ -21,12 +21,12 @@ import { isIndexedDBAvailable, validateIndexedDBOpenable } from '@firebase/util'; -import { FirebaseApp } from '../dist/app/src'; import { deleteHeartbeatsFromIndexedDB, readHeartbeatsFromIndexedDB, writeHeartbeatsToIndexedDB } from './indexeddb'; +import { FirebaseApp } from './public-types'; import { HeartbeatsByUserAgent, HeartbeatService, diff --git a/packages/app/src/indexeddb.ts b/packages/app/src/indexeddb.ts index e6038dad5da..d0e7a8a568d 100644 --- a/packages/app/src/indexeddb.ts +++ b/packages/app/src/indexeddb.ts @@ -84,9 +84,7 @@ export function writeHeartbeatsToIndexedDB( return write(computeKey(app), heartbeatObject); } -export function deleteHeartbeatsFromIndexedDB( - app: FirebaseApp -): Promise { +export function deleteHeartbeatsFromIndexedDB(app: FirebaseApp): Promise { return deleteEntry(computeKey(app)); } diff --git a/packages/app/src/public-types.ts b/packages/app/src/public-types.ts index 672b5cfdb8a..f5b2ce33613 100644 --- a/packages/app/src/public-types.ts +++ b/packages/app/src/public-types.ts @@ -16,8 +16,11 @@ */ import { ComponentContainer } from '@firebase/component'; -import { HeartbeatServiceImpl } from './heartbeatService'; -import { PlatformLoggerService, VersionService } from './types'; +import { + PlatformLoggerService, + VersionService, + HeartbeatService +} from './types'; /** * A {@link @firebase/app#FirebaseApp} holds the initialization information for a collection of @@ -163,7 +166,7 @@ declare module '@firebase/component' { interface NameServiceMapping { 'app': FirebaseApp; 'app-version': VersionService; - 'heartbeat': HeartbeatServiceImpl; + 'heartbeat': HeartbeatService; 'platform-logger': PlatformLoggerService; } } diff --git a/packages/app/src/types.ts b/packages/app/src/types.ts index 07088772e28..b217572792d 100644 --- a/packages/app/src/types.ts +++ b/packages/app/src/types.ts @@ -27,27 +27,26 @@ export interface PlatformLoggerService { export interface HeartbeatService { // The persistence layer for heartbeats storage: HeartbeatStorage; - /** - * in-memory cache for heartbeats, used by getHeartbeatsHeader() to generate the header string. - * Populated from indexedDB when the controller is instantiated and should be kept in sync with indexedDB - */ + /** + * in-memory cache for heartbeats, used by getHeartbeatsHeader() to generate the header string. + * Populated from indexedDB when the controller is instantiated and should be kept in sync with indexedDB + */ heartbeatsCache: HeartbeatsByUserAgent[] | null; - + /** * the initialization promise for populating heartbeatCache. * If getHeartbeatsHeader() is called before the promise resolves (hearbeatsCache == null), it should wait for this promise */ - heartbeatsCachePromise: Promise; - + heartbeatsCachePromise: Promise; /** - * Called to report a heartbeat. The function will generate - * a HeartbeatsByUserAgent object, update heartbeatsCache, and persist it + * 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, the subsequent calls to this function in the same day will be ignored. */ - triggerHeartbeat(): Promise + triggerHeartbeat(): Promise; /** * Returns a based64 encoded string which can be attached to the X-firebase-client(TBD) header directly. @@ -56,7 +55,6 @@ export interface HeartbeatService { * NOTE: It will read heartbeats from the heartbeatsCache, instead of from the indexedDB to reduce latency */ getHeartbeatsHeader(): Promise; - } // Heartbeats grouped by the same user agent string @@ -67,17 +65,17 @@ export interface HeartbeatsByUserAgent { export interface HeartbeatStorage { // overwrite the storage with the provided heartbeats - overwrite(heartbeats: HeartbeatsByUserAgent[]): Promise + overwrite(heartbeats: HeartbeatsByUserAgent[]): Promise; // add heartbeats - add(heartbeats: HeartbeatsByUserAgent[]): Promise + add(heartbeats: HeartbeatsByUserAgent[]): Promise; // delete heartbeats - delete(heartbeats: HeartbeatsByUserAgent[]): Promise + delete(heartbeats: HeartbeatsByUserAgent[]): Promise; // delete all heartbeats - deleteAll(): Promise + deleteAll(): Promise; // read all heartbeats - read(): Promise + read(): Promise; } export interface HeartbeatsInIndexedDB { - heartbeats: HeartbeatsByUserAgent[] -} \ No newline at end of file + heartbeats: HeartbeatsByUserAgent[]; +} From e0322fe4ccbb27a078776855db26fcb4760081dc Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Thu, 11 Nov 2021 16:20:36 -0800 Subject: [PATCH 03/15] Move based indexedDB operations to util --- packages/app/src/heartbeatService.ts | 75 +++++++++--- packages/app/src/indexeddb.ts | 165 +++++++-------------------- packages/app/src/types.ts | 28 ----- packages/util/index.node.ts | 3 + packages/util/index.ts | 1 + packages/util/src/indexeddb.ts | 132 +++++++++++++++++++++ 6 files changed, 232 insertions(+), 172 deletions(-) create mode 100644 packages/util/src/indexeddb.ts diff --git a/packages/app/src/heartbeatService.ts b/packages/app/src/heartbeatService.ts index a192f98ab06..1cb99de9148 100644 --- a/packages/app/src/heartbeatService.ts +++ b/packages/app/src/heartbeatService.ts @@ -34,56 +34,95 @@ import { } from './types'; export class HeartbeatServiceImpl implements HeartbeatService { - storage: HeartbeatStorageImpl; - heartbeatsCache: HeartbeatsByUserAgent[] | null = null; - heartbeatsCachePromise: Promise; + // The persistence layer for heartbeats + private _storage: HeartbeatStorageImpl; + + /** + * in-memory cache for heartbeats, used by getHeartbeatsHeader() to generate + * the header string. + * Populated from indexedDB when the controller is instantiated and should + * be kept in sync with indexedDB. + */ + private _heartbeatsCache: HeartbeatsByUserAgent[] | null = null; + + /** + * the initialization promise for populating heartbeatCache. + * If getHeartbeatsHeader() is called before the promise resolves (hearbeatsCache == null), it should wait for this promise + */ + private _heartbeatsCachePromise: Promise; constructor(private readonly container: ComponentContainer) { const app = this.container.getProvider('app').getImmediate(); - this.storage = new HeartbeatStorageImpl(app); - this.heartbeatsCachePromise = this.storage + this._storage = new HeartbeatStorageImpl(app); + this._heartbeatsCachePromise = this._storage .read() - .then(result => (this.heartbeatsCache = result)); + .then(result => (this._heartbeatsCache = 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 = getDateString(); - if (!this.heartbeatsCache) { - await this.heartbeatsCachePromise; + const date = getUTCDateString(); + if (!this._heartbeatsCache) { + await this._heartbeatsCachePromise; } - let heartbeatsEntry = this.heartbeatsCache!.find( + let heartbeatsEntry = this._heartbeatsCache!.find( heartbeats => heartbeats.userAgent === userAgent ); if (heartbeatsEntry) { if (heartbeatsEntry.dates.includes(date)) { + // Only one per day. return; } else { + // Modify in-place in this.heartbeatsCache heartbeatsEntry.dates.push(date); } } else { + // There is no entry for this Firebase user agent. Create one. heartbeatsEntry = { userAgent, dates: [date] }; + this._heartbeatsCache!.push(heartbeatsEntry); } - return this.storage.overwrite([]); + 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) { - await this.heartbeatsCachePromise; + if (!this._heartbeatsCache) { + await this._heartbeatsCachePromise; } - return base64Encode(JSON.stringify(this.heartbeatsCache!)); + const headerString = base64Encode(JSON.stringify(this._heartbeatsCache!)); + this._heartbeatsCache = null; + // Do not wait for this, to reduce latency. + void this._storage.deleteAll(); + return headerString; } } -function getDateString(): string { +function getUTCDateString(): string { const today = new Date(); - const yearString = today.getFullYear().toString(); - const month = today.getMonth() + 1; + const yearString = today.getUTCFullYear().toString(); + const month = today.getUTCMonth() + 1; const monthString = month < 10 ? '0' + month : month.toString(); - const date = today.getDate(); + const date = today.getUTCDate(); const dayString = date < 10 ? '0' + date : date.toString(); return `${yearString}-${monthString}-${dayString}`; } diff --git a/packages/app/src/indexeddb.ts b/packages/app/src/indexeddb.ts index d0e7a8a568d..5ad072a3389 100644 --- a/packages/app/src/indexeddb.ts +++ b/packages/app/src/indexeddb.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2020 Google LLC + * 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. @@ -16,151 +16,64 @@ */ import { FirebaseApp } from '@firebase/app'; +import { + IndexedDbDatabaseService, + write, + read, + deleteEntry +} from '@firebase/util'; import { AppError, ERROR_FACTORY } from './errors'; 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) { - return dbPromise; +const dbService = new IndexedDbDatabaseService( + DB_NAME, + STORE_NAME, + DB_VERSION, + error => { + throw ERROR_FACTORY.create(AppError.STORAGE_OPEN, { + originalErrorMessage: error.message + }); } - - dbPromise = new Promise((resolve, reject) => { - try { - const request = indexedDB.open(DB_NAME, DB_VERSION); - - request.onsuccess = event => { - resolve((event.target as IDBOpenDBRequest).result); - }; - - request.onerror = event => { - reject( - ERROR_FACTORY.create(AppError.STORAGE_OPEN, { - originalErrorMessage: (event.target as IDBRequest).error?.message - }) - ); - }; - - request.onupgradeneeded = event => { - const db = (event.target as IDBOpenDBRequest).result; - - // 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 (event.oldVersion) { - case 0: - db.createObjectStore(STORE_NAME, { - keyPath: 'compositeKey' - }); - } - }; - } catch (e) { - reject( - ERROR_FACTORY.create(AppError.STORAGE_OPEN, { - originalErrorMessage: e.message - }) - ); - } - }); - - return dbPromise; -} +); export function readHeartbeatsFromIndexedDB( app: FirebaseApp ): Promise { - return read(computeKey(app)) as Promise; + try { + return read(dbService, computeKey(app)) as Promise< + HeartbeatsInIndexedDB | undefined + >; + } catch (e) { + throw ERROR_FACTORY.create(AppError.STORAGE_GET, { + originalErrorMessage: e.message + }); + } } export function writeHeartbeatsToIndexedDB( app: FirebaseApp, heartbeatObject: HeartbeatsInIndexedDB ): Promise { - return write(computeKey(app), heartbeatObject); + try { + return write(dbService, computeKey(app), heartbeatObject); + } catch (e) { + throw ERROR_FACTORY.create(AppError.STORAGE_WRITE, { + originalErrorMessage: e.message + }); + } } export function deleteHeartbeatsFromIndexedDB(app: FirebaseApp): Promise { - return deleteEntry(computeKey(app)); -} - -async function write(key: string, value: unknown): Promise { - const db = await getDBPromise(); - - const transaction = db.transaction(STORE_NAME, 'readwrite'); - const store = transaction.objectStore(STORE_NAME); - const request = store.put({ - compositeKey: key, - value - }); - - return new Promise((resolve, reject) => { - request.onsuccess = _event => { - resolve(); - }; - - transaction.onerror = event => { - reject( - ERROR_FACTORY.create(AppError.STORAGE_WRITE, { - originalErrorMessage: (event.target as IDBRequest).error?.message - }) - ); - }; - }); -} - -async function read(key: string): Promise { - const db = await getDBPromise(); - - const transaction = db.transaction(STORE_NAME, 'readonly'); - const store = transaction.objectStore(STORE_NAME); - const request = store.get(key); - - return new Promise((resolve, reject) => { - request.onsuccess = event => { - const result = (event.target as IDBRequest).result; - - if (result) { - resolve(result.value); - } else { - resolve(undefined); - } - }; - - transaction.onerror = event => { - reject( - ERROR_FACTORY.create(AppError.STORAGE_GET, { - originalErrorMessage: (event.target as IDBRequest).error?.message - }) - ); - }; - }); -} - -async function deleteEntry(key: string): Promise { - const db = await getDBPromise(); - - const transaction = db.transaction(STORE_NAME, 'readonly'); - const store = transaction.objectStore(STORE_NAME); - const request = store.delete(key); - - return new Promise((resolve, reject) => { - request.onsuccess = () => { - resolve(); - }; - - transaction.onerror = event => { - reject( - ERROR_FACTORY.create(AppError.STORAGE_DELETE, { - originalErrorMessage: (event.target as IDBRequest).error?.message - }) - ); - }; - }); + try { + return deleteEntry(dbService, computeKey(app)); + } catch (e) { + throw ERROR_FACTORY.create(AppError.STORAGE_DELETE, { + originalErrorMessage: e.message + }); + } } function computeKey(app: FirebaseApp): string { diff --git a/packages/app/src/types.ts b/packages/app/src/types.ts index b217572792d..bf8005d243f 100644 --- a/packages/app/src/types.ts +++ b/packages/app/src/types.ts @@ -25,35 +25,7 @@ export interface PlatformLoggerService { } export interface HeartbeatService { - // The persistence layer for heartbeats - storage: HeartbeatStorage; - /** - * in-memory cache for heartbeats, used by getHeartbeatsHeader() to generate the header string. - * Populated from indexedDB when the controller is instantiated and should be kept in sync with indexedDB - */ - heartbeatsCache: HeartbeatsByUserAgent[] | null; - - /** - * the initialization promise for populating heartbeatCache. - * If getHeartbeatsHeader() is called before the promise resolves (hearbeatsCache == null), it should wait for this promise - */ - heartbeatsCachePromise: Promise; - - /** - * 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, the subsequent calls to this function in the same day will be ignored. - */ triggerHeartbeat(): Promise; - - /** - * Returns a based64 encoded string which can be attached to the X-firebase-client(TBD) 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 the indexedDB to reduce latency - */ getHeartbeatsHeader(): Promise; } diff --git a/packages/util/index.node.ts b/packages/util/index.node.ts index 8dace3b8e1e..c9d0059ce5c 100644 --- a/packages/util/index.node.ts +++ b/packages/util/index.node.ts @@ -39,3 +39,6 @@ export * from './src/utf8'; export * from './src/exponential_backoff'; export * from './src/formatters'; export * from './src/compat'; +// IndexedDB isn't available in Node but we don't want an import error importing +// these methods from util. +export * from './src/indexeddb'; diff --git a/packages/util/index.ts b/packages/util/index.ts index 00d661734b8..0cf518fbd81 100644 --- a/packages/util/index.ts +++ b/packages/util/index.ts @@ -34,3 +34,4 @@ export * from './src/utf8'; export * from './src/exponential_backoff'; export * from './src/formatters'; export * from './src/compat'; +export * from './src/indexeddb'; diff --git a/packages/util/src/indexeddb.ts b/packages/util/src/indexeddb.ts new file mode 100644 index 00000000000..d0bacda5c8e --- /dev/null +++ b/packages/util/src/indexeddb.ts @@ -0,0 +1,132 @@ +/** + * @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. + */ + +export class IndexedDbDatabaseService { + dbPromise: Promise; + constructor( + public dbName: string, + public storeName: string, + public dbVersion: number, + errorHandler: (error: Error) => void + ) { + this.dbPromise = new Promise((resolve, reject) => { + try { + const request = indexedDB.open(this.dbName, this.dbVersion); + + request.onsuccess = event => { + resolve((event.target as IDBOpenDBRequest).result); + }; + + request.onerror = event => { + reject((event.target as IDBRequest).error?.message); + }; + + request.onupgradeneeded = event => { + const db = (event.target as IDBOpenDBRequest).result; + + // 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 (event.oldVersion) { + case 0: + db.createObjectStore(this.storeName, { + keyPath: 'compositeKey' + }); + } + }; + } catch (e) { + reject(e.message); + } + }); + this.dbPromise.catch(errorHandler); + } +} + +export async function write( + dbService: IndexedDbDatabaseService, + key: string, + value: unknown +): Promise { + const db = await dbService.dbPromise; + + const transaction = db.transaction(dbService.storeName, 'readwrite'); + const store = transaction.objectStore(dbService.storeName); + const request = store.put({ + compositeKey: key, + value + }); + + return new Promise((resolve, reject) => { + request.onsuccess = _event => { + resolve(); + }; + + transaction.onerror = event => { + reject((event.target as IDBRequest).error?.message); + }; + }); +} + +export async function read( + dbService: IndexedDbDatabaseService, + key: string +): Promise { + const db = await dbService.dbPromise; + + const transaction = db.transaction(dbService.storeName, 'readonly'); + const store = transaction.objectStore(dbService.storeName); + const request = store.get(key); + + return new Promise((resolve, reject) => { + request.onsuccess = event => { + const result = (event.target as IDBRequest).result; + + if (result) { + resolve(result.value); + } else { + resolve(undefined); + } + }; + + transaction.onerror = event => { + reject((event.target as IDBRequest).error?.message); + }; + }); +} + +export async function deleteEntry( + dbService: IndexedDbDatabaseService, + key: string +): Promise { + const db = await dbService.dbPromise; + + const transaction = db.transaction(dbService.storeName, 'readonly'); + const store = transaction.objectStore(dbService.storeName); + const request = store.delete(key); + + return new Promise((resolve, reject) => { + request.onsuccess = () => { + resolve(); + }; + + transaction.onerror = event => { + reject((event.target as IDBRequest).error?.message); + }; + }); +} From 45af5db768eefdac678e658fb88e8d9fec3e5d11 Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Fri, 12 Nov 2021 09:30:56 -0800 Subject: [PATCH 04/15] Add tests --- packages/app/src/heartbeatService.test.ts | 59 +++++++++++++++++++++++ packages/app/src/indexeddb.ts | 2 +- packages/app/src/internal.ts | 6 ++- 3 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 packages/app/src/heartbeatService.test.ts diff --git a/packages/app/src/heartbeatService.test.ts b/packages/app/src/heartbeatService.test.ts new file mode 100644 index 00000000000..2661961cddc --- /dev/null +++ b/packages/app/src/heartbeatService.test.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2017 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 { HeartbeatServiceImpl } from './heartbeatService'; +import { + Component, + ComponentType, + ComponentContainer +} from '@firebase/component'; +import { PlatformLoggerService } from './types'; +import { FirebaseApp } from './public-types'; +import { base64Decode } from '@firebase/util'; + +declare module '@firebase/component' { + interface NameServiceMapping { + 'platform-logger': PlatformLoggerService; + } +} + +describe('Heartbeat Service', () => { + it(`logs a heartbeat`, async () => { + 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: () => 'vs1/1.2.3 vs2/2.3.4' }), + ComponentType.VERSION + ) + ); + const heartbeatService = new HeartbeatServiceImpl(container); + await heartbeatService.triggerHeartbeat(); + const heartbeatHeaders = base64Decode(await heartbeatService.getHeartbeatsHeader()); + expect(heartbeatHeaders).to.include('vs1/1.2.3'); + expect(heartbeatHeaders).to.include('2021-'); + }); +}); diff --git a/packages/app/src/indexeddb.ts b/packages/app/src/indexeddb.ts index 5ad072a3389..17136967d45 100644 --- a/packages/app/src/indexeddb.ts +++ b/packages/app/src/indexeddb.ts @@ -15,7 +15,6 @@ * limitations under the License. */ -import { FirebaseApp } from '@firebase/app'; import { IndexedDbDatabaseService, write, @@ -23,6 +22,7 @@ import { deleteEntry } from '@firebase/util'; 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; diff --git a/packages/app/src/internal.ts b/packages/app/src/internal.ts index 1ab44e0701e..9026a36b26a 100644 --- a/packages/app/src/internal.ts +++ b/packages/app/src/internal.ts @@ -108,8 +108,10 @@ export function _getProvider( ): Provider { const heartbeatController = (app as FirebaseAppImpl).container .getProvider('heartbeat') - .getImmediate(); - void heartbeatController.triggerHeartbeat(); + .getImmediate({ optional: true }); + if (heartbeatController) { + void heartbeatController.triggerHeartbeat(); + } return (app as FirebaseAppImpl).container.getProvider(name); } From cb8e308b46db8765a4b44b40adff15eb30db2ec0 Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Fri, 12 Nov 2021 12:05:14 -0800 Subject: [PATCH 05/15] add tests --- packages/app/src/heartbeatService.test.ts | 199 +++++++++++++++++++--- packages/app/src/heartbeatService.ts | 36 ++-- packages/app/src/indexeddb.ts | 12 +- packages/util/src/indexeddb.ts | 8 +- packages/util/test/indexeddb.test.ts | 43 +++++ packages/util/test/object.test.ts | 2 +- 6 files changed, 253 insertions(+), 47 deletions(-) create mode 100644 packages/util/test/indexeddb.test.ts diff --git a/packages/app/src/heartbeatService.test.ts b/packages/app/src/heartbeatService.test.ts index 2661961cddc..e470785c02b 100644 --- a/packages/app/src/heartbeatService.test.ts +++ b/packages/app/src/heartbeatService.test.ts @@ -25,35 +25,186 @@ import { } from '@firebase/component'; import { PlatformLoggerService } from './types'; import { FirebaseApp } from './public-types'; -import { base64Decode } from '@firebase/util'; +import * as firebaseUtil from '@firebase/util'; +import { SinonStub, stub, useFakeTimers } from 'sinon'; +import * as indexedDb from './indexeddb'; +import { isIndexedDBAvailable } from '@firebase/util'; declare module '@firebase/component' { interface NameServiceMapping { 'platform-logger': PlatformLoggerService; } } - -describe('Heartbeat Service', () => { - it(`logs a heartbeat`, async () => { - 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: () => 'vs1/1.2.3 vs2/2.3.4' }), - ComponentType.VERSION - ) - ); - const heartbeatService = new HeartbeatServiceImpl(container); - await heartbeatService.triggerHeartbeat(); - const heartbeatHeaders = base64Decode(await heartbeatService.getHeartbeatsHeader()); - expect(heartbeatHeaders).to.include('vs1/1.2.3'); - expect(heartbeatHeaders).to.include('2021-'); +describe('HeartbeatServiceImpl', () => { + describe('If IndexedDB has no entries', () => { + let heartbeatService: HeartbeatServiceImpl; + let clock = useFakeTimers(); + let userAgentString = 'vs1/1.2.3 vs2/2.3.4'; + 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('vs1/1.2.3 vs2/2.3.4'); + expect(heartbeat1?.dates[0]).to.equal('1970-01-01'); + expect(writeStub).to.be.calledWith([heartbeat1]); + }); + it(`triggerHeartbeat() doesn't store another heartbeat on the same day`, async () => { + await heartbeatService.triggerHeartbeat(); + const heartbeat1 = heartbeatService._heartbeatsCache?.[0]; + expect(heartbeat1?.dates.length).to.equal(1); + }); + it(`triggerHeartbeat() does store another heartbeat on a different day`, async () => { + clock.tick(24 * 60 * 60 * 1000); + await heartbeatService.triggerHeartbeat(); + const heartbeat1 = heartbeatService._heartbeatsCache?.[0]; + expect(heartbeat1?.dates.length).to.equal(2); + expect(heartbeat1?.dates[1]).to.equal('1970-01-02'); + }); + it(`triggerHeartbeat() stores another entry for a different user agent`, async () => { + userAgentString = 'different/1.2.3'; + clock.tick(2 * 24 * 60 * 60 * 1000); + await heartbeatService.triggerHeartbeat(); + expect(heartbeatService._heartbeatsCache?.length).to.equal(2); + const heartbeat2 = heartbeatService._heartbeatsCache?.[1]; + expect(heartbeat2?.dates.length).to.equal(1); + expect(heartbeat2?.dates[0]).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('vs1/1.2.3 vs2/2.3.4'); + expect(heartbeatHeaders).to.include('different/1.2.3'); + expect(heartbeatHeaders).to.include('1970-01-01'); + expect(heartbeatHeaders).to.include('1970-01-02'); + expect(heartbeatHeaders).to.include('1970-01-03'); + 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 = 'vs1/1.2.3 vs2/2.3.4'; + const mockIndexedDBHeartbeats = [ + { + userAgent: 'old-user-agent', + dates: ['1969-01-01', '1969-01-02'] + } + ]; + 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 without removing old ones`, async () => { + userAgentString = 'different/1.2.3'; + clock.tick(3 * 24 * 60 * 60 * 1000); + await heartbeatService.triggerHeartbeat(); + if (isIndexedDBAvailable()) { + expect(writeStub).to.be.calledWith([ + ...mockIndexedDBHeartbeats, + { userAgent: 'different/1.2.3', dates: ['1970-01-04'] } + ]); + } else { + expect(writeStub).to.be.calledWith([ + { userAgent: 'different/1.2.3', dates: ['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-01-01'); + expect(heartbeatHeaders).to.include('1969-01-02'); + } + expect(heartbeatHeaders).to.include('different/1.2.3'); + expect(heartbeatHeaders).to.include('1970-01-04'); + expect(heartbeatService._heartbeatsCache).to.equal(null); + const emptyHeaders = await heartbeatService.getHeartbeatsHeader(); + expect(emptyHeaders).to.equal(''); + expect(deleteStub).to.be.called; + }); }); }); diff --git a/packages/app/src/heartbeatService.ts b/packages/app/src/heartbeatService.ts index 1cb99de9148..8e65f6907fd 100644 --- a/packages/app/src/heartbeatService.ts +++ b/packages/app/src/heartbeatService.ts @@ -34,28 +34,35 @@ import { } from './types'; export class HeartbeatServiceImpl implements HeartbeatService { - // The persistence layer for heartbeats - private _storage: HeartbeatStorageImpl; + /** + * The persistence layer for heartbeats + * Leave public for easier testing. + */ + _storage: HeartbeatStorageImpl; /** - * in-memory cache for heartbeats, used by getHeartbeatsHeader() to generate + * In-memory cache for heartbeats, used by getHeartbeatsHeader() to generate * the header string. * Populated from indexedDB when the controller is instantiated and should * be kept in sync with indexedDB. + * Leave public for easier testing. */ - private _heartbeatsCache: HeartbeatsByUserAgent[] | null = null; + _heartbeatsCache: HeartbeatsByUserAgent[] | null = null; /** * the initialization promise for populating heartbeatCache. - * If getHeartbeatsHeader() is called before the promise resolves (hearbeatsCache == null), it should wait for this promise + * If getHeartbeatsHeader() is called before the promise resolves + * (hearbeatsCache == null), it should wait for this promise + * Leave public for easier testing. */ - private _heartbeatsCachePromise: Promise; + _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)); + this._heartbeatsCachePromise = this._storage.read().then(result => { + this._heartbeatsCache = result; + return result; + }); } /** @@ -74,7 +81,7 @@ export class HeartbeatServiceImpl implements HeartbeatService { // service, not the browser user agent. const userAgent = platformLogger.getPlatformInfoString(); const date = getUTCDateString(); - if (!this._heartbeatsCache) { + if (this._heartbeatsCache === null) { await this._heartbeatsCachePromise; } let heartbeatsEntry = this._heartbeatsCache!.find( @@ -106,12 +113,17 @@ export class HeartbeatServiceImpl implements HeartbeatService { * NOTE: It will read heartbeats from the heartbeatsCache, instead of from indexedDB to reduce latency */ async getHeartbeatsHeader(): Promise { - if (!this._heartbeatsCache) { + if (this._heartbeatsCache === null) { await this._heartbeatsCachePromise; } - const headerString = base64Encode(JSON.stringify(this._heartbeatsCache!)); + // If it's still null, it's been cleared and has not been repopulated. + if (this._heartbeatsCache === null) { + return ''; + } + const headerString = base64Encode(JSON.stringify(this._heartbeatsCache)); this._heartbeatsCache = null; // Do not wait for this, to reduce latency. + console.log('calling deleteAll'); void this._storage.deleteAll(); return headerString; } diff --git a/packages/app/src/indexeddb.ts b/packages/app/src/indexeddb.ts index 17136967d45..3e724c341c9 100644 --- a/packages/app/src/indexeddb.ts +++ b/packages/app/src/indexeddb.ts @@ -17,9 +17,9 @@ import { IndexedDbDatabaseService, - write, - read, - deleteEntry + idbWrite, + idbRead, + idbDelete } from '@firebase/util'; import { AppError, ERROR_FACTORY } from './errors'; import { FirebaseApp } from './public-types'; @@ -43,7 +43,7 @@ export function readHeartbeatsFromIndexedDB( app: FirebaseApp ): Promise { try { - return read(dbService, computeKey(app)) as Promise< + return idbRead(dbService, computeKey(app)) as Promise< HeartbeatsInIndexedDB | undefined >; } catch (e) { @@ -58,7 +58,7 @@ export function writeHeartbeatsToIndexedDB( heartbeatObject: HeartbeatsInIndexedDB ): Promise { try { - return write(dbService, computeKey(app), heartbeatObject); + return idbWrite(dbService, computeKey(app), heartbeatObject); } catch (e) { throw ERROR_FACTORY.create(AppError.STORAGE_WRITE, { originalErrorMessage: e.message @@ -68,7 +68,7 @@ export function writeHeartbeatsToIndexedDB( export function deleteHeartbeatsFromIndexedDB(app: FirebaseApp): Promise { try { - return deleteEntry(dbService, computeKey(app)); + return idbDelete(dbService, computeKey(app)); } catch (e) { throw ERROR_FACTORY.create(AppError.STORAGE_DELETE, { originalErrorMessage: e.message diff --git a/packages/util/src/indexeddb.ts b/packages/util/src/indexeddb.ts index d0bacda5c8e..882ecb7bd4a 100644 --- a/packages/util/src/indexeddb.ts +++ b/packages/util/src/indexeddb.ts @@ -58,7 +58,7 @@ export class IndexedDbDatabaseService { } } -export async function write( +export async function idbWrite( dbService: IndexedDbDatabaseService, key: string, value: unknown @@ -83,7 +83,7 @@ export async function write( }); } -export async function read( +export async function idbRead( dbService: IndexedDbDatabaseService, key: string ): Promise { @@ -110,13 +110,13 @@ export async function read( }); } -export async function deleteEntry( +export async function idbDelete( dbService: IndexedDbDatabaseService, key: string ): Promise { const db = await dbService.dbPromise; - const transaction = db.transaction(dbService.storeName, 'readonly'); + const transaction = db.transaction(dbService.storeName, 'readwrite'); const store = transaction.objectStore(dbService.storeName); const request = store.delete(key); diff --git a/packages/util/test/indexeddb.test.ts b/packages/util/test/indexeddb.test.ts new file mode 100644 index 00000000000..334d46710cc --- /dev/null +++ b/packages/util/test/indexeddb.test.ts @@ -0,0 +1,43 @@ +/** + * @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 { isIndexedDBAvailable } from '../src/environment'; +import { + IndexedDbDatabaseService, + idbRead, + idbWrite, + idbDelete +} from '../src/indexeddb'; + +describe('IndexedDbDatabaseService', () => { + it('Can write, read, and delete', async () => { + if (!isIndexedDBAvailable()) { + // skip if in Node + return; + } + const idb = new IndexedDbDatabaseService('test-db', 'test-store', 1, e => { + console.error(e); + }); + await idbWrite(idb, 'a-key', { data: 'abcd' }); + const result = await idbRead(idb, 'a-key'); + expect((result as any).data).to.equal('abcd'); + await idbDelete(idb, 'a-key'); + const resultAfterDelete = await idbRead(idb, 'a-key'); + expect(resultAfterDelete).to.not.exist; + }); +}); diff --git a/packages/util/test/object.test.ts b/packages/util/test/object.test.ts index d6f86209a83..a3691aacc8e 100644 --- a/packages/util/test/object.test.ts +++ b/packages/util/test/object.test.ts @@ -19,7 +19,7 @@ import { expect } from 'chai'; import { deepEqual } from '../src/obj'; // eslint-disable-next-line no-restricted-properties -describe.only('deepEqual()', () => { +describe('deepEqual()', () => { it('returns true for comparing empty objects', () => { expect(deepEqual({}, {})).to.be.true; }); From bd55e7be959d3186590b607bac385d0777fe4432 Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Mon, 29 Nov 2021 14:13:27 -0800 Subject: [PATCH 06/15] Fix year --- packages/app/src/heartbeatService.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/heartbeatService.test.ts b/packages/app/src/heartbeatService.test.ts index e470785c02b..3ea33e22055 100644 --- a/packages/app/src/heartbeatService.test.ts +++ b/packages/app/src/heartbeatService.test.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google LLC + * 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. From de237828bb5ee53f43e5b48f83c381bff9a551cd Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Thu, 2 Dec 2021 10:02:06 -0800 Subject: [PATCH 07/15] use idb --- packages/app/package.json | 1 + packages/app/src/indexeddb.ts | 52 +++++++------ packages/util/index.node.ts | 3 - packages/util/index.ts | 1 - packages/util/src/indexeddb.ts | 132 --------------------------------- 5 files changed, 31 insertions(+), 158 deletions(-) delete mode 100644 packages/util/src/indexeddb.ts diff --git a/packages/app/package.json b/packages/app/package.json index b888dda00eb..792a3108fdf 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -40,6 +40,7 @@ "@firebase/util": "1.4.2", "@firebase/logger": "0.3.2", "@firebase/component": "0.5.9", + "idb": "3.0.2", "tslib": "^2.1.0" }, "license": "Apache-2.0", diff --git a/packages/app/src/indexeddb.ts b/packages/app/src/indexeddb.ts index 3e724c341c9..0bffa4ff50b 100644 --- a/packages/app/src/indexeddb.ts +++ b/packages/app/src/indexeddb.ts @@ -15,12 +15,7 @@ * limitations under the License. */ -import { - IndexedDbDatabaseService, - idbWrite, - idbRead, - idbDelete -} from '@firebase/util'; +import { DB, openDb } from 'idb'; import { AppError, ERROR_FACTORY } from './errors'; import { FirebaseApp } from './public-types'; import { HeartbeatsInIndexedDB } from './types'; @@ -28,24 +23,30 @@ const DB_NAME = 'firebase-heartbeat-database'; const DB_VERSION = 1; const STORE_NAME = 'firebase-heartbeat-store'; -const dbService = new IndexedDbDatabaseService( - DB_NAME, - STORE_NAME, - DB_VERSION, - error => { - throw ERROR_FACTORY.create(AppError.STORAGE_OPEN, { - originalErrorMessage: error.message +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); + } }); } -); + return dbPromise; +} -export function readHeartbeatsFromIndexedDB( +export async function readHeartbeatsFromIndexedDB( app: FirebaseApp ): Promise { try { - return idbRead(dbService, computeKey(app)) as Promise< - HeartbeatsInIndexedDB | undefined - >; + 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 @@ -53,12 +54,16 @@ export function readHeartbeatsFromIndexedDB( } } -export function writeHeartbeatsToIndexedDB( +export async function writeHeartbeatsToIndexedDB( app: FirebaseApp, heartbeatObject: HeartbeatsInIndexedDB ): Promise { try { - return idbWrite(dbService, computeKey(app), heartbeatObject); + 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 @@ -66,9 +71,12 @@ export function writeHeartbeatsToIndexedDB( } } -export function deleteHeartbeatsFromIndexedDB(app: FirebaseApp): Promise { +export async function deleteHeartbeatsFromIndexedDB(app: FirebaseApp): Promise { try { - return idbDelete(dbService, computeKey(app)); + 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 diff --git a/packages/util/index.node.ts b/packages/util/index.node.ts index c9d0059ce5c..8dace3b8e1e 100644 --- a/packages/util/index.node.ts +++ b/packages/util/index.node.ts @@ -39,6 +39,3 @@ export * from './src/utf8'; export * from './src/exponential_backoff'; export * from './src/formatters'; export * from './src/compat'; -// IndexedDB isn't available in Node but we don't want an import error importing -// these methods from util. -export * from './src/indexeddb'; diff --git a/packages/util/index.ts b/packages/util/index.ts index 0cf518fbd81..00d661734b8 100644 --- a/packages/util/index.ts +++ b/packages/util/index.ts @@ -34,4 +34,3 @@ export * from './src/utf8'; export * from './src/exponential_backoff'; export * from './src/formatters'; export * from './src/compat'; -export * from './src/indexeddb'; diff --git a/packages/util/src/indexeddb.ts b/packages/util/src/indexeddb.ts deleted file mode 100644 index 882ecb7bd4a..00000000000 --- a/packages/util/src/indexeddb.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * @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. - */ - -export class IndexedDbDatabaseService { - dbPromise: Promise; - constructor( - public dbName: string, - public storeName: string, - public dbVersion: number, - errorHandler: (error: Error) => void - ) { - this.dbPromise = new Promise((resolve, reject) => { - try { - const request = indexedDB.open(this.dbName, this.dbVersion); - - request.onsuccess = event => { - resolve((event.target as IDBOpenDBRequest).result); - }; - - request.onerror = event => { - reject((event.target as IDBRequest).error?.message); - }; - - request.onupgradeneeded = event => { - const db = (event.target as IDBOpenDBRequest).result; - - // 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 (event.oldVersion) { - case 0: - db.createObjectStore(this.storeName, { - keyPath: 'compositeKey' - }); - } - }; - } catch (e) { - reject(e.message); - } - }); - this.dbPromise.catch(errorHandler); - } -} - -export async function idbWrite( - dbService: IndexedDbDatabaseService, - key: string, - value: unknown -): Promise { - const db = await dbService.dbPromise; - - const transaction = db.transaction(dbService.storeName, 'readwrite'); - const store = transaction.objectStore(dbService.storeName); - const request = store.put({ - compositeKey: key, - value - }); - - return new Promise((resolve, reject) => { - request.onsuccess = _event => { - resolve(); - }; - - transaction.onerror = event => { - reject((event.target as IDBRequest).error?.message); - }; - }); -} - -export async function idbRead( - dbService: IndexedDbDatabaseService, - key: string -): Promise { - const db = await dbService.dbPromise; - - const transaction = db.transaction(dbService.storeName, 'readonly'); - const store = transaction.objectStore(dbService.storeName); - const request = store.get(key); - - return new Promise((resolve, reject) => { - request.onsuccess = event => { - const result = (event.target as IDBRequest).result; - - if (result) { - resolve(result.value); - } else { - resolve(undefined); - } - }; - - transaction.onerror = event => { - reject((event.target as IDBRequest).error?.message); - }; - }); -} - -export async function idbDelete( - dbService: IndexedDbDatabaseService, - key: string -): Promise { - const db = await dbService.dbPromise; - - const transaction = db.transaction(dbService.storeName, 'readwrite'); - const store = transaction.objectStore(dbService.storeName); - const request = store.delete(key); - - return new Promise((resolve, reject) => { - request.onsuccess = () => { - resolve(); - }; - - transaction.onerror = event => { - reject((event.target as IDBRequest).error?.message); - }; - }); -} From 6960753cb45f596032c530cc68fa912b839333fa Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Thu, 2 Dec 2021 11:08:22 -0800 Subject: [PATCH 08/15] Add version to payload --- packages/app/src/heartbeatService.test.ts | 2 ++ packages/app/src/heartbeatService.ts | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/app/src/heartbeatService.test.ts b/packages/app/src/heartbeatService.test.ts index 3ea33e22055..8d5262968fa 100644 --- a/packages/app/src/heartbeatService.test.ts +++ b/packages/app/src/heartbeatService.test.ts @@ -110,6 +110,7 @@ describe('HeartbeatServiceImpl', () => { 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(''); @@ -201,6 +202,7 @@ describe('HeartbeatServiceImpl', () => { } expect(heartbeatHeaders).to.include('different/1.2.3'); 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(''); diff --git a/packages/app/src/heartbeatService.ts b/packages/app/src/heartbeatService.ts index 8e65f6907fd..f564781f187 100644 --- a/packages/app/src/heartbeatService.ts +++ b/packages/app/src/heartbeatService.ts @@ -120,10 +120,11 @@ export class HeartbeatServiceImpl implements HeartbeatService { if (this._heartbeatsCache === null) { return ''; } - const headerString = base64Encode(JSON.stringify(this._heartbeatsCache)); + const headerString = base64Encode( + JSON.stringify({ version: 2, heartbeats: this._heartbeatsCache }) + ); this._heartbeatsCache = null; // Do not wait for this, to reduce latency. - console.log('calling deleteAll'); void this._storage.deleteAll(); return headerString; } From 9852f5aebfc0f8e5309a856e5bfb000f68c3e866 Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Thu, 2 Dec 2021 11:13:45 -0800 Subject: [PATCH 09/15] Clean up, add storage_open error --- packages/app/src/indexeddb.ts | 13 +++++++++++-- packages/util/test/object.test.ts | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/app/src/indexeddb.ts b/packages/app/src/indexeddb.ts index 0bffa4ff50b..5bdaad0b1b3 100644 --- a/packages/app/src/indexeddb.ts +++ b/packages/app/src/indexeddb.ts @@ -36,6 +36,10 @@ function getDbPromise(): Promise { case 0: upgradeDB.createObjectStore(STORE_NAME); } + }).catch(e => { + throw ERROR_FACTORY.create(AppError.STORAGE_OPEN, { + originalErrorMessage: e.message + }); }); } return dbPromise; @@ -46,7 +50,10 @@ export async function readHeartbeatsFromIndexedDB( ): Promise { try { const db = await getDbPromise(); - return db.transaction(STORE_NAME).objectStore(STORE_NAME).get(computeKey(app)); + return db + .transaction(STORE_NAME) + .objectStore(STORE_NAME) + .get(computeKey(app)); } catch (e) { throw ERROR_FACTORY.create(AppError.STORAGE_GET, { originalErrorMessage: e.message @@ -71,7 +78,9 @@ export async function writeHeartbeatsToIndexedDB( } } -export async function deleteHeartbeatsFromIndexedDB(app: FirebaseApp): Promise { +export async function deleteHeartbeatsFromIndexedDB( + app: FirebaseApp +): Promise { try { const db = await getDbPromise(); const tx = db.transaction(STORE_NAME, 'readwrite'); diff --git a/packages/util/test/object.test.ts b/packages/util/test/object.test.ts index a3691aacc8e..d6f86209a83 100644 --- a/packages/util/test/object.test.ts +++ b/packages/util/test/object.test.ts @@ -19,7 +19,7 @@ import { expect } from 'chai'; import { deepEqual } from '../src/obj'; // eslint-disable-next-line no-restricted-properties -describe('deepEqual()', () => { +describe.only('deepEqual()', () => { it('returns true for comparing empty objects', () => { expect(deepEqual({}, {})).to.be.true; }); From c462daa35a0cf6d01a09e9866a7bc5358182c365 Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Thu, 2 Dec 2021 11:14:38 -0800 Subject: [PATCH 10/15] clean up --- packages/util/test/indexeddb.test.ts | 43 ---------------------------- 1 file changed, 43 deletions(-) delete mode 100644 packages/util/test/indexeddb.test.ts diff --git a/packages/util/test/indexeddb.test.ts b/packages/util/test/indexeddb.test.ts deleted file mode 100644 index 334d46710cc..00000000000 --- a/packages/util/test/indexeddb.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * @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 { isIndexedDBAvailable } from '../src/environment'; -import { - IndexedDbDatabaseService, - idbRead, - idbWrite, - idbDelete -} from '../src/indexeddb'; - -describe('IndexedDbDatabaseService', () => { - it('Can write, read, and delete', async () => { - if (!isIndexedDBAvailable()) { - // skip if in Node - return; - } - const idb = new IndexedDbDatabaseService('test-db', 'test-store', 1, e => { - console.error(e); - }); - await idbWrite(idb, 'a-key', { data: 'abcd' }); - const result = await idbRead(idb, 'a-key'); - expect((result as any).data).to.equal('abcd'); - await idbDelete(idb, 'a-key'); - const resultAfterDelete = await idbRead(idb, 'a-key'); - expect(resultAfterDelete).to.not.exist; - }); -}); From 1251cbad5bb9012d6581da5d93c83abd9f668f55 Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Thu, 2 Dec 2021 16:08:47 -0800 Subject: [PATCH 11/15] Add comments to HeartbeatService interface methods --- packages/app/src/types.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/app/src/types.ts b/packages/app/src/types.ts index bf8005d243f..e6d2a0dfe2c 100644 --- a/packages/app/src/types.ts +++ b/packages/app/src/types.ts @@ -25,7 +25,18 @@ export interface PlatformLoggerService { } 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; } From 7fffc5734dfdea6235375edb76f584aa45fbceac Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Fri, 10 Dec 2021 10:02:07 -0800 Subject: [PATCH 12/15] Address PR comments --- packages/app/src/heartbeatService.test.ts | 24 +++++++++++++---------- packages/app/src/heartbeatService.ts | 7 +------ 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/app/src/heartbeatService.test.ts b/packages/app/src/heartbeatService.test.ts index 8d5262968fa..0e664dee4ce 100644 --- a/packages/app/src/heartbeatService.test.ts +++ b/packages/app/src/heartbeatService.test.ts @@ -35,11 +35,15 @@ declare module '@firebase/component' { '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'; + describe('HeartbeatServiceImpl', () => { describe('If IndexedDB has no entries', () => { let heartbeatService: HeartbeatServiceImpl; let clock = useFakeTimers(); - let userAgentString = 'vs1/1.2.3 vs2/2.3.4'; + let userAgentString = USER_AGENT_STRING_1; let writeStub: SinonStub; before(() => { const container = new ComponentContainer('heartbeatTestContainer'); @@ -75,7 +79,7 @@ describe('HeartbeatServiceImpl', () => { await heartbeatService.triggerHeartbeat(); expect(heartbeatService._heartbeatsCache?.length).to.equal(1); const heartbeat1 = heartbeatService._heartbeatsCache?.[0]; - expect(heartbeat1?.userAgent).to.equal('vs1/1.2.3 vs2/2.3.4'); + expect(heartbeat1?.userAgent).to.equal(USER_AGENT_STRING_1); expect(heartbeat1?.dates[0]).to.equal('1970-01-01'); expect(writeStub).to.be.calledWith([heartbeat1]); }); @@ -92,7 +96,7 @@ describe('HeartbeatServiceImpl', () => { expect(heartbeat1?.dates[1]).to.equal('1970-01-02'); }); it(`triggerHeartbeat() stores another entry for a different user agent`, async () => { - userAgentString = 'different/1.2.3'; + userAgentString = USER_AGENT_STRING_2; clock.tick(2 * 24 * 60 * 60 * 1000); await heartbeatService.triggerHeartbeat(); expect(heartbeatService._heartbeatsCache?.length).to.equal(2); @@ -105,8 +109,8 @@ describe('HeartbeatServiceImpl', () => { const heartbeatHeaders = firebaseUtil.base64Decode( await heartbeatService.getHeartbeatsHeader() ); - expect(heartbeatHeaders).to.include('vs1/1.2.3 vs2/2.3.4'); - expect(heartbeatHeaders).to.include('different/1.2.3'); + 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'); @@ -121,7 +125,7 @@ describe('HeartbeatServiceImpl', () => { let heartbeatService: HeartbeatServiceImpl; let clock = useFakeTimers(); let writeStub: SinonStub; - let userAgentString = 'vs1/1.2.3 vs2/2.3.4'; + let userAgentString = USER_AGENT_STRING_1; const mockIndexedDBHeartbeats = [ { userAgent: 'old-user-agent', @@ -176,17 +180,17 @@ describe('HeartbeatServiceImpl', () => { } }); it(`triggerHeartbeat() writes new heartbeats without removing old ones`, async () => { - userAgentString = 'different/1.2.3'; + userAgentString = USER_AGENT_STRING_2; clock.tick(3 * 24 * 60 * 60 * 1000); await heartbeatService.triggerHeartbeat(); if (isIndexedDBAvailable()) { expect(writeStub).to.be.calledWith([ ...mockIndexedDBHeartbeats, - { userAgent: 'different/1.2.3', dates: ['1970-01-04'] } + { userAgent: USER_AGENT_STRING_2, dates: ['1970-01-04'] } ]); } else { expect(writeStub).to.be.calledWith([ - { userAgent: 'different/1.2.3', dates: ['1970-01-04'] } + { userAgent: USER_AGENT_STRING_2, dates: ['1970-01-04'] } ]); } }); @@ -200,7 +204,7 @@ describe('HeartbeatServiceImpl', () => { expect(heartbeatHeaders).to.include('1969-01-01'); expect(heartbeatHeaders).to.include('1969-01-02'); } - expect(heartbeatHeaders).to.include('different/1.2.3'); + 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); diff --git a/packages/app/src/heartbeatService.ts b/packages/app/src/heartbeatService.ts index f564781f187..838e1e31338 100644 --- a/packages/app/src/heartbeatService.ts +++ b/packages/app/src/heartbeatService.ts @@ -132,12 +132,7 @@ export class HeartbeatServiceImpl implements HeartbeatService { function getUTCDateString(): string { const today = new Date(); - const yearString = today.getUTCFullYear().toString(); - const month = today.getUTCMonth() + 1; - const monthString = month < 10 ? '0' + month : month.toString(); - const date = today.getUTCDate(); - const dayString = date < 10 ? '0' + date : date.toString(); - return `${yearString}-${monthString}-${dayString}`; + return today.toISOString().substring(0,10); } export class HeartbeatStorageImpl implements HeartbeatStorage { From 449fd181a24fb375e747c0d73b29fedbbf664639 Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Mon, 31 Jan 2022 10:05:47 -0800 Subject: [PATCH 13/15] Cache heartbeats one per date (#5945) --- packages/app/src/heartbeatService.test.ts | 142 ++++++++++++++++++--- packages/app/src/heartbeatService.ts | 144 +++++++++++++++++----- packages/app/src/types.ts | 15 ++- 3 files changed, 246 insertions(+), 55 deletions(-) diff --git a/packages/app/src/heartbeatService.test.ts b/packages/app/src/heartbeatService.test.ts index 0e664dee4ce..ad0d453b706 100644 --- a/packages/app/src/heartbeatService.test.ts +++ b/packages/app/src/heartbeatService.test.ts @@ -17,7 +17,11 @@ import { expect } from 'chai'; import '../test/setup'; -import { HeartbeatServiceImpl } from './heartbeatService'; +import { + countBytes, + HeartbeatServiceImpl, + extractHeartbeatsForHeader +} from './heartbeatService'; import { Component, ComponentType, @@ -28,7 +32,7 @@ import { FirebaseApp } from './public-types'; import * as firebaseUtil from '@firebase/util'; import { SinonStub, stub, useFakeTimers } from 'sinon'; import * as indexedDb from './indexeddb'; -import { isIndexedDBAvailable } from '@firebase/util'; +import { base64Encode, isIndexedDBAvailable } from '@firebase/util'; declare module '@firebase/component' { interface NameServiceMapping { @@ -39,6 +43,24 @@ declare module '@firebase/component' { 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; @@ -80,29 +102,32 @@ describe('HeartbeatServiceImpl', () => { expect(heartbeatService._heartbeatsCache?.length).to.equal(1); const heartbeat1 = heartbeatService._heartbeatsCache?.[0]; expect(heartbeat1?.userAgent).to.equal(USER_AGENT_STRING_1); - expect(heartbeat1?.dates[0]).to.equal('1970-01-01'); + 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(); - const heartbeat1 = heartbeatService._heartbeatsCache?.[0]; - expect(heartbeat1?.dates.length).to.equal(1); + 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(); - const heartbeat1 = heartbeatService._heartbeatsCache?.[0]; - expect(heartbeat1?.dates.length).to.equal(2); - expect(heartbeat1?.dates[1]).to.equal('1970-01-02'); + 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(2); - const heartbeat2 = heartbeatService._heartbeatsCache?.[1]; - expect(heartbeat2?.dates.length).to.equal(1); - expect(heartbeat2?.dates[0]).to.equal('1970-01-03'); + 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'); @@ -127,9 +152,14 @@ describe('HeartbeatServiceImpl', () => { 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', - dates: ['1969-01-01', '1969-01-02'] + date: '1969-12-01' + }, + { + userAgent: 'old-user-agent', + date: '1969-12-31' } ]; before(() => { @@ -179,18 +209,19 @@ describe('HeartbeatServiceImpl', () => { expect(heartbeatService._heartbeatsCache).to.deep.equal([]); } }); - it(`triggerHeartbeat() writes new heartbeats without removing old ones`, async () => { + 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([ - ...mockIndexedDBHeartbeats, - { userAgent: USER_AGENT_STRING_2, dates: ['1970-01-04'] } + // 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, dates: ['1970-01-04'] } + { userAgent: USER_AGENT_STRING_2, date: '1970-01-04' } ]); } }); @@ -201,8 +232,7 @@ describe('HeartbeatServiceImpl', () => { ); if (isIndexedDBAvailable()) { expect(heartbeatHeaders).to.include('old-user-agent'); - expect(heartbeatHeaders).to.include('1969-01-01'); - expect(heartbeatHeaders).to.include('1969-01-02'); + expect(heartbeatHeaders).to.include('1969-12-31'); } expect(heartbeatHeaders).to.include(USER_AGENT_STRING_2); expect(heartbeatHeaders).to.include('1970-01-04'); @@ -213,4 +243,78 @@ describe('HeartbeatServiceImpl', () => { 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 index 838e1e31338..88ada8c9cf9 100644 --- a/packages/app/src/heartbeatService.ts +++ b/packages/app/src/heartbeatService.ts @@ -30,9 +30,14 @@ import { FirebaseApp } from './public-types'; import { HeartbeatsByUserAgent, HeartbeatService, - HeartbeatStorage + 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 @@ -43,11 +48,13 @@ export class HeartbeatServiceImpl implements HeartbeatService { /** * 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: HeartbeatsByUserAgent[] | null = null; + _heartbeatsCache: SingleDateHeartbeat[] | null = null; /** * the initialization promise for populating heartbeatCache. @@ -55,7 +62,7 @@ export class HeartbeatServiceImpl implements HeartbeatService { * (hearbeatsCache == null), it should wait for this promise * Leave public for easier testing. */ - _heartbeatsCachePromise: Promise; + _heartbeatsCachePromise: Promise; constructor(private readonly container: ComponentContainer) { const app = this.container.getProvider('app').getImmediate(); this._storage = new HeartbeatStorageImpl(app); @@ -82,28 +89,28 @@ export class HeartbeatServiceImpl implements HeartbeatService { const userAgent = platformLogger.getPlatformInfoString(); const date = getUTCDateString(); if (this._heartbeatsCache === null) { - await this._heartbeatsCachePromise; + this._heartbeatsCache = await this._heartbeatsCachePromise; } - let heartbeatsEntry = this._heartbeatsCache!.find( - heartbeats => heartbeats.userAgent === userAgent - ); - if (heartbeatsEntry) { - if (heartbeatsEntry.dates.includes(date)) { - // Only one per day. - return; - } else { - // Modify in-place in this.heartbeatsCache - heartbeatsEntry.dates.push(date); - } + 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 Firebase user agent. Create one. - heartbeatsEntry = { - userAgent, - dates: [date] - }; - this._heartbeatsCache!.push(heartbeatsEntry); + // There is no entry for this date. Create one. + this._heartbeatsCache.push({ date, userAgent }); } - return this._storage.overwrite(this._heartbeatsCache!); + // 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); } /** @@ -120,19 +127,81 @@ export class HeartbeatServiceImpl implements HeartbeatService { 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: this._heartbeatsCache }) + JSON.stringify({ version: 2, heartbeats: heartbeatsToSend }) ); - this._heartbeatsCache = null; - // Do not wait for this, to reduce latency. - void this._storage.deleteAll(); + 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(); - return today.toISOString().substring(0,10); + // 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 { @@ -152,7 +221,7 @@ export class HeartbeatStorageImpl implements HeartbeatStorage { /** * Read all heartbeats. */ - async read(): Promise { + async read(): Promise { const canUseIndexedDB = await this._canUseIndexedDBPromise; if (!canUseIndexedDB) { return []; @@ -162,7 +231,7 @@ export class HeartbeatStorageImpl implements HeartbeatStorage { } } // overwrite the storage with the provided heartbeats - async overwrite(heartbeats: HeartbeatsByUserAgent[]): Promise { + async overwrite(heartbeats: SingleDateHeartbeat[]): Promise { const canUseIndexedDB = await this._canUseIndexedDBPromise; if (!canUseIndexedDB) { return; @@ -171,7 +240,7 @@ export class HeartbeatStorageImpl implements HeartbeatStorage { } } // add heartbeats - async add(heartbeats: HeartbeatsByUserAgent[]): Promise { + async add(heartbeats: SingleDateHeartbeat[]): Promise { const canUseIndexedDB = await this._canUseIndexedDBPromise; if (!canUseIndexedDB) { return; @@ -183,7 +252,7 @@ export class HeartbeatStorageImpl implements HeartbeatStorage { } } // delete heartbeats - async delete(heartbeats: HeartbeatsByUserAgent[]): Promise { + async delete(heartbeats: SingleDateHeartbeat[]): Promise { const canUseIndexedDB = await this._canUseIndexedDBPromise; if (!canUseIndexedDB) { return; @@ -206,3 +275,16 @@ export class HeartbeatStorageImpl implements HeartbeatStorage { } } } + +/** + * 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/types.ts b/packages/app/src/types.ts index e6d2a0dfe2c..2b47c967382 100644 --- a/packages/app/src/types.ts +++ b/packages/app/src/types.ts @@ -46,19 +46,24 @@ export interface HeartbeatsByUserAgent { dates: string[]; } +export interface SingleDateHeartbeat { + userAgent: string; + date: string; +} + export interface HeartbeatStorage { // overwrite the storage with the provided heartbeats - overwrite(heartbeats: HeartbeatsByUserAgent[]): Promise; + overwrite(heartbeats: SingleDateHeartbeat[]): Promise; // add heartbeats - add(heartbeats: HeartbeatsByUserAgent[]): Promise; + add(heartbeats: SingleDateHeartbeat[]): Promise; // delete heartbeats - delete(heartbeats: HeartbeatsByUserAgent[]): Promise; + delete(heartbeats: SingleDateHeartbeat[]): Promise; // delete all heartbeats deleteAll(): Promise; // read all heartbeats - read(): Promise; + read(): Promise; } export interface HeartbeatsInIndexedDB { - heartbeats: HeartbeatsByUserAgent[]; + heartbeats: SingleDateHeartbeat[]; } From 5bb0fe0e9e93aa98720541dac33f724f3d2b5b76 Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Mon, 31 Jan 2022 17:09:43 -0800 Subject: [PATCH 14/15] Add changeset --- .changeset/quick-moons-play.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/quick-moons-play.md diff --git a/.changeset/quick-moons-play.md b/.changeset/quick-moons-play.md new file mode 100644 index 00000000000..2d2ca78a06c --- /dev/null +++ b/.changeset/quick-moons-play.md @@ -0,0 +1,5 @@ +--- +'@firebase/app': minor +--- + +Add heartbeat controller for platform logging. From 3125e64ded5ca40db9972b9dc7083ce69f914624 Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Tue, 22 Feb 2022 15:56:06 -0800 Subject: [PATCH 15/15] Change to patch bump --- .changeset/quick-moons-play.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/quick-moons-play.md b/.changeset/quick-moons-play.md index 2d2ca78a06c..8b5f493a337 100644 --- a/.changeset/quick-moons-play.md +++ b/.changeset/quick-moons-play.md @@ -1,5 +1,5 @@ --- -'@firebase/app': minor +'@firebase/app': patch --- Add heartbeat controller for platform logging.