package org.radargun.reporting.html; import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.stream.Collector; import java.util.stream.Collectors; import java.util.stream.LongStream; import org.radargun.Operation; import org.radargun.config.Property; import org.radargun.logging.Log; import org.radargun.logging.LogFactory; import org.radargun.reporting.Report; import org.radargun.reporting.commons.Aggregation; import org.radargun.stats.OperationStats; import org.radargun.stats.Statistics; import org.radargun.stats.representation.DataThroughput; import org.radargun.stats.representation.DefaultOutcome; import org.radargun.stats.representation.Histogram; import org.radargun.stats.representation.MeanAndDev; import org.radargun.stats.representation.OperationThroughput; import org.radargun.stats.representation.Percentile; import org.radargun.utils.Utils; /** * Shows results of the tests executed in the benchmark. Also creates the image files displayed in this HTML document. * * @author Radim Vansa <rvansa@redhat.com> * @since 2.0 */ // TODO: reduce max report size in order to not overload browser with huge tables public abstract class ReportDocument extends HtmlDocument { protected static final Log log = LogFactory.getLog(ReportDocument.class); private int elementCounter = 0; private List<Future> chartTaskFutures = new ArrayList<>(); private Map<String, List<ChartDescription>> generatedCharts = new HashMap<>(); protected final int maxConfigurations; protected final int maxIterations; protected final int maxClusters; protected final Configuration configuration; protected final String testName; public ReportDocument(String targetDir, String testName, int maxConfigurations, int maxClusters, int maxIterations, Configuration configuration) { super(targetDir, String.format("test_%s.html", testName), "Test " + testName); this.testName = testName; this.maxConfigurations = maxConfigurations; this.maxClusters = maxClusters; this.maxIterations = maxIterations; this.configuration = configuration; } public void createHistogramAndPercentileChart(Statistics statistics, final String operation, final String configurationName, int cluster, int iteration, String node, Collection<StatisticType> presentedStatistics) { if (statistics == null || !presentedStatistics.contains(StatisticType.HISTOGRAM)) { return; } final Histogram histogram = statistics.getRepresentation(operation, Histogram.class, configuration.histogramBuckets, configuration.histogramPercentile); if (histogram == null) { return; } if (!LongStream.of(histogram.counts).anyMatch(count -> count > 0)) { return; } final String histogramFilename = getHistogramName(statistics, operation, configurationName, cluster, iteration, node, presentedStatistics); chartTaskFutures.add(HtmlReporter.executor.submit(new Callable<Void>() { @Override public Void call() throws Exception { log.debug("Generating histogram " + histogramFilename); HistogramChart chart = new HistogramChart().setData(operation, histogram); chart.setWidth(configuration.histogramWidth).setHeight(configuration.histogramHeight); chart.save(directory + File.separator + histogramFilename); return null; } })); final Histogram fullHistogram = statistics.getRepresentation(operation, Histogram.class); final String percentilesFilename = getPercentileChartName(statistics, operation, configurationName, cluster, iteration, node, presentedStatistics); chartTaskFutures.add(HtmlReporter.executor.submit(new Callable<Void>() { @Override public Void call() throws Exception { log.debug("Generating percentiles " + percentilesFilename); PercentilesChart chart = new PercentilesChart().addSeries(configurationName, fullHistogram); chart.setWidth(configuration.histogramWidth).setHeight(configuration.histogramHeight); chart.save(directory + File.separator + percentilesFilename); return null; } })); } protected boolean createChart(String filename, int clusterSize, String target, String rangeAxisLabel, ChartType chartType) throws IOException { ComparisonChart chart = generateChart(clusterSize, target, rangeAxisLabel, chartType); if (chart != null) { chart.setWidth(Math.min(Math.max(maxConfigurations, maxIterations) * 100 + 200, 1800)); chart.setHeight(Math.min(maxConfigurations * 100 + 200, 800)); chart.save(filename); return true; } return false; } protected ComparisonChart createComparisonChart(String iterationsName, String rangeAxisLabel, ChartType chartType) { ComparisonChart chart; // We've simplified the rule: when we have more iterations, it's always line chart, // with tests/sizes included in the categoryName and iterations on domain axis. // When there's only one iteration, we put cluster sizes on domain axis but use bar chart. if (maxIterations > 1 || chartType.requiresLineChart) { chart = new LineChart(iterationsName != null ? iterationsName : chartType.defaultDomainLabel, rangeAxisLabel); } else { chart = new BarChart("Cluster size", rangeAxisLabel); } return chart; } protected boolean addToChart(ComparisonChart chart, String subCategory, String target, ChartType chartType, Map<Report, List<Aggregation>> reportAggregationMap) { Map<String, List<Report>> byConfiguration = reportAggregationMap.keySet().stream().collect(Collectors.groupingBy(report -> report.getConfiguration().name)); for (Map.Entry<Report, List<Aggregation>> entry : reportAggregationMap.entrySet()) { for (Aggregation aggregation : entry.getValue()) { for (Map<String, OperationStats> operationStatsMap : aggregation.totalStats.getOperationsStats()) { OperationStats operationStats = operationStatsMap.get(target); if (operationStats == null) { // For throughput charts, check whether target is a group of operations OperationStats groupOperationStats = null; for (Map<String, OperationStats> operationStatsForGroups : aggregation.totalStats.getOperationStatsForGroups()) { groupOperationStats = operationStatsForGroups.get(target); if (groupOperationStats != null) break; } // Generate only throughput chart for group statistics if (groupOperationStats != null && (chartType != ChartType.OPERATION_THROUGHPUT_NET)) { return false; } } else { // Check whether operation belongs to any group. If so, skip throughput chart generation String operationsGroup = aggregation.totalStats.getOperationsGroup(Operation.getByName(target)); if (operationsGroup != null && (chartType == ChartType.OPERATION_THROUGHPUT_NET)) { return false; } } String categoryName = entry.getKey().getConfiguration().name; if (subCategory != null) { categoryName = String.format("%s, %s", categoryName, subCategory); } // if there are multiple reports for the same configuration (multiple clusters), use cluster size in category if (byConfiguration.get(entry.getKey().getConfiguration().name).size() > 1) { categoryName = String.format("%s, size %d", categoryName, entry.getKey().getCluster().getSize()); } double subCategoryNumeric; String subCategoryValue; String seriesCategoryName; if (maxIterations > 1) { subCategoryNumeric = aggregation.iteration.id; subCategoryValue = aggregation.iteration.getValue() != null ? aggregation.iteration.getValue() : String.valueOf(aggregation.iteration.id); seriesCategoryName = categoryName + ", Iteration " + subCategoryValue; } else { subCategoryNumeric = entry.getKey().getCluster().getSize(); subCategoryValue = String.format("Size %.0f", subCategoryNumeric); seriesCategoryName = categoryName; } switch (chartType) { case MEAN_AND_DEV: { MeanAndDev meanAndDev = aggregation.totalStats.getRepresentation(target, MeanAndDev.class); if (meanAndDev == null) return false; chart.addValue(toMillis(meanAndDev.mean), toMillis(meanAndDev.dev), categoryName, subCategoryNumeric, subCategoryValue); break; } case OPERATION_THROUGHPUT_NET: { OperationThroughput throughput = aggregation.totalStats.getRepresentation(target, OperationThroughput.class); if (throughput == null) return false; chart.addValue(throughput.net, 0, categoryName, subCategoryNumeric, subCategoryValue); break; } case DATA_THROUGHPUT: { DataThroughput dataThroughput = aggregation.totalStats.getRepresentation(target, DataThroughput.class); if (dataThroughput == null) return false; chart.addValue(dataThroughput.meanThroughput / (1024.0 * 1024.0), dataThroughput.deviation / (1024.0 * 1024.0), categoryName, subCategoryNumeric, subCategoryValue); break; } case MEAN_AND_DEV_SERIES: { MeanAndDev.Series series = aggregation.totalStats.getRepresentation(target, MeanAndDev.Series.class); if (series == null) return false; int sample = 0; for (MeanAndDev meanAndDev : series.samples) { chart.addValue(toMillis(meanAndDev.mean), toMillis(meanAndDev.dev), seriesCategoryName, sample++, String.valueOf(TimeUnit.MILLISECONDS.toSeconds(sample * series.period))); } break; } case REQUESTS_SERIES: { DefaultOutcome.Series series = aggregation.totalStats.getRepresentation(target, DefaultOutcome.Series.class); if (series == null) return false; int sample = 0; for (DefaultOutcome defaultOutcome : series.samples) { chart.addValue(defaultOutcome.requests, 0, seriesCategoryName, sample++, String.valueOf(TimeUnit.MILLISECONDS.toSeconds(sample * series.period))); } break; } case OPERATION_THROUGHPUT_GROSS_SERIES: { OperationThroughput.Series series = aggregation.totalStats.getRepresentation(target, OperationThroughput.Series.class); if (series == null) return false; int sample = 0; for (OperationThroughput defaultOutcome : series.samples) { chart.addValue(defaultOutcome.gross, 0, seriesCategoryName, sample++, String.valueOf(TimeUnit.MILLISECONDS.toSeconds(sample * series.period))); } break; } case OPERATION_THROUGHPUT_NET_SERIES: { OperationThroughput.Series series = aggregation.totalStats.getRepresentation(target, OperationThroughput.Series.class); if (series == null) return false; int sample = 0; for (OperationThroughput defaultOutcome : series.samples) { chart.addValue(defaultOutcome.net, 0, seriesCategoryName, sample++, String.valueOf(TimeUnit.MILLISECONDS.toSeconds(sample * series.period))); } break; } } } } } return true; } private double toMillis(double nanos) { return nanos / TimeUnit.MILLISECONDS.toNanos(1); } public int getMaxThreads(List<Aggregation> aggregations, final int slaveIndex) { return aggregations.stream().map(aggregation -> { List<Statistics> statistics = aggregation.iteration.getStatistics(slaveIndex); return statistics == null ? 0 : statistics.size(); }).max(Integer::max).orElse(0); } protected static Collector<String, StringBuilder, String> concatOrDefault(String def) { return Collector.of(StringBuilder::new, (sb, s) -> { if (sb.length() > 0) sb.append(", "); sb.append(s); }, (sb1, sb2) -> sb1.length() > 0 ? sb1.append(", ").append(sb2.toString()) : sb2, sb -> sb.length() == 0 ? def : sb.toString() ); } protected static boolean hasRepresentation(final String operation, Map<Report, List<Aggregation>> reportAggregationMap, final Class<?> representationClass, final Object... representationArgs) { List<Aggregation> aggregations = reportAggregationMap.values().stream().filter(as -> as != null && as.stream().anyMatch(aggregation -> aggregation != null)).flatMap(List::stream).collect(Collectors.toList()); for (Aggregation aggregation : aggregations) { for (Map<String, OperationStats> operationStatsMap : aggregation.totalStats.getOperationsStats()) { OperationStats operationStats = operationStatsMap.get(operation); if (operationStats == null) { for (Map<String, OperationStats> operationStatsForGroups : aggregation.totalStats.getOperationStatsForGroups()) { operationStats = operationStatsForGroups.get(operation); if (operationStats != null) { if (operationStats != null && operationStats.getRepresentation(representationClass, aggregation.totalStats, representationArgs) != null) return true; } } } else if (OperationThroughput.class.equals(representationClass)) { // Both op stats present -> operation group defined, skip throughput String operationsGroup = aggregation.totalStats.getOperationsGroup(Operation.getByName(operation)); if (operationsGroup != null) { // result is false break; } } if (operationStats != null && operationStats.getRepresentation(representationClass, aggregation.totalStats, representationArgs) != null) return true; } } return false; } public void createCharts(String target, int clusterSize) throws IOException { String suffix = clusterSize > 0 ? "_" + clusterSize : ""; String directory = this.directory.endsWith(File.separator) ? this.directory : this.directory + File.separator; List<ChartDescription> charts = generatedCharts.get(target); if (charts == null) { generatedCharts.put(target, charts = new ArrayList<>()); } for (ChartDescription cd : new ChartDescription[] { new ChartDescription(ChartType.MEAN_AND_DEV, "mean_dev" + "_" + target, "Response time mean", "Response time (ms)"), new ChartDescription(ChartType.OPERATION_THROUGHPUT_NET, "throughput_net" + "_" + target, "Operation throughput", "Operations/sec"), new ChartDescription(ChartType.DATA_THROUGHPUT, "data_throughput" + "_" + target, "Data throughput mean", "MB/sec"), new ChartDescription(ChartType.MEAN_AND_DEV_SERIES, "mean_dev_series" + "_" + target, "Response time over time", "Response time (ms)"), new ChartDescription(ChartType.REQUESTS_SERIES, "requests_series" + "_" + target, "Requests progression", "Number of requests"), new ChartDescription(ChartType.OPERATION_THROUGHPUT_NET_SERIES, "throughput_net_series" + "_" + target, "Operation throughput over time", "Operations/sec"), }) { if (createChart(String.format("%s%s%s_%s%s_%s.png", directory, File.separator, testName, target, suffix, cd.name), clusterSize, target, cd.yLabel, cd.type)) { charts.add(cd); } } } // generating Histogram and Percentile Graphs protected void createHistogramAndPercentileCharts(final String operation, Map<Report, List<Aggregation>> reportAggregationMap, String singleTestName) { Collection<StatisticType> presentedStatistics = new ArrayList<>(); if (hasRepresentation(operation, reportAggregationMap, Histogram.class, configuration.histogramBuckets, configuration.histogramPercentile)) { presentedStatistics.add(StatisticType.HISTOGRAM); } for (Map.Entry<Report, List<Aggregation>> entry : reportAggregationMap.entrySet()) { createSingleHistogramAndPercentileCharts(operation, presentedStatistics, entry.getKey(), entry.getValue()); } } private void createSingleHistogramAndPercentileCharts(String operation, Collection<StatisticType> presentedStatistics, Report report, List<Aggregation> aggregations) { int nodeCount = aggregations.isEmpty() ? 0 : aggregations.get(0).nodeStats.size(); for (Aggregation aggregation : aggregations) { createHistogramAndPercentileChart(aggregation.totalStats, operation, report.getConfiguration().name, report.getCluster().getClusterIndex(), aggregation.iteration.id, "total", presentedStatistics); } if (configuration.generateNodeStats) { for (int node = 0; node < nodeCount; ++node) { for (Aggregation aggregation : aggregations) { Statistics statistics = node >= aggregation.nodeStats.size() ? null : aggregation.nodeStats.get(node); createHistogramAndPercentileChart(statistics, operation, report.getConfiguration().name, report.getCluster().getClusterIndex(), aggregation.iteration.id, "node" + node, presentedStatistics); } if (configuration.generateThreadStats) { int maxThreads = getMaxThreads(aggregations, node); for (int thread = 0; thread < maxThreads; ++thread) { for (Aggregation aggregation : aggregations) { List<Statistics> nodeStats = aggregation.iteration.getStatistics(node); Statistics threadStats = nodeStats == null || nodeStats.size() <= thread ? null : nodeStats.get(thread); createHistogramAndPercentileChart(threadStats, operation, report.getConfiguration().name, report.getCluster().getClusterIndex(), aggregation.iteration.id, "thread" + node + "_" + thread, presentedStatistics); } } } } } } protected void waitForChartsGeneration() { for (Future f : chartTaskFutures) { try { f.get(); } catch (Exception e) { log.error("Failed to create chart", e); } } chartTaskFutures.clear(); } /** * The following methods are used in Freemarker templates * e.g. method getPercentiles() can be used as getPercentiles() or percentiles in template */ public String getTestName() { return testName; } public int getMaxClusters() { return maxClusters; } public Configuration getConfiguration() { return configuration; } public int getMaxIterations() { return maxIterations; } public int getElementCounter() { return elementCounter; } public void incElementCounter() { this.elementCounter++; } public Class defaultOutcomeClass() { return DefaultOutcome.class; } public Class meanAndDevClass() { return MeanAndDev.class; } public Class operationThroughputClass() { return OperationThroughput.class; } public Class dataThroughputClass() { return DataThroughput.class; } public Class percentileClass() { return Percentile.class; } public String formatTime(double value) { return Utils.prettyPrintTime((long) value, TimeUnit.NANOSECONDS).replaceAll(" ", " "); } //These methods are used in templates public String generateImageName(String operation, String suffix, String name) { return String.format("%s_%s%s_%s\"", testName, operation, suffix, name); } public List<ChartDescription> getGeneratedCharts(String operation) { return generatedCharts.getOrDefault(operation, Collections.emptyList()); } public String getHistogramName(Statistics statistics, final String operation, String configurationName, int cluster, int iteration, String node, Collection<StatisticType> presentedStatistics) { String resultFileName = ""; if (presentedStatistics.contains(StatisticType.HISTOGRAM)) { final Histogram histogram = statistics.getRepresentation(operation, Histogram.class, configuration.histogramBuckets, configuration.histogramPercentile); if (histogram == null) { return resultFileName; } else { resultFileName = String.format("histogram_%s_%s_%s_%d_%d_%s.png", testName, operation, configurationName, cluster, iteration, node); } } return resultFileName; } public String getPercentileChartName(Statistics statistics, final String operation, String configurationName, int cluster, int iteration, String node, Collection<StatisticType> presentedStatistics) { String resultFileName = ""; if (presentedStatistics.contains(StatisticType.HISTOGRAM)) { final Histogram histogram = statistics.getRepresentation(operation, Histogram.class, configuration.histogramBuckets, configuration.histogramPercentile); if (histogram == null) { return resultFileName; } else { resultFileName = String.format("percentiles_%s_%s_%s_%d_%d_%s.png", testName, operation, configurationName, cluster, iteration, node); } } return resultFileName; } public long period(Statistics statistics) { long period = 0; if (statistics != null) { period = TimeUnit.MILLISECONDS.toNanos(statistics.getEnd() - statistics.getBegin()); } return period; } public String rowClass(boolean suspect) { String rowClass = suspect && configuration.highlightSuspects ? "highlight" : ""; return rowClass; } public String formatOperationThroughput(double operationThroughput) { return String.format("%.0f reqs/s", operationThroughput); } protected abstract ComparisonChart generateChart(int clusterSize, String operation, String rangeAxisLabel, ChartType chartType); public String formatDataThroughput(double value) { return String.format("%.0f MB/s ", value / (1024.0 * 1024.0)); } public int numberOfColumns(Collection<StatisticType> presentedStatistics) { int columns = 4; columns += presentedStatistics.contains(StatisticType.HISTOGRAM) ? 1 : 0; columns += presentedStatistics.contains(StatisticType.PERCENTILES) ? configuration.percentiles.length : 0; columns += presentedStatistics.contains(StatisticType.OPERATION_THROUGHPUT) ? 2 : 0; columns += presentedStatistics.contains(StatisticType.DATA_THROUGHPUT) ? 4 : 0; return columns; } public int calculateExpandableRows(List<Aggregation> aggregations, int nodeCount) { int expandableRows = 0; if (configuration.generateNodeStats) { expandableRows += nodeCount; if (configuration.generateThreadStats) { for (int node = 0; node < nodeCount; ++node) { expandableRows += getMaxThreads(aggregations, node); } } } return expandableRows; } public Statistics getStatistics(Aggregation aggregation, int node) { Statistics statistics = node >= aggregation.nodeStats.size() ? null : aggregation.nodeStats.get(node); return statistics; } public Statistics getThreadStatistics(Aggregation aggregation, int node, int thread) { List<Statistics> nodeStats = aggregation.iteration.getStatistics(node); Statistics threadStats = nodeStats == null || nodeStats.size() <= thread ? null : nodeStats.get(thread); return threadStats; } public int getThreads(Aggregation aggregation, int node) { int threads = node >= aggregation.nodeThreads.size() ? 0 : aggregation.nodeThreads.get(node); return threads; } public boolean separateClusterCharts() { return configuration.separateClusterCharts; } public OperationData getOperationData(final String operation, Map<Report, List<Aggregation>> reportAggregationMap) { Collection<StatisticType> presentedStatistics = new ArrayList<>(); presentedStatistics.add(StatisticType.MEAN_AND_DEV); if (configuration.percentiles.length > 0 && hasRepresentation(operation, reportAggregationMap, Percentile.class, configuration.percentiles[0])) { presentedStatistics.add(StatisticType.PERCENTILES); } if (hasRepresentation(operation, reportAggregationMap, Histogram.class, configuration.histogramBuckets, configuration.histogramPercentile)) { presentedStatistics.add(StatisticType.HISTOGRAM); } if (hasRepresentation(operation, reportAggregationMap, OperationThroughput.class)) { presentedStatistics.add(StatisticType.OPERATION_THROUGHPUT); } if (hasRepresentation(operation, reportAggregationMap, DataThroughput.class, 100L, 100L, 100L, 100.0)) { presentedStatistics.add(StatisticType.DATA_THROUGHPUT); } List<String> iterations = new ArrayList<>(maxIterations); for (int iteration = 0; iteration < maxIterations; ++iteration) { // in fact we shouldn't have different iterations values for iterations with the same id, // but it's possible Set<String> iterationValues = new HashSet<>(); for (List<Aggregation> aggregations : reportAggregationMap.values()) { if (aggregations != null && iteration < aggregations.size()) { Aggregation aggregation = aggregations.get(iteration); if (aggregation != null && aggregation.iteration.getValue() != null) { iterationValues.add(aggregation.iteration.test.iterationsName + " = " + aggregation.iteration.getValue()); } } } iterations.add(iterationValues.stream().collect(concatOrDefault("iteration " + String.valueOf(iteration)))); } return new OperationData(presentedStatistics, iterations); } //helper class for FreeMarker template public class OperationData { private List<String> iterationValues; private Collection<StatisticType> presentedStatistics; public OperationData(Collection<StatisticType> presentedStatistics, List<String> iterationValues) { this.presentedStatistics = presentedStatistics; this.iterationValues = iterationValues; } public List<String> getIterationValues() { return iterationValues; } public Collection<StatisticType> getPresentedStatistics() { return presentedStatistics; } } protected enum StatisticType { MEAN_AND_DEV, OPERATION_THROUGHPUT, DATA_THROUGHPUT, HISTOGRAM, PERCENTILES } protected enum ChartType { MEAN_AND_DEV(false, "Iteration"), OPERATION_THROUGHPUT_NET(false, "Iteration"), DATA_THROUGHPUT(false, "Iteration"), MEAN_AND_DEV_SERIES(true, "Time (seconds)"), REQUESTS_SERIES(true, "Time (seconds)"), OPERATION_THROUGHPUT_NET_SERIES(true, "Time (seconds)"), OPERATION_THROUGHPUT_GROSS_SERIES(true, "Time (seconds)"); private final boolean requiresLineChart; private final String defaultDomainLabel; ChartType(boolean requiresLineChart, String defaultDomainLabel) { this.requiresLineChart = requiresLineChart; this.defaultDomainLabel = defaultDomainLabel; } } public static class ChartDescription { public final ChartType type; public final String name; public final String title; public final String yLabel; public ChartDescription(ChartType type, String name, String title, String yLabel) { this.type = type; this.name = name; this.title = title; this.yLabel = yLabel; } } public static class Configuration { @Property(doc = "Generate separate charts for different cluster sizes. Default is false.") protected boolean separateClusterCharts = false; @Property(doc = "List of test names that should be reported together. Default is empty.") protected List<List<String>> combinedTests = Collections.EMPTY_LIST; @Property(name = "histogram.buckets", doc = "Number of bars the histogram chart will show. Default is 40.") protected int histogramBuckets = 40; @Property(name = "histogram.percentile", doc = "Percentage of fastest responses that will be presented in the chart. Default is 99%.") protected double histogramPercentile = 99d; @Property(name = "histogram.chart.width", doc = "Width of the histogram chart in pixels. Default is 800.") protected int histogramWidth = 800; @Property(name = "histogram.chart.height", doc = "Height of the histogram chart in pixels. Default is 600.") protected int histogramHeight = 600; @Property(doc = "Show response time at certain percentiles. Default is 95% and 99%.") protected double[] percentiles = new double[] {95d, 99d}; @Property(doc = "Generate statistics for each node (expandable menu). Default is true.") protected boolean generateNodeStats = true; @Property(doc = "Generate statistics for each thread (expandable menu). Default is false.") protected boolean generateThreadStats = false; @Property(doc = "Highlight suspicious results in the report. Default is true.") protected boolean highlightSuspects = true; /** * The following methods are used in Freemarker templates * e.g. method getPercentiles() can be used as getPercentiles() or percentiles in template */ public int getHistogramBuckets() { return histogramBuckets; } public double getHistogramPercentile() { return histogramPercentile; } public int getHistogramWidth() { return histogramWidth; } public int getHistogramHeight() { return histogramHeight; } public double[] getPercentiles() { return percentiles; } public boolean getGenerateNodeStats() { return generateNodeStats; } public boolean getGenerateThreadStats() { return generateThreadStats; } public boolean getHighlightSuspects() { return highlightSuspects; } public boolean getSeparateClusterCharts() { return separateClusterCharts; } public List<List<String>> getCombinedTests() { return combinedTests; } } }