Skip to content

Commit acb4561

Browse files
committed
Add support for capturing web vitals metrics in Firebase performance for Web.
Modifies export to use sendBeacon instead of fetch API, and shifts the upload time to the first time the page is hidden or unloaded.
1 parent ffbf5a6 commit acb4561

File tree

9 files changed

+238
-125
lines changed

9 files changed

+238
-125
lines changed

packages/performance/package.json

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,7 @@
1414
},
1515
"./package.json": "./package.json"
1616
},
17-
"files": [
18-
"dist"
19-
],
17+
"files": ["dist"],
2018
"scripts": {
2119
"lint": "eslint -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'",
2220
"lint:fix": "eslint --fix -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'",
@@ -42,7 +40,8 @@
4240
"@firebase/installations": "0.6.11",
4341
"@firebase/util": "1.10.2",
4442
"@firebase/component": "0.6.11",
45-
"tslib": "^2.1.0"
43+
"tslib": "^2.1.0",
44+
"web-vitals": "^4.2.4"
4645
},
4746
"license": "Apache-2.0",
4847
"devDependencies": {
@@ -62,9 +61,7 @@
6261
},
6362
"typings": "dist/src/index.d.ts",
6463
"nyc": {
65-
"extension": [
66-
".ts"
67-
],
64+
"extension": [".ts"],
6865
"reportDir": "./coverage/node"
6966
}
7067
}

packages/performance/src/constants.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@ export const FIRST_CONTENTFUL_PAINT_COUNTER_NAME = '_fcp';
3333

3434
export const FIRST_INPUT_DELAY_COUNTER_NAME = '_fid';
3535

36+
export const LARGEST_CONTENTFUL_PAINT_METRIC_NAME = '_lcp';
37+
export const LARGEST_CONTENTFUL_PAINT_ATTRIBUTE_NAME = 'lcp_element';
38+
39+
export const INTERACTION_TO_NEXT_PAINT_METRIC_NAME = '_inp';
40+
export const INTERACTION_TO_NEXT_PAINT_ATTRIBUTE_NAME = 'inp_interactionTarget';
41+
42+
export const CUMULATIVE_LAYOUT_SHIFT_METRIC_NAME = '_cls';
43+
export const CUMULATIVE_LAYOUT_SHIFT_ATTRIBUTE_NAME = 'cls_largestShiftTarget';
44+
3645
export const CONFIG_LOCAL_STORAGE_KEY = '@firebase/performance/config';
3746

3847
export const CONFIG_EXPIRY_LOCAL_STORAGE_KEY =

packages/performance/src/resources/trace.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,16 @@ import {
2222
OOB_TRACE_PAGE_LOAD_PREFIX,
2323
FIRST_PAINT_COUNTER_NAME,
2424
FIRST_CONTENTFUL_PAINT_COUNTER_NAME,
25-
FIRST_INPUT_DELAY_COUNTER_NAME
25+
FIRST_INPUT_DELAY_COUNTER_NAME,
26+
LARGEST_CONTENTFUL_PAINT_METRIC_NAME,
27+
LARGEST_CONTENTFUL_PAINT_ATTRIBUTE_NAME,
28+
INTERACTION_TO_NEXT_PAINT_METRIC_NAME,
29+
INTERACTION_TO_NEXT_PAINT_ATTRIBUTE_NAME,
30+
CUMULATIVE_LAYOUT_SHIFT_METRIC_NAME,
31+
CUMULATIVE_LAYOUT_SHIFT_ATTRIBUTE_NAME
2632
} from '../constants';
2733
import { Api } from '../services/api_service';
28-
import { logTrace } from '../services/perf_logger';
34+
import { logTrace, flushLogs } from '../services/perf_logger';
2935
import { ERROR_FACTORY, ErrorCode } from '../utils/errors';
3036
import {
3137
isValidCustomAttributeName,
@@ -37,6 +43,7 @@ import {
3743
} from '../utils/metric_utils';
3844
import { PerformanceTrace } from '../public_types';
3945
import { PerformanceController } from '../controllers/perf';
46+
import { CoreVitalMetric, WebVitalMetrics } from './web_vitals';
4047

4148
const enum TraceState {
4249
UNINITIALIZED = 1,
@@ -279,6 +286,7 @@ export class Trace implements PerformanceTrace {
279286
performanceController: PerformanceController,
280287
navigationTimings: PerformanceNavigationTiming[],
281288
paintTimings: PerformanceEntry[],
289+
webVitalMetrics: WebVitalMetrics,
282290
firstInputDelay?: number
283291
): void {
284292
const route = Api.getInstance().getUrl();
@@ -340,7 +348,43 @@ export class Trace implements PerformanceTrace {
340348
}
341349
}
342350

351+
this.addWebVitalMetric(
352+
trace,
353+
LARGEST_CONTENTFUL_PAINT_METRIC_NAME,
354+
LARGEST_CONTENTFUL_PAINT_ATTRIBUTE_NAME,
355+
webVitalMetrics.lcp
356+
);
357+
this.addWebVitalMetric(
358+
trace,
359+
CUMULATIVE_LAYOUT_SHIFT_METRIC_NAME,
360+
CUMULATIVE_LAYOUT_SHIFT_ATTRIBUTE_NAME,
361+
webVitalMetrics.cls
362+
);
363+
this.addWebVitalMetric(
364+
trace,
365+
INTERACTION_TO_NEXT_PAINT_METRIC_NAME,
366+
INTERACTION_TO_NEXT_PAINT_ATTRIBUTE_NAME,
367+
webVitalMetrics.inp
368+
);
369+
370+
// Page load logs are sent at unload time and so should be logged and
371+
// flushed immediately.
343372
logTrace(trace);
373+
flushLogs();
374+
}
375+
376+
static addWebVitalMetric(
377+
trace: Trace,
378+
metricKey: string,
379+
attributeKey: string,
380+
metric?: CoreVitalMetric
381+
): void {
382+
if (metric) {
383+
trace.putMetric(metricKey, Math.floor(metric.value * 1000));
384+
if (metric.elementAttribution) {
385+
trace.putAttribute(attributeKey, metric.elementAttribution);
386+
}
387+
}
344388
}
345389

346390
static createUserTimingTrace(
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* @license
3+
* Copyright 2024 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+
export interface CoreVitalMetric {
19+
value: number;
20+
elementAttribution?: string;
21+
}
22+
23+
export interface WebVitalMetrics {
24+
cls?: CoreVitalMetric;
25+
inp?: CoreVitalMetric;
26+
lcp?: CoreVitalMetric;
27+
}

packages/performance/src/services/oob_resources_service.ts

Lines changed: 89 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,22 @@ import { createNetworkRequestEntry } from '../resources/network_request';
2121
import { TRACE_MEASURE_PREFIX } from '../constants';
2222
import { getIid } from './iid_service';
2323
import { PerformanceController } from '../controllers/perf';
24+
import { WebVitalMetrics } from '../resources/web_vitals';
25+
import {
26+
onCLS,
27+
onLCP,
28+
onINP,
29+
LCPMetricWithAttribution,
30+
CLSMetricWithAttribution,
31+
INPMetricWithAttribution
32+
} from 'web-vitals/attribution';
2433

2534
const FID_WAIT_TIME_MS = 5000;
2635

36+
const webVitalMetrics: WebVitalMetrics = {};
37+
38+
let sentPageLoadTrace: boolean = false;
39+
2740
export function setupOobResources(
2841
performanceController: PerformanceController
2942
): void {
@@ -53,41 +66,40 @@ function setupNetworkRequests(
5366

5467
function setupOobTraces(performanceController: PerformanceController): void {
5568
const api = Api.getInstance();
56-
const navigationTimings = api.getEntriesByType(
57-
'navigation'
58-
) as PerformanceNavigationTiming[];
59-
const paintTimings = api.getEntriesByType('paint');
60-
// If First Input Delay polyfill is added to the page, report the fid value.
61-
// https://github.com/GoogleChromeLabs/first-input-delay
62-
if (api.onFirstInputDelay) {
63-
// If the fid call back is not called for certain time, continue without it.
64-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
65-
let timeoutId: any = setTimeout(() => {
66-
Trace.createOobTrace(
67-
performanceController,
68-
navigationTimings,
69-
paintTimings
70-
);
71-
timeoutId = undefined;
72-
}, FID_WAIT_TIME_MS);
73-
api.onFirstInputDelay((fid: number) => {
74-
if (timeoutId) {
75-
clearTimeout(timeoutId);
76-
Trace.createOobTrace(
77-
performanceController,
78-
navigationTimings,
79-
paintTimings,
80-
fid
81-
);
82-
}
83-
});
69+
// Better support for Safari
70+
if ('onpagehide' in window) {
71+
api.document.addEventListener('pagehide', () =>
72+
sendOobTrace(performanceController)
73+
);
8474
} else {
85-
Trace.createOobTrace(
86-
performanceController,
87-
navigationTimings,
88-
paintTimings
75+
api.document.addEventListener('unload', () =>
76+
sendOobTrace(performanceController)
8977
);
9078
}
79+
api.document.addEventListener('visibilitychange', () => {
80+
if (api.document.visibilityState === 'hidden') {
81+
sendOobTrace(performanceController);
82+
}
83+
});
84+
85+
onLCP((metric: LCPMetricWithAttribution) => {
86+
webVitalMetrics.lcp = {
87+
value: metric.value,
88+
elementAttribution: metric.attribution.element
89+
};
90+
});
91+
onCLS((metric: CLSMetricWithAttribution) => {
92+
webVitalMetrics.cls = {
93+
value: metric.value,
94+
elementAttribution: metric.attribution.largestShiftTarget
95+
};
96+
});
97+
onINP((metric: INPMetricWithAttribution) => {
98+
webVitalMetrics.inp = {
99+
value: metric.value,
100+
elementAttribution: metric.attribution.interactionTarget
101+
};
102+
});
91103
}
92104

93105
function setupUserTimingTraces(
@@ -119,3 +131,48 @@ function createUserTimingTrace(
119131
}
120132
Trace.createUserTimingTrace(performanceController, measureName);
121133
}
134+
135+
function sendOobTrace(performanceController: PerformanceController): void {
136+
if (!sentPageLoadTrace) {
137+
sentPageLoadTrace = true;
138+
const api = Api.getInstance();
139+
const navigationTimings = api.getEntriesByType(
140+
'navigation'
141+
) as PerformanceNavigationTiming[];
142+
const paintTimings = api.getEntriesByType('paint');
143+
// If First Input Delay polyfill is added to the page, report the fid value.
144+
// https://github.com/GoogleChromeLabs/first-input-delay
145+
if (api.onFirstInputDelay) {
146+
// If the fid call back is not called for certain time, continue without it.
147+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
148+
let timeoutId: any = setTimeout(() => {
149+
Trace.createOobTrace(
150+
performanceController,
151+
navigationTimings,
152+
paintTimings,
153+
webVitalMetrics
154+
);
155+
timeoutId = undefined;
156+
}, FID_WAIT_TIME_MS);
157+
api.onFirstInputDelay((fid: number) => {
158+
if (timeoutId) {
159+
clearTimeout(timeoutId);
160+
Trace.createOobTrace(
161+
performanceController,
162+
navigationTimings,
163+
paintTimings,
164+
webVitalMetrics,
165+
fid
166+
);
167+
}
168+
});
169+
} else {
170+
Trace.createOobTrace(
171+
performanceController,
172+
navigationTimings,
173+
paintTimings,
174+
webVitalMetrics
175+
);
176+
}
177+
}
178+
}

packages/performance/src/services/perf_logger.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,13 @@ import { SettingsService } from './settings_service';
2323
import {
2424
getServiceWorkerStatus,
2525
getVisibilityState,
26-
VisibilityState,
2726
getEffectiveConnectionType
2827
} from '../utils/attributes_utils';
2928
import {
3029
isPerfInitialized,
3130
getInitializationPromise
3231
} from './initialization_service';
33-
import { transportHandler } from './transport_service';
32+
import { transportHandler, flushQueuedEvents } from './transport_service';
3433
import { SDK_VERSION } from '../constants';
3534
import { FirebaseApp } from '@firebase/app';
3635
import { getAppId } from '../utils/app_utils';
@@ -85,19 +84,25 @@ interface TraceMetric {
8584
custom_attributes?: { [key: string]: string };
8685
}
8786

88-
let logger: (
89-
resource: NetworkRequest | Trace,
90-
resourceType: ResourceType
91-
) => void | undefined;
87+
interface Logger {
88+
send: (resource: NetworkRequest | Trace, resourceType: ResourceType) => void | undefined;
89+
flush: () => void;
90+
}
91+
92+
let logger: Logger;
93+
//
9294
// This method is not called before initialization.
9395
function sendLog(
9496
resource: NetworkRequest | Trace,
9597
resourceType: ResourceType
9698
): void {
9799
if (!logger) {
98-
logger = transportHandler(serializer);
100+
logger = {
101+
send: transportHandler(serializer),
102+
flush: flushQueuedEvents,
103+
};
99104
}
100-
logger(resource, resourceType);
105+
logger.send(resource, resourceType);
101106
}
102107

103108
export function logTrace(trace: Trace): void {
@@ -115,11 +120,6 @@ export function logTrace(trace: Trace): void {
115120
return;
116121
}
117122

118-
// Only log the page load auto traces if page is visible.
119-
if (trace.isAuto && getVisibilityState() !== VisibilityState.VISIBLE) {
120-
return;
121-
}
122-
123123
if (isPerfInitialized()) {
124124
sendTraceLog(trace);
125125
} else {
@@ -132,6 +132,12 @@ export function logTrace(trace: Trace): void {
132132
}
133133
}
134134

135+
export function flushLogs(): void {
136+
if (logger) {
137+
logger.flush();
138+
}
139+
}
140+
135141
function sendTraceLog(trace: Trace): void {
136142
if (!getIid()) {
137143
return;
@@ -145,7 +151,7 @@ function sendTraceLog(trace: Trace): void {
145151
return;
146152
}
147153

148-
setTimeout(() => sendLog(trace, ResourceType.Trace), 0);
154+
sendLog(trace, ResourceType.Trace);
149155
}
150156

151157
export function logNetworkRequest(networkRequest: NetworkRequest): void {
@@ -177,7 +183,7 @@ export function logNetworkRequest(networkRequest: NetworkRequest): void {
177183
return;
178184
}
179185

180-
setTimeout(() => sendLog(networkRequest, ResourceType.NetworkRequest), 0);
186+
sendLog(networkRequest, ResourceType.NetworkRequest);
181187
}
182188

183189
function serializer(

0 commit comments

Comments
 (0)