@artilleryio/int-core 1.0.0

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.
package/lib/runner.js ADDED
@@ -0,0 +1,454 @@
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
+
5
+ 'use strict';
6
+
7
+ const EventEmitter = require('eventemitter3');
8
+ const path = require('path');
9
+ const _ = require('lodash');
10
+ const debug = require('debug')('runner');
11
+ const debugPerf = require('debug')('perf');
12
+ const uuidv4 = require('uuid').v4;
13
+ const A = require('async');
14
+ const { SSMS } = require('./ssms');
15
+ const tryResolve = require('try-require').resolve;
16
+ const createPhaser = require('./phases');
17
+ const isIdlePhase = require('./is-idle-phase');
18
+ const createReader = require('./readers');
19
+ const engineUtil = require('@artilleryio/int-commons').engine_util;
20
+ const wl = require('./weighted-pick');
21
+
22
+ const Engines = {
23
+ http: require('./engine_http'),
24
+ ws: require('./engine_ws'),
25
+ socketio: require('./engine_socketio')
26
+ };
27
+
28
+ module.exports = {
29
+ runner: runner,
30
+ contextFuncs: {
31
+ $randomString,
32
+ $randomNumber
33
+ },
34
+ runnerFuncs: {
35
+ handleScriptHook,
36
+ prepareScript,
37
+ loadProcessor
38
+ }
39
+ };
40
+
41
+ function loadEngines(
42
+ script,
43
+ ee,
44
+ warnings = {
45
+ engines: {}
46
+ }
47
+ ) {
48
+ const loadedEngines = _.map(
49
+ Object.assign({}, Engines, script.config.engines),
50
+ function loadEngine(engineConfig, engineName) {
51
+ let moduleName = 'artillery-engine-' + engineName;
52
+ try {
53
+ let Engine;
54
+ if (typeof Engines[engineName] !== 'undefined') {
55
+ Engine = Engines[engineName];
56
+ } else {
57
+ Engine = require(moduleName);
58
+ }
59
+ const engine = new Engine(script, ee, engineUtil);
60
+ engine.__name = engineName;
61
+ return engine;
62
+ } catch (err) {
63
+ console.log(
64
+ 'WARNING: engine %s specified but module %s could not be loaded',
65
+ engineName,
66
+ moduleName
67
+ );
68
+ console.log(err.stack);
69
+ warnings.engines[engineName] = {
70
+ message: 'Could not load',
71
+ error: err
72
+ };
73
+ }
74
+ }
75
+ );
76
+
77
+ return { loadedEngines, warnings };
78
+ }
79
+
80
+ function loadProcessor(script, options) {
81
+ if (script.config.processor) {
82
+ const absoluteScriptPath = path.resolve(process.cwd(), options.scriptPath);
83
+ const processorPath = path.resolve(
84
+ path.dirname(absoluteScriptPath),
85
+ script.config.processor
86
+ );
87
+ const processor = require(processorPath);
88
+ script.config.processor = processor;
89
+ }
90
+
91
+ return script;
92
+ }
93
+
94
+ function prepareScript(script, payload) {
95
+ const runnableScript = _.cloneDeep(script);
96
+
97
+ _.each(runnableScript.config.phases, function (phaseSpec) {
98
+ phaseSpec.mode = phaseSpec.mode || runnableScript.config.mode;
99
+ });
100
+
101
+ if (payload) {
102
+ if (_.isArray(payload[0])) {
103
+ runnableScript.config.payload = [
104
+ {
105
+ fields: runnableScript.config.payload.fields,
106
+ reader: createReader(runnableScript.config.payload.order, runnableScript.config.payload),
107
+ data: payload
108
+ }
109
+ ];
110
+ } else {
111
+ runnableScript.config.payload = payload;
112
+ _.each(runnableScript.config.payload, function (el) {
113
+ el.reader = createReader(el.order, el);
114
+ });
115
+ }
116
+ } else {
117
+ runnableScript.config.payload = null;
118
+ }
119
+
120
+ // Flatten flows (can have nested arrays of request specs with YAML references):
121
+ _.each(runnableScript.scenarios, function (scenarioSpec) {
122
+ scenarioSpec.flow = _.flatten(scenarioSpec.flow);
123
+ });
124
+
125
+ return runnableScript;
126
+ }
127
+
128
+ async function runner(script, payload, options, callback) {
129
+ let opts = _.assign(
130
+ {
131
+ periodicStats: script.config.statsInterval || 30,
132
+ mode: script.config.mode || 'uniform'
133
+ },
134
+ options
135
+ );
136
+
137
+ const metrics = new SSMS();
138
+
139
+ const warnings = {
140
+ engines: {}
141
+ };
142
+
143
+ const runnableScript = prepareScript(script, payload);
144
+
145
+ let ee = new EventEmitter();
146
+
147
+ //
148
+ // load engines:
149
+ //
150
+ const { loadedEngines: runnerEngines } = loadEngines(
151
+ runnableScript,
152
+ ee,
153
+ warnings
154
+ );
155
+
156
+ const promise = new Promise(function (resolve, reject) {
157
+ ee.run = function (contextVars) {
158
+ let runState = {
159
+ pendingScenarios: 0,
160
+ // pendingRequests: 0,
161
+ compiledScenarios: null,
162
+ scenarioEvents: null,
163
+ picker: undefined,
164
+ engines: runnerEngines,
165
+ metrics: metrics
166
+ };
167
+ debug('run() with: %j', runnableScript);
168
+ run(runnableScript, ee, opts, runState, contextVars);
169
+ };
170
+
171
+ ee.stop = async function (done) {
172
+ metrics.stop();
173
+ };
174
+
175
+ // FIXME: Warnings should be returned from this function instead along with
176
+ // the event emitter. That will be a breaking change.
177
+ ee.warnings = warnings;
178
+
179
+ resolve(ee);
180
+ });
181
+
182
+ if (callback && typeof callback === 'function') {
183
+ promise.then(callback.bind(null, null), callback);
184
+ }
185
+
186
+ return promise;
187
+ }
188
+
189
+ function run(script, ee, options, runState, contextVars) {
190
+ const metrics = runState.metrics;
191
+ const intermediates = [];
192
+
193
+ let phaser = createPhaser(script.config.phases);
194
+ let scenarioContext;
195
+
196
+ phaser.on('arrival', function (spec) {
197
+ if (runState.pendingScenarios >= spec.maxVusers) {
198
+ metrics.counter('vusers.skipped', 1);
199
+ } else {
200
+ scenarioContext = runScenario(script, metrics, runState, contextVars);
201
+ }
202
+ });
203
+ phaser.on('phaseStarted', function (spec) {
204
+ ee.emit('phaseStarted', spec);
205
+ if (isIdlePhase(spec)) {
206
+ ee.emit('stats', SSMS.empty());
207
+ }
208
+ });
209
+ phaser.on('phaseCompleted', function (spec) {
210
+ ee.emit('phaseCompleted', spec);
211
+ if (isIdlePhase(spec)) {
212
+ ee.emit('stats', SSMS.empty());
213
+ }
214
+ });
215
+ phaser.on('done', function () {
216
+ debug('All phases launched');
217
+
218
+ const doneYet = setInterval(function checkIfDone() {
219
+ if (runState.pendingScenarios === 0) {
220
+ clearInterval(doneYet);
221
+
222
+ metrics.aggregate(true);
223
+
224
+ const totals = SSMS.pack(intermediates);
225
+
226
+ ee.emit('done', totals);
227
+ } else {
228
+ debug('Pending scenarios: %s', runState.pendingScenarios);
229
+ }
230
+ }, 1000);
231
+ });
232
+
233
+ metrics.on('metricData', (ts, periodData) => {
234
+ const cloned = SSMS.deserializeMetrics(SSMS.serializeMetrics(periodData));
235
+ intermediates.push(periodData);
236
+ ee.emit('stats', cloned);
237
+ });
238
+
239
+ phaser.run();
240
+ }
241
+
242
+ function runScenario(script, metrics, runState, contextVars) {
243
+ const start = process.hrtime();
244
+
245
+ //
246
+ // Compile scenarios if needed
247
+ //
248
+ if (!runState.compiledScenarios) {
249
+ _.each(script.scenarios, function (scenario) {
250
+ if (typeof scenario.weight === 'undefined') {
251
+ scenario.weight = 1;
252
+ } else {
253
+ debug(`scenario ${scenario.name} weight = ${scenario.weight}`);
254
+ const variableValues = Object.assign(
255
+ datafileVariables(script),
256
+ inlineVariables(script),
257
+ { $processEnvironment: process.env }
258
+ );
259
+
260
+ const w = engineUtil.template(scenario.weight, {
261
+ vars: variableValues
262
+ });
263
+ scenario.weight = isNaN(parseInt(w)) ? 0 : parseInt(w);
264
+ debug(
265
+ `scenario ${scenario.name} weight has been set to ${scenario.weight}`
266
+ );
267
+ }
268
+ });
269
+
270
+ runState.picker = wl(script.scenarios);
271
+
272
+ runState.scenarioEvents = new EventEmitter();
273
+ runState.scenarioEvents.on('counter', function (name, value) {
274
+ metrics.counter(name, value);
275
+ });
276
+ // TODO: Deprecate
277
+ runState.scenarioEvents.on('customStat', function (stat) {
278
+ metrics.summary(stat.stat, stat.value);
279
+ });
280
+ runState.scenarioEvents.on('summary', function (name, value) {
281
+ metrics.summary(name, value);
282
+ });
283
+ runState.scenarioEvents.on('histogram', function (name, value) {
284
+ metrics.summary(name, value);
285
+ });
286
+ runState.scenarioEvents.on('rate', function (name) {
287
+ metrics.rate(name);
288
+ });
289
+ runState.scenarioEvents.on('started', function () {
290
+ runState.pendingScenarios++;
291
+ });
292
+ // TODO: Take an object so that it can have code, description etc
293
+ runState.scenarioEvents.on('error', function (errCode) {
294
+ metrics.counter(`errors.${errCode}`, 1);
295
+ });
296
+
297
+ runState.compiledScenarios = _.map(
298
+ script.scenarios,
299
+ function (scenarioSpec) {
300
+ const name = scenarioSpec.engine || script.config.engine || 'http';
301
+ const engine = runState.engines.find((e) => e.__name === name);
302
+ return engine.createScenario(scenarioSpec, runState.scenarioEvents);
303
+ }
304
+ );
305
+ }
306
+
307
+ let i = runState.picker()[0];
308
+
309
+ debug(
310
+ 'picking scenario %s (%s) weight = %s',
311
+ i,
312
+ script.scenarios[i].name,
313
+ script.scenarios[i].weight
314
+ );
315
+
316
+ metrics.counter(`vusers.created_by_name.${script.scenarios[i].name || i}`, 1);
317
+ metrics.counter('vusers.created', 1);
318
+
319
+ const scenarioStartedAt = process.hrtime();
320
+ const scenarioContext = createContext(script, contextVars);
321
+
322
+ const finish = process.hrtime(start);
323
+ const runScenarioDelta = finish[0] * 1e9 + finish[1];
324
+ debugPerf(
325
+ 'runScenarioDelta: %s',
326
+ Math.round((runScenarioDelta / 1e6) * 100) / 100
327
+ );
328
+ runState.compiledScenarios[i](scenarioContext, function (err, context) {
329
+ runState.pendingScenarios--;
330
+ if (err) {
331
+ debug(err);
332
+ metrics.counter('vusers.failed', 1);
333
+ } else {
334
+ metrics.counter('vusers.failed', 0);
335
+ metrics.counter('vusers.completed', 1);
336
+ const scenarioFinishedAt = process.hrtime(scenarioStartedAt);
337
+ const delta = scenarioFinishedAt[0] * 1e9 + scenarioFinishedAt[1];
338
+ metrics.summary('vusers.session_length', delta / 1e6);
339
+ }
340
+ });
341
+
342
+ return scenarioContext;
343
+ }
344
+
345
+ function datafileVariables(script) {
346
+ let result = {};
347
+ if (script.config.payload) {
348
+ _.each(script.config.payload, function (el) {
349
+ // If data = [] (i.e. the CSV file is empty, or only has headers and
350
+ // skipHeaders = true), then row could = undefined
351
+ let row = el.reader(el.data) || [];
352
+ _.each(el.fields, function (fieldName, j) {
353
+ result[fieldName] = row[j];
354
+ });
355
+ if (typeof el.name !== 'undefined') {
356
+ // Make the entire CSV available
357
+ result[el.name] = el.reader(el.data);
358
+ }
359
+ });
360
+ }
361
+ return result;
362
+ }
363
+
364
+ function inlineVariables(script) {
365
+ let result = {};
366
+ if (script.config.variables) {
367
+ _.each(script.config.variables, function (v, k) {
368
+ let val;
369
+ if (_.isArray(v)) {
370
+ val = _.sample(v);
371
+ } else {
372
+ val = v;
373
+ }
374
+ result[k] = val;
375
+ });
376
+ }
377
+ return result;
378
+ }
379
+
380
+ /**
381
+ * Create initial context for a scenario.
382
+ */
383
+ function createContext(script, contextVars) {
384
+ const INITIAL_CONTEXT = {
385
+ vars: Object.assign(
386
+ {
387
+ target: script.config.target,
388
+ $environment: script._environment,
389
+ $processEnvironment: process.env
390
+ },
391
+ contextVars || {}
392
+ ),
393
+ funcs: {
394
+ $randomNumber: $randomNumber,
395
+ $randomString: $randomString,
396
+ $template: (input) => engineUtil.template(input, { vars: result.vars })
397
+ }
398
+ };
399
+
400
+ let result = INITIAL_CONTEXT;
401
+
402
+ // variables from payloads:
403
+ const variableValues1 = datafileVariables(script);
404
+ Object.assign(result.vars, variableValues1);
405
+ // inline variables:
406
+ const variableValues2 = inlineVariables(script);
407
+ Object.assign(result.vars, variableValues2);
408
+
409
+ result._uid = uuidv4();
410
+ result.vars.$uuid = result._uid;
411
+ return result;
412
+ }
413
+
414
+ //
415
+ // Generator functions for template strings:
416
+ //
417
+ function $randomNumber(min, max) {
418
+ return _.random(min, max);
419
+ }
420
+
421
+ function $randomString(length) {
422
+ return Math.random().toString(36).substr(2, length);
423
+ }
424
+
425
+ function handleScriptHook(hook, script, hookEvents, contextVars = {}) {
426
+ if (!script[hook]) {
427
+ return {};
428
+ }
429
+
430
+ const { loadedEngines: engines } = loadEngines(script, hookEvents);
431
+ const ee = new EventEmitter();
432
+
433
+ return new Promise(function (resolve, reject) {
434
+ ee.on('request', function () {
435
+ hookEvents.emit(`${hook}TestRequest`);
436
+ });
437
+ ee.on('error', function (error) {
438
+ hookEvents.emit(`${hook}TestError`, error);
439
+ });
440
+
441
+ const name = script[hook].engine || 'http';
442
+ const engine = engines.find((e) => e.__name === name);
443
+ const hookScenario = engine.createScenario(script[hook], ee);
444
+ const hookContext = createContext(script, contextVars);
445
+ hookScenario(hookContext, function (err, context) {
446
+ if (err) {
447
+ debug(err);
448
+ return reject(err);
449
+ } else {
450
+ return resolve(context.vars);
451
+ }
452
+ });
453
+ });
454
+ }