feat(har): add bodySize, transportSize, headersSize (#7470)

Co-authored-by: tnolet <tim@checklyhq.com>
This commit is contained in:
Max Schmitt 2021-07-08 18:22:37 +02:00 committed by GitHub
parent 07d44587d9
commit 1cc2a2dc59
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 201 additions and 34 deletions

1
.gitignore vendored
View file

@ -7,6 +7,7 @@
*.swp *.swp
*.pyc *.pyc
.vscode .vscode
.idea
yarn.lock yarn.lock
/src/generated/* /src/generated/*
lib/ lib/

View file

@ -310,7 +310,7 @@ export class CRNetworkManager {
responseStart: -1, responseStart: -1,
}; };
} }
const response = new network.Response(request.request, responsePayload.status, responsePayload.statusText, headersObjectToArray(responsePayload.headers), timing, getResponseBody); const response = new network.Response(request.request, responsePayload.status, responsePayload.statusText, headersObjectToArray(responsePayload.headers), timing, getResponseBody, responsePayload.protocol);
if (responsePayload?.remoteIPAddress && typeof responsePayload?.remotePort === 'number') { if (responsePayload?.remoteIPAddress && typeof responsePayload?.remotePort === 'number') {
response._serverAddrFinished({ response._serverAddrFinished({
ipAddress: responsePayload.remoteIPAddress, ipAddress: responsePayload.remoteIPAddress,
@ -361,7 +361,7 @@ export class CRNetworkManager {
// event from protocol. @see https://crbug.com/883475 // event from protocol. @see https://crbug.com/883475
const response = request.request._existingResponse(); const response = request.request._existingResponse();
if (response) if (response)
response._requestFinished(helper.secondsToRoundishMillis(event.timestamp - request._timestamp)); response._requestFinished(helper.secondsToRoundishMillis(event.timestamp - request._timestamp), undefined, event.encodedDataLength);
this._requestIdToRequest.delete(request._requestId); this._requestIdToRequest.delete(request._requestId);
if (request._interceptionId) if (request._interceptionId)
this._attemptedAuthentications.delete(request._interceptionId); this._attemptedAuthentications.delete(request._interceptionId);

View file

@ -118,7 +118,7 @@ export class FFNetworkManager {
response._requestFinished(this._relativeTiming(event.responseEndTime), 'Response body is unavailable for redirect responses'); response._requestFinished(this._relativeTiming(event.responseEndTime), 'Response body is unavailable for redirect responses');
} else { } else {
this._requests.delete(request._id); this._requests.delete(request._id);
response._requestFinished(this._relativeTiming(event.responseEndTime)); response._requestFinished(this._relativeTiming(event.responseEndTime), undefined, event.transferSize);
} }
this._page._frameManager.requestFinished(request.request); this._page._frameManager.requestFinished(request.request);
} }

View file

@ -311,8 +311,10 @@ export class Response extends SdkObject {
private _serverAddrPromiseCallback: (arg?: RemoteAddr) => void = () => {}; private _serverAddrPromiseCallback: (arg?: RemoteAddr) => void = () => {};
private _securityDetailsPromise: Promise<SecurityDetails|undefined>; private _securityDetailsPromise: Promise<SecurityDetails|undefined>;
private _securityDetailsPromiseCallback: (arg?: SecurityDetails) => void = () => {}; private _securityDetailsPromiseCallback: (arg?: SecurityDetails) => void = () => {};
_httpVersion: string | undefined;
_transferSize: number | undefined;
constructor(request: Request, status: number, statusText: string, headers: types.HeadersArray, timing: ResourceTiming, getResponseBodyCallback: GetResponseBodyCallback) { constructor(request: Request, status: number, statusText: string, headers: types.HeadersArray, timing: ResourceTiming, getResponseBodyCallback: GetResponseBodyCallback, httpVersion?: string) {
super(request.frame(), 'response'); super(request.frame(), 'response');
this._request = request; this._request = request;
this._timing = timing; this._timing = timing;
@ -333,6 +335,7 @@ export class Response extends SdkObject {
this._finishedPromiseCallback = f; this._finishedPromiseCallback = f;
}); });
this._request._setResponse(this); this._request._setResponse(this);
this._httpVersion = httpVersion;
} }
_serverAddrFinished(addr?: RemoteAddr) { _serverAddrFinished(addr?: RemoteAddr) {
@ -343,11 +346,16 @@ export class Response extends SdkObject {
this._securityDetailsPromiseCallback(securityDetails); this._securityDetailsPromiseCallback(securityDetails);
} }
_requestFinished(responseEndTiming: number, error?: string) { _requestFinished(responseEndTiming: number, error?: string, transferSize?: number) {
this._request._responseEndTiming = Math.max(responseEndTiming, this._timing.responseStart); this._request._responseEndTiming = Math.max(responseEndTiming, this._timing.responseStart);
this._transferSize = transferSize;
this._finishedPromiseCallback({ error }); this._finishedPromiseCallback({ error });
} }
_setHttpVersion(httpVersion: string) {
this._httpVersion = httpVersion;
}
url(): string { url(): string {
return this._url; return this._url;
} }

View file

@ -81,6 +81,7 @@ export type Response = {
redirectURL: string; redirectURL: string;
headersSize: number; headersSize: number;
bodySize: number; bodySize: number;
_transferSize: number;
}; };
export type Cookie = { export type Cookie = {

View file

@ -14,12 +14,16 @@
* limitations under the License. * limitations under the License.
*/ */
import { URL } from 'url';
import fs from 'fs'; import fs from 'fs';
import { BrowserContext } from '../../browserContext'; import { BrowserContext } from '../../browserContext';
import { helper } from '../../helper'; import { helper } from '../../helper';
import * as network from '../../network'; import * as network from '../../network';
import { Page } from '../../page'; import { Page } from '../../page';
import * as har from './har'; import * as har from './har';
import * as types from '../../types';
const FALLBACK_HTTP_VERSION = 'HTTP/1.1';
type HarOptions = { type HarOptions = {
path: string; path: string;
@ -51,6 +55,7 @@ export class HarTracer {
}; };
context.on(BrowserContext.Events.Page, (page: Page) => this._ensurePageEntry(page)); context.on(BrowserContext.Events.Page, (page: Page) => this._ensurePageEntry(page));
context.on(BrowserContext.Events.Request, (request: network.Request) => this._onRequest(request)); context.on(BrowserContext.Events.Request, (request: network.Request) => this._onRequest(request));
context.on(BrowserContext.Events.RequestFinished, (request: network.Request) => this._onRequestFinished(request).catch(() => {}));
context.on(BrowserContext.Events.Response, (response: network.Response) => this._onResponse(response)); context.on(BrowserContext.Events.Response, (response: network.Response) => this._onResponse(response));
} }
@ -128,27 +133,28 @@ export class HarTracer {
request: { request: {
method: request.method(), method: request.method(),
url: request.url(), url: request.url(),
httpVersion: 'HTTP/1.1', httpVersion: FALLBACK_HTTP_VERSION,
cookies: [], cookies: [],
headers: [], headers: [],
queryString: [...url.searchParams].map(e => ({ name: e[0], value: e[1] })), queryString: [...url.searchParams].map(e => ({ name: e[0], value: e[1] })),
postData: undefined, postData: postDataForHar(request),
headersSize: -1, headersSize: -1,
bodySize: -1, bodySize: calculateRequestBodySize(request) || 0,
}, },
response: { response: {
status: -1, status: -1,
statusText: '', statusText: '',
httpVersion: 'HTTP/1.1', httpVersion: FALLBACK_HTTP_VERSION,
cookies: [], cookies: [],
headers: [], headers: [],
content: { content: {
size: -1, size: -1,
mimeType: request.headerValue('content-type') || 'application/octet-stream', mimeType: request.headerValue('content-type') || 'x-unknown',
}, },
headersSize: -1, headersSize: -1,
bodySize: -1, bodySize: -1,
redirectURL: '' redirectURL: '',
_transferSize: -1
}, },
cache: { cache: {
beforeRequest: null, beforeRequest: null,
@ -168,29 +174,63 @@ export class HarTracer {
this._entries.set(request, harEntry); this._entries.set(request, harEntry);
} }
private async _onRequestFinished(request: network.Request) {
const page = request.frame()._page;
const harEntry = this._entries.get(request)!;
const response = await request.response();
if (!response)
return;
const httpVersion = normaliseHttpVersion(response._httpVersion);
const transferSize = response._transferSize || -1;
const headersSize = calculateResponseHeadersSize(httpVersion, response.status(), response.statusText(), response.headers());
const bodySize = transferSize !== -1 ? transferSize - headersSize : -1;
harEntry.request.httpVersion = httpVersion;
harEntry.response.bodySize = bodySize;
harEntry.response.headersSize = headersSize;
harEntry.response._transferSize = transferSize;
harEntry.request.headersSize = calculateRequestHeadersSize(request.method(), request.url(), httpVersion, request.headers());
const promise = response.body().then(buffer => {
const content = harEntry.response.content;
content.size = buffer.length;
content.compression = harEntry.response.bodySize !== -1 ? buffer.length - harEntry.response.bodySize : 0;
if (!this._options.omitContent && buffer && buffer.length > 0) {
content.text = buffer.toString('base64');
content.encoding = 'base64';
}
}).catch(() => {});
this._addBarrier(page, promise);
}
private _onResponse(response: network.Response) { private _onResponse(response: network.Response) {
const page = response.frame()._page; const page = response.frame()._page;
const pageEntry = this._ensurePageEntry(page); const pageEntry = this._ensurePageEntry(page);
const harEntry = this._entries.get(response.request())!; const harEntry = this._entries.get(response.request())!;
// Rewrite provisional headers with actual // Rewrite provisional headers with actual
const request = response.request(); const request = response.request();
harEntry.request.headers = request.headers().map(header => ({ name: header.name, value: header.value })); harEntry.request.headers = request.headers().map(header => ({ name: header.name, value: header.value }));
harEntry.request.cookies = cookiesForHar(request.headerValue('cookie'), ';'); harEntry.request.cookies = cookiesForHar(request.headerValue('cookie'), ';');
harEntry.request.postData = postDataForHar(request) || undefined; harEntry.request.postData = postDataForHar(request);
harEntry.response = { harEntry.response = {
status: response.status(), status: response.status(),
statusText: response.statusText(), statusText: response.statusText(),
httpVersion: 'HTTP/1.1', httpVersion: normaliseHttpVersion(response._httpVersion),
cookies: cookiesForHar(response.headerValue('set-cookie'), '\n'), cookies: cookiesForHar(response.headerValue('set-cookie'), '\n'),
headers: response.headers().map(header => ({ name: header.name, value: header.value })), headers: response.headers().map(header => ({ name: header.name, value: header.value })),
content: { content: {
size: -1, size: -1,
mimeType: response.headerValue('content-type') || 'application/octet-stream', mimeType: response.headerValue('content-type') || 'x-unknown',
}, },
headersSize: -1, headersSize: -1,
bodySize: -1, bodySize: -1,
redirectURL: '' redirectURL: '',
_transferSize: -1
}; };
const timing = response.timing(); const timing = response.timing();
if (pageEntry.startedDateTime.valueOf() > timing.startTime) if (pageEntry.startedDateTime.valueOf() > timing.startTime)
@ -220,14 +260,6 @@ export class HarTracer {
if (details) if (details)
harEntry._securityDetails = details; harEntry._securityDetails = details;
})); }));
if (!this._options.omitContent && response.status() === 200) {
const promise = response.body().then(buffer => {
harEntry.response.content.text = buffer.toString('base64');
harEntry.response.content.encoding = 'base64';
}).catch(() => {});
this._addBarrier(page, promise);
}
} }
async flush() { async flush() {
@ -246,10 +278,10 @@ export class HarTracer {
} }
} }
function postDataForHar(request: network.Request): har.PostData | null { function postDataForHar(request: network.Request): har.PostData | undefined {
const postData = request.postDataBuffer(); const postData = request.postDataBuffer();
if (!postData) if (!postData)
return null; return;
const contentType = request.headerValue('content-type') || 'application/octet-stream'; const contentType = request.headerValue('content-type') || 'application/octet-stream';
const result: har.PostData = { const result: har.PostData = {
@ -305,3 +337,33 @@ function parseCookie(c: string): har.Cookie {
} }
return cookie; return cookie;
} }
function calculateResponseHeadersSize(protocol: string, status: number, statusText: string , headers: types.HeadersArray) {
let rawHeaders = `${protocol} ${status} ${statusText}\r\n`;
for (const header of headers)
rawHeaders += `${header.name}: ${header.value}\r\n`;
rawHeaders += '\r\n';
return rawHeaders.length;
}
function calculateRequestHeadersSize(method: string, url: string, httpVersion: string, headers: types.HeadersArray) {
let rawHeaders = `${method} ${(new URL(url)).pathname} ${httpVersion}\r\n`;
for (const header of headers)
rawHeaders += `${header.name}: ${header.value}\r\n`;
return rawHeaders.length;
}
function normaliseHttpVersion(httpVersion?: string) {
if (!httpVersion)
return FALLBACK_HTTP_VERSION;
if (httpVersion === 'http/1.1')
return 'HTTP/1.1';
return httpVersion;
}
function calculateRequestBodySize(request: network.Request): number|undefined {
const postData = request.postDataBuffer();
if (!postData)
return;
return new TextEncoder().encode(postData.toString('utf8')).length;
}

View file

@ -1044,7 +1044,11 @@ export class WKPage implements PageDelegate {
validFrom: responseReceivedPayload?.response.security?.certificate?.validFrom, validFrom: responseReceivedPayload?.response.security?.certificate?.validFrom,
validTo: responseReceivedPayload?.response.security?.certificate?.validUntil, validTo: responseReceivedPayload?.response.security?.certificate?.validUntil,
}); });
response._requestFinished(helper.secondsToRoundishMillis(event.timestamp - request._timestamp)); const { responseBodyBytesReceived, responseHeaderBytesReceived } = event.metrics || {};
const transferSize = responseBodyBytesReceived ? responseBodyBytesReceived + (responseHeaderBytesReceived || 0) : undefined;
if (event.metrics?.protocol)
response._setHttpVersion(event.metrics.protocol);
response._requestFinished(helper.secondsToRoundishMillis(event.timestamp - request._timestamp), undefined, transferSize);
} }
this._requestIdToResponseReceivedPayloadEvent.delete(request._requestId); this._requestIdToResponseReceivedPayloadEvent.delete(request._requestId);

View file

@ -16,8 +16,11 @@
*/ */
import { browserTest as it, expect } from './config/browserTest'; import { browserTest as it, expect } from './config/browserTest';
import * as path from 'path';
import fs from 'fs'; import fs from 'fs';
import http2 from 'http2';
import type { BrowserContext, BrowserContextOptions } from '../index'; import type { BrowserContext, BrowserContextOptions } from '../index';
import type { AddressInfo } from 'net';
async function pageWithHar(contextFactory: (options?: BrowserContextOptions) => Promise<BrowserContext>, testInfo: any) { async function pageWithHar(contextFactory: (options?: BrowserContextOptions) => Promise<BrowserContext>, testInfo: any) {
const harPath = testInfo.outputPath('test.har'); const harPath = testInfo.outputPath('test.har');
@ -96,6 +99,7 @@ it('should include request', async ({ contextFactory, server }, testInfo) => {
expect(entry.request.httpVersion).toBe('HTTP/1.1'); expect(entry.request.httpVersion).toBe('HTTP/1.1');
expect(entry.request.headers.length).toBeGreaterThan(1); expect(entry.request.headers.length).toBeGreaterThan(1);
expect(entry.request.headers.find(h => h.name.toLowerCase() === 'user-agent')).toBeTruthy(); expect(entry.request.headers.find(h => h.name.toLowerCase() === 'user-agent')).toBeTruthy();
expect(entry.request.bodySize).toBe(0);
}); });
it('should include response', async ({ contextFactory, server }, testInfo) => { it('should include response', async ({ contextFactory, server }, testInfo) => {
@ -242,15 +246,36 @@ it('should include content', async ({ contextFactory, server }, testInfo) => {
await page.goto(server.PREFIX + '/har.html'); await page.goto(server.PREFIX + '/har.html');
const log = await getLog(); const log = await getLog();
const content1 = log.entries[0].response.content; expect(log.entries[0].response.httpVersion).toBe('HTTP/1.1');
expect(content1.encoding).toBe('base64'); expect(log.entries[0].response.content.encoding).toBe('base64');
expect(content1.mimeType).toBe('text/html; charset=utf-8'); expect(log.entries[0].response.content.mimeType).toBe('text/html; charset=utf-8');
expect(Buffer.from(content1.text, 'base64').toString()).toContain('HAR Page'); expect(Buffer.from(log.entries[0].response.content.text, 'base64').toString()).toContain('HAR Page');
expect(log.entries[0].response.content.size).toBeGreaterThanOrEqual(96);
expect(log.entries[0].response.content.compression).toBe(0);
const content2 = log.entries[1].response.content; expect(log.entries[1].response.httpVersion).toBe('HTTP/1.1');
expect(content2.encoding).toBe('base64'); expect(log.entries[1].response.content.encoding).toBe('base64');
expect(content2.mimeType).toBe('text/css; charset=utf-8'); expect(log.entries[1].response.content.mimeType).toBe('text/css; charset=utf-8');
expect(Buffer.from(content2.text, 'base64').toString()).toContain('pink'); expect(Buffer.from(log.entries[1].response.content.text, 'base64').toString()).toContain('pink');
expect(log.entries[1].response.content.size).toBeGreaterThanOrEqual(37);
expect(log.entries[1].response.content.compression).toBe(0);
});
it('should include sizes', async ({ contextFactory, server, browserName, platform }, testInfo) => {
it.fixme(browserName === 'webkit' && platform === 'linux', 'blocked by libsoup3');
const { page, getLog } = await pageWithHar(contextFactory, testInfo);
await page.goto(server.PREFIX + '/har.html');
const log = await getLog();
expect(log.entries[0].request.headersSize).toBeGreaterThanOrEqual(280);
expect(log.entries[0].response.bodySize).toBeGreaterThanOrEqual(96);
expect(log.entries[0].response.headersSize).toBe(198);
expect(log.entries[0].response._transferSize).toBeGreaterThanOrEqual(294);
expect(log.entries[1].response.bodySize).toBeGreaterThanOrEqual(37);
expect(log.entries[1].response.headersSize).toBe(197);
expect(log.entries[1].response._transferSize).toBeGreaterThanOrEqual(234);
}); });
it('should calculate time', async ({ contextFactory, server }, testInfo) => { it('should calculate time', async ({ contextFactory, server }, testInfo) => {
@ -260,6 +285,48 @@ it('should calculate time', async ({ contextFactory, server }, testInfo) => {
expect(log.entries[0].time).toBeGreaterThan(0); expect(log.entries[0].time).toBeGreaterThan(0);
}); });
it('should report the correct _transferSize with PNG files', async ({ contextFactory, server, browserName, platform }, testInfo) => {
it.fixme(browserName === 'webkit' && platform === 'linux', 'blocked by libsoup3');
const { page, getLog } = await pageWithHar(contextFactory, testInfo);
await page.goto(server.EMPTY_PAGE);
await page.setContent(`
<img src="${server.PREFIX}/pptr.png" />
`);
const log = await getLog();
expect(log.entries[1].response._transferSize).toBe(6323);
});
it('should have -1 _transferSize when its a failed request', async ({ contextFactory, server }, testInfo) => {
const { page, getLog } = await pageWithHar(contextFactory, testInfo);
server.setRoute('/one-style.css', (req, res) => {
res.setHeader('Content-Type', 'text/css');
res.connection.destroy();
});
const failedRequests = [];
page.on('requestfailed', request => failedRequests.push(request));
await page.goto(server.PREFIX + '/har.html');
const log = await getLog();
expect(log.entries[1].request.url.endsWith('/one-style.css')).toBe(true);
expect(log.entries[1].response._transferSize).toBe(-1);
});
it('should report the correct body size', async ({ contextFactory, server }, testInfo) => {
server.setRoute('/api', (req, res) => res.end());
const { page, getLog } = await pageWithHar(contextFactory, testInfo);
await page.goto(server.EMPTY_PAGE);
await Promise.all([
page.waitForResponse(server.PREFIX + '/api'),
page.evaluate(() => {
fetch('/api', {
method: 'POST',
body: 'abc123'
});
})
]);
const log = await getLog();
expect(log.entries[1].request.bodySize).toBe(6);
});
it('should have popup requests', async ({ contextFactory, server }, testInfo) => { it('should have popup requests', async ({ contextFactory, server }, testInfo) => {
const { page, getLog } = await pageWithHar(contextFactory, testInfo); const { page, getLog } = await pageWithHar(contextFactory, testInfo);
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
@ -385,3 +452,27 @@ it('should return security details directly from response', async ({ contextFact
else else
expect(securityDetails).toEqual({issuer: 'puppeteer-tests', protocol: 'TLS 1.3', subjectName: 'puppeteer-tests', validFrom: 1550084863, validTo: 33086084863}); expect(securityDetails).toEqual({issuer: 'puppeteer-tests', protocol: 'TLS 1.3', subjectName: 'puppeteer-tests', validFrom: 1550084863, validTo: 33086084863});
}); });
it('should contain http2 for http2 requests', async ({ contextFactory, browserName }, testInfo) => {
it.fixme(browserName === 'firefox' || browserName === 'webkit');
const server = http2.createSecureServer({
key: await fs.promises.readFile(path.join(__dirname, '..', 'utils', 'testserver', 'key.pem')),
cert: await fs.promises.readFile(path.join(__dirname, '..', 'utils', 'testserver', 'cert.pem')),
});
server.on('stream', stream => {
stream.respond({
'content-type': 'text/html; charset=utf-8',
':status': 200
});
stream.end('<h1>Hello World</h1>');
});
server.listen(0);
const { page, getLog } = await pageWithHar(contextFactory, testInfo);
await page.goto(`https://localhost:${(server.address() as AddressInfo).port}`);
const log = await getLog();
expect(log.entries[0].request.httpVersion).toBe('h2');
expect(log.entries[0].response.httpVersion).toBe('h2');
server.close();
});