Skip to content

Commit

Permalink
feat: link lcp attribution to image resource and navigation page load (
Browse files Browse the repository at this point in the history
  • Loading branch information
williazz authored Oct 2, 2023
1 parent 7339c78 commit 4b8506e
Show file tree
Hide file tree
Showing 16 changed files with 401 additions and 34 deletions.
Binary file added app/assets/lcp.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions app/web_vital_event.html
Original file line number Diff line number Diff line change
Expand Up @@ -111,5 +111,6 @@
<td id="response_body"></td>
</tr>
</table>
<img src="assets/lcp.png" alt="LCP resource image" />
</body>
</html>
10 changes: 10 additions & 0 deletions src/dispatch/dataplane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,3 +31,11 @@ export interface RumEvent {
metadata?: string;
details: string;
}

export interface ParsedRumEvent {
id: string;
timestamp: Date;
type: string;
metadata?: MetaData;
details: object;
}
2 changes: 1 addition & 1 deletion src/event-bus/EventBus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export default class EventBus<T = Topic> {

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);
Expand Down
37 changes: 36 additions & 1 deletion src/event-bus/__tests__/EventBus.test.ts
Original file line number Diff line number Diff line change
@@ -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<MockTopics>;
const l1 = jest.fn();
Expand Down Expand Up @@ -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');
});
});
8 changes: 8 additions & 0 deletions src/event-schemas/largest-contentful-paint-event.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
8 changes: 7 additions & 1 deletion src/loader/loader-web-vital-event.js
Original file line number Diff line number Diff line change
@@ -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
});
Expand Down
3 changes: 2 additions & 1 deletion src/orchestration/Orchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,8 @@ export class Orchestration {
id: applicationId,
version: applicationVersion
},
this.config
this.config,
this.eventBus
);
}

Expand Down
9 changes: 9 additions & 0 deletions src/orchestration/__tests__/Orchestration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions src/plugins/event-plugins/ResourcePlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export class ResourcePlugin extends InternalPlugin {

recordResourceEvent = ({
name,
startTime,
initiatorType,
duration,
transferSize
Expand All @@ -105,6 +106,7 @@ export class ResourcePlugin extends InternalPlugin {
const eventData: ResourceEvent = {
version: '1.0.0',
initiatorType,
startTime,
duration,
fileType: getResourceFileType(name, initiatorType),
transferSize
Expand Down
74 changes: 62 additions & 12 deletions src/plugins/event-plugins/WebVitalsPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,30 @@ 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';

export class WebVitalsPlugin extends InternalPlugin {
constructor() {
super(WEB_VITAL_EVENT_PLUGIN_ID);
}
private resourceEventIds = new Map<string, string>();
private navigationEventId?: string;
private cacheLCPCandidates = isLCPSupported();

// eslint-disable-next-line @typescript-eslint/no-empty-function
enable(): void {}
Expand All @@ -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',
Expand All @@ -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',
Expand Down
62 changes: 59 additions & 3 deletions src/plugins/event-plugins/__integ__/WebVitalsPlugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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}"`);
});
1 change: 1 addition & 0 deletions src/plugins/event-plugins/__tests__/ResourcePlugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 4b8506e

Please sign in to comment.