diff --git a/packages/performance/package.json b/packages/performance/package.json index 0c48828325a..0e63125061d 100644 --- a/packages/performance/package.json +++ b/packages/performance/package.json @@ -14,9 +14,7 @@ }, "./package.json": "./package.json" }, - "files": [ - "dist" - ], + "files": ["dist"], "scripts": { "lint": "eslint -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", "lint:fix": "eslint --fix -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", @@ -42,7 +40,8 @@ "@firebase/installations": "0.6.11", "@firebase/util": "1.10.2", "@firebase/component": "0.6.11", - "tslib": "^2.1.0" + "tslib": "^2.1.0", + "web-vitals": "^4.2.4" }, "license": "Apache-2.0", "devDependencies": { @@ -62,9 +61,7 @@ }, "typings": "dist/src/index.d.ts", "nyc": { - "extension": [ - ".ts" - ], + "extension": [".ts"], "reportDir": "./coverage/node" } } diff --git a/packages/performance/src/constants.ts b/packages/performance/src/constants.ts index 2cac126da97..66350190603 100644 --- a/packages/performance/src/constants.ts +++ b/packages/performance/src/constants.ts @@ -33,6 +33,15 @@ export const FIRST_CONTENTFUL_PAINT_COUNTER_NAME = '_fcp'; export const FIRST_INPUT_DELAY_COUNTER_NAME = '_fid'; +export const LARGEST_CONTENTFUL_PAINT_METRIC_NAME = '_lcp'; +export const LARGEST_CONTENTFUL_PAINT_ATTRIBUTE_NAME = 'lcp_element'; + +export const INTERACTION_TO_NEXT_PAINT_METRIC_NAME = '_inp'; +export const INTERACTION_TO_NEXT_PAINT_ATTRIBUTE_NAME = 'inp_interactionTarget'; + +export const CUMULATIVE_LAYOUT_SHIFT_METRIC_NAME = '_cls'; +export const CUMULATIVE_LAYOUT_SHIFT_ATTRIBUTE_NAME = 'cls_largestShiftTarget'; + export const CONFIG_LOCAL_STORAGE_KEY = '@firebase/performance/config'; export const CONFIG_EXPIRY_LOCAL_STORAGE_KEY = diff --git a/packages/performance/src/resources/trace.ts b/packages/performance/src/resources/trace.ts index 792bf1677ea..d6657f14ba6 100644 --- a/packages/performance/src/resources/trace.ts +++ b/packages/performance/src/resources/trace.ts @@ -22,10 +22,16 @@ import { OOB_TRACE_PAGE_LOAD_PREFIX, FIRST_PAINT_COUNTER_NAME, FIRST_CONTENTFUL_PAINT_COUNTER_NAME, - FIRST_INPUT_DELAY_COUNTER_NAME + FIRST_INPUT_DELAY_COUNTER_NAME, + LARGEST_CONTENTFUL_PAINT_METRIC_NAME, + LARGEST_CONTENTFUL_PAINT_ATTRIBUTE_NAME, + INTERACTION_TO_NEXT_PAINT_METRIC_NAME, + INTERACTION_TO_NEXT_PAINT_ATTRIBUTE_NAME, + CUMULATIVE_LAYOUT_SHIFT_METRIC_NAME, + CUMULATIVE_LAYOUT_SHIFT_ATTRIBUTE_NAME } from '../constants'; import { Api } from '../services/api_service'; -import { logTrace } from '../services/perf_logger'; +import { logTrace, flushLogs } from '../services/perf_logger'; import { ERROR_FACTORY, ErrorCode } from '../utils/errors'; import { isValidCustomAttributeName, @@ -37,6 +43,7 @@ import { } from '../utils/metric_utils'; import { PerformanceTrace } from '../public_types'; import { PerformanceController } from '../controllers/perf'; +import { CoreVitalMetric, WebVitalMetrics } from './web_vitals'; const enum TraceState { UNINITIALIZED = 1, @@ -279,6 +286,7 @@ export class Trace implements PerformanceTrace { performanceController: PerformanceController, navigationTimings: PerformanceNavigationTiming[], paintTimings: PerformanceEntry[], + webVitalMetrics: WebVitalMetrics, firstInputDelay?: number ): void { const route = Api.getInstance().getUrl(); @@ -340,7 +348,43 @@ export class Trace implements PerformanceTrace { } } + this.addWebVitalMetric( + trace, + LARGEST_CONTENTFUL_PAINT_METRIC_NAME, + LARGEST_CONTENTFUL_PAINT_ATTRIBUTE_NAME, + webVitalMetrics.lcp + ); + this.addWebVitalMetric( + trace, + CUMULATIVE_LAYOUT_SHIFT_METRIC_NAME, + CUMULATIVE_LAYOUT_SHIFT_ATTRIBUTE_NAME, + webVitalMetrics.cls + ); + this.addWebVitalMetric( + trace, + INTERACTION_TO_NEXT_PAINT_METRIC_NAME, + INTERACTION_TO_NEXT_PAINT_ATTRIBUTE_NAME, + webVitalMetrics.inp + ); + + // Page load logs are sent at unload time and so should be logged and + // flushed immediately. logTrace(trace); + flushLogs(); + } + + static addWebVitalMetric( + trace: Trace, + metricKey: string, + attributeKey: string, + metric?: CoreVitalMetric + ): void { + if (metric) { + trace.putMetric(metricKey, Math.floor(metric.value * 1000)); + if (metric.elementAttribution) { + trace.putAttribute(attributeKey, metric.elementAttribution); + } + } } static createUserTimingTrace( diff --git a/packages/performance/src/resources/web_vitals.ts b/packages/performance/src/resources/web_vitals.ts new file mode 100644 index 00000000000..850768b4655 --- /dev/null +++ b/packages/performance/src/resources/web_vitals.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2024 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 interface CoreVitalMetric { + value: number; + elementAttribution?: string; +} + +export interface WebVitalMetrics { + cls?: CoreVitalMetric; + inp?: CoreVitalMetric; + lcp?: CoreVitalMetric; +} diff --git a/packages/performance/src/services/api_service.ts b/packages/performance/src/services/api_service.ts index 494574f650c..97c3f2943c4 100644 --- a/packages/performance/src/services/api_service.ts +++ b/packages/performance/src/services/api_service.ts @@ -18,6 +18,14 @@ import { ERROR_FACTORY, ErrorCode } from '../utils/errors'; import { isIndexedDBAvailable, areCookiesEnabled } from '@firebase/util'; import { consoleLogger } from '../utils/console_logger'; +import { + CLSMetricWithAttribution, + INPMetricWithAttribution, + LCPMetricWithAttribution, + onCLS as vitalsOnCLS, + onINP as vitalsOnINP, + onLCP as vitalsOnLCP +} from 'web-vitals/attribution'; declare global { interface Window { @@ -47,6 +55,9 @@ export class Api { private readonly PerformanceObserver: typeof PerformanceObserver; private readonly windowLocation: Location; readonly onFirstInputDelay?: (fn: (fid: number) => void) => void; + readonly onLCP: (fn: (metric: LCPMetricWithAttribution) => void) => void; + readonly onINP: (fn: (metric: INPMetricWithAttribution) => void) => void; + readonly onCLS: (fn: (metric: CLSMetricWithAttribution) => void) => void; readonly localStorage?: Storage; readonly document: Document; readonly navigator: Navigator; @@ -68,6 +79,9 @@ export class Api { if (window.perfMetrics && window.perfMetrics.onFirstInputDelay) { this.onFirstInputDelay = window.perfMetrics.onFirstInputDelay; } + this.onLCP = vitalsOnLCP; + this.onINP = vitalsOnINP; + this.onCLS = vitalsOnCLS; } getUrl(): string { diff --git a/packages/performance/src/services/oob_resources_service.test.ts b/packages/performance/src/services/oob_resources_service.test.ts index 6f8ba259a94..e927d21fd09 100644 --- a/packages/performance/src/services/oob_resources_service.test.ts +++ b/packages/performance/src/services/oob_resources_service.test.ts @@ -18,6 +18,7 @@ import { spy, stub, + restore as sinonRestore, SinonSpy, SinonStub, useFakeTimers, @@ -26,13 +27,23 @@ import { import { expect } from 'chai'; import { Api, setupApi, EntryType } from './api_service'; import * as iidService from './iid_service'; -import { setupOobResources } from './oob_resources_service'; +import { setupOobResources, resetForUnitTests } from './oob_resources_service'; import { Trace } from '../resources/trace'; import '../../test/setup'; import { PerformanceController } from '../controllers/perf'; import { FirebaseApp } from '@firebase/app'; import { FirebaseInstallations } from '@firebase/installations-types'; +import { WebVitalMetrics } from '../resources/web_vitals'; +import { + CLSAttribution, + CLSMetricWithAttribution, + INPAttribution, + INPMetricWithAttribution, + LCPAttribution, + LCPMetricWithAttribution +} from 'web-vitals/attribution'; +// eslint-disable-next-line no-restricted-properties describe('Firebase Performance > oob_resources_service', () => { const MOCK_ID = 'idasdfsffe'; @@ -82,23 +93,36 @@ describe('Firebase Performance > oob_resources_service', () => { let getIidStub: SinonStub<[], string | undefined>; let apiGetInstanceSpy: SinonSpy<[], Api>; + let eventListenerSpy: SinonSpy< + [ + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions | undefined + ], + void + >; let getEntriesByTypeStub: SinonStub<[EntryType], PerformanceEntry[]>; let setupObserverStub: SinonStub< [EntryType, (entry: PerformanceEntry) => void], void >; - let createOobTraceStub: SinonStub< + let createOobTraceSpy: SinonSpy< [ PerformanceController, PerformanceNavigationTiming[], PerformanceEntry[], + WebVitalMetrics, (number | undefined)? ], void >; let clock: SinonFakeTimers; + let lcpSpy: SinonSpy<[(m: LCPMetricWithAttribution) => void], void>; + let inpSpy: SinonSpy<[(m: INPMetricWithAttribution) => void], void>; + let clsSpy: SinonSpy<[(m: CLSMetricWithAttribution) => void], void>; - setupApi(self); + const mockWindow = { ...self }; + setupApi(mockWindow); const fakeFirebaseConfig = { apiKey: 'api-key', @@ -120,9 +144,22 @@ describe('Firebase Performance > oob_resources_service', () => { fakeInstallations ); + function callEventListener(name: string): void { + for (let i = eventListenerSpy.callCount; i > 0; i--) { + const [eventName, eventFn] = eventListenerSpy.getCall(i - 1).args; + if (eventName === name) { + if (typeof eventFn === 'function') { + eventFn(new CustomEvent(name)); + } + } + } + } + beforeEach(() => { + resetForUnitTests(); getIidStub = stub(iidService, 'getIid'); - apiGetInstanceSpy = spy(Api, 'getInstance'); + eventListenerSpy = spy(mockWindow.document, 'addEventListener'); + clock = useFakeTimers(); getEntriesByTypeStub = stub(Api.prototype, 'getEntriesByType').callsFake( entry => { @@ -133,11 +170,20 @@ describe('Firebase Performance > oob_resources_service', () => { } ); setupObserverStub = stub(Api.prototype, 'setupObserver'); - createOobTraceStub = stub(Trace, 'createOobTrace'); + createOobTraceSpy = spy(Trace, 'createOobTrace'); + const api = Api.getInstance(); + lcpSpy = spy(api, 'onLCP'); + inpSpy = spy(api, 'onINP'); + clsSpy = spy(api, 'onCLS'); + apiGetInstanceSpy = spy(Api, 'getInstance'); }); afterEach(() => { clock.restore(); + sinonRestore(); + const api = Api.getInstance(); + //@ts-ignore Assignment to read-only property. + api.onFirstInputDelay = undefined; }); describe('setupOobResources', () => { @@ -158,36 +204,51 @@ describe('Firebase Performance > oob_resources_service', () => { expect(setupObserverStub).to.be.calledWith('resource'); }); - it('sets up page load trace collection', () => { + it('does not create page load trace before hidden', () => { getIidStub.returns(MOCK_ID); setupOobResources(performanceController); clock.tick(1); expect(apiGetInstanceSpy).to.be.called; + expect(createOobTraceSpy).not.to.be.called; + }); + + it('creates page load trace after hidden', () => { + getIidStub.returns(MOCK_ID); + setupOobResources(performanceController); + clock.tick(1); + + stub(mockWindow.document, 'visibilityState').value('hidden'); + callEventListener('visibilitychange'); + clock.tick(1); + expect(getEntriesByTypeStub).to.be.calledWith('navigation'); expect(getEntriesByTypeStub).to.be.calledWith('paint'); - expect(createOobTraceStub).to.be.calledWithExactly( + expect(createOobTraceSpy).to.be.calledWithExactly( performanceController, [NAVIGATION_PERFORMANCE_ENTRY], - [PAINT_PERFORMANCE_ENTRY] + [PAINT_PERFORMANCE_ENTRY], + {}, + undefined ); }); - it('waits for first input delay if polyfill is available', () => { + it('creates page load trace after pagehide', () => { getIidStub.returns(MOCK_ID); - const api = Api.getInstance(); - //@ts-ignore Assignment to read-only property. - api.onFirstInputDelay = stub(); setupOobResources(performanceController); clock.tick(1); - expect(api.onFirstInputDelay).to.be.called; - expect(createOobTraceStub).not.to.be.called; - clock.tick(5000); - expect(createOobTraceStub).to.be.calledWithExactly( + callEventListener('pagehide'); + clock.tick(1); + + expect(getEntriesByTypeStub).to.be.calledWith('navigation'); + expect(getEntriesByTypeStub).to.be.calledWith('paint'); + expect(createOobTraceSpy).to.be.calledWithExactly( performanceController, [NAVIGATION_PERFORMANCE_ENTRY], - [PAINT_PERFORMANCE_ENTRY] + [PAINT_PERFORMANCE_ENTRY], + {}, + undefined ); }); @@ -206,10 +267,16 @@ describe('Firebase Performance > oob_resources_service', () => { clock.tick(1); firstInputDelayCallback(FIRST_INPUT_DELAY); - expect(createOobTraceStub).to.be.calledWithExactly( + // Force the page load event to be sent + stub(mockWindow.document, 'visibilityState').value('hidden'); + callEventListener('visibilitychange'); + clock.tick(1); + + expect(createOobTraceSpy).to.be.calledWithExactly( performanceController, [NAVIGATION_PERFORMANCE_ENTRY], [PAINT_PERFORMANCE_ENTRY], + {}, FIRST_INPUT_DELAY ); }); @@ -223,5 +290,127 @@ describe('Firebase Performance > oob_resources_service', () => { expect(getEntriesByTypeStub).to.be.calledWith('measure'); expect(setupObserverStub).to.be.calledWith('measure'); }); + + it('sends LCP metrics with attribution', () => { + getIidStub.returns(MOCK_ID); + setupOobResources(performanceController); + clock.tick(1); + + lcpSpy.getCall(-1).args[0]({ + value: 12.34, + attribution: { + element: 'some-element' + } as LCPAttribution + } as LCPMetricWithAttribution); + + // Force the page load event to be sent + stub(mockWindow.document, 'visibilityState').value('hidden'); + callEventListener('visibilitychange'); + clock.tick(1); + + expect(createOobTraceSpy).to.be.calledWithExactly( + performanceController, + [NAVIGATION_PERFORMANCE_ENTRY], + [PAINT_PERFORMANCE_ENTRY], + { + lcp: { value: 12.34, elementAttribution: 'some-element' } + }, + undefined + ); + }); + + it('sends INP metrics with attribution', () => { + getIidStub.returns(MOCK_ID); + setupOobResources(performanceController); + clock.tick(1); + + inpSpy.getCall(-1).args[0]({ + value: 0.198, + attribution: { + interactionTarget: 'another-element' + } as INPAttribution + } as INPMetricWithAttribution); + + // Force the page load event to be sent + stub(mockWindow.document, 'visibilityState').value('hidden'); + callEventListener('visibilitychange'); + clock.tick(1); + + expect(createOobTraceSpy).to.be.calledWithExactly( + performanceController, + [NAVIGATION_PERFORMANCE_ENTRY], + [PAINT_PERFORMANCE_ENTRY], + { + inp: { value: 0.198, elementAttribution: 'another-element' } + }, + undefined + ); + }); + + it('sends CLS metrics with attribution', () => { + getIidStub.returns(MOCK_ID); + setupOobResources(performanceController); + clock.tick(1); + + clsSpy.getCall(-1).args[0]({ + value: 0.3, + // eslint-disable-next-line + attribution: { + largestShiftTarget: 'large-shift-element' + } as CLSAttribution + } as CLSMetricWithAttribution); + + // Force the page load event to be sent + stub(mockWindow.document, 'visibilityState').value('hidden'); + callEventListener('visibilitychange'); + clock.tick(1); + + expect(createOobTraceSpy).to.be.calledWithExactly( + performanceController, + [NAVIGATION_PERFORMANCE_ENTRY], + [PAINT_PERFORMANCE_ENTRY], + { + cls: { value: 0.3, elementAttribution: 'large-shift-element' } + }, + undefined + ); + }); + + it('sends all core web vitals metrics', () => { + getIidStub.returns(MOCK_ID); + setupOobResources(performanceController); + clock.tick(1); + + lcpSpy.getCall(-1).args[0]({ + value: 5.91, + attribution: { element: 'an-element' } as LCPAttribution + } as LCPMetricWithAttribution); + inpSpy.getCall(-1).args[0]({ + value: 0.1 + } as INPMetricWithAttribution); + clsSpy.getCall(-1).args[0]({ + value: 0.3, + attribution: { + largestShiftTarget: 'large-shift-element' + } as CLSAttribution + } as CLSMetricWithAttribution); + + // Force the page load event to be sent + stub(mockWindow.document, 'visibilityState').value('hidden'); + callEventListener('visibilitychange'); + clock.tick(1); + + expect(createOobTraceSpy).to.be.calledWithExactly( + performanceController, + [NAVIGATION_PERFORMANCE_ENTRY], + [PAINT_PERFORMANCE_ENTRY], + { + lcp: { value: 5.91, elementAttribution: 'an-element' }, + inp: { value: 0.1, elementAttribution: undefined }, + cls: { value: 0.3, elementAttribution: 'large-shift-element' } + }, + undefined + ); + }); }); }); diff --git a/packages/performance/src/services/oob_resources_service.ts b/packages/performance/src/services/oob_resources_service.ts index aede0fa85c9..7e27e247b8d 100644 --- a/packages/performance/src/services/oob_resources_service.ts +++ b/packages/performance/src/services/oob_resources_service.ts @@ -15,14 +15,24 @@ * limitations under the License. */ -import { Api } from './api_service'; -import { Trace } from '../resources/trace'; -import { createNetworkRequestEntry } from '../resources/network_request'; +import { + CLSMetricWithAttribution, + INPMetricWithAttribution, + LCPMetricWithAttribution +} from 'web-vitals/attribution'; + import { TRACE_MEASURE_PREFIX } from '../constants'; -import { getIid } from './iid_service'; import { PerformanceController } from '../controllers/perf'; +import { createNetworkRequestEntry } from '../resources/network_request'; +import { Trace } from '../resources/trace'; +import { WebVitalMetrics } from '../resources/web_vitals'; -const FID_WAIT_TIME_MS = 5000; +import { Api } from './api_service'; +import { getIid } from './iid_service'; + +let webVitalMetrics: WebVitalMetrics = {}; +let sentPageLoadTrace: boolean = false; +let firstInputDelay: number | undefined; export function setupOobResources( performanceController: PerformanceController @@ -31,8 +41,9 @@ export function setupOobResources( if (!getIid()) { return; } - // The load event might not have fired yet, and that means performance navigation timing - // object has a duration of 0. The setup should run after all current tasks in js queue. + // The load event might not have fired yet, and that means performance + // navigation timing object has a duration of 0. The setup should run after + // all current tasks in js queue. setTimeout(() => setupOobTraces(performanceController), 0); setTimeout(() => setupNetworkRequests(performanceController), 0); setTimeout(() => setupUserTimingTraces(performanceController), 0); @@ -53,41 +64,46 @@ function setupNetworkRequests( function setupOobTraces(performanceController: PerformanceController): void { const api = Api.getInstance(); - const navigationTimings = api.getEntriesByType( - 'navigation' - ) as PerformanceNavigationTiming[]; - const paintTimings = api.getEntriesByType('paint'); - // If First Input Delay polyfill is added to the page, report the fid value. - // https://github.com/GoogleChromeLabs/first-input-delay + // Better support for Safari + if ('onpagehide' in window) { + api.document.addEventListener('pagehide', () => + sendOobTrace(performanceController) + ); + } else { + api.document.addEventListener('unload', () => + sendOobTrace(performanceController) + ); + } + api.document.addEventListener('visibilitychange', () => { + if (api.document.visibilityState === 'hidden') { + sendOobTrace(performanceController); + } + }); + if (api.onFirstInputDelay) { - // If the fid call back is not called for certain time, continue without it. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let timeoutId: any = setTimeout(() => { - Trace.createOobTrace( - performanceController, - navigationTimings, - paintTimings - ); - timeoutId = undefined; - }, FID_WAIT_TIME_MS); api.onFirstInputDelay((fid: number) => { - if (timeoutId) { - clearTimeout(timeoutId); - Trace.createOobTrace( - performanceController, - navigationTimings, - paintTimings, - fid - ); - } + firstInputDelay = fid; }); - } else { - Trace.createOobTrace( - performanceController, - navigationTimings, - paintTimings - ); } + + api.onLCP((metric: LCPMetricWithAttribution) => { + webVitalMetrics.lcp = { + value: metric.value, + elementAttribution: metric.attribution?.element + }; + }); + api.onCLS((metric: CLSMetricWithAttribution) => { + webVitalMetrics.cls = { + value: metric.value, + elementAttribution: metric.attribution?.largestShiftTarget + }; + }); + api.onINP((metric: INPMetricWithAttribution) => { + webVitalMetrics.inp = { + value: metric.value, + elementAttribution: metric.attribution?.interactionTarget + }; + }); } function setupUserTimingTraces( @@ -110,7 +126,8 @@ function createUserTimingTrace( measure: PerformanceEntry ): void { const measureName = measure.name; - // Do not create a trace, if the user timing marks and measures are created by the sdk itself. + // Do not create a trace, if the user timing marks and measures are created by + // the sdk itself. if ( measureName.substring(0, TRACE_MEASURE_PREFIX.length) === TRACE_MEASURE_PREFIX @@ -119,3 +136,36 @@ function createUserTimingTrace( } Trace.createUserTimingTrace(performanceController, measureName); } + +function sendOobTrace(performanceController: PerformanceController): void { + if (!sentPageLoadTrace) { + sentPageLoadTrace = true; + const api = Api.getInstance(); + const navigationTimings = api.getEntriesByType( + 'navigation' + ) as PerformanceNavigationTiming[]; + const paintTimings = api.getEntriesByType('paint'); + + // On page unload web vitals may be updated so queue the oob trace creation + // so that these updates have time to be included. + setTimeout(() => { + Trace.createOobTrace( + performanceController, + navigationTimings, + paintTimings, + webVitalMetrics, + firstInputDelay + ); + }, 0); + } +} + +/** + * This service will only export the page load trace once. This function allows + * resetting it for unit tests + */ +export function resetForUnitTests(): void { + sentPageLoadTrace = false; + firstInputDelay = undefined; + webVitalMetrics = {}; +} diff --git a/packages/performance/src/services/perf_logger.test.ts b/packages/performance/src/services/perf_logger.test.ts index f0b167707b1..3116769f7e1 100644 --- a/packages/performance/src/services/perf_logger.test.ts +++ b/packages/performance/src/services/perf_logger.test.ts @@ -32,11 +32,12 @@ import { mergeStrings } from '../utils/string_merger'; import { FirebaseInstallations } from '@firebase/installations-types'; import { PerformanceController } from '../controllers/perf'; +// eslint-disable-next-line no-restricted-properties describe('Performance Monitoring > perf_logger', () => { const IID = 'idasdfsffe'; const PAGE_URL = 'http://mock-page.com'; const APP_ID = '1:123:web:2er'; - const VISIBILITY_STATE = 3; + const VISIBILITY_STATE = attributeUtils.VisibilityState.UNKNOWN; const EFFECTIVE_CONNECTION_TYPE = 2; const SERVICE_WORKER_STATUS = 3; const TIME_ORIGIN = 1556512199893.9033; @@ -222,7 +223,9 @@ describe('Performance Monitoring > perf_logger', () => { "application_process_state":0},"trace_metric":{"name":"_wt_${PAGE_URL}","is_auto":true,\ "client_start_time_us":${flooredStartTime},"duration_us":${DURATION * 1000},\ "counters":{"domInteractive":10000,"domContentLoadedEventEnd":20000,"loadEventEnd":10000,\ -"_fp":40000,"_fcp":50000,"_fid":90000}}}`; +"_fp":40000,"_fcp":50000,"_fid":90000,"_lcp":3999,"_cls":250,"_inp":100},\ +"custom_attributes":{"lcp_element":"lcp-element","cls_largestShiftTarget":"cls-element",\ +"inp_interactionTarget":"inp-element"}}}`; stub(initializationService, 'isPerfInitialized').returns(true); getIidStub.returns(IID); SettingsService.getInstance().loggingEnabled = true; @@ -275,6 +278,11 @@ describe('Performance Monitoring > perf_logger', () => { performanceController, navigationTimings, paintTimings, + { + lcp: { value: 3.999, elementAttribution: 'lcp-element' }, + inp: { value: 0.1, elementAttribution: 'inp-element' }, + cls: { value: 0.25, elementAttribution: 'cls-element' } + }, 90 ); clock.tick(1); diff --git a/packages/performance/src/services/perf_logger.ts b/packages/performance/src/services/perf_logger.ts index 60bffb051e9..57bd7cdeca4 100644 --- a/packages/performance/src/services/perf_logger.ts +++ b/packages/performance/src/services/perf_logger.ts @@ -23,14 +23,13 @@ import { SettingsService } from './settings_service'; import { getServiceWorkerStatus, getVisibilityState, - VisibilityState, getEffectiveConnectionType } from '../utils/attributes_utils'; import { isPerfInitialized, getInitializationPromise } from './initialization_service'; -import { transportHandler } from './transport_service'; +import { transportHandler, flushQueuedEvents } from './transport_service'; import { SDK_VERSION } from '../constants'; import { FirebaseApp } from '@firebase/app'; import { getAppId } from '../utils/app_utils'; @@ -85,19 +84,28 @@ interface TraceMetric { custom_attributes?: { [key: string]: string }; } -let logger: ( - resource: NetworkRequest | Trace, - resourceType: ResourceType -) => void | undefined; +interface Logger { + send: ( + resource: NetworkRequest | Trace, + resourceType: ResourceType + ) => void | undefined; + flush: () => void; +} + +let logger: Logger; +// // This method is not called before initialization. function sendLog( resource: NetworkRequest | Trace, resourceType: ResourceType ): void { if (!logger) { - logger = transportHandler(serializer); + logger = { + send: transportHandler(serializer), + flush: flushQueuedEvents + }; } - logger(resource, resourceType); + logger.send(resource, resourceType); } export function logTrace(trace: Trace): void { @@ -115,11 +123,6 @@ export function logTrace(trace: Trace): void { return; } - // Only log the page load auto traces if page is visible. - if (trace.isAuto && getVisibilityState() !== VisibilityState.VISIBLE) { - return; - } - if (isPerfInitialized()) { sendTraceLog(trace); } else { @@ -132,6 +135,12 @@ export function logTrace(trace: Trace): void { } } +export function flushLogs(): void { + if (logger) { + logger.flush(); + } +} + function sendTraceLog(trace: Trace): void { if (!getIid()) { return; @@ -145,7 +154,7 @@ function sendTraceLog(trace: Trace): void { return; } - setTimeout(() => sendLog(trace, ResourceType.Trace), 0); + sendLog(trace, ResourceType.Trace); } export function logNetworkRequest(networkRequest: NetworkRequest): void { @@ -177,7 +186,7 @@ export function logNetworkRequest(networkRequest: NetworkRequest): void { return; } - setTimeout(() => sendLog(networkRequest, ResourceType.NetworkRequest), 0); + sendLog(networkRequest, ResourceType.NetworkRequest); } function serializer( diff --git a/packages/performance/src/services/transport_service.test.ts b/packages/performance/src/services/transport_service.test.ts index c249206c33e..124ce1f415b 100644 --- a/packages/performance/src/services/transport_service.test.ts +++ b/packages/performance/src/services/transport_service.test.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { stub, useFakeTimers, SinonStub, SinonFakeTimers, match } from 'sinon'; +import { stub, useFakeTimers, SinonFakeTimers, SinonStub } from 'sinon'; import { use, expect } from 'chai'; import sinonChai from 'sinon-chai'; import { @@ -27,7 +27,12 @@ import { SettingsService } from './settings_service'; use(sinonChai); +/* eslint-disable no-restricted-properties */ describe('Firebase Performance > transport_service', () => { + let sendBeaconStub: SinonStub< + [url: string | URL, data?: BodyInit | null | undefined], + boolean + >; let fetchStub: SinonStub< [RequestInfo | URL, RequestInit?], Promise @@ -35,7 +40,6 @@ describe('Firebase Performance > transport_service', () => { const INITIAL_SEND_TIME_DELAY_MS = 5.5 * 1000; const DEFAULT_SEND_INTERVAL_MS = 10 * 1000; const MAX_EVENT_COUNT_PER_REQUEST = 1000; - const TRANSPORT_DELAY_INTERVAL = 10000; // Starts date at timestamp 1 instead of 0, otherwise it causes validation errors. let clock: SinonFakeTimers; const testTransportHandler = transportHandler((...args) => { @@ -43,15 +47,18 @@ describe('Firebase Performance > transport_service', () => { }); beforeEach(() => { - fetchStub = stub(window, 'fetch'); clock = useFakeTimers(1); setupTransportService(); + sendBeaconStub = stub(navigator, 'sendBeacon'); + sendBeaconStub.returns(true); + fetchStub = stub(window, 'fetch'); }); afterEach(() => { - fetchStub.restore(); clock.restore(); resetTransportService(); + sendBeaconStub.restore(); + fetchStub.restore(); }); it('throws an error when logging an empty message', () => { @@ -61,43 +68,24 @@ describe('Firebase Performance > transport_service', () => { }); it('does not attempt to log an event after INITIAL_SEND_TIME_DELAY_MS if queue is empty', () => { - fetchStub.resolves( - new Response('', { - status: 200, - headers: { 'Content-type': 'application/json' } - }) - ); - clock.tick(INITIAL_SEND_TIME_DELAY_MS); + expect(sendBeaconStub).to.not.have.been.called; expect(fetchStub).to.not.have.been.called; }); it('attempts to log an event after DEFAULT_SEND_INTERVAL_MS if queue not empty', async () => { - fetchStub.resolves( - new Response('', { - status: 200, - headers: { 'Content-type': 'application/json' } - }) - ); - clock.tick(INITIAL_SEND_TIME_DELAY_MS); testTransportHandler('someEvent'); clock.tick(DEFAULT_SEND_INTERVAL_MS); - expect(fetchStub).to.have.been.calledOnce; + expect(sendBeaconStub).to.have.been.calledOnce; + expect(fetchStub).to.not.have.been.called; }); it('successful send a message to transport', () => { - const setting = SettingsService.getInstance(); - const flTransportFullUrl = - setting.flTransportEndpointUrl + '?key=' + setting.transportKey; - fetchStub.withArgs(flTransportFullUrl, match.any).resolves( - // DELETE_REQUEST means event dispatch is successful. - generateSuccessResponse() - ); - testTransportHandler('event1'); clock.tick(INITIAL_SEND_TIME_DELAY_MS); - expect(fetchStub).to.have.been.calledOnce; + expect(sendBeaconStub).to.have.been.calledOnce; + expect(fetchStub).to.not.have.been.called; }); it('sends up to the maximum event limit in one request', async () => { @@ -106,11 +94,6 @@ describe('Firebase Performance > transport_service', () => { const flTransportFullUrl = setting.flTransportEndpointUrl + '?key=' + setting.transportKey; - // Returns successful response from fl for logRequests. - const response = generateSuccessResponse(); - stub(response, 'json').resolves(JSON.parse(generateSuccessResponseBody())); - fetchStub.resolves(response); - // Act // Generate 1020 events, which should be dispatched in two batches (1000 events and 20 events). for (let i = 0; i < 1020; i++) { @@ -131,10 +114,10 @@ describe('Firebase Performance > transport_service', () => { 'event_time_ms': '1' }); } - expect(fetchStub).which.to.have.been.calledWith(flTransportFullUrl, { - method: 'POST', - body: JSON.stringify(firstLogRequest) - }); + expect(sendBeaconStub).which.to.have.been.calledWith( + flTransportFullUrl, + JSON.stringify(firstLogRequest) + ); // Expects the second logRequest which contains remaining 20 events; const secondLogRequest = generateLogRequest('15501'); for (let i = 0; i < 20; i++) { @@ -144,10 +127,24 @@ describe('Firebase Performance > transport_service', () => { 'event_time_ms': '1' }); } - expect(fetchStub).calledWith(flTransportFullUrl, { - method: 'POST', - body: JSON.stringify(secondLogRequest) - }); + expect(sendBeaconStub).calledWith( + flTransportFullUrl, + JSON.stringify(secondLogRequest) + ); + expect(fetchStub).to.not.have.been.called; + }); + + it('falls back to fetch if sendBeacon fails.', async () => { + sendBeaconStub.returns(false); + fetchStub.resolves( + new Response('{}', { + status: 200, + headers: { 'Content-type': 'application/json' } + }) + ); + testTransportHandler('event1'); + clock.tick(INITIAL_SEND_TIME_DELAY_MS); + expect(fetchStub).to.have.been.calledOnce; }); function generateLogRequest(requestTimeMs: string): any { @@ -161,26 +158,4 @@ describe('Firebase Performance > transport_service', () => { 'log_event': [] as any }; } - - function generateSuccessResponse(): Response { - return new Response(generateSuccessResponseBody(), { - status: 200, - headers: { 'Content-type': 'application/json' } - }); - } - - function generateSuccessResponseBody(): string { - return ( - '{\ - "nextRequestWaitMillis": "' + - TRANSPORT_DELAY_INTERVAL + - '",\ - "logResponseDetails": [\ - {\ - "responseAction": "DELETE_REQUEST"\ - }\ - ]\ - }' - ); - } }); diff --git a/packages/performance/src/services/transport_service.ts b/packages/performance/src/services/transport_service.ts index b90090e3209..cf5e3972972 100644 --- a/packages/performance/src/services/transport_service.ts +++ b/packages/performance/src/services/transport_service.ts @@ -21,14 +21,10 @@ import { consoleLogger } from '../utils/console_logger'; const DEFAULT_SEND_INTERVAL_MS = 10 * 1000; const INITIAL_SEND_TIME_DELAY_MS = 5.5 * 1000; -// If end point does not work, the call will be tried for these many times. -const DEFAULT_REMAINING_TRIES = 3; const MAX_EVENT_COUNT_PER_REQUEST = 1000; -let remainingTries = DEFAULT_REMAINING_TRIES; +const DEFAULT_REMAINING_TRIES = 3; -interface LogResponseDetails { - responseAction?: string; -} +let remainingTries = DEFAULT_REMAINING_TRIES; interface BatchEvent { message: string; @@ -81,12 +77,10 @@ function processQueue(timeOffset: number): void { return; } - // If there are no events to process, wait for DEFAULT_SEND_INTERVAL_MS and try again. - if (!queue.length) { - return processQueue(DEFAULT_SEND_INTERVAL_MS); + if (queue.length > 0) { + dispatchQueueEvents(); } - - dispatchQueueEvents(); + processQueue(DEFAULT_SEND_INTERVAL_MS); }, timeOffset); } @@ -114,60 +108,32 @@ function dispatchQueueEvents(): void { }; /* eslint-enable camelcase */ - sendEventsToFl(data, staged).catch(() => { - // If the request fails for some reason, add the events that were attempted - // back to the primary queue to retry later. - queue = [...staged, ...queue]; - remainingTries--; - consoleLogger.info(`Tries left: ${remainingTries}.`); - processQueue(DEFAULT_SEND_INTERVAL_MS); - }); -} - -function sendEventsToFl( - data: TransportBatchLogFormat, - staged: BatchEvent[] -): Promise { - return postToFlEndpoint(data) - .then(res => { - if (!res.ok) { - consoleLogger.info('Call to Firebase backend failed.'); - } - return res.json(); - }) - .then(res => { - // Find the next call wait time from the response. - const transportWait = Number(res.nextRequestWaitMillis); - let requestOffset = DEFAULT_SEND_INTERVAL_MS; - if (!isNaN(transportWait)) { - requestOffset = Math.max(transportWait, requestOffset); - } - - // Delete request if response include RESPONSE_ACTION_UNKNOWN or DELETE_REQUEST action. - // Otherwise, retry request using normal scheduling if response include RETRY_REQUEST_LATER. - const logResponseDetails: LogResponseDetails[] = res.logResponseDetails; - if ( - Array.isArray(logResponseDetails) && - logResponseDetails.length > 0 && - logResponseDetails[0].responseAction === 'RETRY_REQUEST_LATER' - ) { - queue = [...staged, ...queue]; - consoleLogger.info(`Retry transport request later.`); - } - + postToFlEndpoint(data) + .then(() => { remainingTries = DEFAULT_REMAINING_TRIES; - // Schedule the next process. - processQueue(requestOffset); + }) + .catch(() => { + // If the request fails for some reason, add the events that were attempted + // back to the primary queue to retry later. + queue = [...staged, ...queue]; + remainingTries--; + consoleLogger.info(`Tries left: ${remainingTries}.`); + processQueue(DEFAULT_SEND_INTERVAL_MS); }); } -function postToFlEndpoint(data: TransportBatchLogFormat): Promise { +function postToFlEndpoint(data: TransportBatchLogFormat): Promise { const flTransportFullUrl = SettingsService.getInstance().getFlTransportFullUrl(); - return fetch(flTransportFullUrl, { - method: 'POST', - body: JSON.stringify(data) - }); + const body = JSON.stringify(data); + + return navigator.sendBeacon && navigator.sendBeacon(flTransportFullUrl, body) + ? Promise.resolve() + : fetch(flTransportFullUrl, { + method: 'POST', + body, + keepalive: true + }).then(); } function addToQueue(evt: BatchEvent): void { @@ -191,3 +157,13 @@ export function transportHandler( }); }; } + +/** + * Force flush the queued events. Useful at page unload time to ensure all + * events are uploaded. + */ +export function flushQueuedEvents(): void { + while (queue.length > 0) { + dispatchQueueEvents(); + } +} diff --git a/packages/performance/src/utils/metric_utils.ts b/packages/performance/src/utils/metric_utils.ts index 9bbc4886aef..699fb83da3a 100644 --- a/packages/performance/src/utils/metric_utils.ts +++ b/packages/performance/src/utils/metric_utils.ts @@ -19,7 +19,10 @@ import { FIRST_PAINT_COUNTER_NAME, FIRST_CONTENTFUL_PAINT_COUNTER_NAME, FIRST_INPUT_DELAY_COUNTER_NAME, - OOB_TRACE_PAGE_LOAD_PREFIX + OOB_TRACE_PAGE_LOAD_PREFIX, + CUMULATIVE_LAYOUT_SHIFT_METRIC_NAME, + INTERACTION_TO_NEXT_PAINT_METRIC_NAME, + LARGEST_CONTENTFUL_PAINT_METRIC_NAME } from '../constants'; import { consoleLogger } from '../utils/console_logger'; @@ -28,7 +31,10 @@ const RESERVED_AUTO_PREFIX = '_'; const oobMetrics = [ FIRST_PAINT_COUNTER_NAME, FIRST_CONTENTFUL_PAINT_COUNTER_NAME, - FIRST_INPUT_DELAY_COUNTER_NAME + FIRST_INPUT_DELAY_COUNTER_NAME, + LARGEST_CONTENTFUL_PAINT_METRIC_NAME, + CUMULATIVE_LAYOUT_SHIFT_METRIC_NAME, + INTERACTION_TO_NEXT_PAINT_METRIC_NAME ]; /** diff --git a/yarn.lock b/yarn.lock index bd59a038213..b6e794614e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17827,6 +17827,11 @@ wcwidth@^1.0.0, wcwidth@^1.0.1: dependencies: defaults "^1.0.3" +web-vitals@^4.2.4: + version "4.2.4" + resolved "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz#1d20bc8590a37769bd0902b289550936069184b7" + integrity sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw== + webdriver-js-extender@2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/webdriver-js-extender/-/webdriver-js-extender-2.1.0.tgz"