360 lines
12 KiB
JavaScript
360 lines
12 KiB
JavaScript
/**
|
|
* 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");
|