Skip to content

Commit

Permalink
feat!: capture all W3C fields in ResourceEvents (#489)
Browse files Browse the repository at this point in the history
* 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 5061775.

* chore: change title to PerformanceResourceTimingEvent

* chore: remove version and add enum for initiatorType

* fix: isPutRumEvents()

* Revert "fix: isPutRumEvents()"

This reverts commit 3e29234.

* 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 <hanquinn@amazon.com>
  • Loading branch information
williazz and qhanam committed Jul 16, 2024
1 parent 0ae8db5 commit 15c0076
Show file tree
Hide file tree
Showing 15 changed files with 426 additions and 431 deletions.
7 changes: 5 additions & 2 deletions src/__integ__/customEvents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/event-cache/EventCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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"]
}
3 changes: 2 additions & 1 deletion src/orchestration/__tests__/Orchestration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
};
});

Expand Down
153 changes: 72 additions & 81 deletions src/plugins/event-plugins/ResourcePlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,128 +2,119 @@ 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';

/**
* This plugin records resource performance timing events generated during every page load/re-load.
*/
export class ResourcePlugin extends InternalPlugin {
private config: PerformancePluginConfig;
private resourceObserver: PerformanceObserver;
private eventCount: number;
private resourceObserver?: PerformanceObserver;
private sampleCount: number;

constructor(config?: Partial<PerformancePluginConfig>) {
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 {
if (this.enabled) {
return;
}
this.enabled = true;
this.resourceObserver.observe({
type: RESOURCE,
buffered: true
});
this.observe();
}

disable(): void {
if (!this.enabled) {
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();
}
}
Loading

0 comments on commit 15c0076

Please sign in to comment.