feat(fetch): record timings (#32613)
Related to https://github.com/microsoft/playwright/issues/19621 Adds some instrumentation to collect timings for `APIRequestContext` requests and adds them to the HAR trace. Doesn't yet expose them via an API, but makes our `Duration` field in the trace viewer show a nice duration: <img width="1392" alt="Screenshot 2024-09-14 at 11 46 04" src="https://github.com/user-attachments/assets/8020382d-9494-4634-9cfd-22b6f4a1d770"> I'm gonna add it to our API in a separate PR. --------- Signed-off-by: Simon Knott <info@simonknott.de> Co-authored-by: Dmitry Gozman <dgozman@gmail.com>
This commit is contained in:
parent
c216c25a1d
commit
751b939d3a
|
|
@ -30,7 +30,7 @@ import { HttpsProxyAgent, SocksProxyAgent } from '../utilsBundle';
|
||||||
import { BrowserContext, verifyClientCertificates } from './browserContext';
|
import { BrowserContext, verifyClientCertificates } from './browserContext';
|
||||||
import { CookieStore, domainMatches, parseRawCookie } from './cookieStore';
|
import { CookieStore, domainMatches, parseRawCookie } from './cookieStore';
|
||||||
import { MultipartFormData } from './formData';
|
import { MultipartFormData } from './formData';
|
||||||
import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent } from '../utils/happy-eyeballs';
|
import { httpHappyEyeballsAgent, httpsHappyEyeballsAgent, timingForSocket } from '../utils/happy-eyeballs';
|
||||||
import type { CallMetadata } from './instrumentation';
|
import type { CallMetadata } from './instrumentation';
|
||||||
import { SdkObject } from './instrumentation';
|
import { SdkObject } from './instrumentation';
|
||||||
import type { Playwright } from './playwright';
|
import type { Playwright } from './playwright';
|
||||||
|
|
@ -40,6 +40,7 @@ import { Tracing } from './trace/recorder/tracing';
|
||||||
import type * as types from './types';
|
import type * as types from './types';
|
||||||
import type { HeadersArray, ProxySettings } from './types';
|
import type { HeadersArray, ProxySettings } from './types';
|
||||||
import { getMatchingTLSOptionsForOrigin, rewriteOpenSSLErrorIfNeeded } from './socksClientCertificatesInterceptor';
|
import { getMatchingTLSOptionsForOrigin, rewriteOpenSSLErrorIfNeeded } from './socksClientCertificatesInterceptor';
|
||||||
|
import type * as har from '@trace/har';
|
||||||
|
|
||||||
type FetchRequestOptions = {
|
type FetchRequestOptions = {
|
||||||
userAgent: string;
|
userAgent: string;
|
||||||
|
|
@ -71,6 +72,7 @@ export type APIRequestFinishedEvent = {
|
||||||
statusCode: number;
|
statusCode: number;
|
||||||
statusMessage: string;
|
statusMessage: string;
|
||||||
body?: Buffer;
|
body?: Buffer;
|
||||||
|
timings: har.Timings;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SendRequestOptions = https.RequestOptions & {
|
type SendRequestOptions = https.RequestOptions & {
|
||||||
|
|
@ -294,8 +296,28 @@ export abstract class APIRequestContext extends SdkObject {
|
||||||
// If we have a proxy agent already, do not override it.
|
// If we have a proxy agent already, do not override it.
|
||||||
const agent = options.agent || (url.protocol === 'https:' ? httpsHappyEyeballsAgent : httpHappyEyeballsAgent);
|
const agent = options.agent || (url.protocol === 'https:' ? httpsHappyEyeballsAgent : httpHappyEyeballsAgent);
|
||||||
const requestOptions = { ...options, agent };
|
const requestOptions = { ...options, agent };
|
||||||
|
|
||||||
|
const startAt = monotonicTime();
|
||||||
|
let dnsLookupAt: number | undefined;
|
||||||
|
let tcpConnectionAt: number | undefined;
|
||||||
|
let tlsHandshakeAt: number | undefined;
|
||||||
|
let requestFinishAt: number | undefined;
|
||||||
|
|
||||||
const request = requestConstructor(url, requestOptions as any, async response => {
|
const request = requestConstructor(url, requestOptions as any, async response => {
|
||||||
|
const responseAt = monotonicTime();
|
||||||
const notifyRequestFinished = (body?: Buffer) => {
|
const notifyRequestFinished = (body?: Buffer) => {
|
||||||
|
const endAt = monotonicTime();
|
||||||
|
// spec: http://www.softwareishard.com/blog/har-12-spec/#timings
|
||||||
|
const timings: har.Timings = {
|
||||||
|
send: requestFinishAt! - startAt,
|
||||||
|
wait: responseAt - requestFinishAt!,
|
||||||
|
receive: endAt - responseAt,
|
||||||
|
dns: dnsLookupAt ? dnsLookupAt - startAt : -1,
|
||||||
|
connect: (tlsHandshakeAt ?? tcpConnectionAt!) - startAt, // "If [ssl] is defined then the time is also included in the connect field "
|
||||||
|
ssl: tlsHandshakeAt ? tlsHandshakeAt - tcpConnectionAt! : -1,
|
||||||
|
blocked: -1,
|
||||||
|
};
|
||||||
|
|
||||||
const requestFinishedEvent: APIRequestFinishedEvent = {
|
const requestFinishedEvent: APIRequestFinishedEvent = {
|
||||||
requestEvent,
|
requestEvent,
|
||||||
httpVersion: response.httpVersion,
|
httpVersion: response.httpVersion,
|
||||||
|
|
@ -304,7 +326,8 @@ export abstract class APIRequestContext extends SdkObject {
|
||||||
headers: response.headers,
|
headers: response.headers,
|
||||||
rawHeaders: response.rawHeaders,
|
rawHeaders: response.rawHeaders,
|
||||||
cookies,
|
cookies,
|
||||||
body
|
body,
|
||||||
|
timings,
|
||||||
};
|
};
|
||||||
this.emit(APIRequestContext.Events.RequestFinished, requestFinishedEvent);
|
this.emit(APIRequestContext.Events.RequestFinished, requestFinishedEvent);
|
||||||
};
|
};
|
||||||
|
|
@ -450,6 +473,19 @@ export abstract class APIRequestContext extends SdkObject {
|
||||||
this.on(APIRequestContext.Events.Dispose, disposeListener);
|
this.on(APIRequestContext.Events.Dispose, disposeListener);
|
||||||
request.on('close', () => this.off(APIRequestContext.Events.Dispose, disposeListener));
|
request.on('close', () => this.off(APIRequestContext.Events.Dispose, disposeListener));
|
||||||
|
|
||||||
|
request.on('socket', socket => {
|
||||||
|
// happy eyeballs don't emit lookup and connect events, so we use our custom ones
|
||||||
|
const happyEyeBallsTimings = timingForSocket(socket);
|
||||||
|
dnsLookupAt = happyEyeBallsTimings.dnsLookupAt;
|
||||||
|
tcpConnectionAt = happyEyeBallsTimings.tcpConnectionAt;
|
||||||
|
|
||||||
|
// non-happy-eyeballs sockets
|
||||||
|
socket.on('lookup', () => { dnsLookupAt = monotonicTime(); });
|
||||||
|
socket.on('connect', () => { tcpConnectionAt = monotonicTime(); });
|
||||||
|
socket.on('secureConnect', () => { tlsHandshakeAt = monotonicTime(); });
|
||||||
|
});
|
||||||
|
request.on('finish', () => { requestFinishAt = monotonicTime(); });
|
||||||
|
|
||||||
progress.log(`→ ${options.method} ${url.toString()}`);
|
progress.log(`→ ${options.method} ${url.toString()}`);
|
||||||
if (options.headers) {
|
if (options.headers) {
|
||||||
for (const [name, value] of Object.entries(options.headers))
|
for (const [name, value] of Object.entries(options.headers))
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,12 @@ export class HarTracer {
|
||||||
harEntry.response.statusText = event.statusMessage;
|
harEntry.response.statusText = event.statusMessage;
|
||||||
harEntry.response.httpVersion = event.httpVersion;
|
harEntry.response.httpVersion = event.httpVersion;
|
||||||
harEntry.response.redirectURL = event.headers.location || '';
|
harEntry.response.redirectURL = event.headers.location || '';
|
||||||
|
|
||||||
|
if (!this._options.omitTiming) {
|
||||||
|
harEntry.timings = event.timings;
|
||||||
|
this._computeHarEntryTotalTime(harEntry);
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < event.rawHeaders.length; i += 2) {
|
for (let i = 0; i < event.rawHeaders.length; i += 2) {
|
||||||
harEntry.response.headers.push({
|
harEntry.response.headers.push({
|
||||||
name: event.rawHeaders[i],
|
name: event.rawHeaders[i],
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import * as net from 'net';
|
||||||
import * as tls from 'tls';
|
import * as tls from 'tls';
|
||||||
import { ManualPromise } from './manualPromise';
|
import { ManualPromise } from './manualPromise';
|
||||||
import { assert } from './debug';
|
import { assert } from './debug';
|
||||||
|
import { monotonicTime } from './time';
|
||||||
|
|
||||||
// Implementation(partial) of Happy Eyeballs 2 algorithm described in
|
// Implementation(partial) of Happy Eyeballs 2 algorithm described in
|
||||||
// https://www.rfc-editor.org/rfc/rfc8305
|
// https://www.rfc-editor.org/rfc/rfc8305
|
||||||
|
|
@ -28,6 +29,9 @@ import { assert } from './debug';
|
||||||
// Same as in Chromium (https://source.chromium.org/chromium/chromium/src/+/5666ff4f5077a7e2f72902f3a95f5d553ea0d88d:net/socket/transport_connect_job.cc;l=102)
|
// Same as in Chromium (https://source.chromium.org/chromium/chromium/src/+/5666ff4f5077a7e2f72902f3a95f5d553ea0d88d:net/socket/transport_connect_job.cc;l=102)
|
||||||
const connectionAttemptDelayMs = 300;
|
const connectionAttemptDelayMs = 300;
|
||||||
|
|
||||||
|
const kDNSLookupAt = Symbol('kDNSLookupAt')
|
||||||
|
const kTCPConnectionAt = Symbol('kTCPConnectionAt')
|
||||||
|
|
||||||
class HttpHappyEyeballsAgent extends http.Agent {
|
class HttpHappyEyeballsAgent extends http.Agent {
|
||||||
createConnection(options: http.ClientRequestArgs, oncreate?: (err: Error | null, socket?: net.Socket) => void): net.Socket | undefined {
|
createConnection(options: http.ClientRequestArgs, oncreate?: (err: Error | null, socket?: net.Socket) => void): net.Socket | undefined {
|
||||||
// There is no ambiguity in case of IP address.
|
// There is no ambiguity in case of IP address.
|
||||||
|
|
@ -107,6 +111,7 @@ export async function createConnectionAsync(
|
||||||
const lookup = (options as any).__testHookLookup || lookupAddresses;
|
const lookup = (options as any).__testHookLookup || lookupAddresses;
|
||||||
const hostname = clientRequestArgsToHostName(options);
|
const hostname = clientRequestArgsToHostName(options);
|
||||||
const addresses = await lookup(hostname);
|
const addresses = await lookup(hostname);
|
||||||
|
const dnsLookupAt = monotonicTime();
|
||||||
const sockets = new Set<net.Socket>();
|
const sockets = new Set<net.Socket>();
|
||||||
let firstError;
|
let firstError;
|
||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
|
|
@ -132,9 +137,13 @@ export async function createConnectionAsync(
|
||||||
port: options.port as number,
|
port: options.port as number,
|
||||||
host: address });
|
host: address });
|
||||||
|
|
||||||
|
(socket as any)[kDNSLookupAt] = dnsLookupAt;
|
||||||
|
|
||||||
// Each socket may fire only one of 'connect', 'timeout' or 'error' events.
|
// Each socket may fire only one of 'connect', 'timeout' or 'error' events.
|
||||||
// None of these events are fired after socket.destroy() is called.
|
// None of these events are fired after socket.destroy() is called.
|
||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
|
(socket as any)[kTCPConnectionAt] = monotonicTime();
|
||||||
|
|
||||||
connected.resolve();
|
connected.resolve();
|
||||||
oncreate?.(null, socket);
|
oncreate?.(null, socket);
|
||||||
// TODO: Cache the result?
|
// TODO: Cache the result?
|
||||||
|
|
@ -189,3 +198,9 @@ function clientRequestArgsToHostName(options: http.ClientRequestArgs): string {
|
||||||
throw new Error('Either options.hostname or options.host must be provided');
|
throw new Error('Either options.hostname or options.host must be provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function timingForSocket(socket: net.Socket | tls.TLSSocket) {
|
||||||
|
return {
|
||||||
|
dnsLookupAt: (socket as any)[kDNSLookupAt] as number | undefined,
|
||||||
|
tcpConnectionAt: (socket as any)[kTCPConnectionAt] as number | undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,9 @@ import type { Log } from '../../packages/trace/src/har';
|
||||||
import { parseHar } from '../config/utils';
|
import { parseHar } from '../config/utils';
|
||||||
const { createHttp2Server } = require('../../packages/playwright-core/lib/utils');
|
const { createHttp2Server } = require('../../packages/playwright-core/lib/utils');
|
||||||
|
|
||||||
async function pageWithHar(contextFactory: (options?: BrowserContextOptions) => Promise<BrowserContext>, testInfo: any, options: { outputPath?: string, content?: 'embed' | 'attach' | 'omit', omitContent?: boolean } = {}) {
|
async function pageWithHar(contextFactory: (options?: BrowserContextOptions) => Promise<BrowserContext>, testInfo: any, options: { outputPath?: string } & Partial<Pick<BrowserContextOptions['recordHar'], 'content' | 'omitContent' | 'mode'>> = {}) {
|
||||||
const harPath = testInfo.outputPath(options.outputPath || 'test.har');
|
const harPath = testInfo.outputPath(options.outputPath || 'test.har');
|
||||||
const context = await contextFactory({ recordHar: { path: harPath, content: options.content, omitContent: options.omitContent }, ignoreHTTPSErrors: true });
|
const context = await contextFactory({ recordHar: { path: harPath, ...options }, ignoreHTTPSErrors: true });
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
return {
|
return {
|
||||||
page,
|
page,
|
||||||
|
|
@ -820,6 +820,47 @@ it('should include API request', async ({ contextFactory, server }, testInfo) =>
|
||||||
expect(entry.response.headers.find(h => h.name.toLowerCase() === 'content-type')?.value).toContain('application/json');
|
expect(entry.response.headers.find(h => h.name.toLowerCase() === 'content-type')?.value).toContain('application/json');
|
||||||
expect(entry.response.content.size).toBe(15);
|
expect(entry.response.content.size).toBe(15);
|
||||||
expect(entry.response.content.text).toBe(responseBody.toString());
|
expect(entry.response.content.text).toBe(responseBody.toString());
|
||||||
|
|
||||||
|
expect(entry.time).toBeGreaterThan(0);
|
||||||
|
expect(entry.timings).toEqual(expect.objectContaining({
|
||||||
|
blocked: -1,
|
||||||
|
connect: expect.any(Number),
|
||||||
|
dns: expect.any(Number),
|
||||||
|
receive: expect.any(Number),
|
||||||
|
send: expect.any(Number),
|
||||||
|
ssl: expect.any(Number),
|
||||||
|
wait: expect.any(Number),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect minimal mode for API Requests', async ({ contextFactory, server }, testInfo) => {
|
||||||
|
const { page, getLog } = await pageWithHar(contextFactory, testInfo, { mode: 'minimal' });
|
||||||
|
const url = server.PREFIX + '/simple.json';
|
||||||
|
await page.request.post(url, {
|
||||||
|
headers: { cookie: 'a=b; c=d' },
|
||||||
|
data: { foo: 'bar' }
|
||||||
|
});
|
||||||
|
const { entries } = await getLog();
|
||||||
|
expect(entries).toHaveLength(1);
|
||||||
|
const [entry] = entries;
|
||||||
|
expect(entry.timings).toEqual({ receive: -1, send: -1, wait: -1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include redirects from API request', async ({ contextFactory, server }, testInfo) => {
|
||||||
|
server.setRedirect('/redirect-me', '/simple.json');
|
||||||
|
const { page, getLog } = await pageWithHar(contextFactory, testInfo);
|
||||||
|
await page.request.post(server.PREFIX + '/redirect-me', {
|
||||||
|
headers: { cookie: 'a=b; c=d' },
|
||||||
|
data: { foo: 'bar' }
|
||||||
|
});
|
||||||
|
const log = await getLog();
|
||||||
|
expect(log.entries.length).toBe(2);
|
||||||
|
const [redirect, json] = log.entries;
|
||||||
|
expect(redirect.request.url).toBe(server.PREFIX + '/redirect-me');
|
||||||
|
expect(json.request.url).toBe(server.PREFIX + '/simple.json');
|
||||||
|
|
||||||
|
expect(redirect.timings).toBeDefined();
|
||||||
|
expect(json.timings).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not hang on resources served from cache', async ({ contextFactory, server, browserName }, testInfo) => {
|
it('should not hang on resources served from cache', async ({ contextFactory, server, browserName }, testInfo) => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue