Files
grafana/e2e-playwright/utils/RequestsRecorder.ts
Josh Hunt fbca70747c E2E: Add performance monitoring E2E test (#109417)
* initial workflow

* add bench reporter

* add pr trigger

* update workflow

* actually add the test

* debug

.....

* more debug

* this it???

* Debugging GHA workflow

https

newline

tree

cache node modules

try bench again

fix path

* Add RequestsRecorder and tweak GHA workflow

* More debugging GHA workflow

cache

cache key

try renaming path

specify env var correctly

* Run tests on both envs, wait for requests to finish

fix status

cat stats

cat stats

* prefix metric names

* cleanup

* cleanup gha

* codeowners

* make gen-jsonnet

* quote things

* Add host_type label for categorising the various hosts

* rename pw_boot_time_seconds to pw_test_run_time_seconds

* change prefix

* codeowners, gha name

* remove node-modules caching

* fix codeowners
2025-08-20 19:36:54 +00:00

165 lines
5.0 KiB
TypeScript

import { Page, Response, Request } from '@playwright/test';
import * as prom from 'prom-client';
/**
* Records and tracks network request body sizes.
*
* You must call listen() before page.goto() for accurate results - this ensures all responses
* for a page are tracked.
*/
export class RequestsRecorder {
#page: Page;
#documentUrl: string | undefined;
#requestsInFlight = 0;
#currentRequests: Set<Request> = new Set<Request>();
#inflatedSizeBytesCounter: prom.Counter<'type' | 'host_type'>;
#transferSizeBytesCounter: prom.Counter<'type' | 'host_type'>;
#requestCountCounter: prom.Counter<'type' | 'host_type'>;
#resolve?: () => void;
constructor(page: Page) {
this.#page = page;
this.#inflatedSizeBytesCounter = new prom.Counter({
name: 'fe_perf_inflated_size_bytes',
help: 'The size of the inflated response body in bytes',
labelNames: ['type', 'host_type'],
registers: [],
});
this.#transferSizeBytesCounter = new prom.Counter({
name: 'fe_perf_transfer_size_bytes',
help: 'The size of the transfered response body in bytes',
labelNames: ['type', 'host_type'],
registers: [],
});
this.#requestCountCounter = new prom.Counter({
name: 'fe_perf_request_count',
help: 'The number of requests made',
labelNames: ['type', 'host_type'],
registers: [],
});
}
listen() {
const handler = this.#handleResponse.bind(this);
const reqHandler = this.#handleRequest.bind(this);
this.#page.on('request', reqHandler);
this.#page.on('response', handler);
return () => {
this.#page.off('response', handler);
this.#page.off('request', reqHandler);
if (this.#requestsInFlight === 0) {
return Promise.resolve();
}
console.log('waiting for', this.#requestsInFlight, 'requests to finish');
return new Promise<void>((resolve) => {
this.#resolve = resolve;
});
};
}
async #handleRequest(request: Request) {
const type = request.resourceType();
// Once we've recieved a document response, create a list of requests that we'll count the responses of.
// We also want to count the document request itself.
if (this.#documentUrl || type === 'document') {
this.#currentRequests.add(request);
}
}
/*
* The Playwright page object has have multiple page loads, and responses for one page may come in after the page
* has been navigated away from. Attempting to get the body of these responses will results in an error, but is also
* not what we want to track.
*
* To solve this, we wait for the 'document' response to come in (a new page has loaded) and then keep track of all
* future requests. We only record responses for requests that were made after the document response.
*/
async #handleResponse(response: Response) {
const request = response.request();
const url = response.url();
const type = response.request().resourceType();
// Record when a document response comes in so we can keep track of future requests
if (type === 'document') {
if (this.#documentUrl) {
console.warn('recieved additional document response', url);
}
this.#documentUrl = url;
}
// Disregard responses that for requests that were initiated before the current page
if (!this.#currentRequests.has(request)) {
return;
}
this.#requestsInFlight += 1;
const hostType = getHostType(response.url(), this.#documentUrl ?? '');
// Attempting to get the body of an empty response results in an error, so guess if the response will be empty or not
const statusCode = response.status();
const noBodyStatusCode = statusCode <= 199 || statusCode === 204 || statusCode === 205 || statusCode === 304;
if (!noBodyStatusCode) {
const body = await response.body();
this.#inflatedSizeBytesCounter.inc({ type, host_type: hostType }, body.length);
}
const sizes = await response.request().sizes();
this.#transferSizeBytesCounter.inc(
{ type, host_type: hostType },
sizes.responseBodySize + sizes.responseHeadersSize
);
this.#requestCountCounter.inc({ type, host_type: hostType });
this.#requestsInFlight -= 1;
if (this.#requestsInFlight === 0 && this.#resolve) {
this.#resolve();
}
}
getMetrics() {
return [this.#inflatedSizeBytesCounter, this.#transferSizeBytesCounter, this.#requestCountCounter];
}
}
/**
* Instead of setting the request host as a label, which may have too high cardinality, we categorise
* the host into a limited set of types.
*/
function getHostType(requestUrl: string, documentUrl: string) {
const url = new URL(requestUrl);
const hostname = url.hostname; // FYI `hostname` doesn't include port, `host` does
const documentHost = new URL(documentUrl).hostname;
if (hostname.match(/^grafana-assets\.grafana(-\w+)?\.net$/)) {
return 'assets_cdn';
}
if (hostname.match(/^plugins-cdn\.grafana(-\w+)?\.net$/)) {
return 'plugin_cdn';
}
if (hostname === documentHost) {
return 'self';
}
return 'other';
}