artillery
Version:
Cloud-scale load testing. https://www.artillery.io
1,307 lines (1,163 loc) • 42.8 kB
text/typescript
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import crypto from 'node:crypto';
import fs from 'node:fs';
import type http from 'node:http';
import path from 'node:path';
import qs from 'node:querystring';
import { parse as urlparse } from 'node:url';
import { callbackify, promisify } from 'node:util';
import HttpAgent from 'agentkeepalive';
import async from 'async';
import createDebug from 'debug';
import decompressResponse from 'decompress-response';
import { compileExpression as filtrex } from 'filtrex';
import FormData from 'form-data';
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
import _ from 'lodash';
import * as tough from 'tough-cookie';
import { engine_util as engineUtil } from '../commons/index.ts';
const debug = createDebug('http');
const debugRequests = createDebug('http:request');
const debugResponse = createDebug('http:response');
const USER_AGENT = 'Artillery (https://artillery.io)';
const ensurePropertyIsAList = engineUtil.ensurePropertyIsAList;
const template = engineUtil.template;
const { HttpsAgent } = HttpAgent;
// NOTE on typing: request specs, request params and VU variables are
// dynamic by design (user scripts + processor hooks may attach
// arbitrary properties), so they are typed as Record<string, any>
// until the canonical script/flow input types exist (modernization
// plan, phase 2). Structural contracts that are stable - engine
// surface, VU context internals, response handling - are typed.
type ProcessorFunction = (...args: any[]) => any;
// Structural view of got's request function: its own overloaded types
// reject dynamically-assembled option objects.
interface CancelableRequestLike extends Promise<unknown> {
on(event: string, listener: (...args: any[]) => void): CancelableRequestLike;
}
type GotRequester = (options: Record<string, any>) => CancelableRequestLike;
type StepCallback = (
err: Error | null | undefined,
context?: VUContext
) => void;
type StepFunction = (context: VUContext, callback: StepCallback) => void;
interface EventEmitterLike {
emit(event: string, ...args: any[]): unknown;
}
// What callers hand to a compiled scenario: VU variables plus
// whatever the runner attached. Engine internals (_jar, agents,
// counters) are attached by setInitialContext().
export interface InitialVUContext {
vars: Record<string, any>;
[key: string]: any;
}
export interface VUContext {
vars: Record<string, any>;
_successCount: number;
_enableCookieJar: boolean;
_jar: tough.CookieJar;
_defaultCookie?: Record<string, string>;
_defaultStrictCapture?: boolean;
_httpAgent: http.Agent;
_httpsAgent: http.Agent;
// Engines, plugins and user hooks attach ad-hoc state:
[key: string]: any;
}
// Raw response as surfaced by got's 'response' event: an
// http.IncomingMessage with got's timing info attached.
interface WireResponse {
statusCode: number;
headers: Record<string, string | string[] | undefined>;
timings: { phases: Record<string, number> };
body?: unknown;
on(event: string, listener: (...args: any[]) => void): unknown;
}
interface AgentOptions {
keepAlive?: boolean;
keepAliveMsec?: number;
maxSockets?: number;
maxFreeSockets?: number;
timeout?: number;
proxy?: string;
}
interface HttpDefaultsConfig {
headers?: Record<string, string>;
cookie?: Record<string, string>;
strictCapture?: boolean | string;
think?: Record<string, unknown>;
[key: string]: any;
}
interface NormalizedHttpEngineConfig {
target?: string;
timeout?: number;
tls?: Record<string, unknown>;
processor?: Record<string, ProcessorFunction>;
defaults: HttpDefaultsConfig;
http: {
defaults: HttpDefaultsConfig;
cookieJarOptions: Record<string, unknown>;
pool?: number | string;
timeout?: number;
extendedMetrics?: boolean;
distributedTracing?:
| boolean
| { enabled?: boolean; sampled?: boolean; traceIdPrefix?: string };
[key: string]: any;
};
[key: string]: any;
}
const GOT_OPTION_NAMES = [
'url',
'searchParams',
'method',
'headers',
'body',
'json',
'form',
'allowGetBody',
'timeout',
'retry',
'encoding',
'cookieJar',
'followRedirect',
'maxRedirects',
'decompress',
'http2',
'agent',
'username',
'password',
'https',
'throwHttpErrors'
];
// NOTE: pre-existing quirk preserved: this object is mutated by
// Object.assign() in the constructor and in setInitialContext(), so
// options accumulate on the shared constant across instances.
const DEFAULT_AGENT_OPTIONS: AgentOptions = {
keepAlive: true,
keepAliveMsec: 1000
};
// agentkeepalive defaults `timeout` (active socket inactivity) to a hardcoded
// floor of 8000ms (Math.max(freeSocketTimeout * 2, 8000)). When a request's
// origin doesn't send response bytes within 8s, the socket is destroyed with
// `error.code = 'ERR_SOCKET_TIMEOUT'` regardless of the user's configured
// `http.timeout`. Mirror `http.timeout` (or top-level `timeout`) into the agent
// so the YAML config becomes the actual binding constraint.
function deriveAgentTimeoutMs(
scriptConfig: Record<string, any>
): number | undefined {
const timeoutSec = scriptConfig?.timeout || scriptConfig?.http?.timeout;
if (typeof timeoutSec !== 'number') return undefined;
// Never go below the existing 8s floor — preserves current behaviour for
// users who explicitly configure a smaller http.timeout.
return Math.max(8000, timeoutSec * 1000);
}
function createAgents(
proxies: { http?: string; https?: string },
opts: AgentOptions
): { httpAgent: http.Agent; httpsAgent: http.Agent } {
const agentOpts: AgentOptions = Object.assign(
{},
DEFAULT_AGENT_OPTIONS,
opts
);
// HTTP proxy endpoint will be used for all requests, unless a separate
// HTTPS proxy URL is also set, which will be used for HTTPS requests:
if (proxies.http) {
agentOpts.proxy = proxies.http;
const httpAgent = new HttpProxyAgent(
agentOpts as ConstructorParameters<typeof HttpProxyAgent>[0]
);
if (proxies.https) {
agentOpts.proxy = proxies.https;
}
const httpsAgent = new HttpsProxyAgent(
agentOpts as ConstructorParameters<typeof HttpsProxyAgent>[0]
);
return { httpAgent, httpsAgent };
}
// If only HTTPS proxy is provided, it will be used for HTTPS requests,
// but not for HTTP requests:
if (proxies.https) {
return {
httpAgent: new HttpAgent(agentOpts),
httpsAgent: new HttpsProxyAgent(
Object.assign(
{ proxy: proxies.https },
agentOpts
) as ConstructorParameters<typeof HttpsProxyAgent>[0]
)
};
}
// By default nothing is proxied:
return {
httpAgent: new HttpAgent(agentOpts),
httpsAgent: new HttpsAgent(agentOpts)
};
}
export default class HttpEngine {
declare config: NormalizedHttpEngineConfig;
declare maxSockets: number;
declare _httpAgent: http.Agent;
declare _httpsAgent: http.Agent;
declare extendedHTTPMetrics?: boolean;
declare request: GotRequester;
// Set by the runner after loading:
declare __name?: string;
constructor(script: { config: Record<string, any> }) {
this.config = script.config as NormalizedHttpEngineConfig;
if (typeof this.config.defaults === 'undefined') {
this.config.defaults = {};
}
if (typeof this.config.http === 'undefined') {
this.config.http = {} as NormalizedHttpEngineConfig['http'];
}
if (typeof this.config.http.defaults === 'undefined') {
this.config.http.defaults = {};
}
if (typeof this.config.http.cookieJarOptions === 'undefined') {
this.config.http.cookieJarOptions = {};
}
// If config.http.pool is set, create & reuse agents for all requests (with
// max sockets set). That's what we're done here.
// If config.http.pool is not set, we create new agents for each virtual user.
// That's done when the VU is initialized.
this.maxSockets = Infinity;
if (script.config.http?.pool) {
this.maxSockets = Number(script.config.http.pool);
}
const agentOpts: AgentOptions = Object.assign(DEFAULT_AGENT_OPTIONS, {
maxSockets: this.maxSockets,
maxFreeSockets: this.maxSockets
});
const agentTimeoutMs = deriveAgentTimeoutMs(script.config);
if (agentTimeoutMs !== undefined) {
agentOpts.timeout = agentTimeoutMs;
}
const agents = createAgents(
{
http: process.env.HTTP_PROXY,
https: process.env.HTTPS_PROXY
},
agentOpts
);
this._httpAgent = agents.httpAgent;
this._httpsAgent = agents.httpsAgent;
if (
(script.config.http && script.config.http.extendedMetrics === true) ||
global.artillery?.runtimeOptions.extendedHTTPMetrics
) {
this.extendedHTTPMetrics = true;
}
}
async init(): Promise<void> {
this.request = (await import('got')).default as unknown as GotRequester;
}
_isDistributedTracingEnabled(config: NormalizedHttpEngineConfig): boolean {
const dtConfig = config.http?.distributedTracing;
if (!dtConfig) {
return false;
}
// Handle both boolean and object forms
if (typeof dtConfig === 'boolean') {
return dtConfig;
}
if (typeof dtConfig === 'object' && dtConfig.enabled !== undefined) {
return dtConfig.enabled;
}
// Default to true if distributedTracing is set but enabled is not specified
return true;
}
_generateTraceparent(config: NormalizedHttpEngineConfig): string {
// W3C Trace Context format: version-trace-id-parent-id-trace-flags
const version = '00';
// Get configuration
const dtConfig = config.http?.distributedTracing;
let sampled = true; // Default to sampled
let traceIdPrefix = 'a9'; // Default prefix
if (typeof dtConfig === 'object') {
if (dtConfig.sampled !== undefined) {
sampled = dtConfig.sampled;
}
if (dtConfig.traceIdPrefix !== undefined) {
traceIdPrefix = dtConfig.traceIdPrefix;
}
}
// Validate and normalize prefix (must be valid hex, max 8 chars)
traceIdPrefix = traceIdPrefix
.toLowerCase()
.replace(/[^0-9a-f]/g, '')
.slice(0, 8);
if (traceIdPrefix.length === 0) {
traceIdPrefix = 'a9'; // Fallback to default if invalid
}
// Generate trace-id with prefix (32 hex chars total)
const remainingBytes = Math.ceil((32 - traceIdPrefix.length) / 2);
const randomPart = crypto.randomBytes(remainingBytes).toString('hex');
const traceId = (traceIdPrefix + randomPart).slice(0, 32);
// Generate 8-byte parent-id (16 hex chars)
const parentId = crypto.randomBytes(8).toString('hex');
const traceFlags = sampled ? '01' : '00';
return `${version}-${traceId}-${parentId}-${traceFlags}`;
}
createScenario(scenarioSpec: Record<string, any>, ee: EventEmitterLike) {
ensurePropertyIsAList(scenarioSpec, 'beforeRequest');
ensurePropertyIsAList(scenarioSpec, 'afterResponse');
ensurePropertyIsAList(scenarioSpec, 'beforeScenario');
ensurePropertyIsAList(scenarioSpec, 'afterScenario');
ensurePropertyIsAList(scenarioSpec, 'onError');
// Add scenario-level hooks if needed:
// For now, just turn them into function steps and insert them
// directly into the flow array.
// TODO: Scenario-level hooks will probably want access to the
// entire scenario spec rather than just the userContext.
const beforeScenarioFns = _.map(
scenarioSpec.beforeScenario,
(hookFunctionName) => ({ function: hookFunctionName })
);
const afterScenarioFns = _.map(
scenarioSpec.afterScenario,
(hookFunctionName) => ({ function: hookFunctionName })
);
const newFlow = beforeScenarioFns.concat(
scenarioSpec.flow.concat(afterScenarioFns)
);
scenarioSpec.flow = newFlow;
const tasks = _.map(scenarioSpec.flow, (rs) =>
this.step(rs, ee, {
beforeRequest: scenarioSpec.beforeRequest,
afterResponse: scenarioSpec.afterResponse,
onError: scenarioSpec.onError
})
);
return this.compile(tasks, scenarioSpec, ee);
}
step(
requestSpec: Record<string, any>,
ee: EventEmitterLike,
opts?: {
beforeRequest?: string[];
afterResponse?: string[];
onError?: string[];
}
): StepFunction {
opts = opts || {};
const self = this;
const config = this.config;
if (requestSpec.loop) {
const steps = _.map(requestSpec.loop, (rs) => self.step(rs, ee, opts));
return engineUtil.createLoopWithCount(requestSpec.count || -1, steps, {
loopValue: requestSpec.loopValue || '$loopCount',
loopElement: requestSpec.loopElement || '$loopElement',
overValues: requestSpec.over,
whileTrue: self.config.processor
? self.config.processor[requestSpec.whileTrue]
: undefined
});
}
if (requestSpec.parallel) {
const steps = _.map(requestSpec.parallel, (rs) =>
self.step(rs, ee, opts)
);
return engineUtil.createParallel(steps, {
limitValue: requestSpec.limit
});
}
if (typeof requestSpec.think !== 'undefined') {
return engineUtil.createThink(
requestSpec,
self.config.http.defaults.think || self.config.defaults.think
);
}
if (typeof requestSpec.log !== 'undefined') {
return (context, callback) => {
console.log(template(requestSpec.log, context));
return process.nextTick(() => {
callback(null, context);
});
};
}
if (requestSpec.function) {
return (context, callback) => {
const processFunc = self.config.processor?.[requestSpec.function];
if (processFunc) {
let f: ProcessorFunction;
if (processFunc.constructor.name === 'Function') {
f = processFunc;
} else {
f = callbackify(
processFunc as (...args: any[]) => Promise<unknown>
);
}
return f(context, ee, (hookErr: Error | null | undefined) =>
callback(hookErr, context)
);
} else {
debug(`Function "${requestSpec.function}" not defined`);
debug('processor: %o', self.config.processor);
ee.emit('error', `Undefined function "${requestSpec.function}"`);
return process.nextTick(() => {
callback(null, context);
});
}
};
}
const f: StepFunction = (context, callback) => {
const method = _.keys(requestSpec)[0].toUpperCase();
const params: Record<string, any> = requestSpec[method.toLowerCase()];
const onErrorHandlers = opts.onError; // only scenario-lever onError handlers are supported
// A special case for when "url" attribute is missing. We need to check for
// it manually as request.js won't emit an 'error' event when the argument
// is missing.
// This will be obsoleted by better script validation.
if (!params.url && !params.uri) {
const err = new Error('an URL must be specified');
return callback(err, context);
}
const tls = config.tls || {};
const timeout = config.timeout || _.get(config, 'http.timeout') || 10;
if (!engineUtil.isProbableEnough(params)) {
return process.nextTick(() => {
callback(null, context);
});
}
if (!_.isUndefined(params.ifTrue)) {
let result;
try {
const cond = _.has(config.processor, params.ifTrue)
? (config.processor as Record<string, ProcessorFunction>)[
params.ifTrue
]
: filtrex(params.ifTrue);
result = cond(context.vars);
} catch (err) {
debug('ifTrue error:', err);
result = 1; // if the expression is incorrect, just proceed
}
if (!result) {
return process.nextTick(() => {
callback(null, context);
});
}
}
// Run beforeRequest processors (scenario-level ones too)
const requestParams: Record<string, any> = _.extend(_.clone(params), {
url: maybePrependBase(params.url || params.uri, config), // *NOT* templating here
method: method,
timeout: timeout,
uuid: crypto.randomUUID()
});
if (context._enableCookieJar) {
requestParams.cookieJar = context._jar;
}
if (tls) {
requestParams.https = requestParams.https || {};
requestParams.https = _.extend(requestParams.https, tls);
}
const functionNames = _.concat(
opts.beforeRequest || [],
params.beforeRequest || []
);
async.eachSeries(
functionNames,
function iteratee(functionName: string, next) {
const fn = template(functionName, context);
let processFunc = config.processor?.[fn];
if (!processFunc) {
processFunc = (_r: unknown, _c: unknown, _e: unknown, cb: any) =>
cb(null);
console.log(`WARNING: custom function ${fn} could not be found`); // TODO: a 'warning' event
}
if (processFunc.constructor.name === 'Function') {
processFunc(requestParams, context, ee, (err: unknown) => {
if (err) {
return next(err as Error);
}
return next(null);
});
} else {
processFunc(requestParams, context, ee).then(next).catch(next);
}
},
function done(err) {
if (err) {
debug(err);
return callback(err as Error, context);
}
// Order of precedence: json set in a function, json set in the script, body set in a function, body set in the script.
if (requestParams.json) {
requestParams.json = template(requestParams.json, context);
delete requestParams.body;
} else if (requestParams.body) {
requestParams.body = template(requestParams.body, context);
// TODO: Warn if body is not a string or a buffer
}
// add loop, name & uri elements to be interpolated
if (context.vars.$loopElement) {
context.vars.$loopElement = template(
context.vars.$loopElement,
context
);
}
if (requestParams.name) {
requestParams.name = template(requestParams.name, context);
}
if (requestParams.uri) {
requestParams.uri = template(requestParams.uri, context);
}
if (requestParams.url) {
requestParams.url = template(requestParams.url, context);
}
// Follow all redirects by default unless specified otherwise
if (typeof requestParams.followRedirect === 'undefined') {
requestParams.followRedirect = true;
}
// TODO: Use traverse on the entire flow instead
// Request.js -> Got.js translation
if (params.qs) {
requestParams.searchParams = qs.stringify(
template(params.qs, context)
);
}
if (typeof params.gzip === 'boolean') {
requestParams.decompress = params.gzip;
} else {
requestParams.decompress = true;
}
if (params.form) {
requestParams.form = _.reduce(
requestParams.form,
(acc: Record<string, unknown>, v, k) => {
acc[k] = template(v, context);
return acc;
},
{}
);
}
if (params.formData) {
let fileUpload: string | undefined;
const f = new FormData();
requestParams.body = _.reduce(
requestParams.formData,
(acc: FormData, v, k) => {
let V = template(v, context);
let options;
if (V && _.isPlainObject(V)) {
if (V.contentType) {
options = { contentType: V.contentType };
}
if (V.fromFile) {
const absPath = path.resolve(
path.dirname(context.vars.$scenarioFile),
V.fromFile
);
fileUpload = absPath;
V = fs.createReadStream(absPath);
} else if (V.value) {
V = V.value;
}
}
acc.append(k, V, options);
return acc;
},
f
);
if (params.setContentLengthHeader && fileUpload) {
try {
requestParams.headers = requestParams.headers || {};
requestParams.headers['content-length'] =
fs.statSync(fileUpload).size;
} catch (err) {
debug(`stat() on ${fileUpload} failed with ${err}`);
}
}
}
// Assign default headers then overwrite as needed
const defaultHeaders = lowcaseKeys(
config.http.defaults.headers ||
config.defaults.headers || { 'user-agent': USER_AGENT }
);
const combinedHeaders = _.extend(
defaultHeaders,
lowcaseKeys(params.headers),
lowcaseKeys(requestParams.headers)
);
const templatedHeaders = _.mapValues(combinedHeaders, (v, _k, _obj) =>
template(v, context)
);
requestParams.headers = templatedHeaders;
// We compute the url here so that the cookies are set properly afterwards
const url = maybePrependBase(
template(requestParams.uri || requestParams.url, context),
config
);
if (requestParams.uri) {
// If a hook function sets requestParams.uri to something, request.js
// will pick that over .url, so we need to delete it.
delete requestParams.uri;
}
requestParams.url = url;
if (
typeof requestParams.cookie === 'object' ||
typeof context._defaultCookie === 'object'
) {
requestParams.cookieJar = context._jar;
const cookie: Record<string, unknown> = Object.assign(
{},
context._defaultCookie,
requestParams.cookie
);
Object.keys(cookie).forEach((k) => {
context._jar.setCookieSync(
`${k}=${template(cookie[k], context)}`,
requestParams.url
);
});
}
if (typeof requestParams.auth === 'object') {
requestParams.username = template(requestParams.auth.user, context);
requestParams.password = template(requestParams.auth.pass, context);
delete requestParams.auth;
}
// TODO: Bypass proxy if "proxy: false" is set
requestParams.agent = {
http: context._httpAgent,
https: context._httpsAgent
};
requestParams.throwHttpErrors = false;
if (!requestParams.url || !requestParams.url.startsWith('http')) {
const err = new Error(`Invalid URL - ${requestParams.url}`);
return callback(err, context);
}
function responseProcessor(
isLast: boolean,
res: WireResponse,
body: string,
done: StepCallback
) {
if (process.env.DEBUG) {
let requestInfo: Record<string, any> = {
url: requestParams.url,
method: requestParams.method,
headers: requestParams.headers
};
// Internal handle of the wrapped cookie jar - debug only:
const jarInternal = (
context._jar as unknown as {
_jar?: { getCookieStringSync?: (url: string) => string };
}
)._jar;
if (
jarInternal &&
typeof jarInternal.getCookieStringSync === 'function'
) {
requestInfo = Object.assign(requestInfo, {
cookie: jarInternal.getCookieStringSync(requestParams.url)
});
}
if (
requestParams.json &&
typeof requestParams.json !== 'boolean'
) {
requestInfo.json = requestParams.json;
}
// If "json" is set to an object, it will be serialised and sent as body and the value of the "body" attribute will be ignored.
if (
requestParams.body &&
typeof requestParams.json !== 'object'
) {
if (process.env.DEBUG.indexOf('http:full_body') > -1) {
// Show the entire body
requestInfo.body = requestParams.body;
} else {
// Only show the beginning of long bodies
if (typeof requestParams.body === 'string') {
requestInfo.body = requestParams.body.substring(0, 512);
if (requestParams.body.length > 512) {
requestInfo.body += ' ...';
}
} else if (typeof requestParams.body === 'object') {
requestInfo.body = `< ${requestParams.body.constructor.name} >`;
} else {
requestInfo.body = String(requestInfo.body);
}
}
}
if (requestParams.qs) {
requestInfo.qs = qs.encode(
Object.assign(
qs.parse(urlparse(requestParams.url).query ?? ''),
template(requestParams.qs, context)
)
);
}
debug('request: %s', JSON.stringify(requestInfo, null, 2));
}
debugResponse(JSON.stringify(res.headers, null, 2));
debugResponse(JSON.stringify(body, null, 2));
// capture/match/response hooks run only for last request in a task
if (!isLast) {
return done(null, context);
}
const resForCapture = { headers: res.headers, body: body };
engineUtil.captureOrMatch(
params,
resForCapture,
context,
function captured(err: Error | null, result: any) {
if (err) {
// Run onError hooks and end the scenario:
runOnErrorHooks(
onErrorHandlers,
config.processor,
err,
requestParams,
context,
ee,
(_asyncErr: unknown) => done(err, context)
);
}
let haveFailedMatches = false;
let haveFailedCaptures = false;
if (result !== null) {
ee.emit('trace:http:capture', result, requestParams.uuid);
if (
Object.keys(result.matches).length > 0 ||
Object.keys(result.captures).length > 0
) {
debug('captures and matches:');
debug(result.matches);
debug(result.captures);
}
// match and capture are strict by default:
haveFailedMatches = _.some(
result.matches,
(v: any, _k) => !v.success && v.strict !== false
);
haveFailedCaptures = _.some(
result.captures,
(v: any, _k) => v.failed
);
if (haveFailedMatches || haveFailedCaptures) {
// TODO: Emit the details of each failed capture/match
} else {
_.each(result.matches, (v: any, _k) => {
ee.emit('match', v.success, {
expected: v.expected,
got: v.got,
expression: v.expression,
strict: v.strict
});
});
_.each(result.captures, (v: any, k) => {
_.set(context.vars, k, v.value);
});
}
}
// Now run afterResponse processors
const functionNames = _.concat(
opts?.afterResponse || [],
params.afterResponse || []
);
async.eachSeries(
functionNames,
function iteratee(functionName: string, next) {
const fn = template(functionName, context);
let processFunc = config.processor?.[fn];
if (!processFunc) {
// TODO: DRY - #223
processFunc = (
_r: unknown,
_res: unknown,
_c: unknown,
_e: unknown,
cb: any
) => cb(null);
console.log(
`WARNING: custom function ${fn} could not be found`
); // TODO: a 'warning' event
}
// Got does not have res.body which Request.js used to have, so we attach it here:
res.body = body;
if (processFunc.constructor.name === 'Function') {
processFunc(
requestParams,
res,
context,
ee,
(err: unknown) => {
if (err) {
return next(err as Error);
}
return next(null);
}
);
} else {
processFunc(requestParams, res, context, ee)
.then(next)
.catch(next);
}
},
(err) => {
if (err) {
debug(err);
return done(err as Error, context);
}
if (haveFailedMatches || haveFailedCaptures) {
// FIXME: This means only one error in the report even if multiple captures failed for the same request.
return done(
new Error('Failed capture or match'),
context
);
}
return done(null, context);
}
);
}
);
}
let needToProcessResponse = false;
if (
typeof requestParams.capture === 'object' ||
typeof requestParams.match === 'object' ||
requestParams.afterResponse ||
(typeof opts?.afterResponse === 'object' &&
opts.afterResponse.length > 0) ||
process.env.DEBUG
) {
needToProcessResponse = true;
}
if (!requestParams.url) {
const err = new Error('an URL must be specified');
// Run onError hooks and end the scenario
runOnErrorHooks(
onErrorHandlers,
config.processor,
err,
requestParams,
context,
ee,
(_asyncErr: unknown) => callback(err, context)
);
}
requestParams.retry = { limit: 0 }; // disable retries - ignored when using streams
// Convert scalar seconds to Got v14 timeout object right before request
const gotOptions: Record<string, any> = _.pick(
requestParams,
GOT_OPTION_NAMES
);
gotOptions.timeout = { response: requestParams.timeout * 1000 };
// Add W3C Trace Context headers if distributed tracing is enabled
if (self._isDistributedTracingEnabled(config)) {
const traceparent = self._generateTraceparent(config);
gotOptions.headers = gotOptions.headers || {};
gotOptions.headers.traceparent = traceparent;
}
let totalDownloaded: number | undefined = 0;
self
.request(gotOptions)
.on('request', (req: http.ClientRequest) => {
ee.emit('trace:http:request', requestParams, requestParams.uuid);
debugRequests('request start: %s', req.path);
ee.emit('counter', 'http.requests', 1);
ee.emit('rate', 'http.request_rate');
req.on('response', (res) => {
res.on('end', () => {
ee.emit('counter', 'http.downloaded_bytes', totalDownloaded);
});
ee.emit('trace:http:response', res, requestParams.uuid);
self._handleResponse(
requestParams,
res as unknown as WireResponse,
ee,
context,
needToProcessResponse ? responseProcessor : null,
callback
);
});
})
.on('downloadProgress', (progress: { total?: number }) => {
totalDownloaded = progress.total;
})
.on('error', (err: NodeJS.ErrnoException) => {
ee.emit('trace:http:error', err, requestParams.uuid);
if (err.name === 'HTTPError') {
return;
}
// this is an ENOTFOUND, ECONNRESET etc
debug(err);
// Run onError hooks and end the scenario:
runOnErrorHooks(
onErrorHandlers,
config.processor,
err,
requestParams,
context,
ee,
(_asyncErr: unknown) => callback(err, context)
);
})
.catch((gotErr: Error) => {
// TODO: Handle the error properly with run hooks
debug(gotErr);
runOnErrorHooks(
onErrorHandlers,
config.processor,
gotErr,
requestParams,
context,
ee,
(_asyncErr: unknown) => callback(gotErr, context)
);
});
}
); // eachSeries
};
return f;
}
_handleResponse(
requestParams: Record<string, any>,
res: WireResponse,
ee: EventEmitterLike,
context: VUContext,
responseProcessor:
| ((
isLast: boolean,
res: WireResponse,
body: string,
done: StepCallback
) => void)
| null,
callback: StepCallback
): void {
const url = requestParams.url;
if (requestParams.decompress) {
res = decompressResponse(
res as unknown as Parameters<typeof decompressResponse>[0]
) as unknown as WireResponse;
}
const code = res.statusCode;
if (!context._enableCookieJar) {
const rawCookies = res.headers['set-cookie'];
if (rawCookies) {
context._enableCookieJar = true;
(rawCookies as string[]).forEach((cookieString) => {
try {
context._jar.setCookieSync(cookieString, url);
} catch (err) {
debug(
`Could not parse cookieString "${cookieString}" from response header, skipping it`
);
debug(err);
ee.emit('error', 'cookie_parse_error_invalid_cookie');
}
});
}
}
ee.emit('counter', `http.codes.${code}`, 1);
ee.emit('counter', 'http.responses', 1);
// ee.emit('rate', 'http.response_rate');
ee.emit('histogram', 'http.response_time', res.timings.phases.firstByte);
const statusCode = res.statusCode;
if (statusCode >= 200 && statusCode < 300) {
ee.emit(
'histogram',
'http.response_time.2xx',
res.timings.phases.firstByte
);
} else if (statusCode >= 300 && statusCode < 400) {
ee.emit(
'histogram',
'http.response_time.3xx',
res.timings.phases.firstByte
);
} else if (statusCode >= 400 && statusCode < 500) {
ee.emit(
'histogram',
'http.response_time.4xx',
res.timings.phases.firstByte
);
} else if (statusCode >= 500 && statusCode < 600) {
ee.emit(
'histogram',
'http.response_time.5xx',
res.timings.phases.firstByte
);
}
if (this.extendedHTTPMetrics) {
ee.emit('histogram', 'http.dns', res.timings.phases.dns);
ee.emit('histogram', 'http.tcp', res.timings.phases.tcp);
ee.emit('histogram', 'http.tls', res.timings.phases.tls);
}
let body = '';
if (responseProcessor) {
res.on('data', (d: Buffer | string) => {
body += d;
});
} else {
res.on('data', () => {});
}
res.on('end', () => {
if (this.extendedHTTPMetrics) {
ee.emit('histogram', 'http.total', res.timings.phases.total);
}
context._successCount++;
// config.defaults won't be taken into account for this
const isLastRequest = lastRequest(res, requestParams);
if (responseProcessor) {
responseProcessor(isLastRequest, res, body, (processResponseErr) => {
// capture/match returned an error object, or a hook function returned
// with an error
if (processResponseErr) {
return callback(processResponseErr, context);
}
if (isLastRequest) {
return callback(null, context);
}
});
} else {
if (isLastRequest) {
return callback(null, context);
}
}
});
}
setInitialContext(initialContext: InitialVUContext): VUContext {
initialContext._successCount = 0;
initialContext._defaultStrictCapture = true;
if (
this.config.http?.defaults?.strictCapture === false ||
this.config.defaults?.strictCapture === false
) {
initialContext._defaultStrictCapture = false;
}
initialContext._jar = new tough.CookieJar(
null,
this.config.http.cookieJarOptions
);
initialContext._enableCookieJar = false;
// If a default cookie is set, we will use the jar straightaway:
if (
typeof this.config.http.defaults.cookie === 'object' ||
typeof this.config.defaults.cookie === 'object'
) {
initialContext._defaultCookie =
this.config.http.defaults.cookie || this.config.defaults.cookie;
initialContext._enableCookieJar = true;
}
if (this.config.http && typeof this.config.http.pool !== 'undefined') {
// Reuse common agents (created in the engine instance constructor)
initialContext._httpAgent = this._httpAgent;
initialContext._httpsAgent = this._httpsAgent;
} else {
// Create agents just for this VU
const agentOpts: AgentOptions = Object.assign(DEFAULT_AGENT_OPTIONS, {
maxSockets: 1,
maxFreeSockets: 1
});
const agentTimeoutMs = deriveAgentTimeoutMs(this.config);
if (agentTimeoutMs !== undefined) {
agentOpts.timeout = agentTimeoutMs;
}
const agents = createAgents(
{
http: process.env.HTTP_PROXY,
https: process.env.HTTPS_PROXY
},
agentOpts
);
initialContext._httpAgent = agents.httpAgent;
initialContext._httpsAgent = agents.httpsAgent;
}
return initialContext as VUContext;
}
compile(
tasks: StepFunction[],
_scenarioSpec: Record<string, any>,
ee: EventEmitterLike
) {
const self = this;
return async function scenario(
initialContext: InitialVUContext,
callback?: StepCallback
): Promise<VUContext | undefined> {
let context = self.setInitialContext(initialContext);
ee.emit('started');
for (const task of tasks) {
try {
context = (await promisify(task)(context)) as VUContext;
} catch (taskErr) {
const err = taskErr as NodeJS.ErrnoException;
ee.emit('error', err.code || err.message);
if (callback) {
return callback(err, context) as undefined;
}
throw taskErr;
}
}
if (callback) {
return callback(null, context) as undefined;
}
return context;
};
}
}
function lastRequest(
res: WireResponse,
requestParams: Record<string, any>
): boolean {
// We're done when:
// - 3xx response and not following redirects
// - not a 3xx response
return (
(res.statusCode >= 300 &&
res.statusCode < 400 &&
!requestParams.followRedirect) ||
res.statusCode < 300 ||
res.statusCode >= 400
);
}
function maybePrependBase(uri: string, config: { target?: string }): string {
if (_.startsWith(uri, '/')) {
return config.target + uri;
} else {
return uri;
}
}
/*
* Given a dictionary, return a dictionary with all keys lowercased.
*/
function lowcaseKeys(h: unknown): Record<string, unknown> {
return _.transform(
(h || {}) as Record<string, unknown>,
(result: Record<string, unknown>, v, k) => {
result[String(k).toLowerCase()] = v;
}
);
}
function runOnErrorHooks(
functionNames: string[] | undefined,
functions: Record<string, ProcessorFunction> | undefined,
err: Error,
requestParams: Record<string, any>,
context: VUContext,
ee: EventEmitterLike,
callback: (err?: Error | null) => void
): void {
async.eachSeries(
functionNames,
function iteratee(functionName: string, next) {
const processFunc = functions?.[functionName] as ProcessorFunction;
processFunc(err, requestParams, context, ee, (asyncErr: unknown) => {
if (asyncErr) {
return next(asyncErr as Error);
}
return next(null);
});
},
function done(asyncErr) {
return callback(asyncErr as Error | null | undefined);
}
);
}