test(performance): implement k6 load testing scenarios and java result parser

This commit is contained in:
2026-05-17 02:21:28 +07:00
parent 4a0ae1d615
commit abeb99ed61
19 changed files with 4036 additions and 0 deletions
@@ -0,0 +1,216 @@
/**
* WalkGuide — k6 HTML Report Generator (Node.js)
* Mengkonversi parsed-report.json menjadi HTML report yang rapi untuk submission.
*
* Usage:
* node html-reporter.js <parsed-report.json> [output.html]
*
* Output: HTML report siap lampir ke laporan final exam.
*/
const fs = require("fs");
const path = require("path");
const inputFile = process.argv[2];
const outputFile = process.argv[3] || "k6-results/load-test-report.html";
if (!inputFile || !fs.existsSync(inputFile)) {
console.error(
"Usage: node html-reporter.js <parsed-report.json> [output.html]",
);
process.exit(1);
}
const report = JSON.parse(fs.readFileSync(inputFile, "utf8"));
// ── HTML Template ─────────────────────────────────────────────────────────────
function badge(result) {
if (!result) return '<span class="badge skip">SKIP</span>';
if (result.includes("PASS")) return '<span class="badge pass">✅ PASS</span>';
if (result.includes("FAIL")) return '<span class="badge fail">❌ FAIL</span>';
return `<span class="badge skip">${result}</span>`;
}
function metricRow(label, val) {
return `<tr><td>${label}</td><td><strong>${val || "N/A"}</strong></td></tr>`;
}
function endpointTableRows() {
const rows = [];
for (const [ep, metrics] of Object.entries(report.endpoints || {})) {
const dur = metrics["http_req_duration"];
if (!dur) continue;
rows.push(`
<tr>
<td><code>${ep}</code></td>
<td>${dur.count || "—"}</td>
<td>${dur.avg || "—"}</td>
<td>${dur.p95 || "—"}</td>
<td>${dur.p99 || "—"}</td>
<td>${dur.max || "—"}</td>
</tr>
`);
}
return rows.join("");
}
function thresholdRows() {
return (report.thresholdResults || [])
.map(
(t) => `
<tr>
<td>${t.name}</td>
<td><code>${t.metric}</code></td>
<td>${t.threshold}</td>
<td>${t.actual}</td>
<td>${badge(t.result)}</td>
</tr>
`,
)
.join("");
}
function wgMetricCards() {
const wg = report.walkguideMetrics || {};
const cards = [
{ label: "🔐 Auth", key: "authLatency", threshold: "< 800ms" },
{ label: "📍 Location", key: "locationLatency", threshold: "< 300ms" },
{ label: "🚧 Obstacle", key: "obstacleLatency", threshold: "< 400ms" },
{ label: "🚨 SOS", key: "sosLatency", threshold: "< 200ms" },
{ label: "📬 Notif", key: "notifLatency", threshold: "< 500ms" },
{ label: "📊 Timeline", key: "timelineLatency", threshold: "< 1000ms" },
{ label: "🔗 Pairing", key: "pairingLatency", threshold: "< 600ms" },
];
return cards
.map((c) => {
const data = wg[c.key];
return `
<div class="metric-card">
<div class="metric-label">${c.label}</div>
<div class="metric-p95">${data ? data.p95 : "N/A"}</div>
<div class="metric-sub">p95 | threshold ${c.threshold}</div>
<div class="metric-avg">avg: ${data ? data.avg : "N/A"} | p99: ${data ? data.p99 : "N/A"}</div>
</div>
`;
})
.join("");
}
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>WalkGuide — k6 Load Test Report</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0f172a; color: #e2e8f0; padding: 24px; }
h1 { font-size: 2rem; color: #38bdf8; margin-bottom: 4px; }
h2 { font-size: 1.2rem; color: #7dd3fc; margin: 32px 0 12px; border-bottom: 1px solid #1e3a5f; padding-bottom: 6px; }
p { color: #94a3b8; margin-bottom: 8px; }
.header { margin-bottom: 32px; }
.header .meta { color: #64748b; font-size: 0.85rem; margin-top: 4px; }
/* Key Metrics Grid */
.key-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 24px; }
.key-card { background: #1e293b; border-radius: 10px; padding: 18px; border: 1px solid #334155; }
.key-card .klabel { font-size: 0.75rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
.key-card .kvalue { font-size: 1.5rem; font-weight: 700; color: #f0f9ff; margin: 6px 0 2px; }
.key-card .kdesc { font-size: 0.72rem; color: #475569; }
/* WalkGuide Metric Cards */
.wg-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; }
.metric-card { background: #1e293b; border-radius: 8px; padding: 14px; border: 1px solid #1e3a5f; text-align: center; }
.metric-label { font-size: 0.85rem; color: #94a3b8; margin-bottom: 6px; }
.metric-p95 { font-size: 1.6rem; font-weight: 700; color: #38bdf8; }
.metric-sub { font-size: 0.7rem; color: #475569; margin-top: 2px; }
.metric-avg { font-size: 0.72rem; color: #64748b; margin-top: 4px; }
/* Tables */
table { width: 100%; border-collapse: collapse; margin-bottom: 24px; font-size: 0.88rem; }
th { background: #1e3a5f; color: #7dd3fc; text-align: left; padding: 10px 12px; }
td { padding: 9px 12px; border-bottom: 1px solid #1e293b; color: #cbd5e1; }
tr:nth-child(even) td { background: #0f1f35; }
code { background: #1e293b; padding: 2px 6px; border-radius: 4px; font-size: 0.82rem; color: #38bdf8; }
/* Badges */
.badge { padding: 3px 10px; border-radius: 12px; font-size: 0.78rem; font-weight: 600; }
.badge.pass { background: #064e3b; color: #34d399; }
.badge.fail { background: #450a0a; color: #f87171; }
.badge.skip { background: #1e293b; color: #94a3b8; }
footer { margin-top: 40px; text-align: center; color: #334155; font-size: 0.78rem; }
</style>
</head>
<body>
<div class="header">
<h1>🦺 WalkGuide — k6 Load Test Report</h1>
<p>Spring Boot Backend Performance Benchmark — Pillar 3</p>
<div class="meta">
Generated: ${report.generatedAt} |
Input: ${path.basename(report.inputFile || "")} |
Total Data Points: ${report.totalPoints}
</div>
</div>
<h2>📊 Key Metrics (Pillar 3 — 5 Required)</h2>
<div class="key-grid">
<div class="key-card">
<div class="klabel">1. Throughput</div>
<div class="kvalue">${report.keyMetrics.throughput.value || "N/A"}</div>
<div class="kdesc">Requests per second</div>
</div>
<div class="key-card">
<div class="klabel">2. p95 Latency</div>
<div class="kvalue">${report.keyMetrics.p95Latency.value || "N/A"}</div>
<div class="kdesc">95th percentile response time</div>
</div>
<div class="key-card">
<div class="klabel">3. Error Rate</div>
<div class="kvalue">${report.keyMetrics.errorRate.value || "N/A"}</div>
<div class="kdesc">Non-2xx responses | ${report.keyMetrics.errorRate.passFail || "—"}</div>
</div>
<div class="key-card">
<div class="klabel">4. DB Query Time</div>
<div class="kvalue">${report.keyMetrics.dbQueryTime.value || "N/A"}</div>
<div class="kdesc">Estimated via write endpoint p95</div>
</div>
<div class="key-card">
<div class="klabel">5. JVM Heap</div>
<div class="kvalue">${report.keyMetrics.jvmHeap.value || "N/A"}</div>
<div class="kdesc">Spring Actuator jvm.memory.used</div>
</div>
</div>
<h2>🏃 WalkGuide Endpoint Latency Breakdown</h2>
<div class="wg-grid">${wgMetricCards()}</div>
<h2>✅ Threshold Evaluation</h2>
<table>
<thead>
<tr><th>Check</th><th>Metric</th><th>Threshold</th><th>Actual</th><th>Result</th></tr>
</thead>
<tbody>${thresholdRows()}</tbody>
</table>
<h2>📋 Per-Endpoint Latency (ms)</h2>
<table>
<thead>
<tr><th>Endpoint</th><th>Requests</th><th>Avg</th><th>p95</th><th>p99</th><th>Max</th></tr>
</thead>
<tbody>${endpointTableRows()}</tbody>
</table>
<footer>
WalkGuide AI — Integrated Mobile Application Project Final Exam |
Generated by html-reporter.js
</footer>
</body>
</html>`;
// ── Write ─────────────────────────────────────────────────────────────────────
const outDir = path.dirname(outputFile);
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(outputFile, html);
console.log(`✅ HTML report written to: ${outputFile}`);
@@ -0,0 +1,359 @@
/**
* WalkGuide — k6 JSON Result Parser (Node.js)
* Parse output dari k6 --out json=output.ndjson dan generate readable report.
*
* Usage:
* node result-parser.js <input.ndjson> [output-report.json]
*
* k6 JSON format: satu JSON object per baris (newline-delimited JSON / NDJSON)
* Setiap baris adalah metric data point atau test info.
*/
const fs = require("fs");
const path = require("path");
// ── Parse args ────────────────────────────────────────────────────────────────
const inputFile = process.argv[2];
const outputFile = process.argv[3] || "k6-results/parsed-report.json";
if (!inputFile) {
console.error(
"Usage: node result-parser.js <k6-output.ndjson> [output.json]",
);
process.exit(1);
}
if (!fs.existsSync(inputFile)) {
console.error(`Input file not found: ${inputFile}`);
process.exit(1);
}
// ── Read and parse NDJSON ─────────────────────────────────────────────────────
const raw = fs.readFileSync(inputFile, "utf8");
const lines = raw.trim().split("\n").filter(Boolean);
const dataPoints = [];
const errors = [];
for (const line of lines) {
try {
dataPoints.push(JSON.parse(line));
} catch (e) {
errors.push({ line: line.slice(0, 100), error: e.message });
}
}
console.log(
`Parsed ${dataPoints.length} data points, ${errors.length} parse errors.`,
);
// ── Aggregate metrics ─────────────────────────────────────────────────────────
const metricAggregates = {};
for (const dp of dataPoints) {
if (dp.type !== "Point") continue;
const metricName = dp.metric;
const value = dp.data && dp.data.value !== undefined ? dp.data.value : 0;
const tags = dp.data && dp.data.tags ? dp.data.tags : {};
if (!metricAggregates[metricName]) {
metricAggregates[metricName] = {
values: [],
byEndpoint: {},
};
}
metricAggregates[metricName].values.push(value);
// Group by endpoint tag
const endpoint = tags.endpoint || "unknown";
if (!metricAggregates[metricName].byEndpoint[endpoint]) {
metricAggregates[metricName].byEndpoint[endpoint] = [];
}
metricAggregates[metricName].byEndpoint[endpoint].push(value);
}
// ── Statistical helpers ───────────────────────────────────────────────────────
function percentile(arr, p) {
if (!arr || arr.length === 0) return 0;
const sorted = [...arr].sort((a, b) => a - b);
const idx = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, idx)];
}
function avg(arr) {
if (!arr || arr.length === 0) return 0;
return arr.reduce((a, b) => a + b, 0) / arr.length;
}
function summarizeSeries(arr) {
if (!arr || arr.length === 0) return null;
return {
count: arr.length,
min: Math.min(...arr).toFixed(2),
max: Math.max(...arr).toFixed(2),
avg: avg(arr).toFixed(2),
p50: percentile(arr, 50).toFixed(2),
p90: percentile(arr, 90).toFixed(2),
p95: percentile(arr, 95).toFixed(2),
p99: percentile(arr, 99).toFixed(2),
};
}
// ── Build structured report ───────────────────────────────────────────────────
const report = {
generatedAt: new Date().toISOString(),
inputFile: path.resolve(inputFile),
totalPoints: dataPoints.length,
parseErrors: errors.length,
// 5 KEY METRICS sesuai ketentuan pillar 3:
keyMetrics: {
// 1. Throughput — request per second
throughput: {
label: "Throughput (req/s)",
description: "Total HTTP requests divided by test duration",
value: null, // dihitung di bawah
},
// 2. p95 Latency — overall
p95Latency: {
label: "p95 Response Time (ms)",
description: "95th percentile of all HTTP request durations",
value: null,
},
// 3. Error Rate
errorRate: {
label: "Error Rate (%)",
description: "Percentage of non-2xx HTTP responses",
value: null,
passFail: null,
},
// 4. DB Query Time (estimated via response time)
dbQueryTime: {
label: "Estimated DB Query Time (ms)",
description: "p95 of write endpoints (location + obstacle) as DB proxy",
value: null,
note: "Actual DB time requires Spring Actuator or DB metrics",
},
// 5. JVM Heap
jvmHeap: {
label: "JVM Heap Used (MB)",
description: "From Spring Actuator metric, collected during test",
value: null,
},
},
// Per-metric detailed stats
metrics: {},
// Per-endpoint breakdown
endpoints: {},
// WalkGuide-specific breakdown
walkguideMetrics: {
authLatency: null,
locationLatency: null,
obstacleLatency: null,
sosLatency: null,
notifLatency: null,
timelineLatency: null,
pairingLatency: null,
},
// Threshold evaluation
thresholdResults: [],
};
// ── Populate metrics ──────────────────────────────────────────────────────────
for (const [name, data] of Object.entries(metricAggregates)) {
report.metrics[name] = summarizeSeries(data.values);
// Per-endpoint
for (const [ep, vals] of Object.entries(data.byEndpoint)) {
if (!report.endpoints[ep]) report.endpoints[ep] = {};
report.endpoints[ep][name] = summarizeSeries(vals);
}
}
// ── Fill key metrics ──────────────────────────────────────────────────────────
if (report.metrics["http_req_duration"]) {
report.keyMetrics.p95Latency.value =
report.metrics["http_req_duration"].p95 + " ms";
}
if (report.metrics["http_req_failed"]) {
const rate = (
parseFloat(report.metrics["http_req_failed"].avg) * 100
).toFixed(2);
report.keyMetrics.errorRate.value = rate + "%";
report.keyMetrics.errorRate.passFail = parseFloat(rate) < 1 ? "PASS" : "FAIL";
}
if (report.metrics["walkguide_jvm_heap_used_mb"]) {
report.keyMetrics.jvmHeap.value =
report.metrics["walkguide_jvm_heap_used_mb"].avg + " MB";
}
// DB estimate: avg of location_update + obstacle_log p95
const dbMetrics = [
"walkguide_location_latency_ms",
"walkguide_obstacle_latency_ms",
];
const dbVals = dbMetrics
.filter((m) => report.metrics[m])
.map((m) => parseFloat(report.metrics[m].p95));
if (dbVals.length > 0) {
report.keyMetrics.dbQueryTime.value =
(dbVals.reduce((a, b) => a + b, 0) / dbVals.length).toFixed(2) + " ms";
}
// Throughput: dari http_reqs total / test duration (estimasi dari data points)
if (report.metrics["http_reqs"]) {
const totalReqs = report.metrics["http_reqs"].count;
// Estimasi durasi dari timestamps
const timestamps = dataPoints
.filter((dp) => dp.type === "Point" && dp.metric === "http_reqs")
.map((dp) => new Date(dp.data.time).getTime());
if (timestamps.length > 1) {
const durationS =
(Math.max(...timestamps) - Math.min(...timestamps)) / 1000;
report.keyMetrics.throughput.value =
(totalReqs / durationS).toFixed(1) + " req/s";
} else {
report.keyMetrics.throughput.value = totalReqs + " total requests";
}
}
// ── WalkGuide-specific ────────────────────────────────────────────────────────
const wgMetricMap = {
authLatency: "walkguide_auth_latency_ms",
locationLatency: "walkguide_location_latency_ms",
obstacleLatency: "walkguide_obstacle_latency_ms",
sosLatency: "walkguide_sos_latency_ms",
notifLatency: "walkguide_notif_latency_ms",
timelineLatency: "walkguide_timeline_latency_ms",
pairingLatency: "walkguide_pairing_latency_ms",
};
for (const [key, metricName] of Object.entries(wgMetricMap)) {
if (report.metrics[metricName]) {
report.walkguideMetrics[key] = {
p95: report.metrics[metricName].p95 + " ms",
p99: report.metrics[metricName].p99 + " ms",
avg: report.metrics[metricName].avg + " ms",
};
}
}
// ── Threshold checks ──────────────────────────────────────────────────────────
const thresholdChecks = [
{
name: "p95 overall < 500ms",
metric: "http_req_duration",
stat: "p95",
threshold: 500,
unit: "ms",
operator: "<",
},
{
name: "error rate < 1%",
metric: "http_req_failed",
stat: "avg",
threshold: 0.01,
unit: "rate",
operator: "<",
},
{
name: "SOS p95 < 200ms",
metric: "walkguide_sos_latency_ms",
stat: "p95",
threshold: 200,
unit: "ms",
operator: "<",
},
{
name: "Location p95 < 300ms",
metric: "walkguide_location_latency_ms",
stat: "p95",
threshold: 300,
unit: "ms",
operator: "<",
},
{
name: "Obstacle p95 < 400ms",
metric: "walkguide_obstacle_latency_ms",
stat: "p95",
threshold: 400,
unit: "ms",
operator: "<",
},
{
name: "Auth p95 < 800ms",
metric: "walkguide_auth_latency_ms",
stat: "p95",
threshold: 800,
unit: "ms",
operator: "<",
},
{
name: "Timeline p95 < 1000ms",
metric: "walkguide_timeline_latency_ms",
stat: "p95",
threshold: 1000,
unit: "ms",
operator: "<",
},
{
name: "Notification p95 < 500ms",
metric: "walkguide_notif_latency_ms",
stat: "p95",
threshold: 500,
unit: "ms",
operator: "<",
},
];
for (const check of thresholdChecks) {
const m = report.metrics[check.metric];
if (!m) {
report.thresholdResults.push({ ...check, actual: "N/A", result: "SKIP" });
continue;
}
const actual = parseFloat(m[check.stat]);
const pass =
check.operator === "<"
? actual < check.threshold
: actual > check.threshold;
report.thresholdResults.push({
name: check.name,
metric: check.metric,
threshold: check.threshold + " " + check.unit,
actual: actual.toFixed(2) + " " + check.unit,
result: pass ? "✅ PASS" : "❌ FAIL",
});
}
// ── Write output ──────────────────────────────────────────────────────────────
const outDir = path.dirname(outputFile);
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(outputFile, JSON.stringify(report, null, 2));
console.log(`\n✅ Report written to: ${outputFile}`);
// ── Print summary to stdout ───────────────────────────────────────────────────
console.log("\n═══════════════════════════════════════════════════════");
console.log(" WalkGuide k6 — Parsed Result Summary");
console.log("═══════════════════════════════════════════════════════");
console.log(` Throughput: ${report.keyMetrics.throughput.value || "N/A"}`);
console.log(` p95 Latency: ${report.keyMetrics.p95Latency.value || "N/A"}`);
console.log(
` Error Rate: ${report.keyMetrics.errorRate.value || "N/A"} [${report.keyMetrics.errorRate.passFail || "N/A"}]`,
);
console.log(` DB Est. Time: ${report.keyMetrics.dbQueryTime.value || "N/A"}`);
console.log(` JVM Heap: ${report.keyMetrics.jvmHeap.value || "N/A"}`);
console.log("\n Threshold Results:");
for (const t of report.thresholdResults) {
console.log(` ${t.result} ${t.name} (actual: ${t.actual})`);
}
console.log("═══════════════════════════════════════════════════════\n");