Repository: mcollina/autocannon
Pull request #347 (2021-03-04 21:46:01)

Adding individual status code statistics table
#346 
Add `--renderStatusCodes` flag to render individual status code statistics table 

```
┌──────┬───────┐
│ Code │ Count │
├──────┼───────┤
│ 401  │ 124   │
└──────┴───────┘
```


Patch:
diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml
index 44d6022..a447212 100644
--- a/.github/workflows/nodejs.yml
+++ b/.github/workflows/nodejs.yml
@@ -27,7 +27,7 @@ jobs:
     needs: test
     runs-on: ubuntu-latest
     steps:
-      - uses: fastify/github-action-merge-dependabot@v1.2.0
+      - uses: fastify/github-action-merge-dependabot@v1.2.1
         if: ${{ github.actor == 'dependabot[bot]' && github.event_name == 'pull_request' }}
         with:
           github-token: ${{secrets.github_token}}
diff --git a/autocannon.js b/autocannon.js
index 5b348cd..44be8f4 100755
--- a/autocannon.js
+++ b/autocannon.js
@@ -33,7 +33,7 @@ module.exports.parseArguments = parseArguments
 
 function parseArguments (argvs) {
   const argv = minimist(argvs, {
-    boolean: ['json', 'n', 'help', 'renderLatencyTable', 'renderProgressBar', 'forever', 'idReplacement', 'excludeErrorStats', 'onPort', 'debug', 'ignoreCoordinatedOmission'],
+    boolean: ['json', 'n', 'help', 'renderLatencyTable', 'renderProgressBar', 'renderStatusCodes', 'forever', 'idReplacement', 'excludeErrorStats', 'onPort', 'debug', 'ignoreCoordinatedOmission'],
     alias: {
       connections: 'c',
       pipelining: 'p',
@@ -57,6 +57,7 @@ function parseArguments (argvs) {
       ignoreCoordinatedOmission: 'C',
       reconnectRate: 'D',
       renderProgressBar: 'progress',
+      renderStatusCodes: 'statusCodes',
       title: 'T',
       version: 'v',
       forever: 'f',
@@ -75,6 +76,7 @@ function parseArguments (argvs) {
       reconnectRate: 0,
       renderLatencyTable: false,
       renderProgressBar: true,
+      renderStatusCodes: false,
       json: false,
       forever: false,
       method: 'GET',
@@ -96,6 +98,7 @@ function parseArguments (argvs) {
   if (argv.n) {
     argv.renderProgressBar = false
     argv.renderResultsTable = false
+    argv.renderStatusCodes = false
   }
 
   if (argv.version) {
diff --git a/help.txt b/help.txt
index 1b474e0..e4fab21 100644
--- a/help.txt
+++ b/help.txt
@@ -82,6 +82,8 @@ Available options:
   -E/--expectBody EXPECTED
         Ensure the body matches this value. If enabled, mismatches count towards bailout.
         Enabling this option will slow down the load testing.
+  --renderStatusCodes
+        Print status codes and their respective statistics.
   --debug
         Print connection errors to stderr.
   -v/--version
diff --git a/lib/aggregateResult.js b/lib/aggregateResult.js
index aafda98..b6976f0 100644
--- a/lib/aggregateResult.js
+++ b/lib/aggregateResult.js
@@ -28,6 +28,14 @@ function aggregateResult (results, opts, histograms) {
     acc['4xx'] += r['4xx']
     acc['5xx'] += r['5xx']
 
+    Object.keys(r.statusCodeStats).forEach(statusCode => {
+      if (!acc.statusCodeStats[statusCode]) {
+        acc.statusCodeStats[statusCode] = r.statusCodeStats[statusCode]
+      } else {
+        acc.statusCodeStats[statusCode].count += r.statusCodeStats[statusCode].count
+      }
+    })
+
     return acc
   })
 
@@ -52,6 +60,7 @@ function aggregateResult (results, opts, histograms) {
     '3xx': aggregated['3xx'],
     '4xx': aggregated['4xx'],
     '5xx': aggregated['5xx'],
+    statusCodeStats: aggregated.statusCodeStats,
 
     latency: addPercentiles(aggregated.latencies, histAsObj(aggregated.latencies)),
     requests: addPercentiles(histograms.requests, histAsObj(histograms.requests, aggregated.totalCompletedRequests)),
diff --git a/lib/printResult.js b/lib/printResult.js
index 79cf07c..50e03ad 100644
--- a/lib/printResult.js
+++ b/lib/printResult.js
@@ -33,6 +33,19 @@ const printResult = (result, opts) => {
   requests.push(asHighRow(chalk.bold('Req/Sec'), result.requests))
   requests.push(asHighRow(chalk.bold('Bytes/Sec'), asBytes(result.throughput)))
   logToLocalStr(requests.toString())
+
+  if (opts.renderStatusCodes === true) {
+    const statusCodeStats = new Table({
+      head: asColor(chalk.cyan, ['Code', 'Count'])
+    })
+    Object.keys(result.statusCodeStats).forEach(statusCode => {
+      const stats = result.statusCodeStats[statusCode]
+      const colorize = colorizeByStatusCode(chalk, statusCode)
+      statusCodeStats.push([colorize(statusCode), stats.count])
+    })
+    logToLocalStr(statusCodeStats.toString())
+  }
+
   logToLocalStr('')
   logToLocalStr('Req/Bytes counts sampled once per second.\n')
 
@@ -131,4 +144,9 @@ function asBytes (stat) {
   return result
 }
 
+function colorizeByStatusCode (chalk, statusCode) {
+  const codeClass = Math.floor(parseInt(statusCode) / 100) - 1
+  return [chalk.cyan, chalk.cyan, chalk.cyan, chalk.redBright, chalk.redBright][codeClass]
+}
+
 module.exports = printResult
diff --git a/lib/run.js b/lib/run.js
index 97cde3e..780fd2e 100644
--- a/lib/run.js
+++ b/lib/run.js
@@ -28,6 +28,8 @@ function run (opts, tracker, cb) {
     0 // 5xx
   ]
 
+  const statusCodeStats = {}
+
   if (opts.overallRate && (opts.overallRate < opts.connections)) opts.connections = opts.overallRate
 
   let counter = 0
@@ -116,6 +118,7 @@ function run (opts, tracker, cb) {
         timeouts: timeouts,
         mismatches: mismatches,
         non2xx: statusCodes[0] + statusCodes[2] + statusCodes[3] + statusCodes[4],
+        statusCodeStats,
         resets: resets,
         duration: Math.round((Date.now() - startTime) / 10) / 100,
         start: new Date(startTime),
@@ -216,6 +219,13 @@ function run (opts, tracker, cb) {
       tracker.emit('response', this, statusCode, resBytes, responseTime)
       const codeIndex = Math.floor(parseInt(statusCode) / 100) - 1
       statusCodes[codeIndex] += 1
+
+      if (!statusCodeStats[statusCode]) {
+        statusCodeStats[statusCode] = { count: 1 }
+      } else {
+        statusCodeStats[statusCode].count++
+      }
+
       // only recordValue 2xx latencies
       if (codeIndex === 1 || includeErrorStats) {
         if (rate && !opts.ignoreCoordinatedOmission) {


Test patch:
diff --git a/test/fixtures/example-result.json b/test/fixtures/example-result.json
index 28a0e01..17c87fd 100644
--- a/test/fixtures/example-result.json
+++ b/test/fixtures/example-result.json
@@ -85,6 +85,12 @@
     "2xx": 500,
     "3xx": 0,
     "4xx": 0,
-    "5xx": 0
+    "5xx": 0,
+    "statusCodeStats": {
+      "200": { "count": "500" }, 
+      "302": { "count": "0" },
+      "401": { "count": "0" },
+      "403": { "count": "0" }
+    }
   }
-  
\ No newline at end of file
+  
diff --git a/test/printResult-process.js b/test/printResult-process.js
index cac16d3..67fd56a 100644
--- a/test/printResult-process.js
+++ b/test/printResult-process.js
@@ -2,6 +2,14 @@
 
 const autocannon = require('../autocannon')
 const exampleResult = require('./fixtures/example-result.json')
+const crossArgv = require('cross-argv')
 
-const resultStr = autocannon.printResult(exampleResult)
+let opts = null
+
+if (process.argv.length > 2) {
+  const args = crossArgv(process.argv.slice(2))
+  opts = autocannon.parseArguments(args)
+}
+
+const resultStr = autocannon.printResult(exampleResult, opts)
 process.stderr.write(resultStr)
diff --git a/test/printResult-renderStatusCodes.test.js b/test/printResult-renderStatusCodes.test.js
new file mode 100644
index 0000000..b830329
--- /dev/null
+++ b/test/printResult-renderStatusCodes.test.js
@@ -0,0 +1,61 @@
+'use strict'
+
+const test = require('tap').test
+const split = require('split2')
+const path = require('path')
+const childProcess = require('child_process')
+
+test('should stdout (print) the result', (t) => {
+  const lines = [
+    /.*/,
+    /Stat.*2\.5%.*50%.*97\.5%.*99%.*Avg.*Stdev.*Max.*$/,
+    /.*/,
+    /Latency.*$/,
+    /$/,
+    /.*/,
+    /Stat.*1%.*2\.5%.*50%.*97\.5%.*Avg.*Stdev.*Min.*$/,
+    /.*/,
+    /Req\/Sec.*$/,
+    /.*/,
+    /Bytes\/Sec.*$/,
+    /.*/,
+    /.*/,
+    /Code.*Count.*$/,
+    /.*/,
+    /200.*500.*$/,
+    /.*/,
+    /302.*0.*$/,
+    /.*/,
+    /401.*0.*$/,
+    /.*/,
+    /403.*0.*$/,
+    /.*/,
+    /$/,
+    /Req\/Bytes counts sampled once per second.*$/,
+    /$/,
+    /.* requests in ([0-9]|\.)+s, .* read/
+  ]
+
+  t.plan(lines.length * 2)
+
+  const child = childProcess.spawn(process.execPath, [path.join(__dirname, 'printResult-process.js'), '--renderStatusCodes', 'http://127.0.0.1'], {
+    cwd: __dirname,
+    env: process.env,
+    stdio: ['ignore', 'pipe', 'pipe'],
+    detached: false
+  })
+
+  t.tearDown(() => {
+    child.kill()
+  })
+
+  child
+    .stderr
+    .pipe(split())
+    .on('data', (line) => {
+      const regexp = lines.shift()
+      t.ok(regexp, 'we are expecting this line')
+      t.ok(regexp.test(line), 'line matches ' + regexp)
+    })
+    .on('end', t.end)
+})