@elastic/synthetics
Version:
Elastic synthetic monitoring agent
253 lines (231 loc) • 8.38 kB
text/typescript
/**
* MIT License
*
* Copyright (c) 2020-present, Elastic NV
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*/
import { APIRequestContext, APIResponse } from 'playwright-core';
import { NetworkInfo, APIDriver } from '../common_types';
import { log } from '../core/logger';
import { Step } from '../dsl';
import { getTimestamp, now } from '../helpers';
import {
calcTotalTime,
getResourceTimings,
roundMilliSecs,
} from '../network-timings';
/**
* Kibana UI expects the requestStartTime and loadEndTime to be baseline
* in seconds as they have the logic to convert it to milliseconds before
* using for offset calculation
*/
function epochTimeInSeconds() {
return getTimestamp() / 1e6;
}
// All `APIRequestContext` verbs funnel into `fetch`, so patching `fetch`
// alone captures every request exactly once.
type RequestLike = string | { url(): string };
function normalizeUrl(urlOrRequest: RequestLike): string {
if (typeof urlOrRequest === 'string') return urlOrRequest;
try {
return urlOrRequest.url();
} catch {
return String(urlOrRequest);
}
}
// Best-effort request body size; returns 0 for shapes we can't measure.
function bodyBytes(body: unknown): number {
if (body == null) return 0;
if (typeof body === 'string') return Buffer.byteLength(body);
if (Buffer.isBuffer(body)) return body.byteLength;
if (body instanceof Uint8Array) return body.byteLength;
if (typeof body === 'object') {
try {
return Buffer.byteLength(JSON.stringify(body));
} catch {
return 0;
}
}
return 0;
}
// Wire size of headers (`name: value\r\n`) for ECS `*.bytes`.
function headersBytes(headers: Record<string, string>): number {
let total = 0;
for (const [k, v] of Object.entries(headers ?? {})) {
total += Buffer.byteLength(k) + Buffer.byteLength(String(v)) + 4; // ": " + CRLF
}
return total;
}
export class APINetworkManager {
results: Array<NetworkInfo> = [];
_currentStep: Partial<Step> | null = null;
private _originalFetch?: APIRequestContext['fetch'];
private _patched = false;
constructor(private driver: APIDriver) {}
async start() {
if (this._patched) return;
log(`Plugins: started collecting API network events`);
const request = this.driver.request;
this._originalFetch = request.fetch.bind(request);
(request as any).fetch = (urlOrRequest: RequestLike, options?: any) =>
this._interceptRequest(urlOrRequest, options);
this._patched = true;
}
private async _interceptRequest(
urlOrRequest: RequestLike,
options?: any
): Promise<APIResponse> {
const url = normalizeUrl(urlOrRequest);
const timestamp = getTimestamp();
const requestSentTime = epochTimeInSeconds();
const httpMethod = (options?.method ?? 'GET').toUpperCase();
log(`API network: ${httpMethod} ${url}`);
const requestBody = options?.postData ?? options?.data;
const requestBodyBytes = bodyBytes(requestBody);
const requestHeaders = options?.headers ?? {};
const entry: NetworkInfo = {
step: this._currentStep,
timestamp,
url,
type: 'fetch',
isNavigationRequest: false,
browser: { name: 'api', version: '' },
request: {
url,
method: httpMethod,
headers: requestHeaders,
bytes: headersBytes(requestHeaders) + requestBodyBytes,
body: requestBodyBytes > 0 ? { bytes: requestBodyBytes } : undefined,
},
response: {
status: -1,
headers: {},
mimeType: 'x-unknown',
},
requestSentTime,
loadEndTime: -1,
responseReceivedTime: -1,
resourceSize: 0,
transferSize: 0,
timings: {
blocked: -1,
dns: -1,
ssl: -1,
connect: -1,
send: -1,
wait: -1,
receive: -1,
total: -1,
},
};
this.results.push(entry);
const startTime = now();
try {
const response = (await this._originalFetch(
urlOrRequest as any,
options
)) as APIResponse;
const headers = response.headers();
// Prefer `Content-Length`; fall back to the body buffer (chunked
// responses omit the header).
const contentLength = parseContentLength(headers['content-length']);
let responseBodyBytes = contentLength;
if (responseBodyBytes < 0) {
try {
const buf = await response.body();
responseBodyBytes = buf?.byteLength ?? 0;
} catch {
responseBodyBytes = 0;
}
}
const responseHeaderBytes = headersBytes(headers);
const transferBytes = responseHeaderBytes + responseBodyBytes;
entry.response = {
url: response.url(),
status: response.status(),
statusText: response.statusText(),
headers,
mimeType: headers['content-type'] ?? 'x-unknown',
bytes: transferBytes,
body: { bytes: responseBodyBytes },
};
entry.transferSize = transferBytes;
entry.resourceSize = responseBodyBytes;
entry.responseReceivedTime = epochTimeInSeconds();
entry.loadEndTime = entry.responseReceivedTime;
// Per-phase Resource Timing (Playwright >= 1.62). Phases that did not
// happen (reused keep-alive socket) or are unknown (HAR replay) come
// back as `-1`; `calcTotalTime` then falls back to the wall-clock span.
const timing = response.timing();
entry.timings = getResourceTimings(timing);
entry.timings.total = calcTotalTime(entry, timing);
// Native (Playwright >= 1.61) TLS/socket info for the final hop;
// both resolve to `null` for non-HTTPS or unknown addresses.
const [serverAddr, securityDetails] = await Promise.all([
response.serverAddr(),
response.securityDetails(),
]);
if (serverAddr) {
entry.response.remoteIPAddress = serverAddr.ipAddress;
entry.response.remotePort = serverAddr.port;
}
if (securityDetails) {
entry.response.securityDetails = {
...securityDetails,
protocol: normalizeTLSProtocol(securityDetails.protocol),
};
}
return response;
} catch (error) {
entry.responseReceivedTime = epochTimeInSeconds();
entry.loadEndTime = entry.responseReceivedTime;
entry.timings.total = roundMilliSecs(now() - startTime);
throw error;
}
}
// Drop the own-property `fetch` so the prototype method is reachable
// again, keeping the context usable after the plugin stops.
private _restore() {
if (!this._patched) return;
delete (this.driver.request as any).fetch;
this._originalFetch = undefined;
this._patched = false;
}
async stop() {
this._restore();
log(`Plugins: stopped collecting API network events`);
return this.results;
}
}
function parseContentLength(raw: string | undefined): number {
if (!raw) return -1;
const n = Number(raw);
return Number.isFinite(n) && n >= 0 ? n : -1;
}
/**
* `securityDetails()` reports `"TLSv1.3"` but the reporter splits on a
* space to derive `tls.version_protocol`/`tls.version`. Normalize to the
* spaced `"TLS 1.3"` shape so both journey types emit consistent fields.
*/
function normalizeTLSProtocol(raw?: string): string | undefined {
if (!raw) return undefined;
return raw.replace(/\s*v(?=\d)/i, ' ');
}