From 5a9037bb4c26f075966268f7c1919812465c31db Mon Sep 17 00:00:00 2001 From: Billy Date: Wed, 31 Jan 2024 23:28:19 -0800 Subject: [PATCH 1/2] feat!: capture all W3C fields in NavigationEvents (#494) --- .../__tests__/EventCache.integ.test.ts | 1 - ...son => performance-navigation-timing.json} | 90 +++-- .../__tests__/Orchestration.test.ts | 3 +- src/plugins/event-plugins/NavigationPlugin.ts | 322 ++++-------------- .../__integ__/NavigationPlugin.test.ts | 202 ++++------- .../__tests__/NavigationPlugin.test.ts | 140 ++++---- src/plugins/utils/constant.ts | 2 +- src/sessions/VirtualPageLoadTimer.ts | 11 +- src/sessions/__tests__/SessionManager.test.ts | 11 +- src/utils/common-utils.ts | 4 + 10 files changed, 273 insertions(+), 513 deletions(-) rename src/event-schemas/{navigation-event.json => performance-navigation-timing.json} (60%) diff --git a/src/event-cache/__tests__/EventCache.integ.test.ts b/src/event-cache/__tests__/EventCache.integ.test.ts index 67eefefc..01c08482 100644 --- a/src/event-cache/__tests__/EventCache.integ.test.ts +++ b/src/event-cache/__tests__/EventCache.integ.test.ts @@ -84,7 +84,6 @@ describe('EventCache tests', () => { allowCookies: false, sessionLengthSeconds: 0, sessionAttributes: { - version: '2.0.0', domain: 'overridden.console.aws.amazon.com', browserLanguage: 'en-UK', browserName: 'Chrome', diff --git a/src/event-schemas/navigation-event.json b/src/event-schemas/performance-navigation-timing.json similarity index 60% rename from src/event-schemas/navigation-event.json rename to src/event-schemas/performance-navigation-timing.json index a269fbae..09bbc44c 100644 --- a/src/event-schemas/navigation-event.json +++ b/src/event-schemas/performance-navigation-timing.json @@ -1,45 +1,38 @@ { - "$id": "com.amazon.rum.performance_navigation_event", + "$id": "com.amazon.rum.performance_navigation_timing", "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "NavigationEvent", + "title": "PerformanceNavigationTimingEvent", "type": "object", "properties": { - "version": { - "const": "1.0.0", - "type": "string", - "description": "Schema version." - }, - "initiatorType": { - "type": "string", - "enum": ["navigation", "route_change"] + "name": { + "type": "string" }, - "navigationType": { - "description": "An unsigned short which indicates how the navigation to this page was done. Possible values are:TYPE_NAVIGATE (0), TYPE_RELOAD (1), TYPE_BACK_FORWARD (2), TYPE_RESERVED (255)", - "type": "string", - "enum": ["navigate", "reload", "back_forward", "reserved"] + "entryType": { + "const": "navigation", + "type": "string" }, "startTime": { - "type": "number" - }, - "unloadEventStart": { - "type": "number" + "type": "number", + "description": "StartTime value is always '0' for PerformanceNavigationTimingEvents created by the PerformanceAPI. However, non-W3C 'route_changes' created by RUM's polyfill for SinglePageApplications can have startTimes >= 0." }, - "promptForUnload": { + "duration": { "type": "number" }, - "redirectCount": { - "type": "integer" + "initiatorType": { + "type": "string", + "enum": ["navigation", "route_change"], + "description": "InitiatorType value is always 'navigation' for PerformanceNavigationTimingEvents created by the PerformanceAPI. However, RUM adds the non-W3C concept 'route_change' as a polyfill because the PerformanceAPI currently does not support Single Page Applications." }, - "redirectStart": { - "type": "number" + "nextHopProtocol": { + "type": "string" }, - "redirectTime": { + "workerStart": { "type": "number" }, - "workerStart": { + "redirectStart": { "type": "number" }, - "workerTime": { + "redirectEnd": { "type": "number" }, "fetchStart": { @@ -48,73 +41,68 @@ "domainLookupStart": { "type": "number" }, - "dns": { + "domainLookupEnd": { "type": "number" }, - "nextHopProtocol": { - "type": "string" - }, "connectStart": { "type": "number" }, - "connect": { + "connectEnd": { "type": "number" }, "secureConnectionStart": { "type": "number" }, - "tlsTime": { - "type": "number" - }, "requestStart": { "type": "number" }, - "timeToFirstByte": { - "type": "number" - }, "responseStart": { "type": "number" }, - "responseTime": { + "responseEnd": { "type": "number" }, - "domInteractive": { + "transferSize": { "type": "number" }, - "domContentLoadedEventStart": { + "encodedBodySize": { "type": "number" }, - "domContentLoaded": { + "decodedBodySize": { "type": "number" }, "domComplete": { "type": "number" }, - "domProcessingTime": { + "domContentLoadedEventEnd": { "type": "number" }, - "loadEventStart": { + "domContentLoadedEventStart": { "type": "number" }, - "loadEventTime": { + "domInteractive": { "type": "number" }, - "duration": { + "loadEventEnd": { "type": "number" }, - "headerSize": { + "loadEventStart": { "type": "number" }, - "transferSize": { - "type": "number" + "redirectCount": { + "type": "integer" }, - "compressionRatio": { + "type": { + "type": "string", + "enum": ["navigate", "reload", "back_forward", "prerender"] + }, + "unloadEventEnd": { "type": "number" }, - "navigationTimingLevel": { + "unloadEventStart": { "type": "number" } }, "additionalProperties": false, - "required": ["version", "initiatorType", "startTime", "duration"] + "required": ["entryType", "startTime", "duration", "initiatorType"] } diff --git a/src/orchestration/__tests__/Orchestration.test.ts b/src/orchestration/__tests__/Orchestration.test.ts index 429258f1..70a75120 100644 --- a/src/orchestration/__tests__/Orchestration.test.ts +++ b/src/orchestration/__tests__/Orchestration.test.ts @@ -28,7 +28,8 @@ jest.mock('../../utils/common-utils', () => { return { __esModule: true, ...originalModule, - isLCPSupported: jest.fn().mockReturnValue(true) + isLCPSupported: jest.fn().mockReturnValue(true), + isNavigationSupported: jest.fn().mockReturnValue(true) }; }); diff --git a/src/plugins/event-plugins/NavigationPlugin.ts b/src/plugins/event-plugins/NavigationPlugin.ts index d2f09ad1..f1475ba5 100644 --- a/src/plugins/event-plugins/NavigationPlugin.ts +++ b/src/plugins/event-plugins/NavigationPlugin.ts @@ -1,26 +1,26 @@ import { InternalPlugin } from '../InternalPlugin'; -import { NavigationEvent } from '../../events/navigation-event'; +import { PerformanceNavigationTimingEvent } from '../../events/performance-navigation-timing'; import { PERFORMANCE_NAVIGATION_EVENT_TYPE } from '../utils/constant'; import { PerformancePluginConfig, defaultPerformancePluginConfig } from '../utils/performance-utils'; +import { isNavigationSupported } from '../../utils/common-utils'; export const NAVIGATION_EVENT_PLUGIN_ID = 'navigation'; - const NAVIGATION = 'navigation'; -const LOAD = 'load'; -/** - * This plugin records performance timing events generated during every page load/re-load activity. - * Paint, resource and performance event types make sense only if all or none are included. - * For RUM, these event types are inter-dependent. So they are recorded under one plugin. - */ +/** This plugin records performance timing events generated during every page load/re-load activity. */ export class NavigationPlugin extends InternalPlugin { private config: PerformancePluginConfig; + private po?: PerformanceObserver; + constructor(config?: Partial) { super(NAVIGATION_EVENT_PLUGIN_ID); this.config = { ...defaultPerformancePluginConfig, ...config }; + this.po = isNavigationSupported() + ? new PerformanceObserver(this.performanceEntryHandler) + : undefined; } enable(): void { @@ -28,7 +28,7 @@ export class NavigationPlugin extends InternalPlugin { return; } this.enabled = true; - window.addEventListener(LOAD, this.eventListener); + this.observe(); } disable(): void { @@ -36,262 +36,72 @@ export class NavigationPlugin extends InternalPlugin { return; } this.enabled = false; - if (this.eventListener) { - window.removeEventListener(LOAD, this.eventListener); - } + this.po?.disconnect(); } /** - * Use the loadEventEnd field from window.performance to check if the website - * has loaded already. - * - * @returns boolean - */ - hasTheWindowLoadEventFired() { - if ( - window.performance && - window.performance.getEntriesByType(NAVIGATION).length - ) { - const navData = window.performance.getEntriesByType( - NAVIGATION - )[0] as PerformanceNavigationTiming; - return Boolean(navData.loadEventEnd); - } - return false; - } - - /** - * Use Navigation timing Level 1 for all browsers by default - - * https://developer.mozilla.org/en-US/docs/Web/API/Performance/timing - * - * If browser provides support, use Navigation Timing Level 2 specification - + * Callback to record PerformanceNavigationTiming as RUM PerformanceNavigationTimingEvent * https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigationTiming - * - * Only the current document resource is included in the performance timeline; - * there is only one PerformanceNavigationTiming object in the performance timeline. - * https://www.w3.org/TR/navigation-timing-2/ */ - eventListener = () => { - if (performance.getEntriesByType(NAVIGATION).length === 0) { - this.performanceNavigationEventHandlerTimingLevel1(); - } else { - const navigationObserver = new PerformanceObserver((list) => { - list.getEntries() - .filter((e) => e.entryType === NAVIGATION) - .filter((e) => !this.config.ignore(e)) - .forEach((event) => { - this.performanceNavigationEventHandlerTimingLevel2( - event as PerformanceNavigationTiming - ); - }); - }); - navigationObserver.observe({ - entryTypes: [NAVIGATION] - }); - } - }; - - /** - * W3C specification: https://www.w3.org/TR/navigation-timing/#sec-navigation-timing-interface - */ - performanceNavigationEventHandlerTimingLevel1 = () => { - const recordNavigation = () => { - const entryData = performance.timing; - const origin = entryData.navigationStart; - const eventDataNavigationTimingLevel1: NavigationEvent = { - version: '1.0.0', - initiatorType: 'navigation', - startTime: 0, - unloadEventStart: - entryData.unloadEventStart > 0 - ? entryData.unloadEventStart - origin - : 0, - promptForUnload: - entryData.unloadEventEnd - entryData.unloadEventStart, - redirectStart: - entryData.redirectStart > 0 - ? entryData.redirectStart - origin - : 0, - redirectTime: entryData.redirectEnd - entryData.redirectStart, - fetchStart: - entryData.fetchStart > 0 - ? entryData.fetchStart - origin - : 0, - domainLookupStart: - entryData.domainLookupStart > 0 - ? entryData.domainLookupStart - origin - : 0, - dns: entryData.domainLookupEnd - entryData.domainLookupStart, - connectStart: - entryData.connectStart > 0 - ? entryData.connectStart - origin - : 0, - connect: entryData.connectEnd - entryData.connectStart, - secureConnectionStart: - entryData.secureConnectionStart > 0 - ? entryData.secureConnectionStart - origin - : 0, - tlsTime: - entryData.secureConnectionStart > 0 - ? entryData.connectEnd - entryData.secureConnectionStart - : 0, - - requestStart: - entryData.requestStart > 0 - ? entryData.requestStart - origin - : 0, - timeToFirstByte: - entryData.responseStart - entryData.requestStart, - responseStart: - entryData.responseStart > 0 - ? entryData.responseStart - origin - : 0, - responseTime: - entryData.responseStart > 0 - ? entryData.responseEnd - entryData.responseStart - : 0, - - domInteractive: - entryData.domInteractive > 0 - ? entryData.domInteractive - origin - : 0, - domContentLoadedEventStart: - entryData.domContentLoadedEventStart > 0 - ? entryData.domContentLoadedEventStart - origin - : 0, - domContentLoaded: - entryData.domContentLoadedEventEnd - - entryData.domContentLoadedEventStart, - domComplete: - entryData.domComplete > 0 - ? entryData.domComplete - origin - : 0, - domProcessingTime: - entryData.loadEventStart - entryData.responseEnd, - loadEventStart: - entryData.loadEventStart > 0 - ? entryData.loadEventStart - origin - : 0, - loadEventTime: - entryData.loadEventEnd - entryData.loadEventStart, - duration: entryData.loadEventEnd - entryData.navigationStart, - navigationTimingLevel: 1 - }; - if (this.context?.record) { - this.context.record( - PERFORMANCE_NAVIGATION_EVENT_TYPE, - eventDataNavigationTimingLevel1 - ); + performanceEntryHandler: PerformanceObserverCallback = ( + list: PerformanceObserverEntryList + ) => { + list.getEntries().forEach((entry) => { + if (!this.enabled || this.config.ignore(entry)) { + return; } - }; - // Timeout is required for loadEventEnd to complete - setTimeout(recordNavigation, 0); - }; - - /** - * W3C specification: https://www.w3.org/TR/navigation-timing-2/#bib-navigation-timing - */ - performanceNavigationEventHandlerTimingLevel2 = ( - entryData: PerformanceNavigationTiming - ): void => { - const eventDataNavigationTimingLevel2: NavigationEvent = { - version: '1.0.0', - initiatorType: entryData.initiatorType as - | 'navigation' - | 'route_change', - navigationType: entryData.type as - | 'back_forward' - | 'navigate' - | 'reload' - | 'reserved' - | undefined, - startTime: entryData.startTime, - unloadEventStart: entryData.unloadEventStart, - promptForUnload: - entryData.unloadEventEnd - entryData.unloadEventStart, - redirectCount: entryData.redirectCount, - redirectStart: entryData.redirectStart, - redirectTime: entryData.redirectEnd - entryData.redirectStart, - - workerStart: entryData.workerStart, - workerTime: - entryData.workerStart > 0 - ? entryData.fetchStart - entryData.workerStart - : 0, - - fetchStart: entryData.fetchStart, - domainLookupStart: entryData.domainLookupStart, - dns: entryData.domainLookupEnd - entryData.domainLookupStart, - - nextHopProtocol: entryData.nextHopProtocol, - connectStart: entryData.connectStart, - connect: entryData.connectEnd - entryData.connectStart, - secureConnectionStart: entryData.secureConnectionStart, - tlsTime: - entryData.secureConnectionStart > 0 - ? entryData.connectEnd - entryData.secureConnectionStart - : 0, - - requestStart: entryData.requestStart, - timeToFirstByte: entryData.responseStart - entryData.requestStart, - responseStart: entryData.responseStart, - responseTime: - entryData.responseStart > 0 - ? entryData.responseEnd - entryData.responseStart - : 0, - - domInteractive: entryData.domInteractive, - domContentLoadedEventStart: entryData.domContentLoadedEventStart, - domContentLoaded: - entryData.domContentLoadedEventEnd - - entryData.domContentLoadedEventStart, - domComplete: entryData.domComplete, - domProcessingTime: entryData.loadEventStart - entryData.responseEnd, - loadEventStart: entryData.loadEventStart, - loadEventTime: entryData.loadEventEnd - entryData.loadEventStart, - - duration: entryData.duration, - - headerSize: - entryData.transferSize > 0 - ? entryData.transferSize - entryData.encodedBodySize - : 0, - transferSize: entryData.transferSize, - compressionRatio: - entryData.encodedBodySize > 0 - ? entryData.decodedBodySize / entryData.encodedBodySize - : 0, - navigationTimingLevel: 2 - }; - if (this.context?.record) { - this.context.record( - PERFORMANCE_NAVIGATION_EVENT_TYPE, - eventDataNavigationTimingLevel2 - ); - } + // Record + const e = entry as PerformanceNavigationTiming; + this.context?.record(PERFORMANCE_NAVIGATION_EVENT_TYPE, { + name: this.context.config.recordResourceUrl + ? e.name + : undefined, + entryType: NAVIGATION, + initiatorType: NAVIGATION, + startTime: e.startTime, + duration: e.duration, + nextHopProtocol: e.nextHopProtocol, + workerStart: e.workerStart, + redirectStart: e.redirectStart, + redirectEnd: e.redirectEnd, + fetchStart: e.fetchStart, + domainLookupStart: e.domainLookupStart, + domainLookupEnd: e.domainLookupEnd, + connectStart: e.connectStart, + connectEnd: e.connectEnd, + secureConnectionStart: e.secureConnectionStart, + requestStart: e.requestStart, + responseStart: e.responseStart, + responseEnd: e.responseEnd, + transferSize: e.transferSize, + encodedBodySize: e.encodedBodySize, + decodedBodySize: e.decodedBodySize, + domComplete: e.domComplete, + domContentLoadedEventEnd: e.domContentLoadedEventEnd, + domContentLoadedEventStart: e.domContentLoadedEventStart, + domInteractive: e.domInteractive, + loadEventEnd: e.loadEventEnd, + loadEventStart: e.loadEventStart, + redirectCount: e.redirectCount, + type: e.type, + unloadEventEnd: e.unloadEventEnd, + unloadEventStart: e.unloadEventStart + } as PerformanceNavigationTimingEvent); + + // Teardown + this.po?.disconnect(); + }); }; - /** - * loadEventEnd is populated as 0 if the web page has not loaded completely, even though LOAD has been fired. - * As a result, if loadEventEnd is populated, we can ignore eventListener and record the data directly. - * On the other hand, if not, we have to use eventListener to initializes PerformanceObserver. - * PerformanceObserver will act as a second check for the final load timings. - */ + private observe() { + this.po?.observe({ + type: NAVIGATION, + buffered: true + }); + } + protected onload(): void { - if (this.enabled) { - if (this.hasTheWindowLoadEventFired()) { - window.performance - .getEntriesByType(NAVIGATION) - .filter((e) => !this.config.ignore(e)) - .forEach((event) => - this.performanceNavigationEventHandlerTimingLevel2( - event - ) - ); - } else { - window.addEventListener(LOAD, this.eventListener); - } - } + this.observe(); } } diff --git a/src/plugins/event-plugins/__integ__/NavigationPlugin.test.ts b/src/plugins/event-plugins/__integ__/NavigationPlugin.test.ts index 01f0afa9..5d323a64 100644 --- a/src/plugins/event-plugins/__integ__/NavigationPlugin.test.ts +++ b/src/plugins/event-plugins/__integ__/NavigationPlugin.test.ts @@ -1,51 +1,12 @@ import { - STATUS_202, DISPATCH_COMMAND, COMMAND, PAYLOAD, SUBMIT, - REQUEST_BODY, - RESPONSE_STATUS, - ID, - TIMESTAMP + REQUEST_BODY } from '../../../test-utils/integ-test-utils'; import { PERFORMANCE_NAVIGATION_EVENT_TYPE } from '../../utils/constant'; -const INITIATOR_TYPE = 'initiatorType'; -const NAVIGATION_TYPE = 'navigationType'; -const START_TIME = 'startTime'; -const UNLOAD_EVENT_START = 'unloadEventStart'; -const PROMPT_FOR_UNLOAD = 'promptForUnload'; -const REDIRECT_COUNT = 'redirectCount'; -const REDIRECT_START = 'redirectStart'; -const REDIRECT_TIME = 'redirectTime'; -const WORKER_START = 'workerStart'; -const WORKER_TIME = 'workerTime'; -const FETCH_START = 'fetchStart'; -const DOMAIN_LOOKUP_START = 'domainLookupStart'; -const DNS = 'dns'; -const NEXT_HOP_PROTOCOL = 'nextHopProtocol'; -const CONNECT_START = 'connectStart'; -const CONNECT = 'connect'; -const SECURE_CONNECTION_START = 'secureConnectionStart'; -const TLS_TIME = 'tlsTime'; -const REQUEST_START = 'requestStart'; -const TIME_TO_FIRST_BYTE = 'timeToFirstByte'; -const RESPONSE_START = 'responseStart'; -const RESPONSE_TIME = 'responseTime'; -const DOM_INTERACTIVE = 'domInteractive'; -const DOM_CONTENT_LOADED_EVENT_START = 'domContentLoadedEventStart'; -const DOM_CONTENT_LOADED = 'domContentLoaded'; -const DOM_COMPLETE = 'domComplete'; -const DOM_PROCESSING_TIME = 'domProcessingTime'; -const LOAD_EVENT_START = 'loadEventStart'; -const LOAD_EVENT_TIME = 'loadEventTime'; -const DURATION = 'duration'; -const HEADER_SIZE = 'headerSize'; -const TRANSFER_SIZE = 'transferSize'; -const COMPRESSION_RATIO = 'compressionRatio'; -const SAFARI = 'Safari'; - fixture('NavigationEvent Plugin').page( 'http://localhost:8080/delayed_page.html' ); @@ -55,98 +16,77 @@ test('when plugin loads after window.load then navigation timing events are reco .typeText(COMMAND, DISPATCH_COMMAND, { replace: true }) .click(PAYLOAD) .pressKey('ctrl+a delete') - .click(SUBMIT); - - const isBrowserSafari = - (await REQUEST_BODY.textContent).indexOf(SAFARI) > -1; - - await t - .expect(REQUEST_BODY.textContent) - .contains(PERFORMANCE_NAVIGATION_EVENT_TYPE) - .expect(REQUEST_BODY.textContent) - .contains(ID) - .expect(REQUEST_BODY.textContent) - .contains(TIMESTAMP) - - .expect(REQUEST_BODY.textContent) - .contains(INITIATOR_TYPE) + .click(SUBMIT) .expect(REQUEST_BODY.textContent) - .contains(START_TIME) - .expect(REQUEST_BODY.textContent) - .contains(UNLOAD_EVENT_START) - .expect(REQUEST_BODY.textContent) - .contains(PROMPT_FOR_UNLOAD) - .expect(REQUEST_BODY.textContent) - .contains(REDIRECT_START) - .expect(REQUEST_BODY.textContent) - .contains(REDIRECT_TIME) + .contains('BatchId'); - .expect(REQUEST_BODY.textContent) - .contains(FETCH_START) - .expect(REQUEST_BODY.textContent) - .contains(DOMAIN_LOOKUP_START) - .expect(REQUEST_BODY.textContent) - .contains(DNS) + const navigationEvent = JSON.parse( + JSON.parse(await REQUEST_BODY.textContent).RumEvents?.find( + (e: any) => e.type === PERFORMANCE_NAVIGATION_EVENT_TYPE + )?.details + ); - .expect(REQUEST_BODY.textContent) - .contains(CONNECT_START) - .expect(REQUEST_BODY.textContent) - .contains(CONNECT) - .expect(REQUEST_BODY.textContent) - .contains(SECURE_CONNECTION_START) - .expect(REQUEST_BODY.textContent) - .contains(TLS_TIME) - .expect(REQUEST_BODY.textContent) - .contains(REQUEST_START) - .expect(REQUEST_BODY.textContent) - .contains(TIME_TO_FIRST_BYTE) - .expect(REQUEST_BODY.textContent) - .contains(RESPONSE_START) - .expect(REQUEST_BODY.textContent) - .contains(RESPONSE_TIME) - .expect(REQUEST_BODY.textContent) - .contains(DOM_INTERACTIVE) - .expect(REQUEST_BODY.textContent) - .contains(DOM_CONTENT_LOADED_EVENT_START) - .expect(REQUEST_BODY.textContent) - .contains(DOM_CONTENT_LOADED) - .expect(REQUEST_BODY.textContent) - .contains(DOM_COMPLETE) - .expect(REQUEST_BODY.textContent) - .contains(DOM_PROCESSING_TIME) - .expect(REQUEST_BODY.textContent) - .contains(LOAD_EVENT_START) - .expect(REQUEST_BODY.textContent) - .contains(LOAD_EVENT_TIME) - .expect(REQUEST_BODY.textContent) - .contains(DURATION) - - .expect(RESPONSE_STATUS.textContent) - .eql(STATUS_202.toString()) - .expect((await REQUEST_BODY.textContent).indexOf(DURATION)) - .gt(0); - - /** - * Deprecated Timing Level1 used for Safari browser do not contain following attributes - * https://nicj.net/navigationtiming-in-practice/ - */ - if (!isBrowserSafari) { - await t - .expect(REQUEST_BODY.textContent) - .contains(REDIRECT_COUNT) - .expect(REQUEST_BODY.textContent) - .contains(NAVIGATION_TYPE) - .expect(REQUEST_BODY.textContent) - .contains(WORKER_START) - .expect(REQUEST_BODY.textContent) - .contains(WORKER_TIME) - .expect(REQUEST_BODY.textContent) - .contains(NEXT_HOP_PROTOCOL) - .expect(REQUEST_BODY.textContent) - .contains(HEADER_SIZE) - .expect(REQUEST_BODY.textContent) - .contains(TRANSFER_SIZE) - .expect(REQUEST_BODY.textContent) - .contains(COMPRESSION_RATIO); - } + await t + .expect(navigationEvent) + .ok() + .expect(navigationEvent.name) + .ok() + .expect(navigationEvent.entryType) + .eql('navigation') + .expect(navigationEvent.startTime) + .gte(0) + .expect(navigationEvent.duration) + .gte(0) + .expect(navigationEvent.initiatorType) + .eql('navigation') + .expect(navigationEvent.nextHopProtocol) + .typeOf('string') + .expect(navigationEvent.redirectStart) + .gte(0) + .expect(navigationEvent.redirectEnd) + .gte(0) + .expect(navigationEvent.fetchStart) + .gte(0) + .expect(navigationEvent.domainLookupStart) + .gte(0) + .expect(navigationEvent.domainLookupEnd) + .gte(0) + .expect(navigationEvent.connectStart) + .gte(0) + .expect(navigationEvent.connectEnd) + .gte(0) + .expect(navigationEvent.secureConnectionStart) + .gte(0) + .expect(navigationEvent.requestStart) + .gte(0) + .expect(navigationEvent.responseStart) + .gte(0) + .expect(navigationEvent.responseEnd) + .gte(0) + .expect(navigationEvent.transferSize) + .gte(0) + .expect(navigationEvent.encodedBodySize) + .gte(0) + .expect(navigationEvent.decodedBodySize) + .gte(0) + .expect(navigationEvent.domComplete) + .gte(0) + .expect(navigationEvent.domContentLoadedEventEnd) + .gte(0) + .expect(navigationEvent.domContentLoadedEventStart) + .gte(0) + .expect(navigationEvent.domInteractive) + .gte(0) + .expect(navigationEvent.loadEventEnd) + .gte(0) + .expect(navigationEvent.loadEventStart) + .gte(0) + .expect(navigationEvent.redirectCount) + .gte(0) + .expect(navigationEvent.type) + .ok() + .expect(navigationEvent.unloadEventEnd) + .gte(0) + .expect(navigationEvent.unloadEventStart) + .gte(0); }); diff --git a/src/plugins/event-plugins/__tests__/NavigationPlugin.test.ts b/src/plugins/event-plugins/__tests__/NavigationPlugin.test.ts index 580f9fae..c9c314dd 100644 --- a/src/plugins/event-plugins/__tests__/NavigationPlugin.test.ts +++ b/src/plugins/event-plugins/__tests__/NavigationPlugin.test.ts @@ -1,11 +1,19 @@ +let isNavigationSupported = true; +jest.mock('../../../utils/common-utils', () => { + const originalModule = jest.requireActual('../../../utils/common-utils'); + return { + __esModule: true, + ...originalModule, + isNavigationSupported: jest + .fn() + .mockImplementation(() => isNavigationSupported) + }; +}); + import { navigationEvent, performanceEvent, - performanceEventNotLoaded, - mockPerformanceObserver, - MockPerformanceTiming, - mockPerformanceObjectWith, - putRumEventsDocument + performanceEventNotLoaded } from '../../../test-utils/mock-data'; import { NavigationPlugin } from '../NavigationPlugin'; import { context, record } from '../../../test-utils/test-utils'; @@ -34,64 +42,44 @@ describe('NavigationPlugin tests', () => { window.dispatchEvent(new Event('load')); plugin.disable(); - expect(record.mock.calls[0][0]).toEqual( - PERFORMANCE_NAVIGATION_EVENT_TYPE - ); - expect(record.mock.calls[0][1]).toEqual( - expect.objectContaining({ - duration: navigationEvent.duration, - startTime: navigationEvent.startTime, - navigationType: navigationEvent.type - }) - ); - }); - - test('When transferSize is 0 then headerSize is 0', async () => { - const plugin: NavigationPlugin = buildNavigationPlugin(); - // Run - plugin.load(context); - window.dispatchEvent(new Event('load')); - plugin.disable(); + const e = navigationEvent; expect(record.mock.calls[0][0]).toEqual( PERFORMANCE_NAVIGATION_EVENT_TYPE ); expect(record.mock.calls[0][1]).toEqual( expect.objectContaining({ - headerSize: 0 - }) - ); - }); - - test('When navigation timing level 2 API is not present then navigation timing level 1 API is recorded', async () => { - jest.useFakeTimers(); - mockPerformanceObjectWith([putRumEventsDocument], [], []); - mockPerformanceObserver(); - - const plugin: NavigationPlugin = buildNavigationPlugin(); - - plugin.load(context); - window.dispatchEvent(new Event('load')); - plugin.disable(); - - jest.runAllTimers(); - - expect(record).toHaveBeenCalledTimes(1); - expect(record.mock.calls[0][0]).toEqual( - PERFORMANCE_NAVIGATION_EVENT_TYPE - ); - - expect(record.mock.calls[0][1]).toEqual( - expect.objectContaining({ - domComplete: - MockPerformanceTiming.domComplete - - MockPerformanceTiming.navigationStart, - responseStart: - MockPerformanceTiming.responseStart - - MockPerformanceTiming.navigationStart, - initiatorType: 'navigation', - redirectStart: MockPerformanceTiming.redirectStart, - navigationTimingLevel: 1 + name: e.name, + entryType: 'navigation', + startTime: e.startTime, + duration: e.duration, + initiatorType: e.initiatorType, + nextHopProtocol: e.nextHopProtocol, + workerStart: e.workerStart, + redirectStart: e.redirectStart, + redirectEnd: e.redirectEnd, + fetchStart: e.fetchStart, + domainLookupStart: e.domainLookupStart, + domainLookupEnd: e.domainLookupEnd, + connectStart: e.connectStart, + connectEnd: e.connectEnd, + secureConnectionStart: e.secureConnectionStart, + requestStart: e.requestStart, + responseStart: e.responseStart, + responseEnd: e.responseEnd, + transferSize: e.transferSize, + encodedBodySize: e.encodedBodySize, + decodedBodySize: e.decodedBodySize, + domComplete: e.domComplete, + domContentLoadedEventEnd: e.domContentLoadedEventEnd, + domContentLoadedEventStart: e.domContentLoadedEventStart, + domInteractive: e.domInteractive, + loadEventEnd: e.loadEventEnd, + loadEventStart: e.loadEventStart, + redirectCount: e.redirectCount, + type: e.type, + unloadEventEnd: e.unloadEventEnd, + unloadEventStart: e.unloadEventStart }) ); }); @@ -123,7 +111,7 @@ describe('NavigationPlugin tests', () => { // Assert expect(record).toHaveBeenCalledTimes(0); }); - test('when entry is ignored then level 2 navigation is not recorded', async () => { + test('when entry is ignored then navigation is not recorded', async () => { // enables plugin by default const plugin: NavigationPlugin = buildNavigationPlugin({ ignore: (event) => true @@ -141,15 +129,16 @@ describe('NavigationPlugin tests', () => { // Setting up new mocked window that has not loaded (window as any).performance = performanceEventNotLoaded.performance(); - // enables plugin by default and loads + // run const plugin: NavigationPlugin = buildNavigationPlugin(); plugin.load(context); - // assert that the plugin did not fire - expect(record).toHaveBeenCalledTimes(0); - - // now that the page has loaded, we should fire window.dispatchEvent(new Event('load')); - expect(record).toHaveBeenCalledTimes(1); + + // assert + expect(record).toHaveBeenCalledWith( + PERFORMANCE_NAVIGATION_EVENT_TYPE, + expect.anything() + ); }); test('when window.load fires before plugin loads then navigation timing is recorded', async () => { @@ -160,6 +149,27 @@ describe('NavigationPlugin tests', () => { // so when we load the plugin now, it should still record event plugin.load(context); // Assert - expect(record).toHaveBeenCalled(); + expect(record).toHaveBeenCalledWith( + PERFORMANCE_NAVIGATION_EVENT_TYPE, + expect.anything() + ); + }); + + test('when PerformanceNavigationTiming is not supported, then the NavigationPlugin does not initialize an observer', async () => { + // init + isNavigationSupported = false; + // jest.mock('../NavigationPlugin'); + + // enables plugin by default + const plugin: NavigationPlugin = buildNavigationPlugin(); + + // window by default has already loaded before the plugin + // so when we load the plugin now, it should still record event + plugin.load(context); + // Assert + expect((plugin as any).po).toBeUndefined(); + + // restore + isNavigationSupported = true; }); }); diff --git a/src/plugins/utils/constant.ts b/src/plugins/utils/constant.ts index c6e67e21..67541d50 100644 --- a/src/plugins/utils/constant.ts +++ b/src/plugins/utils/constant.ts @@ -12,7 +12,7 @@ export const FID_EVENT_TYPE = `${RUM_AMZ_PREFIX}.first_input_delay_event`; export const CLS_EVENT_TYPE = `${RUM_AMZ_PREFIX}.cumulative_layout_shift_event`; // Page load event schemas -export const PERFORMANCE_NAVIGATION_EVENT_TYPE = `${RUM_AMZ_PREFIX}.performance_navigation_event`; +export const PERFORMANCE_NAVIGATION_EVENT_TYPE = `${RUM_AMZ_PREFIX}.performance_navigation_timing`; export const PERFORMANCE_RESOURCE_EVENT_TYPE = `${RUM_AMZ_PREFIX}.performance_resource_event`; // DOM event schemas diff --git a/src/sessions/VirtualPageLoadTimer.ts b/src/sessions/VirtualPageLoadTimer.ts index b6eab0d4..46824725 100644 --- a/src/sessions/VirtualPageLoadTimer.ts +++ b/src/sessions/VirtualPageLoadTimer.ts @@ -1,4 +1,4 @@ -import { NavigationEvent } from '../events/navigation-event'; +import { PerformanceNavigationTimingEvent } from '../events/performance-navigation-timing'; import { PERFORMANCE_NAVIGATION_EVENT_TYPE } from '../plugins/utils/constant'; import { MonkeyPatched } from '../plugins/MonkeyPatched'; import { Config } from '../orchestration/Orchestration'; @@ -200,7 +200,7 @@ export class VirtualPageLoadTimer extends MonkeyPatched< * Checks whether the virtual page is still being loaded. * If completed: * (1) Clear the timers - * (2) Record data using NavigationEvent + * (2) Record data using PerformanceNavigationTimingEvent * (3) Indicate current page has finished loading */ private checkLoadStatus = () => { @@ -240,13 +240,12 @@ export class VirtualPageLoadTimer extends MonkeyPatched< }; private recordRouteChangeNavigationEvent(page: Page) { - const virtualPageNavigationEvent: NavigationEvent = { - version: '1.0.0', + const virtualPageNavigationEvent = { initiatorType: 'route_change', - navigationType: 'navigate', + type: 'navigate', startTime: page.start, duration: this.latestEndTime - page.start - }; + } as PerformanceNavigationTimingEvent; if (this.record) { this.record( PERFORMANCE_NAVIGATION_EVENT_TYPE, diff --git a/src/sessions/__tests__/SessionManager.test.ts b/src/sessions/__tests__/SessionManager.test.ts index d502816f..44aedbc6 100644 --- a/src/sessions/__tests__/SessionManager.test.ts +++ b/src/sessions/__tests__/SessionManager.test.ts @@ -1,3 +1,13 @@ +jest.mock('../../utils/common-utils', () => { + const originalModule = jest.requireActual('../../utils/common-utils'); + return { + __esModule: true, + ...originalModule, + isLCPSupported: jest.fn().mockReturnValue(true), + isNavigationSupported: jest.fn().mockReturnValue(true) + }; +}); + import { Attributes, NIL_UUID, @@ -22,7 +32,6 @@ import { DEFAULT_CONFIG, mockFetch } from '../../test-utils/test-utils'; -import { advanceTo } from 'jest-date-mock'; global.fetch = mockFetch; const NAVIGATION = 'navigation'; diff --git a/src/utils/common-utils.ts b/src/utils/common-utils.ts index 75d0edfa..75ec0396 100644 --- a/src/utils/common-utils.ts +++ b/src/utils/common-utils.ts @@ -223,6 +223,10 @@ export const isLongTaskSupported = () => { return PerformanceObserver.supportedEntryTypes.includes('longtask'); }; +export const isNavigationSupported = () => { + return PerformanceObserver.supportedEntryTypes.includes('navigation'); +}; + /** PutRumEvents regex pattern */ const putRumEventsPattern = /.*\/application\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/events/; From 50352755b158ecac20bf4890cbc52e948e72a459 Mon Sep 17 00:00:00 2001 From: Billy Date: Fri, 9 Feb 2024 13:17:01 -0800 Subject: [PATCH 2/2] feat!: capture all W3C fields in ResourceEvents (#489) * fix: replace ResourceEvent with W3C compliant PerformanceResourceTimingEvent (breaking) * fix: firefox * chore: add version 2.0.0 * chore: dispatch omitted resource fields to event bus * chore: remove internalMessage * chore: cleanup * fix: flaky ms edge integ test * chore: add unit tests for event type validation * fix: integ test port * fix: flaky ms edge integ test * chore: add links to stylesheet filetype * chore: test * fix: disable flaky tests on edge * chore: add integ test for value check * fix: port * chore: remove server timing for now * chore: observe() * chore: restore ignore comment * chore: add length check to integ test * Revert "chore: add length check to integ test" This reverts commit 5061775012a5b3794df9657fbc23e7145ef9ba1e. * chore: change title to PerformanceResourceTimingEvent * chore: remove version and add enum for initiatorType * fix: isPutRumEvents() * Revert "fix: isPutRumEvents()" This reverts commit 3e2923435bd8cae4a3ebc7bc001487572603e5f9. * chore: add initiatorType "other" * chore: add runtime check for resource * chore: restore name * chore: restore name * Add a wait to see if it fixes integ tests --------- Co-authored-by: Quinn Hanam --- src/__integ__/customEvents.test.ts | 7 +- src/event-cache/EventCache.ts | 2 + ....json => performance-resource-timing.json} | 85 ++++---- .../__tests__/Orchestration.test.ts | 3 +- src/plugins/event-plugins/ResourcePlugin.ts | 153 +++++++------- src/plugins/event-plugins/WebVitalsPlugin.ts | 26 ++- .../__integ__/ResourcePlugin.test.ts | 69 ++++++- .../__tests__/ResourcePlugin.test.ts | 95 ++++++--- .../__tests__/WebVitalsPlugin.test.ts | 42 ++-- src/plugins/utils/__tests__/constants.test.ts | 45 +++++ src/plugins/utils/constant.ts | 2 +- src/plugins/utils/performance-utils.ts | 8 +- src/test-utils/mock-data.ts | 10 +- src/utils/__tests__/common-utils.test.ts | 120 +++-------- src/utils/common-utils.ts | 190 +++--------------- 15 files changed, 426 insertions(+), 431 deletions(-) rename src/event-schemas/{resource-event.json => performance-resource-timing.json} (57%) create mode 100644 src/plugins/utils/__tests__/constants.test.ts diff --git a/src/__integ__/customEvents.test.ts b/src/__integ__/customEvents.test.ts index 66683e41..29b88188 100644 --- a/src/__integ__/customEvents.test.ts +++ b/src/__integ__/customEvents.test.ts @@ -18,8 +18,8 @@ fixture('Custom Events API & Plugin').page( const removeUnwantedEvents = (json: any) => { const newEventsList = json.RumEvents.filter( (e) => - /(custom_event_api)/.test(e.type) || - /(custom_event_plugin)/.test(e.type) + /custom_event_api/.test(e.type) || + /custom_event_plugin/.test(e.type) ); json.RumEvents = newEventsList; @@ -136,6 +136,7 @@ test('when a plugin calls recordEvent x times then event is recorded x times', a } await t .click(dispatch) + .wait(100) .expect(REQUEST_BODY.textContent) .contains('BatchId'); @@ -167,12 +168,14 @@ test('when plugin recordEvent has empty event_data then RumEvent details is empt await t .click(pluginRecordEmptyEvent) .click(dispatch) + .wait(100) .expect(REQUEST_BODY.textContent) .contains('BatchId'); const json = removeUnwantedEvents( JSON.parse(await REQUEST_BODY.textContent) ); + await t .expect(json.RumEvents.length) .eql(1) diff --git a/src/event-cache/EventCache.ts b/src/event-cache/EventCache.ts index 6990b53b..cce93bbb 100644 --- a/src/event-cache/EventCache.ts +++ b/src/event-cache/EventCache.ts @@ -94,6 +94,7 @@ export class EventCache { * If the session is not being recorded, the event will not be recorded. * * @param type The event schema. + * @param eventData The RUM Event to be dispatched to PutRumEvents */ public recordEvent = (type: string, eventData: object) => { if (!this.enabled) { @@ -209,6 +210,7 @@ export class EventCache { * Add an event to the cache. * * @param type The event schema. + * @param eventData The RUM Event to be dispatched to PutRumEvents */ private addRecordToCache = (type: string, eventData: object) => { if (!this.enabled) { diff --git a/src/event-schemas/resource-event.json b/src/event-schemas/performance-resource-timing.json similarity index 57% rename from src/event-schemas/resource-event.json rename to src/event-schemas/performance-resource-timing.json index 917b7e76..4ca6398c 100644 --- a/src/event-schemas/resource-event.json +++ b/src/event-schemas/performance-resource-timing.json @@ -1,88 +1,101 @@ { - "$id": "com.amazon.rum.performance_resource_event", + "$id": "com.amazon.rum.performance_resource_timing", "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "ResourceEvent", + "title": "PerformanceResourceTimingEvent", "type": "object", "properties": { - "version": { - "const": "1.0.0", - "type": "string", - "description": "Schema version." - }, - "targetUrl": { - "description": "Page URL", + "name": { "type": "string" }, - "initiatorType": { + "entryType": { + "const": "resource", "type": "string" }, "startTime": { "type": "number" }, - "redirectStart": { + "duration": { "type": "number" }, - "redirectTime": { + "connectStart": { "type": "number" }, - "workerStart": { + "connectEnd": { "type": "number" }, - "workerTime": { + "decodedBodySize": { "type": "number" }, - "fetchStart": { + "domainLookupEnd": { "type": "number" }, "domainLookupStart": { "type": "number" }, - "dns": { + "encodedBodySize": { "type": "number" }, + "fetchStart": { + "type": "number" + }, + "initiatorType": { + "type": "string", + "enum": [ + "audio", + "beacon", + "body", + "css", + "early-hint", + "embed", + "fetch", + "frame", + "iframe", + "icon", + "image", + "img", + "input", + "link", + "navigation", + "object", + "ping", + "script", + "track", + "video", + "xmlhttprequest", + "other" + ] + }, "nextHopProtocol": { "type": "string" }, - "connectStart": { - "type": "number" - }, - "connect": { + "redirectEnd": { "type": "number" }, - "secureConnectionStart": { + "redirectStart": { "type": "number" }, - "tlsTime": { - "type": "number" + "renderBlockingStatus": { + "type": "string" }, "requestStart": { "type": "number" }, - "timeToFirstByte": { + "responseEnd": { "type": "number" }, "responseStart": { "type": "number" }, - "responseTime": { - "type": "number" - }, - "duration": { - "type": "number" - }, - "headerSize": { + "secureConnectionStart": { "type": "number" }, "transferSize": { "type": "number" }, - "compressionRatio": { + "workerStart": { "type": "number" - }, - "fileType": { - "type": "string" } }, "additionalProperties": false, - "required": ["version", "initiatorType", "duration", "fileType"] + "required": ["duration", "entryType", "startTime"] } diff --git a/src/orchestration/__tests__/Orchestration.test.ts b/src/orchestration/__tests__/Orchestration.test.ts index 70a75120..cd6d188c 100644 --- a/src/orchestration/__tests__/Orchestration.test.ts +++ b/src/orchestration/__tests__/Orchestration.test.ts @@ -29,7 +29,8 @@ jest.mock('../../utils/common-utils', () => { __esModule: true, ...originalModule, isLCPSupported: jest.fn().mockReturnValue(true), - isNavigationSupported: jest.fn().mockReturnValue(true) + isNavigationSupported: jest.fn().mockReturnValue(true), + isResourceSupported: jest.fn().mockReturnValue(true) }; }); diff --git a/src/plugins/event-plugins/ResourcePlugin.ts b/src/plugins/event-plugins/ResourcePlugin.ts index adc6a721..4de5c3d5 100644 --- a/src/plugins/event-plugins/ResourcePlugin.ts +++ b/src/plugins/event-plugins/ResourcePlugin.ts @@ -2,17 +2,17 @@ import { InternalPlugin } from '../InternalPlugin'; import { getResourceFileType, isPutRumEventsCall, - shuffle + isResourceSupported } from '../../utils/common-utils'; -import { ResourceEvent } from '../../events/resource-event'; import { PERFORMANCE_RESOURCE_EVENT_TYPE } from '../utils/constant'; +import { PerformanceResourceTimingEvent } from '../../events/performance-resource-timing'; import { defaultPerformancePluginConfig, - PerformancePluginConfig + PerformancePluginConfig, + PerformanceResourceTimingPolyfill } from '../utils/performance-utils'; export const RESOURCE_EVENT_PLUGIN_ID = 'resource'; - const RESOURCE = 'resource'; /** @@ -20,16 +20,16 @@ const RESOURCE = 'resource'; */ export class ResourcePlugin extends InternalPlugin { private config: PerformancePluginConfig; - private resourceObserver: PerformanceObserver; - private eventCount: number; + private resourceObserver?: PerformanceObserver; + private sampleCount: number; constructor(config?: Partial) { super(RESOURCE_EVENT_PLUGIN_ID); this.config = { ...defaultPerformancePluginConfig, ...config }; - this.eventCount = 0; - this.resourceObserver = new PerformanceObserver( - this.performanceEntryHandler - ); + this.sampleCount = 0; + this.resourceObserver = isResourceSupported() + ? new PerformanceObserver(this.performanceEntryHandler) + : undefined; } enable(): void { @@ -37,10 +37,7 @@ export class ResourcePlugin extends InternalPlugin { return; } this.enabled = true; - this.resourceObserver.observe({ - type: RESOURCE, - buffered: true - }); + this.observe(); } disable(): void { @@ -48,82 +45,76 @@ export class ResourcePlugin extends InternalPlugin { return; } this.enabled = false; - this.resourceObserver.disconnect(); + this.resourceObserver?.disconnect(); } - performanceEntryHandler = (list: PerformanceObserverEntryList): void => { - this.recordPerformanceEntries(list.getEntries()); - }; - - recordPerformanceEntries = (list: PerformanceEntryList) => { - const recordAll: PerformanceEntry[] = []; - const sample: PerformanceEntry[] = []; - - list.filter((e) => e.entryType === RESOURCE) - .filter((e) => !this.config.ignore(e)) - .forEach((event) => { - const { name, initiatorType } = - event as PerformanceResourceTiming; - const type = getResourceFileType(name, initiatorType); - if (this.config.recordAllTypes.includes(type)) { - recordAll.push(event); - } else if (this.config.sampleTypes.includes(type)) { - sample.push(event); - } - }); + private observe() { + // We need to set `buffered: true`, so the observer also records past + // resource entries. However, there is a limited buffer size, so we may + // not be able to collect all resource entries. + this.resourceObserver?.observe({ + type: RESOURCE, + buffered: true + }); + } - // Record all events for resources in recordAllTypes - recordAll.forEach((r) => - this.recordResourceEvent(r as PerformanceResourceTiming) - ); + performanceEntryHandler = (list: PerformanceObserverEntryList): void => { + for (const entry of list.getEntries()) { + const e = entry as PerformanceResourceTimingPolyfill; + if ( + this.config.ignore(e) || + // Ignore calls to PutRumEvents (i.e., the CloudWatch RUM data + // plane), otherwise we end up in an infinite loop of recording + // PutRumEvents. + isPutRumEventsCall(e.name, this.context.config.endpointUrl.host) + ) { + continue; + } - // Record events from resources in sample until we hit the resource limit - shuffle(sample); - while (sample.length > 0 && this.eventCount < this.config.eventLimit) { - this.recordResourceEvent(sample.pop() as PerformanceResourceTiming); - this.eventCount++; + // Sampling logic + const fileType = getResourceFileType(e.initiatorType); + if (this.config.recordAllTypes.includes(fileType)) { + // Always record + this.recordResourceEvent(e); + } else if ( + this.sampleCount < this.config.eventLimit && + this.config.sampleTypes.includes(fileType) + ) { + // Only sample first N + this.recordResourceEvent(e); + this.sampleCount++; + } } }; - recordResourceEvent = ({ - name, - startTime, - initiatorType, - duration, - transferSize - }: PerformanceResourceTiming): void => { - if ( - isPutRumEventsCall(name, this.context.config.endpointUrl.hostname) - ) { - // Ignore calls to PutRumEvents (i.e., the CloudWatch RUM data - // plane), otherwise we end up in an infinite loop of recording - // PutRumEvents. - return; - } - - if (this.context?.record) { - const eventData: ResourceEvent = { - version: '1.0.0', - initiatorType, - startTime, - duration, - fileType: getResourceFileType(name, initiatorType), - transferSize - }; - if (this.context.config.recordResourceUrl) { - eventData.targetUrl = name; - } - this.context.record(PERFORMANCE_RESOURCE_EVENT_TYPE, eventData); - } + recordResourceEvent = (e: PerformanceResourceTimingPolyfill): void => { + this.context?.record(PERFORMANCE_RESOURCE_EVENT_TYPE, { + name: this.context.config.recordResourceUrl ? e.name : undefined, + entryType: RESOURCE, + startTime: e.startTime, + duration: e.duration, + connectStart: e.connectStart, + connectEnd: e.connectEnd, + decodedBodySize: e.decodedBodySize, + domainLookupEnd: e.domainLookupEnd, + domainLookupStart: e.domainLookupStart, + fetchStart: e.fetchStart, + encodedBodySize: e.encodedBodySize, + initiatorType: e.initiatorType, + nextHopProtocol: e.nextHopProtocol, + redirectEnd: e.redirectEnd, + redirectStart: e.redirectStart, + renderBlockingStatus: e.renderBlockingStatus, + requestStart: e.requestStart, + responseEnd: e.responseEnd, + responseStart: e.responseStart, + secureConnectionStart: e.secureConnectionStart, + transferSize: e.transferSize, + workerStart: e.workerStart + } as PerformanceResourceTimingEvent); }; protected onload(): void { - // We need to set `buffered: true`, so the observer also records past - // resource entries. However, there is a limited buffer size, so we may - // not be able to collect all resource entries. - this.resourceObserver.observe({ - type: RESOURCE, - buffered: true - }); + this.observe(); } } diff --git a/src/plugins/event-plugins/WebVitalsPlugin.ts b/src/plugins/event-plugins/WebVitalsPlugin.ts index 9dc35c5e..07bf5156 100644 --- a/src/plugins/event-plugins/WebVitalsPlugin.ts +++ b/src/plugins/event-plugins/WebVitalsPlugin.ts @@ -18,16 +18,16 @@ import { PERFORMANCE_NAVIGATION_EVENT_TYPE, PERFORMANCE_RESOURCE_EVENT_TYPE } from '../utils/constant'; -import { Topic } from '../../event-bus/EventBus'; +import { Subscriber, Topic } from '../../event-bus/EventBus'; import { ParsedRumEvent } from '../../dispatch/dataplane'; -import { ResourceEvent } from '../../events/resource-event'; import { - HasLatency, ResourceType, performanceKey, RumLCPAttribution, - isLCPSupported + isLCPSupported, + getResourceFileType } from '../../utils/common-utils'; +import { PerformanceResourceTimingEvent } from '../../events/performance-resource-timing'; export const WEB_VITAL_EVENT_PLUGIN_ID = 'web-vitals'; @@ -55,17 +55,19 @@ export class WebVitalsPlugin extends InternalPlugin { onCLS((metric) => this.handleCLS(metric)); } - private handleEvent = (event: ParsedRumEvent) => { + private handleEvent: Subscriber = (event: ParsedRumEvent) => { switch (event.type) { // lcp resource is either image or text case PERFORMANCE_RESOURCE_EVENT_TYPE: - const details = event.details as ResourceEvent; + const details = event.details as PerformanceResourceTimingEvent; if ( this.cacheLCPCandidates && - details.fileType === ResourceType.IMAGE + details.initiatorType && + getResourceFileType(details.initiatorType) === + ResourceType.IMAGE ) { this.resourceEventIds.set( - performanceKey(event.details as HasLatency), + performanceKey(details.startTime, details.duration), event.id ); } @@ -87,8 +89,12 @@ export class WebVitalsPlugin extends InternalPlugin { elementRenderDelay: a.elementRenderDelay }; if (a.lcpResourceEntry) { - const key = performanceKey(a.lcpResourceEntry as HasLatency); - attribution.lcpResourceEntry = this.resourceEventIds.get(key); + attribution.lcpResourceEntry = this.resourceEventIds.get( + performanceKey( + a.lcpResourceEntry.startTime, + a.lcpResourceEntry.duration + ) + ); } if (this.navigationEventId) { attribution.navigationEntry = this.navigationEventId; diff --git a/src/plugins/event-plugins/__integ__/ResourcePlugin.test.ts b/src/plugins/event-plugins/__integ__/ResourcePlugin.test.ts index 4e3cd1d7..9520ca46 100644 --- a/src/plugins/event-plugins/__integ__/ResourcePlugin.test.ts +++ b/src/plugins/event-plugins/__integ__/ResourcePlugin.test.ts @@ -18,9 +18,8 @@ test('when resource loads after window.load then the resource is recorded', asyn const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents.filter( (e) => e.type === PERFORMANCE_RESOURCE_EVENT_TYPE && - JSON.parse(e.details).targetUrl.includes('blank.png') + JSON.parse(e.details).name.includes('blank.png') ); - await t.expect(events.length).eql(1); }); @@ -34,10 +33,70 @@ test('when resource loads before the plugin then the resource is recorded', asyn const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents.filter( (e) => e.type === PERFORMANCE_RESOURCE_EVENT_TYPE && - JSON.parse(e.details).targetUrl.includes( - 'rum_javascript_telemetry.js' - ) + JSON.parse(e.details).name.includes('rum_javascript_telemetry.js') ); await t.expect(events.length).eql(1); }); + +test('when resource event is record it contains all fields', async (t: TestController) => { + await t + .wait(300) + .click(dispatch) + .expect(REQUEST_BODY.textContent) + .contains('BatchId'); + + const resourceEvent = JSON.parse( + JSON.parse(await REQUEST_BODY.textContent).RumEvents?.find( + (e: any) => e.type === PERFORMANCE_RESOURCE_EVENT_TYPE + )?.details + ); + + await t + .expect(resourceEvent) + .ok() + .expect(resourceEvent.name) + .ok() + .expect(resourceEvent.entryType) + .eql('resource') + .expect(resourceEvent.duration) + .gte(0) + .expect(resourceEvent.initiatorType) + .ok() + .expect(resourceEvent.nextHopProtocol) + .typeOf('string') + .expect(resourceEvent.redirectStart) + .gte(0) + .expect(resourceEvent.redirectEnd) + .gte(0) + .expect(resourceEvent.fetchStart) + .gte(0) + .expect(resourceEvent.domainLookupStart) + .gte(0) + .expect(resourceEvent.domainLookupEnd) + .gte(0) + .expect(resourceEvent.connectStart) + .gte(0) + .expect(resourceEvent.connectEnd) + .gte(0) + .expect(resourceEvent.secureConnectionStart) + .gte(0) + .expect(resourceEvent.requestStart) + .gte(0) + .expect(resourceEvent.responseStart) + .gte(0) + .expect(resourceEvent.responseEnd) + .gte(0) + .expect(resourceEvent.transferSize) + .gte(0) + .expect(resourceEvent.encodedBodySize) + .gte(0) + .expect(resourceEvent.decodedBodySize) + .gte(0); + + // As of now, RenderBlockingStatus is not experimental but has limited support + // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming#browser_compatibility + if (t.browser.name !== 'Firefox' && t.browser.name !== 'Safari') { + await t.expect(resourceEvent.renderBlockingStatus).ok(); + } +}); diff --git a/src/plugins/event-plugins/__tests__/ResourcePlugin.test.ts b/src/plugins/event-plugins/__tests__/ResourcePlugin.test.ts index 62220d16..34a0f822 100644 --- a/src/plugins/event-plugins/__tests__/ResourcePlugin.test.ts +++ b/src/plugins/event-plugins/__tests__/ResourcePlugin.test.ts @@ -1,3 +1,14 @@ +let isResourceSupported = true; +jest.mock('../../../utils/common-utils', () => { + return { + __esModule: true, + ...jest.requireActual('../../../utils/common-utils'), + isResourceSupported: jest + .fn() + .mockImplementation(() => isResourceSupported) + }; +}); + import { resourceTiming, putRumEventsDocument, @@ -16,7 +27,7 @@ import { record } from '../../../test-utils/test-utils'; import { PERFORMANCE_RESOURCE_EVENT_TYPE } from '../../utils/constant'; -import { ResourceEvent } from '../../../events/resource-event'; +import { PerformanceResourceTimingEvent } from '../../../events/performance-resource-timing'; import { PerformancePluginConfig } from 'plugins/utils/performance-utils'; const buildResourcePlugin = (config?: Partial) => { @@ -36,25 +47,42 @@ describe('ResourcePlugin tests', () => { test('When resource event is present then event is recorded', async () => { // Setup mockRandom(0); // Retain order in shuffle - const plugin: ResourcePlugin = buildResourcePlugin(); // Run plugin.load(context); // Assert - expect(record.mock.calls[0][0]).toEqual( + expect(record.mock.calls[1][0]).toEqual( PERFORMANCE_RESOURCE_EVENT_TYPE ); - expect(record.mock.calls[0][1]).toEqual( + const r = resourceTiming; + expect( + record.mock.calls[1][1] as PerformanceResourceTimingEvent + ).toEqual( expect.objectContaining({ - version: '1.0.0', - fileType: 'script', - startTime: resourceTiming.startTime, - duration: resourceTiming.duration, - transferSize: resourceTiming.transferSize, - targetUrl: resourceTiming.name, - initiatorType: resourceTiming.initiatorType + name: r.name, + entryType: 'resource', + startTime: r.startTime, + duration: r.duration, + connectStart: r.connectStart, + connectEnd: r.connectEnd, + decodedBodySize: r.decodedBodySize, + domainLookupEnd: r.domainLookupEnd, + domainLookupStart: r.domainLookupStart, + fetchStart: r.fetchStart, + encodedBodySize: r.encodedBodySize, + initiatorType: r.initiatorType, + nextHopProtocol: r.nextHopProtocol, + redirectEnd: r.redirectEnd, + redirectStart: r.redirectStart, + renderBlockingStatus: r.renderBlockingStatus, + requestStart: r.requestStart, + responseEnd: r.responseEnd, + responseStart: r.responseStart, + secureConnectionStart: r.secureConnectionStart, + transferSize: r.transferSize, + workerStart: r.workerStart }) ); }); @@ -76,7 +104,7 @@ describe('ResourcePlugin tests', () => { PERFORMANCE_RESOURCE_EVENT_TYPE ); expect( - (record.mock.calls[0][1] as ResourceEvent).targetUrl + (record.mock.calls[0][1] as PerformanceResourceTimingEvent).name ).toBeUndefined(); }); @@ -155,37 +183,37 @@ describe('ResourcePlugin tests', () => { expect(record).toHaveBeenCalledTimes(3); }); - test('sampled events are randomized', async () => { + test('sampled events are first N in sequence', async () => { // Setup - doMockPerformanceObserver([imageResourceEventA, imageResourceEventB]); + const images = []; + for (let i = 0; i < 5; i++) { + images.push({ + ...imageResourceEventA, + name: `http://localhost:9000/picture-${i}.jpg` + }); + } + doMockPerformanceObserver(images); - const plugin: ResourcePlugin = buildResourcePlugin({ eventLimit: 4 }); + const plugin: ResourcePlugin = buildResourcePlugin({ eventLimit: 3 }); // Run - mockRandom(0.99); // Retain order in shuffle - plugin.load(context); - mockRandom(0); // Reverse order in shuffle plugin.load(context); // Assert + expect(record).toHaveBeenCalledTimes(3); expect(record.mock.calls[0][1]).toEqual( expect.objectContaining({ - targetUrl: imageResourceEventB.name + name: `http://localhost:9000/picture-0.jpg` }) ); expect(record.mock.calls[1][1]).toEqual( expect.objectContaining({ - targetUrl: imageResourceEventA.name + name: `http://localhost:9000/picture-1.jpg` }) ); expect(record.mock.calls[2][1]).toEqual( expect.objectContaining({ - targetUrl: imageResourceEventA.name - }) - ); - expect(record.mock.calls[3][1]).toEqual( - expect.objectContaining({ - targetUrl: imageResourceEventB.name + name: `http://localhost:9000/picture-2.jpg` }) ); }); @@ -208,6 +236,21 @@ describe('ResourcePlugin tests', () => { expect(record).not.toHaveBeenCalled(); }); + test('when resource is not supported then no performance observer is initiated', async () => { + // init + isResourceSupported = false; + const plugin: ResourcePlugin = buildResourcePlugin(); + + // Run + plugin.load(context); + + // Assert + expect((plugin as any).resourceObserver).toBeUndefined(); + + // restore + isResourceSupported = true; + }); + test('when entry name is an invalid url then resource event is recorded', async () => { // setup const invalidEntry = { diff --git a/src/plugins/event-plugins/__tests__/WebVitalsPlugin.test.ts b/src/plugins/event-plugins/__tests__/WebVitalsPlugin.test.ts index d93bf3d8..84e31323 100644 --- a/src/plugins/event-plugins/__tests__/WebVitalsPlugin.test.ts +++ b/src/plugins/event-plugins/__tests__/WebVitalsPlugin.test.ts @@ -3,10 +3,11 @@ jest.mock('../../../utils/common-utils', () => { return { __esModule: true, ...originalModule, - isLCPSupported: jest.fn().mockReturnValue(true) + isLCPSupported: jest.fn().mockReturnValue(true), + isResourceSupported: jest.fn().mockReturnValue(true) }; }); -import { ResourceType } from '../../../utils/common-utils'; + import { CLS_EVENT_TYPE, FID_EVENT_TYPE, @@ -18,6 +19,8 @@ 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'; +import { PerformanceResourceTimingEvent } from '../../../events/performance-resource-timing'; +import { ParsedRumEvent } from 'dispatch/dataplane'; const mockLCPData = { delta: 239.51, @@ -60,18 +63,23 @@ const mockCLSData = { } }; -// only need hasLatency fields -const imagePerformanceEntry = { +const mockImagePerformanceEntry = { + name: 'https://www.example.com/image.png', duration: 50, startTime: 100 -}; +} as PerformanceEntry; + +const mockImageResourceTimingEvent = { + initiatorType: 'img', + ...mockImagePerformanceEntry +} as PerformanceResourceTimingEvent; -const imageResourceRumEvent: any = { +const mockImageRumEvent: ParsedRumEvent = { id: 'img-id', type: PERFORMANCE_RESOURCE_EVENT_TYPE, + timestamp: new Date(), details: { - fileType: ResourceType.IMAGE, - ...imagePerformanceEntry + ...mockImageResourceTimingEvent } }; @@ -84,14 +92,14 @@ const navigationRumEvent: any = { const mockLCPDataWithImage = Object.assign({}, mockLCPData, { attribution: { ...mockLCPData.attribution, - lcpResourceEntry: imagePerformanceEntry + lcpResourceEntry: mockImagePerformanceEntry } }); jest.mock('web-vitals/attribution', () => { return { onLCP: jest.fn().mockImplementation((callback) => { - context.eventBus.dispatch(Topic.EVENT, imageResourceRumEvent); + context.eventBus.dispatch(Topic.EVENT, mockImageRumEvent); context.eventBus.dispatch(Topic.EVENT, navigationRumEvent); callback(mockLCPDataWithImage); }), @@ -209,7 +217,7 @@ describe('WebVitalsPlugin tests', () => { expect(record).toHaveBeenCalled(); }); - test('when lcp image resource has filetype=image then eventId is attributed to lcp', async () => { + test('when lcp image resource is an image then eventId is attributed to lcp', async () => { const plugin = new WebVitalsPlugin(); plugin.load(context); @@ -217,16 +225,18 @@ describe('WebVitalsPlugin tests', () => { LCP_EVENT_TYPE, expect.objectContaining({ attribution: expect.objectContaining({ - lcpResourceEntry: imageResourceRumEvent.id + lcpResourceEntry: mockImageRumEvent.id }) }) ); }); - test('when no matching image resource does not exist then it is not attributed to lcp', async () => { + test('when no matching image resource exists then it is not attributed to lcp', async () => { // init - const fileType = imageResourceRumEvent.details.fileType; - delete imageResourceRumEvent.details.fileType; + const event = + mockImageRumEvent.details as PerformanceResourceTimingEvent; + const startTime = event.startTime; + event.startTime = -500; // no match const plugin = new WebVitalsPlugin(); // run @@ -243,7 +253,7 @@ describe('WebVitalsPlugin tests', () => { ); // restore - imageResourceRumEvent.details.fileType = fileType; + event.startTime = startTime; }); test('when lcp is recorded then cache is empty', async () => { diff --git a/src/plugins/utils/__tests__/constants.test.ts b/src/plugins/utils/__tests__/constants.test.ts new file mode 100644 index 00000000..f143245e --- /dev/null +++ b/src/plugins/utils/__tests__/constants.test.ts @@ -0,0 +1,45 @@ +import { + CLS_EVENT_TYPE, + DOM_EVENT_TYPE, + FID_EVENT_TYPE, + HTTP_EVENT_TYPE, + JS_ERROR_EVENT_TYPE, + LCP_EVENT_TYPE, + PAGE_VIEW_EVENT_TYPE, + PERFORMANCE_NAVIGATION_EVENT_TYPE, + PERFORMANCE_RESOURCE_EVENT_TYPE, + RUM_AMZ_PREFIX, + SESSION_START_EVENT_TYPE, + TIME_TO_INTERACTIVE_EVENT_TYPE, + XRAY_TRACE_EVENT_TYPE +} from '../constant'; + +describe('Constants', () => { + const types = [ + HTTP_EVENT_TYPE, + XRAY_TRACE_EVENT_TYPE, + LCP_EVENT_TYPE, + FID_EVENT_TYPE, + CLS_EVENT_TYPE, + PERFORMANCE_NAVIGATION_EVENT_TYPE, + PERFORMANCE_RESOURCE_EVENT_TYPE, + DOM_EVENT_TYPE, + JS_ERROR_EVENT_TYPE, + PAGE_VIEW_EVENT_TYPE, + SESSION_START_EVENT_TYPE, + TIME_TO_INTERACTIVE_EVENT_TYPE + ]; + test('rum event type names are valid', async () => { + types.forEach((type) => { + // uses rum prefix + expect(type.startsWith(RUM_AMZ_PREFIX)).toBe(true); + // has snake case phrases delimited by periods + type.split('.').forEach((phrase) => { + expect(phrase).not.toEqual(''); + phrase.split('_').forEach((word) => { + expect(/[a-z]+/.test(word)).toBe(true); + }); + }); + }); + }); +}); diff --git a/src/plugins/utils/constant.ts b/src/plugins/utils/constant.ts index 67541d50..19049fc1 100644 --- a/src/plugins/utils/constant.ts +++ b/src/plugins/utils/constant.ts @@ -13,7 +13,7 @@ export const CLS_EVENT_TYPE = `${RUM_AMZ_PREFIX}.cumulative_layout_shift_event`; // Page load event schemas export const PERFORMANCE_NAVIGATION_EVENT_TYPE = `${RUM_AMZ_PREFIX}.performance_navigation_timing`; -export const PERFORMANCE_RESOURCE_EVENT_TYPE = `${RUM_AMZ_PREFIX}.performance_resource_event`; +export const PERFORMANCE_RESOURCE_EVENT_TYPE = `${RUM_AMZ_PREFIX}.performance_resource_timing`; // DOM event schemas export const DOM_EVENT_TYPE = `${RUM_AMZ_PREFIX}.dom_event`; diff --git a/src/plugins/utils/performance-utils.ts b/src/plugins/utils/performance-utils.ts index 7c4f31f8..5ba47dda 100644 --- a/src/plugins/utils/performance-utils.ts +++ b/src/plugins/utils/performance-utils.ts @@ -4,7 +4,7 @@ export const defaultIgnore = (entry: PerformanceEntry) => entry.entryType === 'resource' && (!/^https?:/.test(entry.name) || /^(fetch|xmlhttprequest)$/.test( - (entry as PerformanceResourceTiming).initiatorType + (entry as PerformanceResourceTimingPolyfill).initiatorType )); export type PerformancePluginConfig = { @@ -25,3 +25,9 @@ export const defaultPerformancePluginConfig = { ], sampleTypes: [ResourceType.IMAGE, ResourceType.OTHER] }; + +/** Field renderBlockingStatus is currently missing from the node runtime and will cause build failures. */ +export interface PerformanceResourceTimingPolyfill + extends PerformanceResourceTiming { + renderBlockingStatus?: string; +} diff --git a/src/test-utils/mock-data.ts b/src/test-utils/mock-data.ts index 06b57dbd..27d01d1f 100644 --- a/src/test-utils/mock-data.ts +++ b/src/test-utils/mock-data.ts @@ -1,4 +1,7 @@ /* eslint-disable max-classes-per-file */ + +import { PerformanceResourceTimingPolyfill } from 'plugins/utils/performance-utils'; + export const firstPaintEvent = { name: 'first-paint', duration: 0, @@ -85,7 +88,7 @@ export const navigationEventNotLoaded = { navigationTimingLevel: 2 }; -export const resourceTiming: PerformanceResourceTiming = { +export const resourceTiming: PerformanceResourceTimingPolyfill = { connectEnd: 0, connectStart: 0, decodedBodySize: 0, @@ -100,6 +103,7 @@ export const resourceTiming: PerformanceResourceTiming = { nextHopProtocol: 'h2', redirectEnd: 0, redirectStart: 0, + renderBlockingStatus: 'blocking', requestStart: 0, responseEnd: 795.9950000004028, responseStart: 0, @@ -211,7 +215,7 @@ export const imageResourceEventA = { encodedBodySize: 79, entryType: 'resource', fetchStart: 386.37999998172745, - initiatorType: 'script', + initiatorType: 'image', name: 'http://localhost:9000/pictureA.jpg', nextHopProtocol: 'http/1.1', redirectEnd: 0, @@ -237,7 +241,7 @@ export const imageResourceEventB = { encodedBodySize: 79, entryType: 'resource', fetchStart: 386.37999998172745, - initiatorType: 'script', + initiatorType: 'img', name: 'http://localhost:9000/pictureB.jpg', nextHopProtocol: 'http/1.1', redirectEnd: 0, diff --git a/src/utils/__tests__/common-utils.test.ts b/src/utils/__tests__/common-utils.test.ts index 8355fb7d..86e45281 100644 --- a/src/utils/__tests__/common-utils.test.ts +++ b/src/utils/__tests__/common-utils.test.ts @@ -1,130 +1,74 @@ -import * as utils from '../../utils/common-utils'; +import { + InitiatorType, + getResourceFileType, + ResourceType, + isPutRumEventsCall +} from '../../utils/common-utils'; describe('Common utils tests', () => { - test('When URL has "png" file extension then return file type as "image"', async () => { + test('when initiator type is of group image then resource type is image', async () => { // Init - const resourceUrl = - 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_92x30dp.png'; // Assert - expect(utils.getResourceFileType(resourceUrl)).toEqual( - utils.ResourceType.IMAGE + expect(getResourceFileType(InitiatorType.IMG)).toEqual( + ResourceType.IMAGE ); - }); - - test('When URL has "js" file extension then return file type as "script"', async () => { - // Init - const resourceUrl = - 'https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js'; - // Assert - expect(utils.getResourceFileType(resourceUrl)).toEqual( - utils.ResourceType.SCRIPT + expect(getResourceFileType(InitiatorType.IMAGE)).toEqual( + ResourceType.IMAGE ); - }); - - test('When URL has no file extension then return file type as "other"', async () => { - // Init - const resourceUrl = - 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRTOllsWmffF4hRvZr8ZgQwv4oHKC_Kyksu39SAuqnZ-5OjnwQBGDNojmfu0C8&usqp=CAQ&s=10'; - // Assert - expect(utils.getResourceFileType(resourceUrl)).toEqual( - utils.ResourceType.OTHER + expect(getResourceFileType(InitiatorType.INPUT)).toEqual( + ResourceType.IMAGE ); }); - test('When URL has "css" file extension then return file type as "stylesheet"', async () => { + test('when initiator type is of group document then resource type is document', async () => { // Init - const resourceUrl = - 'https://cdn.sstatic.net/Shared/stacks.css?v=f0ad20c3c35c'; // Assert - expect(utils.getResourceFileType(resourceUrl)).toEqual( - utils.ResourceType.STYLESHEET + expect(getResourceFileType(InitiatorType.IFRAME)).toEqual( + ResourceType.DOCUMENT ); - }); - - test('When URL has "html" file extension then return file type as "document"', async () => { - // Init - const resourceUrl = - 'https://tpc.googlesyndication.com/sodar/sodar2/222/runner.html'; - // Assert - expect(utils.getResourceFileType(resourceUrl)).toEqual( - utils.ResourceType.DOCUMENT + expect(getResourceFileType(InitiatorType.FRAME)).toEqual( + ResourceType.DOCUMENT ); }); - test('When URL has "woff" file extension then return file type as "font"', async () => { + test('when initiator type is of group script then resource type is script', async () => { // Init - const resourceUrl = - 'https://dco-assets.everestads.net/ics-campaign//5031/t/8417/1/Base/fonts/SegoePro-Semibold.woff'; // Assert - expect(utils.getResourceFileType(resourceUrl)).toEqual( - utils.ResourceType.FONT + expect(getResourceFileType(InitiatorType.SCRIPT)).toEqual( + ResourceType.SCRIPT ); }); - test('when resource is image but file extension is no match, then initiatorType resolves to image', async () => { - // Init - const resourceUrl = 'example.com'; - // Assert - expect( - utils.getResourceFileType(resourceUrl, utils.InitiatorType.IMG) - ).toEqual(utils.ResourceType.IMAGE); - expect( - utils.getResourceFileType(resourceUrl, utils.InitiatorType.IMAGE) - ).toEqual(utils.ResourceType.IMAGE); - expect( - utils.getResourceFileType(resourceUrl, utils.InitiatorType.INPUT) - ).toEqual(utils.ResourceType.IMAGE); - }); - - test('when resource is document but file extension is no match, then initiatorType resolves to document', async () => { - // Init - const resourceUrl = 'example.com'; - // Assert - expect( - utils.getResourceFileType(resourceUrl, utils.InitiatorType.IFRAME) - ).toEqual(utils.ResourceType.DOCUMENT); - expect( - utils.getResourceFileType(resourceUrl, utils.InitiatorType.FRAME) - ).toEqual(utils.ResourceType.DOCUMENT); - }); - - test('when resource is script but file extension is no match, then initiatorType resolves to script', async () => { - // Init - const resourceUrl = 'example.com'; - // Assert - expect( - utils.getResourceFileType(resourceUrl, utils.InitiatorType.SCRIPT) - ).toEqual(utils.ResourceType.SCRIPT); - }); - - test('when resource is stylesheet but file extension is no match, then initiatorType resolves to stylesheet', async () => { + test('when initiator type is of group stylesheet then resource type is stylesheet', async () => { // Init - const resourceUrl = 'example.com'; // Assert - expect( - utils.getResourceFileType(resourceUrl, utils.InitiatorType.CSS) - ).toEqual(utils.ResourceType.STYLESHEET); + expect(getResourceFileType(InitiatorType.CSS)).toEqual( + ResourceType.STYLESHEET + ); + expect(getResourceFileType(InitiatorType.LINK)).toEqual( + ResourceType.STYLESHEET + ); }); test('when url is has endpoint host and path then it is a PutRumEvents call', async () => { const endpointHost = 'dataplane.rum.us-west-2.amazonaws.com'; const resourceUrl = 'https://dataplane.rum.us-west-2.amazonaws.com/gamma/application/aa17a42c-e737-48f7-adaf-2e0905f48073/events'; - expect(utils.isPutRumEventsCall(resourceUrl, endpointHost)).toBe(true); + expect(isPutRumEventsCall(resourceUrl, endpointHost)).toBe(true); }); test('when url has endpoint host but wrong path then it is not a PutRumEvents call', async () => { const endpointHost = 'dataplane.rum.us-west-2.amazonaws.com'; const resourceUrl = 'https://dataplane.rum.us-west-2.amazonaws.com/user'; - expect(utils.isPutRumEventsCall(resourceUrl, endpointHost)).toBe(false); + expect(isPutRumEventsCall(resourceUrl, endpointHost)).toBe(false); }); test('when url has wrong host and wrong path then it is not a PutRumEvents call', async () => { const endpointHost = 'example.com'; const resourceUrl = 'https://dataplane.rum.us-west-2.amazonaws.com/user'; - expect(utils.isPutRumEventsCall(resourceUrl, endpointHost)).toBe(false); + expect(isPutRumEventsCall(resourceUrl, endpointHost)).toBe(false); }); test('when url is invalid then it is not a PutRumEvents call', async () => { @@ -132,6 +76,6 @@ describe('Common utils tests', () => { const resourceUrl = 'dataplane.rum.us-west-2.amazonaws.com/gamma/application/aa17a42c-e737-48f7-adaf-2e0905f48073/events'; expect(() => new URL(endpointHost)).toThrowError(); - expect(utils.isPutRumEventsCall(resourceUrl, endpointHost)).toBe(false); + expect(isPutRumEventsCall(resourceUrl, endpointHost)).toBe(false); }); }); diff --git a/src/utils/common-utils.ts b/src/utils/common-utils.ts index 75ec0396..e6799fb4 100644 --- a/src/utils/common-utils.ts +++ b/src/utils/common-utils.ts @@ -11,184 +11,49 @@ export enum ResourceType { * https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/initiatorType */ export enum InitiatorType { - /** - * IMAGES - * PerformanceResourceTiming with initiatorType=Input must be an image - * Per MDN docs: "if the request was initiated by an element of type image."" - */ + // IMAGES IMG = 'img', IMAGE = 'image', INPUT = 'input', - /** - * DOCUMENTS - */ + // DOCUMENTS IFRAME = 'iframe', FRAME = 'frame', - /** - * SCRIPTS - */ + // SCRIPTS SCRIPT = 'script', - /** - * STYLESHEETS - */ - CSS = 'css' -} - -/** - * A PerformanceEntry or RumEvent that is sourced from the PerformanceAPI - */ -export interface HasLatency { - startTime: DOMHighResTimeStamp; - duration: DOMHighResTimeStamp; + // STYLESHEETS + CSS = 'css', + LINK = 'link' // typically for stylesheets, but also for other things such as icons and fonts } /** * 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. + * e.g. performanceKey(PerformanceResourceTimingEvent) === performanceKey(PerformanceResourceTiming). */ -export const performanceKey = (details: HasLatency) => - [details.startTime, details.duration].join('#'); - -const extensions = [ - { - name: ResourceType.STYLESHEET, - list: ['css', 'less'] - }, - { - name: ResourceType.DOCUMENT, - list: ['htm', 'html', 'ts', 'doc', 'docx', 'pdf', 'xls', 'xlsx'] - }, - { - name: ResourceType.SCRIPT, - list: ['js'] - }, - { - name: ResourceType.IMAGE, - list: [ - 'ai', - 'bmp', - 'gif', - 'ico', - 'jpeg', - 'jpg', - 'png', - 'ps', - 'psd', - 'svg', - 'tif', - 'tiff' - ] - }, - { - name: ResourceType.FONT, - list: ['fnt', 'fon', 'otf', 'ttf', 'woff'] - } -]; - -export const shuffle = (a: any[]) => { - for (let i = a.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - const v = a[i]; - a[i] = a[j]; - a[j] = v; +export const performanceKey = (startTime: number, duration: number) => + [startTime, duration].join('#'); + +export const getResourceFileType = (initiatorType: string): ResourceType => { + switch (initiatorType) { + case InitiatorType.IMAGE: + case InitiatorType.IMG: + case InitiatorType.INPUT: + return ResourceType.IMAGE; + case InitiatorType.IFRAME: + case InitiatorType.FRAME: + return ResourceType.DOCUMENT; + case InitiatorType.SCRIPT: + return ResourceType.SCRIPT; + case InitiatorType.CSS: + case InitiatorType.LINK: + return ResourceType.STYLESHEET; + default: + return ResourceType.OTHER; } }; -export const getResourceFileType = ( - url: string, - initiatorType?: string -): ResourceType => { - let ext = ResourceType.OTHER; - if (url) { - const filename = url.substring(url.lastIndexOf('/') + 1); - const extension = filename - .substring(filename.lastIndexOf('.') + 1) - .split(/[?#]/)[0]; - - extensions.forEach((type) => { - if (type.list.indexOf(extension) > -1) { - ext = type.name; - } - }); - } - - /** - * Resource name sometimes does not have the correct file extension names due to redirects. - * In these cases, they are mislablled as "other". In these cases, we can infer the correct - * fileType from the initiator. - */ - if (initiatorType && ext === ResourceType.OTHER) { - switch (initiatorType) { - case InitiatorType.IMAGE: - case InitiatorType.IMG: - case InitiatorType.INPUT: - ext = ResourceType.IMAGE; - break; - case InitiatorType.IFRAME: - case InitiatorType.FRAME: - ext = ResourceType.DOCUMENT; - break; - case InitiatorType.SCRIPT: - ext = ResourceType.SCRIPT; - break; - case InitiatorType.CSS: - ext = ResourceType.STYLESHEET; - break; - } - } - return ext; -}; - -/* Helpers */ -export const httpStatusText = { - '0': 'Abort Request', - '200': 'OK', - '201': 'Created', - '202': 'Accepted', - '203': 'Non-Authoritative Information', - '204': 'No Content', - '205': 'Reset Content', - '206': 'Partial Content', - '300': 'Multiple Choices', - '301': 'Moved Permanently', - '302': 'Found', - '303': 'See Other', - '304': 'Not Modified', - '305': 'Use Proxy', - '306': 'Unused', - '307': 'Temporary Redirect', - '400': 'Bad Request', - '401': 'Unauthorized', - '402': 'Payment Required', - '403': 'Forbidden', - '404': 'Not Found', - '405': 'Method Not Allowed', - '406': 'Not Acceptable', - '407': 'Proxy Authentication Required', - '408': 'Request Timeout', - '409': 'Conflict', - '410': 'Gone', - '411': 'Length Required', - '412': 'Precondition Required', - '413': 'Request Entry Too Large', - '414': 'Request-URI Too Long', - '415': 'Unsupported Media Type', - '416': 'Requested Range Not Satisfiable', - '417': 'Expectation Failed', - '418': 'I"m a teapot', - '500': 'Internal Server Error', - '501': 'Not Implemented', - '502': 'Bad Gateway', - '503': 'Service Unavailable', - '504': 'Gateway Timeout', - '505': 'HTTP Version Not Supported' -}; - export interface RumLCPAttribution { element?: string; url?: string; @@ -227,6 +92,9 @@ export const isNavigationSupported = () => { return PerformanceObserver.supportedEntryTypes.includes('navigation'); }; +export const isResourceSupported = () => + PerformanceObserver.supportedEntryTypes.includes('resource'); + /** PutRumEvents regex pattern */ const putRumEventsPattern = /.*\/application\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/events/;