mockforge-bench 0.3.196

Load and performance testing for MockForge
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend, Counter } from 'k6/metrics';
{{#each dynamic_imports}}
{{{this}}}
{{/each}}

{{#each dynamic_globals}}
{{{this}}}
{{/each}}

// Custom metrics per operation
{{#each operations}}
const {{this.name}}_latency = new Trend('{{this.metric_name}}_latency');
const {{this.name}}_errors = new Rate('{{this.metric_name}}_errors');
{{/each}}

// Server-injected chaos signals (parsed from MockForge response headers).
// `mockforge_server_injected_latency_ms` and `mockforge_server_injected_jitter_ms`
// are Trends that capture `X-MockForge-Injected-Latency-Ms` / `X-MockForge-
// Injected-Jitter-Ms`, and `mockforge_server_fault_total` counts responses that
// carried an `X-MockForge-Fault` marker (e.g. `partial_response`).
// Issue #79 — Srikanth's round-3 reply asked to see server-side latency on
// the client side.
const server_injected_latency_ms = new Trend('mockforge_server_injected_latency_ms');
const server_injected_jitter_ms = new Trend('mockforge_server_injected_jitter_ms');
const server_fault_total = new Counter('mockforge_server_fault_total');

// Issue #79 round 6 follow-up — k6's built-in `http_req_connecting` is a Trend
// (no `count` field in summary.json), so we can't read total connections opened
// from it. Track explicitly with a Counter: every request whose
// `res.timings.connecting > 0` had to establish a fresh TCP socket. With
// connection reuse on (the default without `--cps`) this ≈ vus_max for the
// run; with `noConnectionReuse`/`--cps` it equals total requests.
const mockforge_connections_opened = new Counter('mockforge_connections_opened');

// Test configuration
export const options = {
  {{#if skip_tls_verify}}
  insecureSkipTLSVerify: true,
  {{/if}}
  {{#if no_keep_alive}}
  // --cps was passed: force a fresh TCP/TLS connection per request so users
  // can drive a high connections-per-second rate. Issue #79.
  noConnectionReuse: true,
  {{/if}}
  scenarios: {
    {{scenario_name}}: {
      {{#if target_rps}}
      // --rps was passed: open-model load at a fixed request rate. VUs are
      // pre-allocated up to `--vus`; k6 fires {{target_rps}} requests/sec
      // regardless of how long each one takes.
      //
      // Issue #79 (round 5): we deliberately do NOT derive duration / VU
      // pool from the chosen scenario's last stage here. Under the default
      // `ramp-up` scenario the last stage is the ramp-DOWN to `target: 0`,
      // which previously gave `preAllocatedVUs: 0` → k6 ran with no VUs and
      // produced 0 requests. With `--rps`, the executor is open-model, so
      // pre-allocate the full `--vus` pool for the full `--duration`.
      executor: 'constant-arrival-rate',
      rate: {{target_rps}},
      timeUnit: '1s',
      duration: '{{duration_secs}}s',
      preAllocatedVUs: {{max_vus}},
      maxVUs: {{max_vus}},
      {{else}}
      executor: 'ramping-vus',
      // Issue #79 round 6 follow-up — Srikanth saw `--vus 5 -d 600s --cps`
      // take until the ~6-minute mark to reach 5 VUs because startVUs was
      // always 0 and `ramping-vus` linearly interpolates between current and
      // target. For `--scenario constant` we now seed startVUs at the target
      // so the test runs at full concurrency from t=0; ramp scenarios still
      // start at 0 and let their stages drive the curve.
      startVUs: {{start_vus}},
      stages: [
        {{#each stages}}
        { duration: '{{this.duration}}', target: {{this.target}} },
        {{/each}}
      ],
      gracefulRampDown: '10s',
      {{/if}}
    },
  },
  thresholds: {
    'http_req_duration': ['{{threshold_percentile}}<{{threshold_ms}}'],
    'http_req_failed': ['rate<{{max_error_rate}}'],
  },
};

// Inspect MockForge response headers and feed the chaos-signal trends/counter.
// Called after every http.* invocation in the body so server-injected latency
// and faults surface in summary.json next to the standard k6 metrics.
// Also tracks new TCP/TLS connection opens via res.timings.connecting > 0
// (Issue #79 round 6 follow-up — client-side connection count for --rps runs).
function recordMockForgeChaosHeaders(res) {
  if (!res) return;
  if (res.timings && res.timings.connecting > 0) {
    mockforge_connections_opened.add(1);
  }
  if (!res.headers) return;
  const lat = res.headers['X-Mockforge-Injected-Latency-Ms'] || res.headers['x-mockforge-injected-latency-ms'];
  const jit = res.headers['X-Mockforge-Injected-Jitter-Ms'] || res.headers['x-mockforge-injected-jitter-ms'];
  const fault = res.headers['X-Mockforge-Fault'] || res.headers['x-mockforge-fault'];
  if (lat != null) {
    const n = parseFloat(lat);
    if (!isNaN(n) && n > 0) server_injected_latency_ms.add(n);
  }
  if (jit != null) {
    const n = parseFloat(jit);
    if (!isNaN(n) && n > 0) server_injected_jitter_ms.add(n);
  }
  if (fault != null && fault !== '') {
    server_fault_total.add(1, { fault: fault });
  }
}

const BASE_URL = '{{base_url}}';

{{#if has_geo_source}}
// Issue #79 round 22.3 — GEODB / forwarded-IP source rotation. Each
// iteration picks the next IP from `GEO_SOURCE_IPS` (round-robin via
// `__ITER`) and assigns it to every header in `GEO_SOURCE_HEADERS`.
// These headers are merged into every request's header map below.
// Configured via `--geo-source-ip` and `--geo-source-header`.
const GEO_SOURCE_IPS = {{{geo_source_ips_json}}};
const GEO_SOURCE_HEADERS = {{{geo_source_headers_json}}};

function buildGeoSourceHeaders() {
  const iter = typeof __ITER === 'number' ? __ITER : 0;
  const ip = GEO_SOURCE_IPS[iter % GEO_SOURCE_IPS.length];
  const out = {};
  for (const name of GEO_SOURCE_HEADERS) {
    out[name] = ip;
  }
  return out;
}
{{/if}}

export default function () {
  {{#if has_geo_source}}
  const __geoHeaders = buildGeoSourceHeaders();
  {{/if}}
  {{#each operations}}
  // Operation {{@index}}: {{this.display_name}}
  {
    {{#if @root.security_testing_enabled}}
    // Get a grouped security payload for each operation (array of related payloads)
    const secPayloadGroup = typeof getNextSecurityPayload === 'function' ? getNextSecurityPayload() : [];
    // Copy headers and build request URL, applying all payloads from the group.
    // Round 22.3 — merge in the rotating GEODB header set so the
    // destination's geo-lookup pipeline sees the synthesised source IP.
    const requestHeaders = { ...{{{this.headers}}}{{#if @root.has_geo_source}}, ...__geoHeaders{{/if}} };
    {{#if this.path_is_dynamic}}
    const __path = {{{this.path}}};
    let requestUrl = `${BASE_URL}${__path}`;
    {{else}}
    let requestUrl = `${BASE_URL}{{{this.path}}}`;
    {{/if}}
    let secBodyPayload = null;
    let hasSecCookie = false;
    for (const secPayload of secPayloadGroup) {
      if (secPayload.location === 'header' && secPayload.headerName) {
        requestHeaders[secPayload.headerName] = secPayload.payload;
        if (secPayload.headerName === 'Cookie') hasSecCookie = true;
      }
      if (secPayload.location === 'uri') {
        if (secPayload.injectAsPath) {
          // Replace URL path so WAF inspects via REQUEST_FILENAME (e.g., CRS 942101)
          requestUrl = `${BASE_URL}/${encodeURI(secPayload.payload)}`;
        } else {
          requestUrl += (requestUrl.includes('?') ? '&' : '?') + 'test=' + encodeURIComponent(secPayload.payload);
        }
      }
      if (secPayload.location === 'body') {
        secBodyPayload = secPayload;
      }
    }
    // Skip CookieJar when security payload sets Cookie header (jar overrides manual Cookie headers)
    const secRequestOpts = hasSecCookie ? { headers: requestHeaders } : { headers: requestHeaders, jar: new http.CookieJar() };
    {{else}}
    // Round 22.3 — merge GEODB rotating header set into the per-op
    // headers so it applies in plain bench mode too (not just
    // self-test). No-op when `--geo-source-ip` wasn't passed.
    const requestHeaders = { ...{{{this.headers}}}{{#if @root.has_geo_source}}, ...__geoHeaders{{/if}} };
    {{/if}}

    {{#if this.has_body}}
    {{#if this.body_is_dynamic}}
    // Dynamic body with runtime placeholders
    let payload = {{{this.body}}};
    {{else}}
    let payload = {{{this.body}}};
    {{/if}}
    {{#if @root.security_testing_enabled}}
    // Apply security payload to body if available
    let requestBody;
    if (secBodyPayload && secBodyPayload.formBody) {
      // Parse form body into object for k6's native form encoding (CRS 942432)
      // k6 auto-encodes objects as application/x-www-form-urlencoded
      const formData = {};
      secBodyPayload.formBody.split('&').forEach(function(pair) {
        const eq = pair.indexOf('=');
        if (eq >= 0) {
          formData[decodeURIComponent(pair.substring(0, eq).replace(/\+/g, ' '))] =
            decodeURIComponent(pair.substring(eq + 1).replace(/\+/g, ' '));
        }
      });
      requestBody = formData;
    } else if (secBodyPayload && typeof applySecurityPayload === 'function') {
      payload = applySecurityPayload(payload, [], secBodyPayload);
      requestBody = JSON.stringify(payload);
    } else {
      requestBody = JSON.stringify(payload);
    }
    const res = http.{{this.method}}(requestUrl, requestBody, secRequestOpts);
    {{else}}
    {{#if this.path_is_dynamic}}
    const url = {{{this.path}}};
    const res = http.{{this.method}}(`${BASE_URL}${url}`, JSON.stringify(payload), { headers: requestHeaders, jar: new http.CookieJar() });
    {{else}}
    const res = http.{{this.method}}(`${BASE_URL}{{{this.path}}}`, JSON.stringify(payload), { headers: requestHeaders, jar: new http.CookieJar() });
    {{/if}}
    {{/if}}
    {{else if this.is_get_or_head}}
    // GET and HEAD only take 2 args: http.get(url, params)
    {{#if @root.security_testing_enabled}}
    const res = http.{{this.method}}(requestUrl, secRequestOpts);
    {{else}}
    {{#if this.path_is_dynamic}}
    const url = {{{this.path}}};
    const res = http.{{this.method}}(`${BASE_URL}${url}`, { headers: requestHeaders, jar: new http.CookieJar() });
    {{else}}
    const res = http.{{this.method}}(`${BASE_URL}{{{this.path}}}`, { headers: requestHeaders, jar: new http.CookieJar() });
    {{/if}}
    {{/if}}
    {{else}}
    // POST, PUT, PATCH, DELETE take 3 args: http.post(url, body, params)
    {{#if @root.security_testing_enabled}}
    // Send form-encoded body if available, otherwise null
    let requestBody = null;
    if (secBodyPayload && secBodyPayload.formBody) {
      requestBody = secBodyPayload.formBody;
      requestHeaders['Content-Type'] = 'application/x-www-form-urlencoded';
    }
    const res = http.{{this.method}}(requestUrl, requestBody, secRequestOpts);
    {{else}}
    {{#if this.path_is_dynamic}}
    const url = {{{this.path}}};
    const res = http.{{this.method}}(`${BASE_URL}${url}`, null, { headers: requestHeaders, jar: new http.CookieJar() });
    {{else}}
    const res = http.{{this.method}}(`${BASE_URL}{{{this.path}}}`, null, { headers: requestHeaders, jar: new http.CookieJar() });
    {{/if}}
    {{/if}}
    {{/if}}

    // Round 48 (#79) — Srikanth on 0.3.192 saw 18 `Request Failed
    // error=EOF` warnings in his k6 log but `conformance-network-events.json`
    // came back empty because the round-47 marker only fires for the
    // /conformance/ generator, not the plain-bench template. Emit the
    // same MOCKFORGE_NETWORK_EVENT marker whenever k6 returns
    // status=0 (transport-level failure: EOF, connection refused,
    // TLS reject, timeout, etc). k6's `error_code` is in the 1200-1599
    // range; coarse-classify into the same kind labels the native
    // executor uses so all three paths produce identical shapes.
    if (res && res.status === 0) {
      const ec = (res.error_code != null) ? res.error_code : 0;
      const em = (res.error != null) ? String(res.error) : '';
      let kind = 'other';
      if (ec >= 1200 && ec < 1300) kind = 'connect';
      else if (ec >= 1300 && ec < 1400) kind = 'tls';
      else if (ec >= 1400 && ec < 1500) kind = 'timeout';
      else if (em.toLowerCase().indexOf('eof') !== -1) kind = 'connect';
      else if (em.toLowerCase().indexOf('timeout') !== -1) kind = 'timeout';
      else if (em.toLowerCase().indexOf('tls') !== -1) kind = 'tls';
      else if (em.toLowerCase().indexOf('connect') !== -1 || em.toLowerCase().indexOf('refused') !== -1) kind = 'connect';
      console.log('MOCKFORGE_NETWORK_EVENT:' + JSON.stringify({
        timestamp: new Date().toISOString(),
        check: '{{this.display_name}}',
        method: res.request ? res.request.method : 'unknown',
        url: res.request ? res.request.url : (res.url || 'unknown'),
        kind: kind,
        error_code: ec,
        message: em,
      }));
    }

    // Pick up MockForge-injected chaos signals before counting check success
    // so the trends/counter populate even on injected error paths.
    recordMockForgeChaosHeaders(res);

    const success = check(res, {
      '{{this.display_name}}: status is OK': (r) => r.status >= 200 && r.status < 300,
      '{{this.display_name}}: has response': (r) => r.body !== null && r.body.length > 0,
    });

    {{this.name}}_latency.add(res.timings.duration);
    {{this.name}}_errors.add(!success);

    {{#unless @root.target_rps}}
    // `constant-arrival-rate` (set via --rps) drives the rate itself, so any
    // per-iteration sleep is dead weight that lowers the achievable RPS. Keep
    // the 1-second pacing only for the legacy ramping-vus executor.
    sleep(1);
    {{/unless}}
  }
  {{/each}}
}

export function handleSummary(data) {
  return {
    'stdout': textSummary(data, { indent: ' ', enableColors: true }),
    'summary.json': JSON.stringify(data),
  };
}

function textSummary(data, options) {
  const indent = options.indent || '';
  const enableColors = options.enableColors || false;

  const metrics = data.metrics;
  let output = '\n';

  output += indent + '='.repeat(60) + '\n';
  output += indent + 'Load Test Summary\n';
  output += indent + '='.repeat(60) + '\n\n';

  // Request metrics
  if (metrics.http_reqs && metrics.http_reqs.values) {
    const count = metrics.http_reqs.values.count || 0;
    const rate = metrics.http_reqs.values.rate;
    output += indent + `Total Requests: ${count}\n`;
    output += indent + `Request Rate: ${rate != null ? rate.toFixed(2) : '0.00'} req/s\n`;
    {{#if no_keep_alive}}
    // --cps was passed → noConnectionReuse is on, so every request opens a
    // fresh TCP/TLS connection. Connections-per-second therefore equals the
    // request rate; emit it explicitly so users running CPS-stress benches
    // see the connection rate they were targeting. Issue #79 (round 5):
    // Srikanth reported "CPS without RPS Command is Working but Client dont
    // report CPS Counts".
    output += indent + `Total Connections: ${count}\n`;
    output += indent + `Connection Rate: ${rate != null ? rate.toFixed(2) : '0.00'} conn/s (--cps)\n`;
    if (metrics.http_req_connecting && metrics.http_req_connecting.values) {
      const tc = metrics.http_req_connecting.values;
      output += indent + `  TCP connect avg: ${tc.avg != null ? tc.avg.toFixed(2) : 'N/A'}ms (max ${tc.max != null ? tc.max.toFixed(2) : 'N/A'}ms)\n`;
    }
    if (metrics.http_req_tls_handshaking && metrics.http_req_tls_handshaking.values) {
      const th = metrics.http_req_tls_handshaking.values;
      if ((th.count || 0) > 0) {
        output += indent + `  TLS handshake avg: ${th.avg != null ? th.avg.toFixed(2) : 'N/A'}ms (max ${th.max != null ? th.max.toFixed(2) : 'N/A'}ms)\n`;
      }
    }
    {{/if}}
    output += '\n';
  } else {
    output += indent + 'Total Requests: 0\n';
    output += indent + 'Request Rate: 0.00 req/s\n\n';
  }

  // Duration metrics
  if (metrics.http_req_duration && metrics.http_req_duration.values) {
    const v = metrics.http_req_duration.values;
    output += indent + 'Response Times:\n';
    output += indent + `  Min: ${v.min != null ? v.min.toFixed(2) : 'N/A'}ms\n`;
    output += indent + `  Avg: ${v.avg != null ? v.avg.toFixed(2) : 'N/A'}ms\n`;
    output += indent + `  Med: ${v.med != null ? v.med.toFixed(2) : 'N/A'}ms\n`;
    output += indent + `  p90: ${v['p(90)'] != null ? v['p(90)'].toFixed(2) : 'N/A'}ms\n`;
    output += indent + `  p95: ${v['p(95)'] != null ? v['p(95)'].toFixed(2) : 'N/A'}ms\n`;
    output += indent + `  p99: ${v['p(99)'] != null ? v['p(99)'].toFixed(2) : 'N/A'}ms\n`;
    output += indent + `  Max: ${v.max != null ? v.max.toFixed(2) : 'N/A'}ms\n\n`;
  } else {
    output += indent + 'Response Times: No successful requests\n\n';
  }

  // Error rate
  if (metrics.http_req_failed && metrics.http_req_failed.values) {
    const rate = metrics.http_req_failed.values.rate;
    const errorRate = rate != null ? (rate * 100).toFixed(2) : '100.00';
    output += indent + `Error Rate: ${errorRate}%\n\n`;
  } else {
    output += indent + 'Error Rate: N/A\n\n';
  }

  // MockForge server-injected chaos signals (Issue #79 — surface server-side
  // injected latency next to client-observed latency so users can see how
  // much of the wire time is artificial).
  const mfLat = metrics.mockforge_server_injected_latency_ms;
  if (mfLat && mfLat.values && mfLat.values.count > 0) {
    output += indent + 'Server-Injected Latency (from X-Mockforge-Injected-Latency-Ms):\n';
    output += indent + `  Samples: ${mfLat.values.count}\n`;
    output += indent + `  Avg: ${mfLat.values.avg != null ? mfLat.values.avg.toFixed(2) : 'N/A'}ms\n`;
    output += indent + `  Min: ${mfLat.values.min != null ? mfLat.values.min.toFixed(2) : 'N/A'}ms\n`;
    output += indent + `  Max: ${mfLat.values.max != null ? mfLat.values.max.toFixed(2) : 'N/A'}ms\n`;
    if (mfLat.values['p(95)'] != null) {
      output += indent + `  p95: ${mfLat.values['p(95)'].toFixed(2)}ms\n`;
    }
    output += '\n';
  }
  const mfJit = metrics.mockforge_server_injected_jitter_ms;
  if (mfJit && mfJit.values && mfJit.values.count > 0) {
    output += indent + 'Server-Injected Jitter:\n';
    output += indent + `  Samples: ${mfJit.values.count}\n`;
    output += indent + `  Avg: ${mfJit.values.avg != null ? mfJit.values.avg.toFixed(2) : 'N/A'}ms\n`;
    output += indent + `  Max: ${mfJit.values.max != null ? mfJit.values.max.toFixed(2) : 'N/A'}ms\n\n`;
  }
  const mfFault = metrics.mockforge_server_fault_total;
  if (mfFault && mfFault.values && mfFault.values.count > 0) {
    output += indent + `Server-Reported Faults (X-Mockforge-Fault): ${mfFault.values.count}\n\n`;
  }

  output += indent + '='.repeat(60) + '\n';

  return output;
}