@artilleryio/int-core 2.27.0 → 2.28.0-670d2fa

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,817 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
+ import crypto from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import qs from 'node:querystring';
8
+ import { parse as urlparse } from 'node:url';
9
+ import { callbackify, promisify } from 'node:util';
10
+ import { engine_util as engineUtil } from '@artilleryio/int-commons';
11
+ import HttpAgent from 'agentkeepalive';
12
+ import async from 'async';
13
+ import createDebug from 'debug';
14
+ import decompressResponse from 'decompress-response';
15
+ import filtrex from 'filtrex';
16
+ import FormData from 'form-data';
17
+ import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
18
+ import _ from 'lodash';
19
+ import * as tough from 'tough-cookie';
20
+ const debug = createDebug('http');
21
+ const debugRequests = createDebug('http:request');
22
+ const debugResponse = createDebug('http:response');
23
+ const USER_AGENT = 'Artillery (https://artillery.io)';
24
+ const ensurePropertyIsAList = engineUtil.ensurePropertyIsAList;
25
+ const template = engineUtil.template;
26
+ const { HttpsAgent } = HttpAgent;
27
+ export default HttpEngine;
28
+ const GOT_OPTION_NAMES = [
29
+ 'url',
30
+ 'searchParams',
31
+ 'method',
32
+ 'headers',
33
+ 'body',
34
+ 'json',
35
+ 'form',
36
+ 'allowGetBody',
37
+ 'timeout',
38
+ 'retry',
39
+ 'encoding',
40
+ 'cookieJar',
41
+ 'followRedirect',
42
+ 'maxRedirects',
43
+ 'decompress',
44
+ 'http2',
45
+ 'agent',
46
+ 'username',
47
+ 'password',
48
+ 'https',
49
+ 'throwHttpErrors'
50
+ ];
51
+ const DEFAULT_AGENT_OPTIONS = {
52
+ keepAlive: true,
53
+ keepAliveMsec: 1000
54
+ };
55
+ // agentkeepalive defaults `timeout` (active socket inactivity) to a hardcoded
56
+ // floor of 8000ms (Math.max(freeSocketTimeout * 2, 8000)). When a request's
57
+ // origin doesn't send response bytes within 8s, the socket is destroyed with
58
+ // `error.code = 'ERR_SOCKET_TIMEOUT'` regardless of the user's configured
59
+ // `http.timeout`. Mirror `http.timeout` (or top-level `timeout`) into the agent
60
+ // so the YAML config becomes the actual binding constraint.
61
+ function deriveAgentTimeoutMs(scriptConfig) {
62
+ const timeoutSec = scriptConfig?.timeout || scriptConfig?.http?.timeout;
63
+ if (typeof timeoutSec !== 'number')
64
+ return undefined;
65
+ // Never go below the existing 8s floor — preserves current behaviour for
66
+ // users who explicitly configure a smaller http.timeout.
67
+ return Math.max(8000, timeoutSec * 1000);
68
+ }
69
+ function createAgents(proxies, opts) {
70
+ const agentOpts = Object.assign({}, DEFAULT_AGENT_OPTIONS, opts);
71
+ const result = {
72
+ httpAgent: null,
73
+ httpsAgent: null
74
+ };
75
+ // HTTP proxy endpoint will be used for all requests, unless a separate
76
+ // HTTPS proxy URL is also set, which will be used for HTTPS requests:
77
+ if (proxies.http) {
78
+ agentOpts.proxy = proxies.http;
79
+ result.httpAgent = new HttpProxyAgent(agentOpts);
80
+ if (proxies.https) {
81
+ agentOpts.proxy = proxies.https;
82
+ }
83
+ result.httpsAgent = new HttpsProxyAgent(agentOpts);
84
+ return result;
85
+ }
86
+ // If only HTTPS proxy is provided, it will be used for HTTPS requests,
87
+ // but not for HTTP requests:
88
+ if (proxies.https) {
89
+ result.httpAgent = new HttpAgent(agentOpts);
90
+ result.httpsAgent = new HttpsProxyAgent(Object.assign({ proxy: proxies.https }, agentOpts));
91
+ return result;
92
+ }
93
+ // By default nothing is proxied:
94
+ result.httpAgent = new HttpAgent(agentOpts);
95
+ result.httpsAgent = new HttpsAgent(agentOpts);
96
+ return result;
97
+ }
98
+ function HttpEngine(script) {
99
+ this.config = script.config;
100
+ if (typeof this.config.defaults === 'undefined') {
101
+ this.config.defaults = {};
102
+ }
103
+ if (typeof this.config.http === 'undefined') {
104
+ this.config.http = {};
105
+ }
106
+ if (typeof this.config.http.defaults === 'undefined') {
107
+ this.config.http.defaults = {};
108
+ }
109
+ if (typeof this.config.http.cookieJarOptions === 'undefined') {
110
+ this.config.http.cookieJarOptions = {};
111
+ }
112
+ // If config.http.pool is set, create & reuse agents for all requests (with
113
+ // max sockets set). That's what we're done here.
114
+ // If config.http.pool is not set, we create new agents for each virtual user.
115
+ // That's done when the VU is initialized.
116
+ this.maxSockets = Infinity;
117
+ if (script.config.http?.pool) {
118
+ this.maxSockets = Number(script.config.http.pool);
119
+ }
120
+ const agentOpts = Object.assign(DEFAULT_AGENT_OPTIONS, {
121
+ maxSockets: this.maxSockets,
122
+ maxFreeSockets: this.maxSockets
123
+ });
124
+ const agentTimeoutMs = deriveAgentTimeoutMs(script.config);
125
+ if (agentTimeoutMs !== undefined) {
126
+ agentOpts.timeout = agentTimeoutMs;
127
+ }
128
+ const agents = createAgents({
129
+ http: process.env.HTTP_PROXY,
130
+ https: process.env.HTTPS_PROXY
131
+ }, agentOpts);
132
+ this._httpAgent = agents.httpAgent;
133
+ this._httpsAgent = agents.httpsAgent;
134
+ if ((script.config.http && script.config.http.extendedMetrics === true) ||
135
+ global.artillery?.runtimeOptions.extendedHTTPMetrics) {
136
+ this.extendedHTTPMetrics = true;
137
+ }
138
+ }
139
+ HttpEngine.prototype.init = async function () {
140
+ this.request = (await import('got')).default;
141
+ };
142
+ HttpEngine.prototype._isDistributedTracingEnabled = (config) => {
143
+ const dtConfig = config.http?.distributedTracing;
144
+ if (!dtConfig) {
145
+ return false;
146
+ }
147
+ // Handle both boolean and object forms
148
+ if (typeof dtConfig === 'boolean') {
149
+ return dtConfig;
150
+ }
151
+ if (typeof dtConfig === 'object' && dtConfig.enabled !== undefined) {
152
+ return dtConfig.enabled;
153
+ }
154
+ // Default to true if distributedTracing is set but enabled is not specified
155
+ return true;
156
+ };
157
+ HttpEngine.prototype._generateTraceparent = (config) => {
158
+ // W3C Trace Context format: version-trace-id-parent-id-trace-flags
159
+ const version = '00';
160
+ // Get configuration
161
+ const dtConfig = config.http?.distributedTracing;
162
+ let sampled = true; // Default to sampled
163
+ let traceIdPrefix = 'a9'; // Default prefix
164
+ if (typeof dtConfig === 'object') {
165
+ if (dtConfig.sampled !== undefined) {
166
+ sampled = dtConfig.sampled;
167
+ }
168
+ if (dtConfig.traceIdPrefix !== undefined) {
169
+ traceIdPrefix = dtConfig.traceIdPrefix;
170
+ }
171
+ }
172
+ // Validate and normalize prefix (must be valid hex, max 8 chars)
173
+ traceIdPrefix = traceIdPrefix.toLowerCase().replace(/[^0-9a-f]/g, '').slice(0, 8);
174
+ if (traceIdPrefix.length === 0) {
175
+ traceIdPrefix = 'a9'; // Fallback to default if invalid
176
+ }
177
+ // Generate trace-id with prefix (32 hex chars total)
178
+ const remainingBytes = Math.ceil((32 - traceIdPrefix.length) / 2);
179
+ const randomPart = crypto.randomBytes(remainingBytes).toString('hex');
180
+ const traceId = (traceIdPrefix + randomPart).slice(0, 32);
181
+ // Generate 8-byte parent-id (16 hex chars)
182
+ const parentId = crypto.randomBytes(8).toString('hex');
183
+ const traceFlags = sampled ? '01' : '00';
184
+ return `${version}-${traceId}-${parentId}-${traceFlags}`;
185
+ };
186
+ HttpEngine.prototype.createScenario = function (scenarioSpec, ee) {
187
+ ensurePropertyIsAList(scenarioSpec, 'beforeRequest');
188
+ ensurePropertyIsAList(scenarioSpec, 'afterResponse');
189
+ ensurePropertyIsAList(scenarioSpec, 'beforeScenario');
190
+ ensurePropertyIsAList(scenarioSpec, 'afterScenario');
191
+ ensurePropertyIsAList(scenarioSpec, 'onError');
192
+ // Add scenario-level hooks if needed:
193
+ // For now, just turn them into function steps and insert them
194
+ // directly into the flow array.
195
+ // TODO: Scenario-level hooks will probably want access to the
196
+ // entire scenario spec rather than just the userContext.
197
+ const beforeScenarioFns = _.map(scenarioSpec.beforeScenario, (hookFunctionName) => ({ function: hookFunctionName }));
198
+ const afterScenarioFns = _.map(scenarioSpec.afterScenario, (hookFunctionName) => ({ function: hookFunctionName }));
199
+ const newFlow = beforeScenarioFns.concat(scenarioSpec.flow.concat(afterScenarioFns));
200
+ scenarioSpec.flow = newFlow;
201
+ const tasks = _.map(scenarioSpec.flow, (rs) => this.step(rs, ee, {
202
+ beforeRequest: scenarioSpec.beforeRequest,
203
+ afterResponse: scenarioSpec.afterResponse,
204
+ onError: scenarioSpec.onError
205
+ }));
206
+ return this.compile(tasks, scenarioSpec, ee);
207
+ };
208
+ HttpEngine.prototype.step = function step(requestSpec, ee, opts) {
209
+ opts = opts || {};
210
+ const self = this;
211
+ const config = this.config;
212
+ if (requestSpec.loop) {
213
+ const steps = _.map(requestSpec.loop, (rs) => self.step(rs, ee, opts));
214
+ return engineUtil.createLoopWithCount(requestSpec.count || -1, steps, {
215
+ loopValue: requestSpec.loopValue || '$loopCount',
216
+ loopElement: requestSpec.loopElement || '$loopElement',
217
+ overValues: requestSpec.over,
218
+ whileTrue: self.config.processor
219
+ ? self.config.processor[requestSpec.whileTrue]
220
+ : undefined
221
+ });
222
+ }
223
+ if (requestSpec.parallel) {
224
+ const steps = _.map(requestSpec.parallel, (rs) => self.step(rs, ee, opts));
225
+ return engineUtil.createParallel(steps, {
226
+ limitValue: requestSpec.limit
227
+ });
228
+ }
229
+ if (typeof requestSpec.think !== 'undefined') {
230
+ return engineUtil.createThink(requestSpec, self.config.http.defaults.think || self.config.defaults.think);
231
+ }
232
+ if (typeof requestSpec.log !== 'undefined') {
233
+ return (context, callback) => {
234
+ console.log(template(requestSpec.log, context));
235
+ return process.nextTick(() => {
236
+ callback(null, context);
237
+ });
238
+ };
239
+ }
240
+ if (requestSpec.function) {
241
+ return (context, callback) => {
242
+ const processFunc = self.config.processor[requestSpec.function];
243
+ if (processFunc) {
244
+ let f;
245
+ if (processFunc.constructor.name === 'Function') {
246
+ f = processFunc;
247
+ }
248
+ else {
249
+ f = callbackify(processFunc);
250
+ }
251
+ return f(context, ee, (hookErr) => callback(hookErr, context));
252
+ }
253
+ else {
254
+ debug(`Function "${requestSpec.function}" not defined`);
255
+ debug('processor: %o', self.config.processor);
256
+ ee.emit('error', `Undefined function "${requestSpec.function}"`);
257
+ return process.nextTick(() => {
258
+ callback(null, context);
259
+ });
260
+ }
261
+ };
262
+ }
263
+ const f = (context, callback) => {
264
+ const method = _.keys(requestSpec)[0].toUpperCase();
265
+ const params = requestSpec[method.toLowerCase()];
266
+ const onErrorHandlers = opts.onError; // only scenario-lever onError handlers are supported
267
+ // A special case for when "url" attribute is missing. We need to check for
268
+ // it manually as request.js won't emit an 'error' event when the argument
269
+ // is missing.
270
+ // This will be obsoleted by better script validation.
271
+ if (!params.url && !params.uri) {
272
+ const err = new Error('an URL must be specified');
273
+ return callback(err, context);
274
+ }
275
+ const tls = config.tls || {};
276
+ const timeout = config.timeout || _.get(config, 'http.timeout') || 10;
277
+ if (!engineUtil.isProbableEnough(params)) {
278
+ return process.nextTick(() => {
279
+ callback(null, context);
280
+ });
281
+ }
282
+ if (!_.isUndefined(params.ifTrue)) {
283
+ let result;
284
+ try {
285
+ const cond = _.has(config.processor, params.ifTrue)
286
+ ? config.processor[params.ifTrue]
287
+ : filtrex(params.ifTrue);
288
+ result = cond(context.vars);
289
+ }
290
+ catch (err) {
291
+ debug('ifTrue error:', err);
292
+ result = 1; // if the expression is incorrect, just proceed
293
+ }
294
+ if (!result) {
295
+ return process.nextTick(() => {
296
+ callback(null, context);
297
+ });
298
+ }
299
+ }
300
+ // Run beforeRequest processors (scenario-level ones too)
301
+ const requestParams = _.extend(_.clone(params), {
302
+ url: maybePrependBase(params.url || params.uri, config), // *NOT* templating here
303
+ method: method,
304
+ timeout: timeout,
305
+ uuid: crypto.randomUUID()
306
+ });
307
+ if (context._enableCookieJar) {
308
+ requestParams.cookieJar = context._jar;
309
+ }
310
+ if (tls) {
311
+ requestParams.https = requestParams.https || {};
312
+ requestParams.https = _.extend(requestParams.https, tls);
313
+ }
314
+ const functionNames = _.concat(opts.beforeRequest || [], params.beforeRequest || []);
315
+ async.eachSeries(functionNames, function iteratee(functionName, next) {
316
+ const fn = template(functionName, context);
317
+ let processFunc = config.processor[fn];
318
+ if (!processFunc) {
319
+ processFunc = (_r, _c, _e, cb) => cb(null);
320
+ console.log(`WARNING: custom function ${fn} could not be found`); // TODO: a 'warning' event
321
+ }
322
+ if (processFunc.constructor.name === 'Function') {
323
+ processFunc(requestParams, context, ee, (err) => {
324
+ if (err) {
325
+ return next(err);
326
+ }
327
+ return next(null);
328
+ });
329
+ }
330
+ else {
331
+ processFunc(requestParams, context, ee).then(next).catch(next);
332
+ }
333
+ }, function done(err) {
334
+ if (err) {
335
+ debug(err);
336
+ return callback(err, context);
337
+ }
338
+ // Order of precedence: json set in a function, json set in the script, body set in a function, body set in the script.
339
+ if (requestParams.json) {
340
+ requestParams.json = template(requestParams.json, context);
341
+ delete requestParams.body;
342
+ }
343
+ else if (requestParams.body) {
344
+ requestParams.body = template(requestParams.body, context);
345
+ // TODO: Warn if body is not a string or a buffer
346
+ }
347
+ // add loop, name & uri elements to be interpolated
348
+ if (context.vars.$loopElement) {
349
+ context.vars.$loopElement = template(context.vars.$loopElement, context);
350
+ }
351
+ if (requestParams.name) {
352
+ requestParams.name = template(requestParams.name, context);
353
+ }
354
+ if (requestParams.uri) {
355
+ requestParams.uri = template(requestParams.uri, context);
356
+ }
357
+ if (requestParams.url) {
358
+ requestParams.url = template(requestParams.url, context);
359
+ }
360
+ // Follow all redirects by default unless specified otherwise
361
+ if (typeof requestParams.followRedirect === 'undefined') {
362
+ requestParams.followRedirect = true;
363
+ }
364
+ // TODO: Use traverse on the entire flow instead
365
+ // Request.js -> Got.js translation
366
+ if (params.qs) {
367
+ requestParams.searchParams = qs.stringify(template(params.qs, context));
368
+ }
369
+ if (typeof params.gzip === 'boolean') {
370
+ requestParams.decompress = params.gzip;
371
+ }
372
+ else {
373
+ requestParams.decompress = true;
374
+ }
375
+ if (params.form) {
376
+ requestParams.form = _.reduce(requestParams.form, (acc, v, k) => {
377
+ acc[k] = template(v, context);
378
+ return acc;
379
+ }, {});
380
+ }
381
+ if (params.formData) {
382
+ let fileUpload;
383
+ const f = new FormData();
384
+ requestParams.body = _.reduce(requestParams.formData, (acc, v, k) => {
385
+ let V = template(v, context);
386
+ let options;
387
+ if (V && _.isPlainObject(V)) {
388
+ if (V.contentType) {
389
+ options = { contentType: V.contentType };
390
+ }
391
+ if (V.fromFile) {
392
+ const absPath = path.resolve(path.dirname(context.vars.$scenarioFile), V.fromFile);
393
+ fileUpload = absPath;
394
+ V = fs.createReadStream(absPath);
395
+ }
396
+ else if (V.value) {
397
+ V = V.value;
398
+ }
399
+ }
400
+ acc.append(k, V, options);
401
+ return acc;
402
+ }, f);
403
+ if (params.setContentLengthHeader && fileUpload) {
404
+ try {
405
+ requestParams.headers = requestParams.headers || {};
406
+ requestParams.headers['content-length'] =
407
+ fs.statSync(fileUpload).size;
408
+ }
409
+ catch (err) {
410
+ debug(`stat() on ${fileUpload} failed with ${err}`);
411
+ }
412
+ }
413
+ }
414
+ // Assign default headers then overwrite as needed
415
+ const defaultHeaders = lowcaseKeys(config.http.defaults.headers ||
416
+ config.defaults.headers || { 'user-agent': USER_AGENT });
417
+ const combinedHeaders = _.extend(defaultHeaders, lowcaseKeys(params.headers), lowcaseKeys(requestParams.headers));
418
+ const templatedHeaders = _.mapValues(combinedHeaders, (v, _k, _obj) => template(v, context));
419
+ requestParams.headers = templatedHeaders;
420
+ // We compute the url here so that the cookies are set properly afterwards
421
+ const url = maybePrependBase(template(requestParams.uri || requestParams.url, context), config);
422
+ if (requestParams.uri) {
423
+ // If a hook function sets requestParams.uri to something, request.js
424
+ // will pick that over .url, so we need to delete it.
425
+ delete requestParams.uri;
426
+ }
427
+ requestParams.url = url;
428
+ if (typeof requestParams.cookie === 'object' ||
429
+ typeof context._defaultCookie === 'object') {
430
+ requestParams.cookieJar = context._jar;
431
+ const cookie = Object.assign({}, context._defaultCookie, requestParams.cookie);
432
+ Object.keys(cookie).forEach((k) => {
433
+ context._jar.setCookieSync(`${k}=${template(cookie[k], context)}`, requestParams.url);
434
+ });
435
+ }
436
+ if (typeof requestParams.auth === 'object') {
437
+ requestParams.username = template(requestParams.auth.user, context);
438
+ requestParams.password = template(requestParams.auth.pass, context);
439
+ delete requestParams.auth;
440
+ }
441
+ // TODO: Bypass proxy if "proxy: false" is set
442
+ requestParams.agent = {
443
+ http: context._httpAgent,
444
+ https: context._httpsAgent
445
+ };
446
+ requestParams.throwHttpErrors = false;
447
+ if (!requestParams.url || !requestParams.url.startsWith('http')) {
448
+ const err = new Error(`Invalid URL - ${requestParams.url}`);
449
+ return callback(err, context);
450
+ }
451
+ function responseProcessor(isLast, res, body, done) {
452
+ if (process.env.DEBUG) {
453
+ let requestInfo = {
454
+ url: requestParams.url,
455
+ method: requestParams.method,
456
+ headers: requestParams.headers
457
+ };
458
+ if (context._jar._jar &&
459
+ typeof context._jar._jar.getCookieStringSync === 'function') {
460
+ requestInfo = Object.assign(requestInfo, {
461
+ cookie: context._jar._jar.getCookieStringSync(requestParams.url)
462
+ });
463
+ }
464
+ if (requestParams.json && typeof requestParams.json !== 'boolean') {
465
+ requestInfo.json = requestParams.json;
466
+ }
467
+ // 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.
468
+ if (requestParams.body && typeof requestParams.json !== 'object') {
469
+ if (process.env.DEBUG.indexOf('http:full_body') > -1) {
470
+ // Show the entire body
471
+ requestInfo.body = requestParams.body;
472
+ }
473
+ else {
474
+ // Only show the beginning of long bodies
475
+ if (typeof requestParams.body === 'string') {
476
+ requestInfo.body = requestParams.body.substring(0, 512);
477
+ if (requestParams.body.length > 512) {
478
+ requestInfo.body += ' ...';
479
+ }
480
+ }
481
+ else if (typeof requestParams.body === 'object') {
482
+ requestInfo.body = `< ${requestParams.body.constructor.name} >`;
483
+ }
484
+ else {
485
+ requestInfo.body = String(requestInfo.body);
486
+ }
487
+ }
488
+ }
489
+ if (requestParams.qs) {
490
+ requestInfo.qs = qs.encode(Object.assign(qs.parse(urlparse(requestParams.url).query), template(requestParams.qs, context)));
491
+ }
492
+ debug('request: %s', JSON.stringify(requestInfo, null, 2));
493
+ }
494
+ debugResponse(JSON.stringify(res.headers, null, 2));
495
+ debugResponse(JSON.stringify(body, null, 2));
496
+ // capture/match/response hooks run only for last request in a task
497
+ if (!isLast) {
498
+ return done(null, context);
499
+ }
500
+ const resForCapture = { headers: res.headers, body: body };
501
+ engineUtil.captureOrMatch(params, resForCapture, context, function captured(err, result) {
502
+ if (err) {
503
+ // Run onError hooks and end the scenario:
504
+ runOnErrorHooks(onErrorHandlers, config.processor, err, requestParams, context, ee, (_asyncErr) => done(err, context));
505
+ }
506
+ let haveFailedMatches = false;
507
+ let haveFailedCaptures = false;
508
+ if (result !== null) {
509
+ ee.emit('trace:http:capture', result, requestParams.uuid);
510
+ if (Object.keys(result.matches).length > 0 ||
511
+ Object.keys(result.captures).length > 0) {
512
+ debug('captures and matches:');
513
+ debug(result.matches);
514
+ debug(result.captures);
515
+ }
516
+ // match and capture are strict by default:
517
+ haveFailedMatches = _.some(result.matches, (v, _k) => !v.success && v.strict !== false);
518
+ haveFailedCaptures = _.some(result.captures, (v, _k) => v.failed);
519
+ if (haveFailedMatches || haveFailedCaptures) {
520
+ // TODO: Emit the details of each failed capture/match
521
+ }
522
+ else {
523
+ _.each(result.matches, (v, _k) => {
524
+ ee.emit('match', v.success, {
525
+ expected: v.expected,
526
+ got: v.got,
527
+ expression: v.expression,
528
+ strict: v.strict
529
+ });
530
+ });
531
+ _.each(result.captures, (v, k) => {
532
+ _.set(context.vars, k, v.value);
533
+ });
534
+ }
535
+ }
536
+ // Now run afterResponse processors
537
+ const functionNames = _.concat(opts.afterResponse || [], params.afterResponse || []);
538
+ async.eachSeries(functionNames, function iteratee(functionName, next) {
539
+ const fn = template(functionName, context);
540
+ let processFunc = config.processor[fn];
541
+ if (!processFunc) {
542
+ // TODO: DRY - #223
543
+ processFunc = (_r, _res, _c, _e, cb) => cb(null);
544
+ console.log(`WARNING: custom function ${fn} could not be found`); // TODO: a 'warning' event
545
+ }
546
+ // Got does not have res.body which Request.js used to have, so we attach it here:
547
+ res.body = body;
548
+ if (processFunc.constructor.name === 'Function') {
549
+ processFunc(requestParams, res, context, ee, (err) => {
550
+ if (err) {
551
+ return next(err);
552
+ }
553
+ return next(null);
554
+ });
555
+ }
556
+ else {
557
+ processFunc(requestParams, res, context, ee)
558
+ .then(next)
559
+ .catch(next);
560
+ }
561
+ }, (err) => {
562
+ if (err) {
563
+ debug(err);
564
+ return done(err, context);
565
+ }
566
+ if (haveFailedMatches || haveFailedCaptures) {
567
+ // FIXME: This means only one error in the report even if multiple captures failed for the same request.
568
+ return done(new Error('Failed capture or match'), context);
569
+ }
570
+ return done(null, context);
571
+ });
572
+ });
573
+ }
574
+ let needToProcessResponse = false;
575
+ if (typeof requestParams.capture === 'object' ||
576
+ typeof requestParams.match === 'object' ||
577
+ requestParams.afterResponse ||
578
+ (typeof opts.afterResponse === 'object' &&
579
+ opts.afterResponse.length > 0) ||
580
+ process.env.DEBUG) {
581
+ needToProcessResponse = true;
582
+ }
583
+ if (!requestParams.url) {
584
+ const err = new Error('an URL must be specified');
585
+ // Run onError hooks and end the scenario
586
+ runOnErrorHooks(onErrorHandlers, config.processor, err, requestParams, context, ee, (_asyncErr) => callback(err, context));
587
+ }
588
+ requestParams.retry = { limit: 0 }; // disable retries - ignored when using streams
589
+ // Convert scalar seconds to Got v14 timeout object right before request
590
+ const gotOptions = _.pick(requestParams, GOT_OPTION_NAMES);
591
+ gotOptions.timeout = { response: requestParams.timeout * 1000 };
592
+ // Add W3C Trace Context headers if distributed tracing is enabled
593
+ if (self._isDistributedTracingEnabled(config)) {
594
+ const traceparent = self._generateTraceparent(config);
595
+ gotOptions.headers = gotOptions.headers || {};
596
+ gotOptions.headers.traceparent = traceparent;
597
+ }
598
+ let totalDownloaded = 0;
599
+ self
600
+ .request(gotOptions)
601
+ .on('request', (req) => {
602
+ ee.emit('trace:http:request', requestParams, requestParams.uuid);
603
+ debugRequests('request start: %s', req.path);
604
+ ee.emit('counter', 'http.requests', 1);
605
+ ee.emit('rate', 'http.request_rate');
606
+ req.on('response', (res) => {
607
+ res.on('end', () => {
608
+ ee.emit('counter', 'http.downloaded_bytes', totalDownloaded);
609
+ });
610
+ ee.emit('trace:http:response', res, requestParams.uuid);
611
+ self._handleResponse(requestParams, res, ee, context, needToProcessResponse ? responseProcessor : null, callback);
612
+ });
613
+ })
614
+ .on('downloadProgress', (progress) => {
615
+ totalDownloaded = progress.total;
616
+ })
617
+ .on('error', (err, _body, _res) => {
618
+ ee.emit('trace:http:error', err, requestParams.uuid);
619
+ if (err.name === 'HTTPError') {
620
+ return;
621
+ }
622
+ // this is an ENOTFOUND, ECONNRESET etc
623
+ debug(err);
624
+ // Run onError hooks and end the scenario:
625
+ runOnErrorHooks(onErrorHandlers, config.processor, err, requestParams, context, ee, (_asyncErr) => callback(err, context));
626
+ })
627
+ .catch((gotErr) => {
628
+ // TODO: Handle the error properly with run hooks
629
+ debug(gotErr);
630
+ runOnErrorHooks(onErrorHandlers, config.processor, gotErr, requestParams, context, ee, (_asyncErr) => callback(gotErr, context));
631
+ });
632
+ }); // eachSeries
633
+ };
634
+ return f;
635
+ };
636
+ HttpEngine.prototype._handleResponse = function (requestParams, res, ee, context, responseProcessor, callback) {
637
+ const url = requestParams.url;
638
+ if (requestParams.decompress) {
639
+ res = decompressResponse(res);
640
+ }
641
+ const code = res.statusCode;
642
+ if (!context._enableCookieJar) {
643
+ const rawCookies = res.headers['set-cookie'];
644
+ if (rawCookies) {
645
+ context._enableCookieJar = true;
646
+ rawCookies.forEach((cookieString) => {
647
+ try {
648
+ context._jar.setCookieSync(cookieString, url);
649
+ }
650
+ catch (err) {
651
+ debug(`Could not parse cookieString "${cookieString}" from response header, skipping it`);
652
+ debug(err);
653
+ ee.emit('error', 'cookie_parse_error_invalid_cookie');
654
+ }
655
+ });
656
+ }
657
+ }
658
+ ee.emit('counter', `http.codes.${code}`, 1);
659
+ ee.emit('counter', 'http.responses', 1);
660
+ // ee.emit('rate', 'http.response_rate');
661
+ ee.emit('histogram', 'http.response_time', res.timings.phases.firstByte);
662
+ const statusCode = res.statusCode;
663
+ if (statusCode >= 200 && statusCode < 300) {
664
+ ee.emit('histogram', 'http.response_time.2xx', res.timings.phases.firstByte);
665
+ }
666
+ else if (statusCode >= 300 && statusCode < 400) {
667
+ ee.emit('histogram', 'http.response_time.3xx', res.timings.phases.firstByte);
668
+ }
669
+ else if (statusCode >= 400 && statusCode < 500) {
670
+ ee.emit('histogram', 'http.response_time.4xx', res.timings.phases.firstByte);
671
+ }
672
+ else if (statusCode >= 500 && statusCode < 600) {
673
+ ee.emit('histogram', 'http.response_time.5xx', res.timings.phases.firstByte);
674
+ }
675
+ if (this.extendedHTTPMetrics) {
676
+ ee.emit('histogram', 'http.dns', res.timings.phases.dns);
677
+ ee.emit('histogram', 'http.tcp', res.timings.phases.tcp);
678
+ ee.emit('histogram', 'http.tls', res.timings.phases.tls);
679
+ }
680
+ let body = '';
681
+ if (responseProcessor) {
682
+ res.on('data', (d) => {
683
+ body += d;
684
+ });
685
+ }
686
+ else {
687
+ res.on('data', () => { });
688
+ }
689
+ res.on('end', () => {
690
+ if (this.extendedHTTPMetrics) {
691
+ ee.emit('histogram', 'http.total', res.timings.phases.total);
692
+ }
693
+ context._successCount++;
694
+ // config.defaults won't be taken into account for this
695
+ const isLastRequest = lastRequest(res, requestParams);
696
+ if (responseProcessor) {
697
+ responseProcessor(isLastRequest, res, body, (processResponseErr) => {
698
+ // capture/match returned an error object, or a hook function returned
699
+ // with an error
700
+ if (processResponseErr) {
701
+ return callback(processResponseErr, context);
702
+ }
703
+ if (isLastRequest) {
704
+ return callback(null, context);
705
+ }
706
+ });
707
+ }
708
+ else {
709
+ if (isLastRequest) {
710
+ return callback(null, context);
711
+ }
712
+ }
713
+ });
714
+ };
715
+ function lastRequest(res, requestParams) {
716
+ // We're done when:
717
+ // - 3xx response and not following redirects
718
+ // - not a 3xx response
719
+ return ((res.statusCode >= 300 &&
720
+ res.statusCode < 400 &&
721
+ !requestParams.followRedirect) ||
722
+ res.statusCode < 300 ||
723
+ res.statusCode >= 400);
724
+ }
725
+ HttpEngine.prototype.setInitialContext = function (initialContext) {
726
+ initialContext._successCount = 0;
727
+ initialContext._defaultStrictCapture = true;
728
+ if (this.config.http?.defaults?.strictCapture === false ||
729
+ this.config.defaults?.strictCapture === false) {
730
+ initialContext._defaultStrictCapture = false;
731
+ }
732
+ initialContext._jar = new tough.CookieJar(null, this.config.http.cookieJarOptions);
733
+ initialContext._enableCookieJar = false;
734
+ // If a default cookie is set, we will use the jar straightaway:
735
+ if (typeof this.config.http.defaults.cookie === 'object' ||
736
+ typeof this.config.defaults.cookie === 'object') {
737
+ initialContext._defaultCookie =
738
+ this.config.http.defaults.cookie || this.config.defaults.cookie;
739
+ initialContext._enableCookieJar = true;
740
+ }
741
+ if (this.config.http && typeof this.config.http.pool !== 'undefined') {
742
+ // Reuse common agents (created in the engine instance constructor)
743
+ initialContext._httpAgent = this._httpAgent;
744
+ initialContext._httpsAgent = this._httpsAgent;
745
+ }
746
+ else {
747
+ // Create agents just for this VU
748
+ const agentOpts = Object.assign(DEFAULT_AGENT_OPTIONS, {
749
+ maxSockets: 1,
750
+ maxFreeSockets: 1
751
+ });
752
+ const agentTimeoutMs = deriveAgentTimeoutMs(this.config);
753
+ if (agentTimeoutMs !== undefined) {
754
+ agentOpts.timeout = agentTimeoutMs;
755
+ }
756
+ const agents = createAgents({
757
+ http: process.env.HTTP_PROXY,
758
+ https: process.env.HTTPS_PROXY
759
+ }, agentOpts);
760
+ initialContext._httpAgent = agents.httpAgent;
761
+ initialContext._httpsAgent = agents.httpsAgent;
762
+ }
763
+ return initialContext;
764
+ };
765
+ HttpEngine.prototype.compile = function compile(tasks, _scenarioSpec, ee) {
766
+ const self = this;
767
+ return async function scenario(initialContext, callback) {
768
+ initialContext = self.setInitialContext(initialContext);
769
+ ee.emit('started');
770
+ let context = initialContext;
771
+ for (const task of tasks) {
772
+ try {
773
+ context = await promisify(task)(context);
774
+ }
775
+ catch (taskErr) {
776
+ ee.emit('error', taskErr.code || taskErr.message);
777
+ if (callback) {
778
+ return callback(taskErr, context);
779
+ }
780
+ throw taskErr;
781
+ }
782
+ }
783
+ if (callback) {
784
+ return callback(null, context);
785
+ }
786
+ return context;
787
+ };
788
+ };
789
+ function maybePrependBase(uri, config) {
790
+ if (_.startsWith(uri, '/')) {
791
+ return config.target + uri;
792
+ }
793
+ else {
794
+ return uri;
795
+ }
796
+ }
797
+ /*
798
+ * Given a dictionary, return a dictionary with all keys lowercased.
799
+ */
800
+ function lowcaseKeys(h) {
801
+ return _.transform(h, (result, v, k) => {
802
+ result[String(k).toLowerCase()] = v;
803
+ });
804
+ }
805
+ function runOnErrorHooks(functionNames, functions, err, requestParams, context, ee, callback) {
806
+ async.eachSeries(functionNames, function iteratee(functionName, next) {
807
+ const processFunc = functions[functionName];
808
+ processFunc(err, requestParams, context, ee, (asyncErr) => {
809
+ if (asyncErr) {
810
+ return next(asyncErr);
811
+ }
812
+ return next(null);
813
+ });
814
+ }, function done(asyncErr) {
815
+ return callback(asyncErr);
816
+ });
817
+ }