Skip to content

Commit

Permalink
feat: Time to interactive (#465)
Browse files Browse the repository at this point in the history
Co-authored-by: Quinn Hanam <hanquinn@amazon.com>
  • Loading branch information
ps863 and qhanam authored Nov 16, 2023
1 parent fdc30fd commit 8cf2753
Show file tree
Hide file tree
Showing 17 changed files with 1,068 additions and 0 deletions.
94 changes: 94 additions & 0 deletions app/time_to_interactive_event.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html>
<head>
<title>RUM Integ Test</title>
<script src="./loader_time_to_interactive_event.js"></script>
<link
rel="icon"
type="image/png"
href="https://awsmedia.s3.amazonaws.com/favicon.ico"
/>

<script>
// Common to all test pages
function dispatch() {
cwr('dispatch');
}

function clearRequestResponse() {
document.getElementById('request_url').innerText = '';
document.getElementById('request_header').innerText = '';
document.getElementById('request_body').innerText = '';

document.getElementById('response_status').innerText = '';
document.getElementById('response_header').innerText = '';
document.getElementById('response_body').innerText = '';
}

function disable() {
cwr('disable');
}

function enable() {
cwr('enable');
}
</script>

<style>
table {
border-collapse: collapse;
margin-top: 10px;
margin-bottom: 10px;
}

td,
th {
border: 1px solid black;
text-align: left;
padding: 8px;
}
</style>
</head>
<body>
<p id="welcome">This application is used for RUM integ testing.</p>

<button id="disable" onclick="disable()">Disable</button>
<button id="enable" onclick="enable()">Enable</button>
<hr />
<button id="dispatch" onclick="dispatch()">Dispatch</button>
<button id="testButton">Test Button</button>

<hr />
<span id="request"></span>
<span id="response"></span>
<table>
<tr>
<td>Request URL</td>
<td id="request_url"></td>
</tr>
<tr>
<td>Request Header</td>
<td id="request_header"></td>
</tr>
<tr>
<td>Request Body</td>
<td id="request_body"></td>
</tr>
</table>
<table>
<tr>
<td>Response Status Code</td>
<td id="response_status"></td>
</tr>
<tr>
<td>Response Header</td>
<td id="response_header"></td>
</tr>
<tr>
<td>Response Body</td>
<td id="response_body"></td>
</tr>
</table>
<img src="blank.png" alt="sample" />
</body>
</html>
19 changes: 19 additions & 0 deletions src/event-schemas/time-to-interactive-event.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"$id": "com.amazon.rum.time_to_interactive_event",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "TimeToInteractiveEvent",
"type": "object",
"properties": {
"version": {
"const": "1.0.0",
"type": "string",
"description": "Schema version."
},
"value": {
"type": "number",
"description": "Time to interactive value"
}
},
"additionalProperties": false,
"required": ["version", "value"]
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export { ClientBuilder } from './dispatch/Dispatch';
export { PageAttributes } from './sessions/PageManager';
export { Plugin } from './plugins/Plugin';
export { PluginContext } from './plugins/types';
export { TTIPlugin } from './plugins/event-plugins/TTIPlugin';
export * from './plugins/event-plugins/DomEventPlugin';
export * from './plugins/event-plugins/JsErrorPlugin';
export * from './plugins/event-plugins/NavigationPlugin';
Expand Down
15 changes: 15 additions & 0 deletions src/loader/loader-time-to-interactive-event.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { loader } from './loader';
import { showRequestClientBuilder } from '../test-utils/mock-http-handler';
import { TTIPlugin } from '../plugins/event-plugins/TTIPlugin';
loader('cwr', 'abc123', '1.0', 'us-west-2', './rum_javascript_telemetry.js', {
dispatchInterval: 0,
metaDataPluginsToLoad: [],
eventPluginsToLoad: [new TTIPlugin()],
telemetries: [],
clientBuilder: showRequestClientBuilder
});
window.cwr('setAwsCredentials', {
accessKeyId: 'a',
secretAccessKey: 'b',
sessionToken: 'c'
});
42 changes: 42 additions & 0 deletions src/plugins/event-plugins/TTIPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { TTIMetric, onTTI } from '../../time-to-interactive/TimeToInteractive';
import { TimeToInteractiveEvent } from '../../events/time-to-interactive-event';
import { TIME_TO_INTERACTIVE_EVENT_TYPE } from '../utils/constant';
import { PluginContext } from 'plugins/types';
import { InternalPlugin } from '../InternalPlugin';

export const TTI_EVENT_PLUGIN_ID = 'time-to-interactive';

export class TTIPlugin extends InternalPlugin {
protected fpsEnabled;

constructor(fpsMeasurementEnabled = false) {
super(TTI_EVENT_PLUGIN_ID);
this.fpsEnabled = fpsMeasurementEnabled;
}

protected context!: PluginContext;

enable(): void {
/* Nothing to do. */
}

disable(): void {
/* Nothing to do. */
}

configure(): void {
/* Nothing to do. */
}

onload(): void {
onTTI(this.handleTTI, { fpsEnabled: this.fpsEnabled });
}

private handleTTI = (metric: TTIMetric): void => {
const ttiEvent: TimeToInteractiveEvent = {
version: '1.0.0',
value: Math.round(metric.value)
};
this.context.record(TIME_TO_INTERACTIVE_EVENT_TYPE, ttiEvent);
};
}
42 changes: 42 additions & 0 deletions src/plugins/event-plugins/__integ__/TTIPlugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {
STATUS_202,
REQUEST_BODY,
RESPONSE_STATUS
} from '../../../test-utils/integ-test-utils';
import { Selector } from 'testcafe';
import { TIME_TO_INTERACTIVE_EVENT_TYPE } from '../../utils/constant';

const testButton: Selector = Selector(`#testButton`);
const dispatch: Selector = Selector(`#dispatch`);

fixture('TTI Plugin').page(
'http://localhost:8080/time_to_interactive_event.html'
);

test('when TTI is recorded, a TTI event is recorded', async (t: TestController) => {
const browser = t.browser.name;
// Skip firefox, till Firefox supports longtasks
if (browser === 'Firefox') {
return 'Test is skipped';
}

await t
.click(testButton)
.wait(100)
.click(dispatch)
.wait(3000)
.click(dispatch)
.expect(RESPONSE_STATUS.textContent)
.eql(STATUS_202.toString())
.expect(REQUEST_BODY.textContent)
.contains('BatchId');

const events = JSON.parse(await REQUEST_BODY.textContent).RumEvents.filter(
(e) => e.type === TIME_TO_INTERACTIVE_EVENT_TYPE
);
await t.expect(events.length).eql(1);

const ttiEvent = JSON.parse(events[0].details);

await t.expect(ttiEvent.value).typeOf('number');
});
103 changes: 103 additions & 0 deletions src/plugins/event-plugins/__tests__/TTIPlugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { mockLongTaskPerformanceObserver } from '../../../test-utils/mock-data';
import { TTIPlugin } from '../TTIPlugin';
import { context, record } from '../../../test-utils/test-utils';
import { TIME_TO_INTERACTIVE_EVENT_TYPE } from '../../../plugins/utils/constant';

const mockTTIData = {
name: 'TTI',
value: 201.2
};

jest.mock('../../../time-to-interactive/TimeToInteractive', () => {
return {
onTTI: jest.fn().mockImplementation((callback) => {
callback(mockTTIData);
})
};
});

describe('Time to Interactive - Plugin Tests', () => {
beforeEach(() => {
// setup
mockLongTaskPerformanceObserver();
record.mockClear();
});

afterEach(() => {
jest.clearAllMocks();
});

test('When TTI resolves successfully, an event is recorded by plugin', async () => {
const plugin: TTIPlugin = new TTIPlugin();

// Run Plugin
plugin.load(context);

// eslint-disable-next-line @typescript-eslint/unbound-method
await new Promise(process.nextTick);

// Assert
expect(record).toHaveBeenCalledTimes(1);
});

test('When TTI resolves successfully, TTI event with resolved value is recorded by plugin', async () => {
const plugin: TTIPlugin = new TTIPlugin();

// Run Plugin
plugin.load(context);

// eslint-disable-next-line @typescript-eslint/unbound-method
await new Promise(process.nextTick);

// Assert
expect(record).toHaveBeenCalledTimes(1);
expect(record.mock.calls[0][0]).toEqual(TIME_TO_INTERACTIVE_EVENT_TYPE);
expect(record.mock.calls[0][1]).toEqual({
value: 201,
version: '1.0.0'
});
});
test('When TTI with frames per second enabled resolves successfully, TTI event with resolved value is recorded by plugin', async () => {
const plugin: TTIPlugin = new TTIPlugin(true);

// Run Plugin
plugin.load(context);

// eslint-disable-next-line @typescript-eslint/unbound-method
await new Promise(process.nextTick);

// Assert
expect(record).toHaveBeenCalledTimes(1);
expect(record.mock.calls[0][0]).toEqual(TIME_TO_INTERACTIVE_EVENT_TYPE);
expect(record.mock.calls[0][1]).toEqual({
value: 201,
version: '1.0.0'
});
});
test('Disable and enable does not have effect on the plugin behavior', async () => {
const plugin: TTIPlugin = new TTIPlugin();

plugin.load(context);

// eslint-disable-next-line @typescript-eslint/unbound-method
await new Promise(process.nextTick);
plugin.disable();
plugin.enable();

// Assert
expect(record).toHaveBeenCalled();
});

test('Disable does not have effect on the plugin behavior', async () => {
const plugin: TTIPlugin = new TTIPlugin();

plugin.load(context);

// eslint-disable-next-line @typescript-eslint/unbound-method
await new Promise(process.nextTick);
plugin.disable();

// Assert
expect(record).toHaveBeenCalled();
});
});
3 changes: 3 additions & 0 deletions src/plugins/utils/constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,6 @@ export const PAGE_VIEW_EVENT_TYPE = `${RUM_AMZ_PREFIX}.page_view_event`;

// Session start event
export const SESSION_START_EVENT_TYPE = `${RUM_AMZ_PREFIX}.session_start_event`;

// Time to interactive event
export const TIME_TO_INTERACTIVE_EVENT_TYPE = `${RUM_AMZ_PREFIX}.time_to_interactive_event`;
5 changes: 5 additions & 0 deletions src/test-utils/mock-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,11 @@ export const mockPerformanceObserver = () => {
(window as any).PerformanceObserver = MockEmptyPerformanceObserver;
};

export const mockLongTaskPerformanceObserver = () => {
(window as any).PerformanceObserver = MockEmptyPerformanceObserver;
(window as any).PerformanceObserver.supportedEntryTypes = ['longtask'];
};

export const httpErrorEvent = {
version: '1.0.0',
eventType: 'ERROR',
Expand Down
Loading

0 comments on commit 8cf2753

Please sign in to comment.