Skip to content

Commit 269e0ee

Browse files
committed
Add a last sent date to heartbeat storage
1 parent 09e6243 commit 269e0ee

File tree

4 files changed

+167
-97
lines changed

4 files changed

+167
-97
lines changed

packages/app/src/heartbeatService.test.ts

Lines changed: 122 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -99,38 +99,37 @@ describe('HeartbeatServiceImpl', () => {
9999
*/
100100
it(`triggerHeartbeat() stores a heartbeat`, async () => {
101101
await heartbeatService.triggerHeartbeat();
102-
expect(heartbeatService._heartbeatsCache?.length).to.equal(1);
103-
const heartbeat1 = heartbeatService._heartbeatsCache?.[0];
102+
expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(1);
103+
const heartbeat1 = heartbeatService._heartbeatsCache?.heartbeats[0];
104104
expect(heartbeat1?.userAgent).to.equal(USER_AGENT_STRING_1);
105105
expect(heartbeat1?.date).to.equal('1970-01-01');
106-
expect(writeStub).to.be.calledWith([heartbeat1]);
106+
expect(writeStub).to.be.calledWith({ heartbeats: [heartbeat1] });
107107
});
108108
it(`triggerHeartbeat() doesn't store another heartbeat on the same day`, async () => {
109-
expect(heartbeatService._heartbeatsCache?.length).to.equal(1);
109+
expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(1);
110110
await heartbeatService.triggerHeartbeat();
111-
expect(heartbeatService._heartbeatsCache?.length).to.equal(1);
111+
expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(1);
112112
});
113113
it(`triggerHeartbeat() does store another heartbeat on a different day`, async () => {
114-
expect(heartbeatService._heartbeatsCache?.length).to.equal(1);
114+
expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(1);
115115
clock.tick(24 * 60 * 60 * 1000);
116116
await heartbeatService.triggerHeartbeat();
117-
expect(heartbeatService._heartbeatsCache?.length).to.equal(2);
118-
expect(heartbeatService._heartbeatsCache?.[1].date).to.equal(
117+
expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(2);
118+
expect(heartbeatService._heartbeatsCache?.heartbeats[1].date).to.equal(
119119
'1970-01-02'
120120
);
121121
});
122122
it(`triggerHeartbeat() stores another entry for a different user agent`, async () => {
123123
userAgentString = USER_AGENT_STRING_2;
124-
expect(heartbeatService._heartbeatsCache?.length).to.equal(2);
124+
expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(2);
125125
clock.tick(2 * 24 * 60 * 60 * 1000);
126126
await heartbeatService.triggerHeartbeat();
127-
expect(heartbeatService._heartbeatsCache?.length).to.equal(3);
128-
expect(heartbeatService._heartbeatsCache?.[2].date).to.equal(
127+
expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(3);
128+
expect(heartbeatService._heartbeatsCache?.heartbeats[2].date).to.equal(
129129
'1970-01-03'
130130
);
131131
});
132132
it('getHeartbeatHeaders() gets stored heartbeats and clears heartbeats', async () => {
133-
const deleteStub = stub(heartbeatService._storage, 'deleteAll');
134133
const heartbeatHeaders = firebaseUtil.base64Decode(
135134
await heartbeatService.getHeartbeatsHeader()
136135
);
@@ -140,10 +139,13 @@ describe('HeartbeatServiceImpl', () => {
140139
expect(heartbeatHeaders).to.include('1970-01-02');
141140
expect(heartbeatHeaders).to.include('1970-01-03');
142141
expect(heartbeatHeaders).to.include(`"version":2`);
143-
expect(heartbeatService._heartbeatsCache).to.equal(null);
142+
expect(heartbeatService._heartbeatsCache?.heartbeats).to.be.empty;
143+
expect(writeStub).to.be.calledWith({
144+
lastSentHeartbeatDate: '1970-01-01',
145+
heartbeats: []
146+
});
144147
const emptyHeaders = await heartbeatService.getHeartbeatsHeader();
145148
expect(emptyHeaders).to.equal('');
146-
expect(deleteStub).to.be.called;
147149
});
148150
});
149151
describe('If IndexedDB has entries', () => {
@@ -198,35 +200,43 @@ describe('HeartbeatServiceImpl', () => {
198200
it(`new heartbeat service reads from indexedDB cache`, async () => {
199201
const promiseResult = await heartbeatService._heartbeatsCachePromise;
200202
if (isIndexedDBAvailable()) {
201-
expect(promiseResult).to.deep.equal(mockIndexedDBHeartbeats);
202-
expect(heartbeatService._heartbeatsCache).to.deep.equal(
203-
mockIndexedDBHeartbeats
204-
);
203+
expect(promiseResult).to.deep.equal({
204+
heartbeats: mockIndexedDBHeartbeats
205+
});
206+
expect(heartbeatService._heartbeatsCache).to.deep.equal({
207+
heartbeats: mockIndexedDBHeartbeats
208+
});
205209
} else {
206210
// In Node or other no-indexed-db environments it will fail the
207211
// `canUseIndexedDb` check and return an empty array.
208-
expect(promiseResult).to.deep.equal([]);
209-
expect(heartbeatService._heartbeatsCache).to.deep.equal([]);
212+
expect(promiseResult).to.deep.equal({
213+
heartbeats: []
214+
});
215+
expect(heartbeatService._heartbeatsCache).to.deep.equal({
216+
heartbeats: []
217+
});
210218
}
211219
});
212220
it(`triggerHeartbeat() writes new heartbeats and retains old ones newer than 30 days`, async () => {
213221
userAgentString = USER_AGENT_STRING_2;
214222
clock.tick(3 * 24 * 60 * 60 * 1000);
215223
await heartbeatService.triggerHeartbeat();
216224
if (isIndexedDBAvailable()) {
217-
expect(writeStub).to.be.calledWith([
218-
// The first entry exceeds the 30 day retention limit.
219-
mockIndexedDBHeartbeats[1],
220-
{ userAgent: USER_AGENT_STRING_2, date: '1970-01-04' }
221-
]);
225+
expect(writeStub).to.be.calledWith({
226+
heartbeats: [
227+
// The first entry exceeds the 30 day retention limit.
228+
mockIndexedDBHeartbeats[1],
229+
{ userAgent: USER_AGENT_STRING_2, date: '1970-01-04' }
230+
]
231+
});
222232
} else {
223-
expect(writeStub).to.be.calledWith([
224-
{ userAgent: USER_AGENT_STRING_2, date: '1970-01-04' }
225-
]);
233+
expect(writeStub).to.be.calledWith({
234+
heartbeats: [{ userAgent: USER_AGENT_STRING_2, date: '1970-01-04' }]
235+
});
226236
}
227237
});
228238
it('getHeartbeatHeaders() gets stored heartbeats and clears heartbeats', async () => {
229-
const deleteStub = stub(heartbeatService._storage, 'deleteAll');
239+
230240
const heartbeatHeaders = firebaseUtil.base64Decode(
231241
await heartbeatService.getHeartbeatsHeader()
232242
);
@@ -237,10 +247,91 @@ describe('HeartbeatServiceImpl', () => {
237247
expect(heartbeatHeaders).to.include(USER_AGENT_STRING_2);
238248
expect(heartbeatHeaders).to.include('1970-01-04');
239249
expect(heartbeatHeaders).to.include(`"version":2`);
240-
expect(heartbeatService._heartbeatsCache).to.equal(null);
250+
expect(heartbeatService._heartbeatsCache?.heartbeats).to.be.empty;
251+
expect(writeStub).to.be.calledWith({
252+
lastSentHeartbeatDate: '1970-01-01',
253+
heartbeats: []
254+
});
241255
const emptyHeaders = await heartbeatService.getHeartbeatsHeader();
242256
expect(emptyHeaders).to.equal('');
243-
expect(deleteStub).to.be.called;
257+
});
258+
});
259+
260+
describe('If IndexedDB records that a header was sent today', () => {
261+
let heartbeatService: HeartbeatServiceImpl;
262+
let writeStub: SinonStub;
263+
const userAgentString = USER_AGENT_STRING_1;
264+
const mockIndexedDBHeartbeats = [
265+
// Chosen so one will exceed 30 day limit and one will not.
266+
{
267+
userAgent: 'old-user-agent',
268+
date: '1969-12-01'
269+
},
270+
{
271+
userAgent: 'old-user-agent',
272+
date: '1969-12-31'
273+
}
274+
];
275+
before(() => {
276+
const container = new ComponentContainer('heartbeatTestContainer');
277+
container.addComponent(
278+
new Component(
279+
'app',
280+
() =>
281+
({
282+
options: { appId: 'an-app-id' },
283+
name: 'an-app-name'
284+
} as FirebaseApp),
285+
ComponentType.VERSION
286+
)
287+
);
288+
container.addComponent(
289+
new Component(
290+
'platform-logger',
291+
() => ({ getPlatformInfoString: () => userAgentString }),
292+
ComponentType.VERSION
293+
)
294+
);
295+
stub(indexedDb, 'readHeartbeatsFromIndexedDB').resolves({
296+
lastSentHeartbeatDate: '1970-01-01',
297+
heartbeats: [...mockIndexedDBHeartbeats]
298+
});
299+
heartbeatService = new HeartbeatServiceImpl(container);
300+
});
301+
beforeEach(() => {
302+
useFakeTimers();
303+
writeStub = stub(heartbeatService._storage, 'overwrite');
304+
});
305+
it(`new heartbeat service reads from indexedDB cache`, async () => {
306+
const promiseResult = await heartbeatService._heartbeatsCachePromise;
307+
if (isIndexedDBAvailable()) {
308+
expect(promiseResult).to.deep.equal({
309+
lastSentHeartbeatDate: '1970-01-01',
310+
heartbeats: mockIndexedDBHeartbeats
311+
});
312+
expect(heartbeatService._heartbeatsCache).to.deep.equal({
313+
lastSentHeartbeatDate: '1970-01-01',
314+
heartbeats: mockIndexedDBHeartbeats
315+
});
316+
} else {
317+
// In Node or other no-indexed-db environments it will fail the
318+
// `canUseIndexedDb` check and return an empty array.
319+
expect(promiseResult).to.deep.equal({
320+
heartbeats: []
321+
});
322+
expect(heartbeatService._heartbeatsCache).to.deep.equal({
323+
heartbeats: []
324+
});
325+
}
326+
});
327+
it(`triggerHeartbeat() will skip storing new data`, async () => {
328+
await heartbeatService.triggerHeartbeat();
329+
expect(writeStub).to.not.be.called;
330+
if (isIndexedDBAvailable()) {
331+
expect(heartbeatService._heartbeatsCache?.heartbeats).to.deep.equal(
332+
mockIndexedDBHeartbeats
333+
);
334+
}
244335
});
245336
});
246337

packages/app/src/heartbeatService.ts

Lines changed: 41 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@ import {
2222
validateIndexedDBOpenable
2323
} from '@firebase/util';
2424
import {
25-
deleteHeartbeatsFromIndexedDB,
2625
readHeartbeatsFromIndexedDB,
2726
writeHeartbeatsToIndexedDB
2827
} from './indexeddb';
2928
import { FirebaseApp } from './public-types';
3029
import {
3130
HeartbeatsByUserAgent,
3231
HeartbeatService,
32+
HeartbeatsInIndexedDB,
3333
HeartbeatStorage,
3434
SingleDateHeartbeat
3535
} from './types';
@@ -54,15 +54,15 @@ export class HeartbeatServiceImpl implements HeartbeatService {
5454
* be kept in sync with indexedDB.
5555
* Leave public for easier testing.
5656
*/
57-
_heartbeatsCache: SingleDateHeartbeat[] | null = null;
57+
_heartbeatsCache: HeartbeatsInIndexedDB | null = null;
5858

5959
/**
6060
* the initialization promise for populating heartbeatCache.
6161
* If getHeartbeatsHeader() is called before the promise resolves
6262
* (hearbeatsCache == null), it should wait for this promise
6363
* Leave public for easier testing.
6464
*/
65-
_heartbeatsCachePromise: Promise<SingleDateHeartbeat[]>;
65+
_heartbeatsCachePromise: Promise<HeartbeatsInIndexedDB>;
6666
constructor(private readonly container: ComponentContainer) {
6767
const app = this.container.getProvider('app').getImmediate();
6868
this._storage = new HeartbeatStorageImpl(app);
@@ -91,19 +91,21 @@ export class HeartbeatServiceImpl implements HeartbeatService {
9191
if (this._heartbeatsCache === null) {
9292
this._heartbeatsCache = await this._heartbeatsCachePromise;
9393
}
94+
// Do not store a heartbeat if one is already stored for this day
95+
// or if a header has already been sent today.
9496
if (
95-
this._heartbeatsCache.some(
97+
this._heartbeatsCache.lastSentHeartbeatDate === date ||
98+
this._heartbeatsCache.heartbeats.some(
9699
singleDateHeartbeat => singleDateHeartbeat.date === date
97100
)
98101
) {
99-
// Do not store a heartbeat if one is already stored for this day.
100102
return;
101103
} else {
102104
// There is no entry for this date. Create one.
103-
this._heartbeatsCache.push({ date, userAgent });
105+
this._heartbeatsCache.heartbeats.push({ date, userAgent });
104106
}
105107
// Remove entries older than 30 days.
106-
this._heartbeatsCache = this._heartbeatsCache.filter(
108+
this._heartbeatsCache.heartbeats = this._heartbeatsCache.heartbeats.filter(
107109
singleDateHeartbeat => {
108110
const hbTimestamp = new Date(singleDateHeartbeat.date).valueOf();
109111
const now = Date.now();
@@ -123,28 +125,34 @@ export class HeartbeatServiceImpl implements HeartbeatService {
123125
if (this._heartbeatsCache === null) {
124126
await this._heartbeatsCachePromise;
125127
}
126-
// If it's still null, it's been cleared and has not been repopulated.
127-
if (this._heartbeatsCache === null) {
128+
// If it's still null or the array is empty, there is no data to send.
129+
if (
130+
this._heartbeatsCache === null ||
131+
this._heartbeatsCache.heartbeats.length === 0
132+
) {
128133
return '';
129134
}
135+
const date = getUTCDateString();
130136
// Extract as many heartbeats from the cache as will fit under the size limit.
131137
const { heartbeatsToSend, unsentEntries } = extractHeartbeatsForHeader(
132-
this._heartbeatsCache
138+
this._heartbeatsCache.heartbeats
133139
);
134140
const headerString = base64Encode(
135141
JSON.stringify({ version: 2, heartbeats: heartbeatsToSend })
136142
);
143+
// Store last sent date to prevent another being logged/sent for the same day.
144+
this._heartbeatsCache.lastSentHeartbeatDate = date;
137145
if (unsentEntries.length > 0) {
138146
// Store any unsent entries if they exist.
139-
this._heartbeatsCache = unsentEntries;
140-
// This seems more likely than deleteAll (below) to lead to some odd state
147+
this._heartbeatsCache.heartbeats = unsentEntries;
148+
// This seems more likely than emptying the array (below) to lead to some odd state
141149
// since the cache isn't empty and this will be called again on the next request,
142150
// and is probably safest if we await it.
143151
await this._storage.overwrite(this._heartbeatsCache);
144152
} else {
145-
this._heartbeatsCache = null;
153+
this._heartbeatsCache.heartbeats = [];
146154
// Do not wait for this, to reduce latency.
147-
void this._storage.deleteAll();
155+
void this._storage.overwrite(this._heartbeatsCache);
148156
}
149157
return headerString;
150158
}
@@ -221,59 +229,48 @@ export class HeartbeatStorageImpl implements HeartbeatStorage {
221229
/**
222230
* Read all heartbeats.
223231
*/
224-
async read(): Promise<SingleDateHeartbeat[]> {
232+
async read(): Promise<HeartbeatsInIndexedDB> {
225233
const canUseIndexedDB = await this._canUseIndexedDBPromise;
226234
if (!canUseIndexedDB) {
227-
return [];
235+
return { heartbeats: [] };
228236
} else {
229237
const idbHeartbeatObject = await readHeartbeatsFromIndexedDB(this.app);
230-
return idbHeartbeatObject?.heartbeats || [];
238+
return idbHeartbeatObject || { heartbeats: [] };
231239
}
232240
}
233241
// overwrite the storage with the provided heartbeats
234-
async overwrite(heartbeats: SingleDateHeartbeat[]): Promise<void> {
235-
const canUseIndexedDB = await this._canUseIndexedDBPromise;
236-
if (!canUseIndexedDB) {
237-
return;
238-
} else {
239-
return writeHeartbeatsToIndexedDB(this.app, { heartbeats });
240-
}
241-
}
242-
// add heartbeats
243-
async add(heartbeats: SingleDateHeartbeat[]): Promise<void> {
242+
async overwrite(heartbeatsObject: HeartbeatsInIndexedDB): Promise<void> {
244243
const canUseIndexedDB = await this._canUseIndexedDBPromise;
245244
if (!canUseIndexedDB) {
246245
return;
247246
} else {
248-
const existingHeartbeats = await this.read();
247+
const existingHeartbeatsObject = await this.read();
249248
return writeHeartbeatsToIndexedDB(this.app, {
250-
heartbeats: [...existingHeartbeats, ...heartbeats]
249+
lastSentHeartbeatDate:
250+
heartbeatsObject.lastSentHeartbeatDate ??
251+
existingHeartbeatsObject.lastSentHeartbeatDate,
252+
heartbeats: heartbeatsObject.heartbeats
251253
});
252254
}
253255
}
254-
// delete heartbeats
255-
async delete(heartbeats: SingleDateHeartbeat[]): Promise<void> {
256+
// add heartbeats
257+
async add(heartbeatsObject: HeartbeatsInIndexedDB): Promise<void> {
256258
const canUseIndexedDB = await this._canUseIndexedDBPromise;
257259
if (!canUseIndexedDB) {
258260
return;
259261
} else {
260-
const existingHeartbeats = await this.read();
262+
const existingHeartbeatsObject = await this.read();
261263
return writeHeartbeatsToIndexedDB(this.app, {
262-
heartbeats: existingHeartbeats.filter(
263-
existingHeartbeat => !heartbeats.includes(existingHeartbeat)
264-
)
264+
lastSentHeartbeatDate:
265+
heartbeatsObject.lastSentHeartbeatDate ??
266+
existingHeartbeatsObject.lastSentHeartbeatDate,
267+
heartbeats: [
268+
...existingHeartbeatsObject.heartbeats,
269+
...heartbeatsObject.heartbeats
270+
]
265271
});
266272
}
267273
}
268-
// delete all heartbeats
269-
async deleteAll(): Promise<void> {
270-
const canUseIndexedDB = await this._canUseIndexedDBPromise;
271-
if (!canUseIndexedDB) {
272-
return;
273-
} else {
274-
return deleteHeartbeatsFromIndexedDB(this.app);
275-
}
276-
}
277274
}
278275

279276
/**

0 commit comments

Comments
 (0)