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");