Skip to content

Commit 1588990

Browse files
authored
Implement heartbeat controller (#5723)
1 parent 091e867 commit 1588990

10 files changed

+796
-3
lines changed

.changeset/quick-moons-play.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@firebase/app': patch
3+
---
4+
5+
Add heartbeat controller for platform logging.

packages/app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"@firebase/util": "1.4.3",
4141
"@firebase/logger": "0.3.2",
4242
"@firebase/component": "0.5.10",
43+
"idb": "3.0.2",
4344
"tslib": "^2.1.0"
4445
},
4546
"license": "Apache-2.0",

packages/app/src/errors.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ export const enum AppError {
2323
DUPLICATE_APP = 'duplicate-app',
2424
APP_DELETED = 'app-deleted',
2525
INVALID_APP_ARGUMENT = 'invalid-app-argument',
26-
INVALID_LOG_ARGUMENT = 'invalid-log-argument'
26+
INVALID_LOG_ARGUMENT = 'invalid-log-argument',
27+
STORAGE_OPEN = 'storage-open',
28+
STORAGE_GET = 'storage-get',
29+
STORAGE_WRITE = 'storage-set',
30+
STORAGE_DELETE = 'storage-delete'
2731
}
2832

2933
const ERRORS: ErrorMap<AppError> = {
@@ -38,7 +42,15 @@ const ERRORS: ErrorMap<AppError> = {
3842
'firebase.{$appName}() takes either no argument or a ' +
3943
'Firebase App instance.',
4044
[AppError.INVALID_LOG_ARGUMENT]:
41-
'First argument to `onLog` must be null or a function.'
45+
'First argument to `onLog` must be null or a function.',
46+
[AppError.STORAGE_OPEN]:
47+
'Error thrown when opening storage. Original error: {$originalErrorMessage}.',
48+
[AppError.STORAGE_GET]:
49+
'Error thrown when reading from storage. Original error: {$originalErrorMessage}.',
50+
[AppError.STORAGE_WRITE]:
51+
'Error thrown when writing to storage. Original error: {$originalErrorMessage}.',
52+
[AppError.STORAGE_DELETE]:
53+
'Error thrown when deleting from storage. Original error: {$originalErrorMessage}.'
4254
};
4355

4456
interface ErrorParams {
@@ -47,6 +59,10 @@ interface ErrorParams {
4759
[AppError.DUPLICATE_APP]: { appName: string };
4860
[AppError.APP_DELETED]: { appName: string };
4961
[AppError.INVALID_APP_ARGUMENT]: { appName: string };
62+
[AppError.STORAGE_OPEN]: { originalErrorMessage?: string };
63+
[AppError.STORAGE_GET]: { originalErrorMessage?: string };
64+
[AppError.STORAGE_WRITE]: { originalErrorMessage?: string };
65+
[AppError.STORAGE_DELETE]: { originalErrorMessage?: string };
5066
}
5167

5268
export const ERROR_FACTORY = new ErrorFactory<AppError, ErrorParams>(
Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
/**
2+
* @license
3+
* Copyright 2021 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { expect } from 'chai';
19+
import '../test/setup';
20+
import {
21+
countBytes,
22+
HeartbeatServiceImpl,
23+
extractHeartbeatsForHeader
24+
} from './heartbeatService';
25+
import {
26+
Component,
27+
ComponentType,
28+
ComponentContainer
29+
} from '@firebase/component';
30+
import { PlatformLoggerService } from './types';
31+
import { FirebaseApp } from './public-types';
32+
import * as firebaseUtil from '@firebase/util';
33+
import { SinonStub, stub, useFakeTimers } from 'sinon';
34+
import * as indexedDb from './indexeddb';
35+
import { base64Encode, isIndexedDBAvailable } from '@firebase/util';
36+
37+
declare module '@firebase/component' {
38+
interface NameServiceMapping {
39+
'platform-logger': PlatformLoggerService;
40+
}
41+
}
42+
43+
const USER_AGENT_STRING_1 = 'vs1/1.2.3 vs2/2.3.4';
44+
const USER_AGENT_STRING_2 = 'different/1.2.3';
45+
46+
function generateUserAgentString(pairs: number): string {
47+
let uaString = '';
48+
for (let i = 0; i < pairs; i++) {
49+
uaString += `test-platform/${i % 10}.${i % 10}.${i % 10}`;
50+
}
51+
return uaString;
52+
}
53+
54+
function generateDates(count: number): string[] {
55+
let currentTimestamp = Date.now();
56+
const dates = [];
57+
for (let i = 0; i < count; i++) {
58+
dates.push(new Date(currentTimestamp).toISOString().slice(0, 10));
59+
currentTimestamp += 24 * 60 * 60 * 1000;
60+
}
61+
return dates;
62+
}
63+
64+
describe('HeartbeatServiceImpl', () => {
65+
describe('If IndexedDB has no entries', () => {
66+
let heartbeatService: HeartbeatServiceImpl;
67+
let clock = useFakeTimers();
68+
let userAgentString = USER_AGENT_STRING_1;
69+
let writeStub: SinonStub;
70+
before(() => {
71+
const container = new ComponentContainer('heartbeatTestContainer');
72+
container.addComponent(
73+
new Component(
74+
'app',
75+
() =>
76+
({
77+
options: { appId: 'an-app-id' },
78+
name: 'an-app-name'
79+
} as FirebaseApp),
80+
ComponentType.VERSION
81+
)
82+
);
83+
container.addComponent(
84+
new Component(
85+
'platform-logger',
86+
() => ({ getPlatformInfoString: () => userAgentString }),
87+
ComponentType.VERSION
88+
)
89+
);
90+
heartbeatService = new HeartbeatServiceImpl(container);
91+
});
92+
beforeEach(() => {
93+
clock = useFakeTimers();
94+
writeStub = stub(heartbeatService._storage, 'overwrite');
95+
});
96+
/**
97+
* NOTE: The clock is being reset between each test because of the global
98+
* restore() in test/setup.ts. Don't assume previous clock state.
99+
*/
100+
it(`triggerHeartbeat() stores a heartbeat`, async () => {
101+
await heartbeatService.triggerHeartbeat();
102+
expect(heartbeatService._heartbeatsCache?.length).to.equal(1);
103+
const heartbeat1 = heartbeatService._heartbeatsCache?.[0];
104+
expect(heartbeat1?.userAgent).to.equal(USER_AGENT_STRING_1);
105+
expect(heartbeat1?.date).to.equal('1970-01-01');
106+
expect(writeStub).to.be.calledWith([heartbeat1]);
107+
});
108+
it(`triggerHeartbeat() doesn't store another heartbeat on the same day`, async () => {
109+
expect(heartbeatService._heartbeatsCache?.length).to.equal(1);
110+
await heartbeatService.triggerHeartbeat();
111+
expect(heartbeatService._heartbeatsCache?.length).to.equal(1);
112+
});
113+
it(`triggerHeartbeat() does store another heartbeat on a different day`, async () => {
114+
expect(heartbeatService._heartbeatsCache?.length).to.equal(1);
115+
clock.tick(24 * 60 * 60 * 1000);
116+
await heartbeatService.triggerHeartbeat();
117+
expect(heartbeatService._heartbeatsCache?.length).to.equal(2);
118+
expect(heartbeatService._heartbeatsCache?.[1].date).to.equal(
119+
'1970-01-02'
120+
);
121+
});
122+
it(`triggerHeartbeat() stores another entry for a different user agent`, async () => {
123+
userAgentString = USER_AGENT_STRING_2;
124+
expect(heartbeatService._heartbeatsCache?.length).to.equal(2);
125+
clock.tick(2 * 24 * 60 * 60 * 1000);
126+
await heartbeatService.triggerHeartbeat();
127+
expect(heartbeatService._heartbeatsCache?.length).to.equal(3);
128+
expect(heartbeatService._heartbeatsCache?.[2].date).to.equal(
129+
'1970-01-03'
130+
);
131+
});
132+
it('getHeartbeatHeaders() gets stored heartbeats and clears heartbeats', async () => {
133+
const deleteStub = stub(heartbeatService._storage, 'deleteAll');
134+
const heartbeatHeaders = firebaseUtil.base64Decode(
135+
await heartbeatService.getHeartbeatsHeader()
136+
);
137+
expect(heartbeatHeaders).to.include(USER_AGENT_STRING_1);
138+
expect(heartbeatHeaders).to.include(USER_AGENT_STRING_2);
139+
expect(heartbeatHeaders).to.include('1970-01-01');
140+
expect(heartbeatHeaders).to.include('1970-01-02');
141+
expect(heartbeatHeaders).to.include('1970-01-03');
142+
expect(heartbeatHeaders).to.include(`"version":2`);
143+
expect(heartbeatService._heartbeatsCache).to.equal(null);
144+
const emptyHeaders = await heartbeatService.getHeartbeatsHeader();
145+
expect(emptyHeaders).to.equal('');
146+
expect(deleteStub).to.be.called;
147+
});
148+
});
149+
describe('If IndexedDB has entries', () => {
150+
let heartbeatService: HeartbeatServiceImpl;
151+
let clock = useFakeTimers();
152+
let writeStub: SinonStub;
153+
let userAgentString = USER_AGENT_STRING_1;
154+
const mockIndexedDBHeartbeats = [
155+
// Chosen so one will exceed 30 day limit and one will not.
156+
{
157+
userAgent: 'old-user-agent',
158+
date: '1969-12-01'
159+
},
160+
{
161+
userAgent: 'old-user-agent',
162+
date: '1969-12-31'
163+
}
164+
];
165+
before(() => {
166+
const container = new ComponentContainer('heartbeatTestContainer');
167+
container.addComponent(
168+
new Component(
169+
'app',
170+
() =>
171+
({
172+
options: { appId: 'an-app-id' },
173+
name: 'an-app-name'
174+
} as FirebaseApp),
175+
ComponentType.VERSION
176+
)
177+
);
178+
container.addComponent(
179+
new Component(
180+
'platform-logger',
181+
() => ({ getPlatformInfoString: () => userAgentString }),
182+
ComponentType.VERSION
183+
)
184+
);
185+
stub(indexedDb, 'readHeartbeatsFromIndexedDB').resolves({
186+
heartbeats: [...mockIndexedDBHeartbeats]
187+
});
188+
heartbeatService = new HeartbeatServiceImpl(container);
189+
});
190+
beforeEach(() => {
191+
clock = useFakeTimers();
192+
writeStub = stub(heartbeatService._storage, 'overwrite');
193+
});
194+
/**
195+
* NOTE: The clock is being reset between each test because of the global
196+
* restore() in test/setup.ts. Don't assume previous clock state.
197+
*/
198+
it(`new heartbeat service reads from indexedDB cache`, async () => {
199+
const promiseResult = await heartbeatService._heartbeatsCachePromise;
200+
if (isIndexedDBAvailable()) {
201+
expect(promiseResult).to.deep.equal(mockIndexedDBHeartbeats);
202+
expect(heartbeatService._heartbeatsCache).to.deep.equal(
203+
mockIndexedDBHeartbeats
204+
);
205+
} else {
206+
// In Node or other no-indexed-db environments it will fail the
207+
// `canUseIndexedDb` check and return an empty array.
208+
expect(promiseResult).to.deep.equal([]);
209+
expect(heartbeatService._heartbeatsCache).to.deep.equal([]);
210+
}
211+
});
212+
it(`triggerHeartbeat() writes new heartbeats and retains old ones newer than 30 days`, async () => {
213+
userAgentString = USER_AGENT_STRING_2;
214+
clock.tick(3 * 24 * 60 * 60 * 1000);
215+
await heartbeatService.triggerHeartbeat();
216+
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+
]);
222+
} else {
223+
expect(writeStub).to.be.calledWith([
224+
{ userAgent: USER_AGENT_STRING_2, date: '1970-01-04' }
225+
]);
226+
}
227+
});
228+
it('getHeartbeatHeaders() gets stored heartbeats and clears heartbeats', async () => {
229+
const deleteStub = stub(heartbeatService._storage, 'deleteAll');
230+
const heartbeatHeaders = firebaseUtil.base64Decode(
231+
await heartbeatService.getHeartbeatsHeader()
232+
);
233+
if (isIndexedDBAvailable()) {
234+
expect(heartbeatHeaders).to.include('old-user-agent');
235+
expect(heartbeatHeaders).to.include('1969-12-31');
236+
}
237+
expect(heartbeatHeaders).to.include(USER_AGENT_STRING_2);
238+
expect(heartbeatHeaders).to.include('1970-01-04');
239+
expect(heartbeatHeaders).to.include(`"version":2`);
240+
expect(heartbeatService._heartbeatsCache).to.equal(null);
241+
const emptyHeaders = await heartbeatService.getHeartbeatsHeader();
242+
expect(emptyHeaders).to.equal('');
243+
expect(deleteStub).to.be.called;
244+
});
245+
});
246+
247+
describe('countBytes()', () => {
248+
it('counts how many bytes there will be in a stringified, encoded header', () => {
249+
const heartbeats = [
250+
{ userAgent: generateUserAgentString(1), dates: generateDates(1) },
251+
{ userAgent: generateUserAgentString(3), dates: generateDates(2) }
252+
];
253+
let size: number = 0;
254+
const headerString = base64Encode(
255+
JSON.stringify({ version: 2, heartbeats })
256+
);
257+
// Use independent methods to validate our byte count method matches.
258+
// We don't use this measurement method in the app because user
259+
// environments are much more unpredictable while we know the
260+
// tests will run in either a standard headless browser or Node.
261+
if (typeof Blob !== 'undefined') {
262+
const blob = new Blob([headerString]);
263+
size = blob.size;
264+
} else if (typeof Buffer !== 'undefined') {
265+
const buffer = Buffer.from(headerString);
266+
size = buffer.byteLength;
267+
}
268+
expect(countBytes(heartbeats)).to.equal(size);
269+
});
270+
});
271+
272+
describe('_extractHeartbeatsForHeader()', () => {
273+
it('returns empty heartbeatsToKeep if it cannot get under maxSize', () => {
274+
const heartbeats = [
275+
{ userAgent: generateUserAgentString(1), date: '2022-01-01' }
276+
];
277+
const { unsentEntries, heartbeatsToSend } = extractHeartbeatsForHeader(
278+
heartbeats,
279+
5
280+
);
281+
expect(heartbeatsToSend.length).to.equal(0);
282+
expect(unsentEntries).to.deep.equal(heartbeats);
283+
});
284+
it('splits heartbeats array', () => {
285+
const heartbeats = [
286+
{ userAgent: generateUserAgentString(20), date: '2022-01-01' },
287+
{ userAgent: generateUserAgentString(4), date: '2022-01-02' }
288+
];
289+
const sizeWithHeartbeat0Only = countBytes([
290+
{ userAgent: heartbeats[0].userAgent, dates: [heartbeats[0].date] }
291+
]);
292+
const { unsentEntries, heartbeatsToSend } = extractHeartbeatsForHeader(
293+
heartbeats,
294+
sizeWithHeartbeat0Only + 1
295+
);
296+
expect(heartbeatsToSend.length).to.equal(1);
297+
expect(unsentEntries.length).to.equal(1);
298+
});
299+
it('splits the first heartbeat if needed', () => {
300+
const uaString = generateUserAgentString(20);
301+
const heartbeats = [
302+
{ userAgent: uaString, date: '2022-01-01' },
303+
{ userAgent: uaString, date: '2022-01-02' },
304+
{ userAgent: uaString, date: '2022-01-03' }
305+
];
306+
const sizeWithHeartbeat0Only = countBytes([
307+
{ userAgent: heartbeats[0].userAgent, dates: [heartbeats[0].date] }
308+
]);
309+
const { unsentEntries, heartbeatsToSend } = extractHeartbeatsForHeader(
310+
heartbeats,
311+
sizeWithHeartbeat0Only + 1
312+
);
313+
expect(heartbeatsToSend.length).to.equal(1);
314+
expect(unsentEntries.length).to.equal(2);
315+
expect(heartbeatsToSend[0].dates.length + unsentEntries.length).to.equal(
316+
heartbeats.length
317+
);
318+
});
319+
});
320+
});

0 commit comments

Comments
 (0)