diff --git a/README.md b/README.md index aeed5cf..0955a2c 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,16 @@ Reports can easily grow to several MB. - `npm install --global yarn` - `yarn start` +## Artifact information +* `/path/to/your/all.json` is the file produced by Hyperfoil. +* `test-report.html` will be the output created by `report.sh` ## Generate the HTML report for testing ```bash yarn run build chmod +x build/report.sh ./build/report.sh /path/to/your/all.json > test-report.html ``` -Open the HTML on your browser. +Open the `test-report.html` HTML on your browser. ## Building diff --git a/src/pages/Phase.js b/src/pages/Phase.js index ab63cdd..6364378 100644 --- a/src/pages/Phase.js +++ b/src/pages/Phase.js @@ -51,8 +51,13 @@ export default () => { rtrn._bucketCount = index > 0 ? entry.totalCount - all[index - 1].totalCount : entry.totalCount rtrn._total = all[all.length - 1].totalCount; rtrn.value = entry.to / 1000000 + if (rtrn.percentile === 1) { - return undefined; // + // Give 100% a safe "inversed" value so it doesn't calculate Infinity. + // We place it one logarithmic step (x10) past the previous maximum point. + const prev = all[index - 1]; + rtrn.inversed = prev && prev.percentile !== 1 ? (1 / (1 - prev.percentile)) * 10 : 100000; + return rtrn; } else { rtrn.inversed = (1 / (1 - rtrn.percentile)) return rtrn; @@ -64,21 +69,32 @@ export default () => { const tickTransform = {} const ranges = {} percentileHisto.forEach((entry,idx,all)=>{ - tickTransform[entry.inversed] = entry.percentile ranges[entry.count] = (idx>0 ? all[idx-1].count : 0) - }) + + // Reusable formatter for dynamic decimal precision + const formatPercentValue = (v) => { + if (v === 1) return "100%"; + const percent = 100 * v; + + if (v >= 0.999999) return percent.toFixed(6) + "%"; + if (v >= 0.99999) return percent.toFixed(5) + "%"; + if (v >= 0.9999) return percent.toFixed(4) + "%"; + if (v >= 0.999) return percent.toFixed(3) + "%"; + + return percent.toFixed(2) + "%"; + }; + const tickFormatter = (v) => { if (typeof tickTransform[v] !== "undefined") { v = tickTransform[v] } - return Number(100 * v).toFixed(2)+"%" + return formatPercentValue(v); } + const responsetimeTickFormatter = (v,f,g) => { - // const entry = responsetimeHisto[v]; return v; - //return Math.round(entry.from / 1000000).toFixed(0)+"-"+Math.round(entry.to / 1000000).toFixed(0) } const extra = [ @@ -86,16 +102,53 @@ export default () => { (v) => ({ color: "grey", name: "after", value: (v._total - v.totalCount) }) ] + const targetPercentiles = [ + { key: 0, label: "p0" }, + { key: 0.25, label: "p25" }, + { key: 0.50, label: "p50" }, + { key: 0.75, label: "p75" }, + { key: 0.90, label: "p90" }, + { key: 0.95, label: "p95" }, + { key: 0.99, label: "p99" }, + { key: 0.999, label: "p99.9" }, + { key: 0.9999, label: "p99.99" }, + { key: 0.99999, label: "p99.999" }, + { key: 0.999999, label: "p99.9999" }, + { key: 1.0, label: "p100" }, + ]; + + // Map the definitions and display the exactly matched percentile + const percentileTableRows = targetPercentiles.map(tp => { + const bucket = stat.histogram.percentiles.find(e => e.percentile >= tp.key) + || stat.histogram.percentiles[stat.histogram.percentiles.length - 1]; + + const valueMs = bucket ? (bucket.to / 1000000).toFixed(2) : "N/A"; + const exactPercentile = bucket ? formatPercentValue(bucket.percentile) : "N/A"; + + return ( + + + {tp.label} ({exactPercentile}) + + + {valueMs} ms + + + ); + }); + segments.push( {stat.name} - - - - {`${stat.metric} response time histogram`} - - - + + {/* 1. Response Time Histogram */} + + + + {`${stat.metric} response time histogram`} + + + {({ height, width }) => { return ( { ) }} - + - - - - {`${stat.metric} percentile distribution`} - - - + + {/* 2. Percentile Distribution Chart */} + + + + {`${stat.metric} percentile distribution`} + + + {({ height, width }) => { return ( { > @@ -179,18 +234,41 @@ export default () => { ) }} - + + + + {/* 3. Percentile Data Table */} + + + + {`${stat.metric} key performance percentiles`} + + + + + + + + + + + + {percentileTableRows} + +
PercentileResponse Time
+
) }) + return (<> - {segments} -
- - - - - - ) + {segments} +
+ + + + + + ) } diff --git a/src/pages/Summary.js b/src/pages/Summary.js index 8560543..c22ea3b 100644 --- a/src/pages/Summary.js +++ b/src/pages/Summary.js @@ -52,51 +52,31 @@ const statAccessors = [ const colors = theme.colors.chart const colorNames = Object.keys(colors); -const phasesTimetable = (data = [], stats = [], getStart = v => v.startTime, getEnd = v => v.endTime, getKey = v=>v._pif) => { +const phasesTimetable = (data = [], stats = [], getStart = v => v.startTime, getEnd = v => v.endTime, getKey = v => v._pif) => { let rtrn = {} - // In runs with multiple agents the wall-clock timestamps often don't match exactly; while merging the stats - // each interval is an union of the agents' intervals and therefore the per-second intervals overlap. - // That would mess up charts, producing a sawtooth-like pattern instead of bars, so we have to artificially correct it. - let ends = {} - data.forEach(entry => { - const key = getKey(entry) - let phaseEnds = ends[key] - if (!phaseEnds) { - ends[key] = phaseEnds = [] - } - phaseEnds.push(getEnd(entry)) - }) - Object.values(ends).forEach(phaseEnds => phaseEnds.sort()) - data.forEach(entry => { - const key = getKey(entry);//phaseName - let start = getStart(entry); + data.forEach(entry => { + const key = getKey(entry); + const start = getStart(entry); const end = getEnd(entry); - const prevEndIndex = ends[key].filter(e => e < end).length - 1 - if (prevEndIndex >= 0 && ends[key][prevEndIndex] >= start) { - start = ends[key][prevEndIndex] + 1 - } - - const rtrnStart = rtrn[start] || { _areaKey: start } - const rtrnEnd = rtrn[end] || { _areaKey: end } + // Plot each bucket at its startTime. With type="stepAfter" recharts + // draws a flat horizontal segment from this point to the next, which + // visually covers exactly the [startTime, endTime] window. The tooltip + // activates anywhere in that span because recharts assigns cursor + // positions to the nearest preceding data point. + const rtrnStart = rtrn[start] || { _areaKey: start, start: start, end: end }; stats.forEach(stat => { const statKey = key + "_" + stat.name; - const statValue = stat.accessor(entry) - rtrnStart[statKey] = statValue - rtrnEnd[statKey] = statValue - }) - rtrnStart.start = start - rtrnStart.end = end - rtrnEnd.start = start - rtrnEnd.end = end + rtrnStart[statKey] = stat.accessor(entry); + }); rtrn[start] = rtrnStart; - rtrn[end] = rtrnEnd; - }) - //sort by the timestamp - rtrn = Object.values(rtrn).sort((a, b) => a._areaKey - b._areaKey) - return rtrn + }); + + // Sort chronologically by start time + rtrn = Object.values(rtrn).sort((a, b) => a._areaKey - b._areaKey); + return rtrn; } const getPhaseTransitionTs = (data = [], getStart = (v) => v.startTime, getEnd = v => v.endTime) => { const rtrn = [] @@ -198,8 +178,8 @@ function Section({ forkName, metricName }) { stroke={color} unit="ns" fill={color} - connectNulls={true} //needs to be true for cases of overlap betweeen phases - type="monotone" + connectNulls={true} + type="stepAfter" yAxisId={0} isAnimationActive={false} style={{ opacity: 0.5 }} @@ -216,6 +196,7 @@ function Section({ forkName, metricName }) { stroke={"#FF0000"} fill={"#FF0000"} connectNulls={true} + type="stepAfter" dot={false} isAnimationActive={false} style={{ strokeWidth: 1 }} @@ -230,6 +211,7 @@ function Section({ forkName, metricName }) { stroke={"#00A300"} fill={"#00A300"} connectNulls={true} + type="stepAfter" dot={false} isAnimationActive={false} style={{ strokeWidth: 1 }} @@ -244,6 +226,7 @@ function Section({ forkName, metricName }) { stroke={"#A30000"} fill={"#A30000"} connectNulls={true} + type="stepAfter" dot={false} isAnimationActive={false} style={{ strokeWidth: 1 }}