diff --git a/app/assets/lcp.png b/app/assets/lcp.png new file mode 100644 index 00000000..b7603abb Binary files /dev/null and b/app/assets/lcp.png differ diff --git a/app/web_vital_event.html b/app/web_vital_event.html index e5e35fa0..abdcd967 100644 --- a/app/web_vital_event.html +++ b/app/web_vital_event.html @@ -111,5 +111,6 @@ + LCP resource image diff --git a/src/dispatch/dataplane.ts b/src/dispatch/dataplane.ts index 06a8d1ed..742413b9 100644 --- a/src/dispatch/dataplane.ts +++ b/src/dispatch/dataplane.ts @@ -5,6 +5,8 @@ // use the type definitions from the CloudWatch RUM SDK, we have made a copy of // them here to completely remove the dependency on the CloudWatch RUM SDK. +import { MetaData } from '../events/meta-data'; + export interface PutRumEventsRequest { BatchId: string; AppMonitorDetails: AppMonitorDetails; @@ -29,3 +31,11 @@ export interface RumEvent { metadata?: string; details: string; } + +export interface ParsedRumEvent { + id: string; + timestamp: Date; + type: string; + metadata?: MetaData; + details: object; +} diff --git a/src/event-bus/EventBus.ts b/src/event-bus/EventBus.ts index 0f0a0d3d..bca737d8 100644 --- a/src/event-bus/EventBus.ts +++ b/src/event-bus/EventBus.ts @@ -10,7 +10,7 @@ export default class EventBus { subscribe(topic: T, subscriber: Subscriber): void { const list = this.subscribers.get(topic) ?? []; - if (list.length === 0) { + if (!list.length) { this.subscribers.set(topic, list); } list.push(subscriber); diff --git a/src/event-bus/__tests__/EventBus.test.ts b/src/event-bus/__tests__/EventBus.test.ts index e11ac2ac..02516177 100644 --- a/src/event-bus/__tests__/EventBus.test.ts +++ b/src/event-bus/__tests__/EventBus.test.ts @@ -1,9 +1,34 @@ -import EventBus from '../EventBus'; +import { context } from '../../test-utils/test-utils'; +import EventBus, { Topic } from '../EventBus'; +import { InternalPlugin } from '../../plugins/InternalPlugin'; +import { PluginContext } from '../../plugins/types'; export enum MockTopics { FOOD = 'food', BOOKS = 'books' } + +const MockPluginId = 'Mock-Plugin'; +class MockPlugin extends InternalPlugin { + count = 0; + + constructor() { + super(MockPluginId); + this.subscriber = this.subscriber.bind(this); + } + + enable(): void {} // eslint-disable-line + disable(): void {} // eslint-disable-line + + protected onload(): void { + this.context.eventBus.subscribe(Topic.EVENT, this.subscriber); // eslint-disable-line + } + + subscriber(msg: any) { + this.count++; + } +} + describe('EventBus tests', () => { let eventBus: EventBus; const l1 = jest.fn(); @@ -50,4 +75,14 @@ describe('EventBus tests', () => { // assert expect(l2).not.toHaveBeenCalled(); }); + + test('when plugin subscribes then observes events', async () => { + const plugin = new MockPlugin(); + const spy = jest.spyOn(plugin, 'subscriber'); + + plugin.load(context); + context.eventBus.dispatch(Topic.EVENT, 'hat'); + + expect(spy).toHaveBeenCalledWith('hat'); + }); }); diff --git a/src/event-schemas/largest-contentful-paint-event.json b/src/event-schemas/largest-contentful-paint-event.json index cd64b1b5..9264863c 100644 --- a/src/event-schemas/largest-contentful-paint-event.json +++ b/src/event-schemas/largest-contentful-paint-event.json @@ -40,6 +40,14 @@ "elementRenderDelay": { "type": "number", "description": "Duration rendering the LCP resource" + }, + "navigationEntry": { + "type": "string", + "description": "Event id of the navigation event for the current page" + }, + "lcpResourceEntry": { + "type": "string", + "description": "Event id of the resource event for the LCP resource, if any" } }, "required": [ diff --git a/src/loader/loader-web-vital-event.js b/src/loader/loader-web-vital-event.js index 693be933..079fa80a 100644 --- a/src/loader/loader-web-vital-event.js +++ b/src/loader/loader-web-vital-event.js @@ -1,10 +1,16 @@ import { loader } from './loader'; import { showRequestClientBuilder } from '../test-utils/mock-http-handler'; import { WebVitalsPlugin } from '../plugins/event-plugins/WebVitalsPlugin'; +import { NavigationPlugin } from '../plugins/event-plugins/NavigationPlugin'; +import { ResourcePlugin } from '../plugins/event-plugins/ResourcePlugin'; loader('cwr', 'abc123', '1.0', 'us-west-2', './rum_javascript_telemetry.js', { dispatchInterval: 0, metaDataPluginsToLoad: [], - eventPluginsToLoad: [new WebVitalsPlugin()], + eventPluginsToLoad: [ + new ResourcePlugin(), + new NavigationPlugin(), + new WebVitalsPlugin() + ], telemetries: [], clientBuilder: showRequestClientBuilder }); diff --git a/src/orchestration/Orchestration.ts b/src/orchestration/Orchestration.ts index 4f363e41..d2858d69 100644 --- a/src/orchestration/Orchestration.ts +++ b/src/orchestration/Orchestration.ts @@ -402,7 +402,8 @@ export class Orchestration { id: applicationId, version: applicationVersion }, - this.config + this.config, + this.eventBus ); } diff --git a/src/orchestration/__tests__/Orchestration.test.ts b/src/orchestration/__tests__/Orchestration.test.ts index ecfb993a..e673c8be 100644 --- a/src/orchestration/__tests__/Orchestration.test.ts +++ b/src/orchestration/__tests__/Orchestration.test.ts @@ -21,6 +21,15 @@ jest.mock('../../dispatch/Dispatch', () => ({ })) })); +jest.mock('../../utils/common-utils', () => { + const originalModule = jest.requireActual('../../utils/common-utils'); + return { + __esModule: true, + ...originalModule, + isLCPSupported: jest.fn().mockReturnValue(true) + }; +}); + const enableEventCache = jest.fn(); const disableEventCache = jest.fn(); const recordPageView = jest.fn(); diff --git a/src/plugins/event-plugins/ResourcePlugin.ts b/src/plugins/event-plugins/ResourcePlugin.ts index d7fc46cd..adff8ac1 100644 --- a/src/plugins/event-plugins/ResourcePlugin.ts +++ b/src/plugins/event-plugins/ResourcePlugin.ts @@ -84,6 +84,7 @@ export class ResourcePlugin extends InternalPlugin { recordResourceEvent = ({ name, + startTime, initiatorType, duration, transferSize @@ -105,6 +106,7 @@ export class ResourcePlugin extends InternalPlugin { const eventData: ResourceEvent = { version: '1.0.0', initiatorType, + startTime, duration, fileType: getResourceFileType(name, initiatorType), transferSize diff --git a/src/plugins/event-plugins/WebVitalsPlugin.ts b/src/plugins/event-plugins/WebVitalsPlugin.ts index b8853f39..9dc35c5e 100644 --- a/src/plugins/event-plugins/WebVitalsPlugin.ts +++ b/src/plugins/event-plugins/WebVitalsPlugin.ts @@ -14,8 +14,20 @@ import { import { CLS_EVENT_TYPE, FID_EVENT_TYPE, - LCP_EVENT_TYPE + LCP_EVENT_TYPE, + PERFORMANCE_NAVIGATION_EVENT_TYPE, + PERFORMANCE_RESOURCE_EVENT_TYPE } from '../utils/constant'; +import { Topic } from '../../event-bus/EventBus'; +import { ParsedRumEvent } from '../../dispatch/dataplane'; +import { ResourceEvent } from '../../events/resource-event'; +import { + HasLatency, + ResourceType, + performanceKey, + RumLCPAttribution, + isLCPSupported +} from '../../utils/common-utils'; export const WEB_VITAL_EVENT_PLUGIN_ID = 'web-vitals'; @@ -23,6 +35,9 @@ export class WebVitalsPlugin extends InternalPlugin { constructor() { super(WEB_VITAL_EVENT_PLUGIN_ID); } + private resourceEventIds = new Map(); + private navigationEventId?: string; + private cacheLCPCandidates = isLCPSupported(); // eslint-disable-next-line @typescript-eslint/no-empty-function enable(): void {} @@ -34,28 +49,63 @@ export class WebVitalsPlugin extends InternalPlugin { configure(config: any): void {} protected onload(): void { + this.context.eventBus.subscribe(Topic.EVENT, this.handleEvent); // eslint-disable-line @typescript-eslint/unbound-method onLCP((metric) => this.handleLCP(metric)); onFID((metric) => this.handleFID(metric)); onCLS((metric) => this.handleCLS(metric)); } - handleLCP(metric: LCPMetricWithAttribution | Metric) { + private handleEvent = (event: ParsedRumEvent) => { + switch (event.type) { + // lcp resource is either image or text + case PERFORMANCE_RESOURCE_EVENT_TYPE: + const details = event.details as ResourceEvent; + if ( + this.cacheLCPCandidates && + details.fileType === ResourceType.IMAGE + ) { + this.resourceEventIds.set( + performanceKey(event.details as HasLatency), + event.id + ); + } + break; + case PERFORMANCE_NAVIGATION_EVENT_TYPE: + this.navigationEventId = event.id; + break; + } + }; + + private handleLCP(metric: LCPMetricWithAttribution | Metric) { const a = (metric as LCPMetricWithAttribution).attribution; + const attribution: RumLCPAttribution = { + element: a.element, + url: a.url, + timeToFirstByte: a.timeToFirstByte, + resourceLoadDelay: a.resourceLoadDelay, + resourceLoadTime: a.resourceLoadTime, + elementRenderDelay: a.elementRenderDelay + }; + if (a.lcpResourceEntry) { + const key = performanceKey(a.lcpResourceEntry as HasLatency); + attribution.lcpResourceEntry = this.resourceEventIds.get(key); + } + if (this.navigationEventId) { + attribution.navigationEntry = this.navigationEventId; + } this.context?.record(LCP_EVENT_TYPE, { version: '1.0.0', value: metric.value, - attribution: { - element: a.element, - url: a.url, - timeToFirstByte: a.timeToFirstByte, - resourceLoadDelay: a.resourceLoadDelay, - resourceLoadTime: a.resourceLoadTime, - elementRenderDelay: a.elementRenderDelay - } + attribution } as LargestContentfulPaintEvent); + + // teardown + this.context?.eventBus.unsubscribe(Topic.EVENT, this.handleEvent); // eslint-disable-line + this.resourceEventIds.clear(); + this.navigationEventId = undefined; } - handleCLS(metric: CLSMetricWithAttribution | Metric) { + private handleCLS(metric: CLSMetricWithAttribution | Metric) { const a = (metric as CLSMetricWithAttribution).attribution; this.context?.record(CLS_EVENT_TYPE, { version: '1.0.0', @@ -69,7 +119,7 @@ export class WebVitalsPlugin extends InternalPlugin { } as CumulativeLayoutShiftEvent); } - handleFID(metric: FIDMetricWithAttribution | Metric) { + private handleFID(metric: FIDMetricWithAttribution | Metric) { const a = (metric as FIDMetricWithAttribution).attribution; this.context?.record(FID_EVENT_TYPE, { version: '1.0.0', diff --git a/src/plugins/event-plugins/__integ__/WebVitalsPlugin.test.ts b/src/plugins/event-plugins/__integ__/WebVitalsPlugin.test.ts index f18792d7..bc0466cf 100644 --- a/src/plugins/event-plugins/__integ__/WebVitalsPlugin.test.ts +++ b/src/plugins/event-plugins/__integ__/WebVitalsPlugin.test.ts @@ -6,8 +6,9 @@ import { import { Selector } from 'testcafe'; import { CLS_EVENT_TYPE, - FID_EVENT_TYPE, - LCP_EVENT_TYPE + LCP_EVENT_TYPE, + PERFORMANCE_NAVIGATION_EVENT_TYPE, + PERFORMANCE_RESOURCE_EVENT_TYPE } from '../../utils/constant'; const testButton: Selector = Selector(`#testButton`); @@ -17,6 +18,34 @@ fixture('WebVitalEvent Plugin').page( 'http://localhost:8080/web_vital_event.html' ); +test('when lcp image resource is recorded then it is attributed to lcp', async (t: TestController) => { + const browser = t.browser.name; + if (browser === 'Safari' || browser === 'Firefox') { + return 'Test is skipped'; + } + + await t + .wait(300) + // Interact with page to trigger lcp event + .click(testButton) + .click(makePageHidden) + .expect(RESPONSE_STATUS.textContent) + .eql(STATUS_202.toString()) + .expect(REQUEST_BODY.textContent) + .contains('BatchId'); + + const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents; + const lcp = events.filter( + (x: { type: string }) => x.type === LCP_EVENT_TYPE + )[0]; + const resource = events.filter( + (x: { details: string; type: string }) => + x.type === PERFORMANCE_RESOURCE_EVENT_TYPE && + x.details.includes('lcp.png') + )[0]; + await t.expect(lcp.details).contains(`"lcpResourceEntry":"${resource.id}"`); +}); + // According to https://github.com/GoogleChrome/web-vitals, // "FID is not reported if the user never interacts with the page." // It doesn't seem like TestCafe actions are registered as user interactions, so cannot test FID @@ -28,9 +57,9 @@ test('WebVitalEvent records lcp and cls events on chrome', async (t: TestControl if (browser === 'Safari' || browser === 'Firefox') { return 'Test is skipped'; } - await t.wait(300); await t + .wait(300) // Interact with page to trigger lcp event .click(testButton) .click(makePageHidden) @@ -61,3 +90,30 @@ test('WebVitalEvent records lcp and cls events on chrome', async (t: TestControl .expect(clsEventDetails.attribution) .typeOf('object'); }); + +test('when navigation is recorded then it is attributed to lcp', async (t: TestController) => { + const browser = t.browser.name; + if (browser === 'Safari' || browser === 'Firefox') { + return 'Test is skipped'; + } + + await t + // Interact with page to trigger lcp event + .wait(300) + .click(testButton) + .click(makePageHidden) + .expect(RESPONSE_STATUS.textContent) + .eql(STATUS_202.toString()) + .expect(REQUEST_BODY.textContent) + .contains('BatchId'); + + const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents; + const lcp = events.filter( + (x: { type: string }) => x.type === LCP_EVENT_TYPE + )[0]; + const nav = events.filter( + (x: { type: string }) => x.type === PERFORMANCE_NAVIGATION_EVENT_TYPE + )[0]; + + await t.expect(lcp.details).contains(`"navigationEntry":"${nav.id}"`); +}); diff --git a/src/plugins/event-plugins/__tests__/ResourcePlugin.test.ts b/src/plugins/event-plugins/__tests__/ResourcePlugin.test.ts index be2af26f..bce7356f 100644 --- a/src/plugins/event-plugins/__tests__/ResourcePlugin.test.ts +++ b/src/plugins/event-plugins/__tests__/ResourcePlugin.test.ts @@ -52,6 +52,7 @@ describe('ResourcePlugin tests', () => { expect.objectContaining({ version: '1.0.0', fileType: 'script', + startTime: resourceTiming.startTime, duration: resourceTiming.duration, transferSize: resourceTiming.transferSize, targetUrl: resourceTiming.name, diff --git a/src/plugins/event-plugins/__tests__/WebVitalsPlugin.test.ts b/src/plugins/event-plugins/__tests__/WebVitalsPlugin.test.ts index c304d20d..d93bf3d8 100644 --- a/src/plugins/event-plugins/__tests__/WebVitalsPlugin.test.ts +++ b/src/plugins/event-plugins/__tests__/WebVitalsPlugin.test.ts @@ -1,10 +1,23 @@ -import { WebVitalsPlugin } from '../WebVitalsPlugin'; -import { context, record } from '../../../test-utils/test-utils'; +jest.mock('../../../utils/common-utils', () => { + const originalModule = jest.requireActual('../../../utils/common-utils'); + return { + __esModule: true, + ...originalModule, + isLCPSupported: jest.fn().mockReturnValue(true) + }; +}); +import { ResourceType } from '../../../utils/common-utils'; import { CLS_EVENT_TYPE, FID_EVENT_TYPE, - LCP_EVENT_TYPE -} from '../../utils/constant'; + LCP_EVENT_TYPE, + PERFORMANCE_NAVIGATION_EVENT_TYPE, + PERFORMANCE_RESOURCE_EVENT_TYPE +} from '../../../plugins/utils/constant'; +import { context, record } from '../../../test-utils/test-utils'; +import { Topic } from '../../../event-bus/EventBus'; +import { WebVitalsPlugin } from '../WebVitalsPlugin'; +import { navigationEvent } from '../../../test-utils/mock-data'; const mockLCPData = { delta: 239.51, @@ -47,11 +60,41 @@ const mockCLSData = { } }; +// only need hasLatency fields +const imagePerformanceEntry = { + duration: 50, + startTime: 100 +}; + +const imageResourceRumEvent: any = { + id: 'img-id', + type: PERFORMANCE_RESOURCE_EVENT_TYPE, + details: { + fileType: ResourceType.IMAGE, + ...imagePerformanceEntry + } +}; + +const navigationRumEvent: any = { + id: 'nav-id', + type: PERFORMANCE_NAVIGATION_EVENT_TYPE, + details: navigationEvent +}; + +const mockLCPDataWithImage = Object.assign({}, mockLCPData, { + attribution: { + ...mockLCPData.attribution, + lcpResourceEntry: imagePerformanceEntry + } +}); + jest.mock('web-vitals/attribution', () => { return { - onLCP: jest - .fn() - .mockImplementation((callback) => callback(mockLCPData)), + onLCP: jest.fn().mockImplementation((callback) => { + context.eventBus.dispatch(Topic.EVENT, imageResourceRumEvent); + context.eventBus.dispatch(Topic.EVENT, navigationRumEvent); + callback(mockLCPDataWithImage); + }), onFID: jest .fn() .mockImplementation((callback) => callback(mockFIDData)), @@ -70,7 +113,6 @@ describe('WebVitalsPlugin tests', () => { // Run plugin.load(context); - window.dispatchEvent(new Event('load')); // Assert expect(record).toHaveBeenCalledTimes(3); @@ -80,7 +122,7 @@ describe('WebVitalsPlugin tests', () => { expect.objectContaining({ version: '1.0.0', value: mockLCPData.value, - attribution: { + attribution: expect.objectContaining({ element: mockLCPData.attribution.element, url: mockLCPData.attribution.url, timeToFirstByte: mockLCPData.attribution.timeToFirstByte, @@ -89,7 +131,7 @@ describe('WebVitalsPlugin tests', () => { resourceLoadTime: mockLCPData.attribution.resourceLoadTime, elementRenderDelay: mockLCPData.attribution.elementRenderDelay - } + }) }) ); }); @@ -100,7 +142,6 @@ describe('WebVitalsPlugin tests', () => { // Run plugin.load(context); - window.dispatchEvent(new Event('load')); // Assert expect(record).toHaveBeenCalledTimes(3); @@ -126,7 +167,6 @@ describe('WebVitalsPlugin tests', () => { // Run plugin.load(context); - window.dispatchEvent(new Event('load')); // Assert expect(record).toHaveBeenCalledTimes(3); @@ -154,7 +194,6 @@ describe('WebVitalsPlugin tests', () => { plugin.load(context); plugin.disable(); plugin.enable(); - window.dispatchEvent(new Event('load')); // Assert expect(record).toHaveBeenCalled(); @@ -165,9 +204,116 @@ describe('WebVitalsPlugin tests', () => { plugin.load(context); plugin.disable(); - window.dispatchEvent(new Event('load')); // Assert expect(record).toHaveBeenCalled(); }); + + test('when lcp image resource has filetype=image then eventId is attributed to lcp', async () => { + const plugin = new WebVitalsPlugin(); + + plugin.load(context); + expect(record).toHaveBeenCalledWith( + LCP_EVENT_TYPE, + expect.objectContaining({ + attribution: expect.objectContaining({ + lcpResourceEntry: imageResourceRumEvent.id + }) + }) + ); + }); + + test('when no matching image resource does not exist then it is not attributed to lcp', async () => { + // init + const fileType = imageResourceRumEvent.details.fileType; + delete imageResourceRumEvent.details.fileType; + const plugin = new WebVitalsPlugin(); + + // run + plugin.load(context); + + // assert + expect(record).toHaveBeenCalledWith( + LCP_EVENT_TYPE, + expect.objectContaining({ + attribution: expect.not.objectContaining({ + lcpResourceEntry: expect.anything() + }) + }) + ); + + // restore + imageResourceRumEvent.details.fileType = fileType; + }); + + test('when lcp is recorded then cache is empty', async () => { + const plugin = new WebVitalsPlugin(); + + plugin.load(context); + expect(record).toHaveBeenCalledWith(LCP_EVENT_TYPE, expect.anything()); + expect((plugin as any).resourceEventIds.size).toEqual(0); + expect((plugin as any).navigationEventId).toBeUndefined(); + }); + + test('when lcp is not supported then cache is empty', async () => { + const plugin = new WebVitalsPlugin(); + (plugin as any).cacheLCPCandidates = false; + + plugin.load(context); + expect(record).toHaveBeenCalledWith( + LCP_EVENT_TYPE, + expect.objectContaining({ + attribution: expect.not.objectContaining({ + lcpResourceEntry: expect.anything() + }) + }) + ); + expect((plugin as any).resourceEventIds.size).toEqual(0); + expect((plugin as any).navigationEventId).toBeUndefined(); + }); + + test('when lcp is recorded then unsubscribe is called', async () => { + // init + const unsubscribeSpy = jest.spyOn(context.eventBus, 'unsubscribe'); + const plugin = new WebVitalsPlugin(); + const recordSpy = jest.spyOn(context, 'record'); + + // run + plugin.load(context); + + // assert + expect(recordSpy).toHaveBeenCalled(); + expect(unsubscribeSpy).toHaveBeenCalled(); + }); + + test('when navigation is recorded then it is attributed to lcp', async () => { + const plugin = new WebVitalsPlugin(); + + plugin.load(context); + expect(record).toHaveBeenCalledWith( + LCP_EVENT_TYPE, + expect.objectContaining({ + attribution: expect.objectContaining({ + navigationEntry: navigationRumEvent.id + }) + }) + ); + }); + + test('when navigation is not recorded then it is not attributed to lcp', async () => { + navigationRumEvent.type = 'invalid'; + const plugin = new WebVitalsPlugin(); + + plugin.load(context); + expect(record).not.toHaveBeenCalledWith( + LCP_EVENT_TYPE, + expect.objectContaining({ + attribution: expect.objectContaining({ + navigationEntry: expect.anything() + }) + }) + ); + + navigationEvent.type = PERFORMANCE_NAVIGATION_EVENT_TYPE; + }); }); diff --git a/src/test-utils/test-utils.ts b/src/test-utils/test-utils.ts index 592c0136..842c5c02 100644 --- a/src/test-utils/test-utils.ts +++ b/src/test-utils/test-utils.ts @@ -18,7 +18,6 @@ import { } from '../dispatch/dataplane'; import { ReadableStream } from 'web-streams-polyfill'; import EventBus from '../event-bus/EventBus'; -jest.mock('../event-bus/EventBus'); export const AWS_RUM_ENDPOINT = new URL( 'https://rumservicelambda.us-west-2.amazonaws.com' diff --git a/src/utils/common-utils.ts b/src/utils/common-utils.ts index ec26309d..acb79657 100644 --- a/src/utils/common-utils.ts +++ b/src/utils/common-utils.ts @@ -37,6 +37,23 @@ export enum InitiatorType { CSS = 'css' } +/** + * A PerformanceEntry or RumEvent that is sourced from the PerformanceAPI + */ +export interface HasLatency { + startTime: DOMHighResTimeStamp; + duration: DOMHighResTimeStamp; +} + +/** + * Creates key to link a RumEvent to the PerformanceEntry that it is sourced from + * e.g. performanceKey(ResourceEvent) === performanceKey(PerformanceResourceTiming). + * There is some worry of collision when startTime or duration are zero, such as when + * resources are cached. But timestamps have not been observed to be zero in these cases. + */ +export const performanceKey = (details: HasLatency) => + [details.startTime, details.duration].join('#'); + const extensions = [ { name: ResourceType.STYLESHEET, @@ -171,3 +188,29 @@ export const httpStatusText = { '504': 'Gateway Timeout', '505': 'HTTP Version Not Supported' }; + +export interface RumLCPAttribution { + element?: string; + url?: string; + timeToFirstByte: number; + resourceLoadDelay: number; + resourceLoadTime: number; + elementRenderDelay: number; + lcpResourceEntry?: string; + navigationEntry?: string; +} + +/** Checks at runtime if the web vitals package will record LCP + * If PerformanceAPI ever changes this API, or if WebVitals package implements a polyfill, + * then this needs to be updated + * + * Reference code from web vitals package: + * https://github.com/GoogleChrome/web-vitals/blob/main/src/lib/observe.ts#L46 + * Discussion for context: + * https://github.com/aws-observability/aws-rum-web/pull/448#issuecomment-1734314463 + */ +export const isLCPSupported = () => { + return PerformanceObserver.supportedEntryTypes.includes( + 'largest-contentful-paint' + ); +};