feat: live timing waterfall with TTFB, DNS, TLS, TCP, sending, receiving
Backend: - Track all k6 HTTP phase metrics via PHASE_MAP in ingestPoint: http_req_blocked (DNS+wait), http_req_connecting, http_req_tls_handshaking, http_req_sending, http_req_waiting (TTFB), http_req_receiving - Compute avg + p95 per phase in computeLiveMetrics, broadcast every second - parseSummary now captures all trend metrics with generic trendRe() Frontend: - Live waterfall bar chart updates every second during the test - Each phase has a distinct colour: indigo/sky/purple/emerald/amber/blue - Bar width = phase avg as % of total request time - TTFB p95 + p99 shown below the waterfall - Bars animate smoothly with CSS transitions - Waterfall resets cleanly on new test start Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -171,13 +171,19 @@ app.post('/api/tests', (req, res) => {
|
||||
|
||||
// Live metric aggregation state
|
||||
const live = {
|
||||
durations: [],
|
||||
durations: [], // http_req_duration (ms)
|
||||
ttfb: [], // http_req_waiting (ms)
|
||||
connecting: [], // http_req_connecting (ms)
|
||||
tls: [], // http_req_tls_handshaking (ms)
|
||||
sending: [], // http_req_sending (ms)
|
||||
receiving: [], // http_req_receiving (ms)
|
||||
blocked: [], // http_req_blocked (ms)
|
||||
totalReqs: 0,
|
||||
failedReqs: 0,
|
||||
checksTotal: 0,
|
||||
checksPassed: 0,
|
||||
bytesIn: 0, // data_received (bytes)
|
||||
bytesOut: 0, // data_sent (bytes)
|
||||
bytesIn: 0,
|
||||
bytesOut: 0,
|
||||
startTime: Date.now(),
|
||||
lastBroadcast: 0,
|
||||
};
|
||||
@@ -307,11 +313,24 @@ app.delete('/api/tests/:id', (req, res) => {
|
||||
});
|
||||
|
||||
// Ingest a single k6 JSON metric point into the live aggregation state
|
||||
const PHASE_MAP = {
|
||||
http_req_duration: 'durations',
|
||||
http_req_waiting: 'ttfb',
|
||||
http_req_connecting: 'connecting',
|
||||
http_req_tls_handshaking:'tls',
|
||||
http_req_sending: 'sending',
|
||||
http_req_receiving: 'receiving',
|
||||
http_req_blocked: 'blocked',
|
||||
};
|
||||
|
||||
function ingestPoint(obj, live) {
|
||||
const { metric, data } = obj;
|
||||
if (metric === 'http_req_duration') {
|
||||
if (live.durations.length < 50000) live.durations.push(data.value);
|
||||
} else if (metric === 'http_reqs') {
|
||||
const arr = PHASE_MAP[metric];
|
||||
if (arr) {
|
||||
if (live[arr].length < 20000) live[arr].push(data.value);
|
||||
return;
|
||||
}
|
||||
if (metric === 'http_reqs') {
|
||||
live.totalReqs++;
|
||||
} else if (metric === 'http_req_failed') {
|
||||
if (data.value === 1) live.failedReqs++;
|
||||
@@ -339,11 +358,29 @@ function computeLiveMetrics(live) {
|
||||
const p95 = pct(95);
|
||||
const score = computeScore({ p95, httpErrRate, checksRate });
|
||||
|
||||
const bwInMBps = +(live.bytesIn / elapsed / 1048576).toFixed(2); // MB/s
|
||||
const bwInMBps = +(live.bytesIn / elapsed / 1048576).toFixed(2);
|
||||
const bwOutMBps = +(live.bytesOut / elapsed / 1048576).toFixed(2);
|
||||
const avgRespKB = live.totalReqs > 0
|
||||
? +(live.bytesIn / live.totalReqs / 1024).toFixed(1)
|
||||
: 0;
|
||||
? +(live.bytesIn / live.totalReqs / 1024).toFixed(1) : 0;
|
||||
|
||||
// Helper: avg of an array
|
||||
const arrAvg = (arr) => arr.length
|
||||
? +(arr.reduce((a, b) => a + b, 0) / arr.length).toFixed(2) : 0;
|
||||
// Helper: percentile of an array
|
||||
const arrPct = (arr, p) => {
|
||||
if (!arr.length) return 0;
|
||||
const s = [...arr].sort((a, b) => a - b);
|
||||
return +s[Math.min(Math.floor(s.length * p / 100), s.length - 1)].toFixed(2);
|
||||
};
|
||||
|
||||
const phases = {
|
||||
blocked: { avg: arrAvg(live.blocked), p95: arrPct(live.blocked, 95) },
|
||||
connecting: { avg: arrAvg(live.connecting), p95: arrPct(live.connecting, 95) },
|
||||
tls: { avg: arrAvg(live.tls), p95: arrPct(live.tls, 95) },
|
||||
sending: { avg: arrAvg(live.sending), p95: arrPct(live.sending, 95) },
|
||||
ttfb: { avg: arrAvg(live.ttfb), p95: arrPct(live.ttfb, 95), p99: arrPct(live.ttfb, 99) },
|
||||
receiving: { avg: arrAvg(live.receiving), p95: arrPct(live.receiving, 95) },
|
||||
};
|
||||
|
||||
return {
|
||||
totalReqs: live.totalReqs,
|
||||
@@ -363,6 +400,7 @@ function computeLiveMetrics(live) {
|
||||
bwInMBps,
|
||||
bwOutMBps,
|
||||
avgRespKB,
|
||||
phases,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -381,27 +419,39 @@ function computeScore({ p95, httpErrRate, checksRate }) {
|
||||
|
||||
// Parse k6 terminal output for key metrics
|
||||
function parseSummary(output) {
|
||||
// Strip ANSI colour codes before matching
|
||||
const plain = output.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
const summary = {};
|
||||
|
||||
const patterns = {
|
||||
http_reqs: /http_reqs[\s.]+(\d+)\s+([\d.]+\/s)/,
|
||||
http_req_duration: /http_req_duration[\s.]+avg=([\d.µms]+)\s+min=([\d.µms]+)\s+med=([\d.µms]+)\s+max=([\d.µms]+)\s+p\(90\)=([\d.µms]+)\s+p\(95\)=([\d.µms]+)\s+p\(99\)=([\d.µms]+)/,
|
||||
http_req_failed: /http_req_failed[\s.]+(\d+\.?\d*%)\s+(\d+) out of (\d+)/,
|
||||
checks: /checks[\s.]+(\d+\.?\d*%)\s+(\d+) out of (\d+)/,
|
||||
iterations: /iterations[\s.]+(\d+)\s+([\d.]+\/s)/,
|
||||
vus: /vus[\s.]+(\d+)\s+min=(\d+)\s+max=(\d+)/,
|
||||
data_received: /data_received[\s.]+([\d.]+ \w+)\s+([\d.]+ \w+\/s)/,
|
||||
data_sent: /data_sent[\s.]+([\d.]+ \w+)\s+([\d.]+ \w+\/s)/,
|
||||
};
|
||||
const trendRe = (name) => new RegExp(
|
||||
`${name}[\\s.]+avg=([\\d.µms]+)\\s+min=([\\d.µms]+)\\s+med=([\\d.µms]+)\\s+max=([\\d.µms]+)\\s+p\\(90\\)=([\\d.µms]+)\\s+p\\(95\\)=([\\d.µms]+)\\s+p\\(99\\)=([\\d.µms]+)`
|
||||
);
|
||||
|
||||
// Trend metrics: [avg, min, med, max, p90, p95, p99]
|
||||
for (const name of [
|
||||
'http_req_duration', 'http_req_waiting', 'http_req_blocked',
|
||||
'http_req_connecting', 'http_req_tls_handshaking',
|
||||
'http_req_sending', 'http_req_receiving', 'iteration_duration',
|
||||
]) {
|
||||
const m = plain.match(trendRe(name));
|
||||
if (m) summary[name] = { avg: m[1], min: m[2], med: m[3], max: m[4], p90: m[5], p95: m[6], p99: m[7] };
|
||||
}
|
||||
|
||||
// Counter / rate metrics
|
||||
const patterns = {
|
||||
http_reqs: /http_reqs[\s.]+(\d+)\s+([\d.]+\/s)/,
|
||||
http_req_failed:/http_req_failed[\s.]+(\d+\.?\d*%)\s+(\d+) out of (\d+)/,
|
||||
checks: /checks[\s.]+(\d+\.?\d*%)\s+(\d+) out of (\d+)/,
|
||||
iterations: /iterations[\s.]+(\d+)\s+([\d.]+\/s)/,
|
||||
vus: /vus[\s.]+(\d+)\s+min=(\d+)\s+max=(\d+)/,
|
||||
data_received: /data_received[\s.]+([\d.]+ \w+)\s+([\d.]+ \w+\/s)/,
|
||||
data_sent: /data_sent[\s.]+([\d.]+ \w+)\s+([\d.]+ \w+\/s)/,
|
||||
};
|
||||
for (const [key, re] of Object.entries(patterns)) {
|
||||
const m = plain.match(re);
|
||||
if (m) summary[key] = m.slice(1);
|
||||
}
|
||||
|
||||
// Extract threshold results (✓ / ✗ lines)
|
||||
// Threshold pass/fail lines
|
||||
const thresholds = [];
|
||||
for (const m of plain.matchAll(/(✓|✗)\s+(.+)/g)) {
|
||||
thresholds.push({ pass: m[1] === '✓', label: m[2].trim() });
|
||||
|
||||
Reference in New Issue
Block a user