/** * WalkGuide — k6 JSON Result Parser (Node.js) * Parse output dari k6 --out json=output.ndjson dan generate readable report. * * Usage: * node result-parser.js [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 [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");