UNPKG

@elastic/synthetics

Version:

Elastic synthetic monitoring agent

253 lines (231 loc) 8.38 kB
/** * 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, ' '); }