/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package org.apache.jmeter.report.dashboard;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.io.FileUtils;
import org.apache.jmeter.report.config.ConfigurationException;
import org.apache.jmeter.report.config.ExporterConfiguration;
import org.apache.jmeter.report.config.GraphConfiguration;
import org.apache.jmeter.report.config.ReportGeneratorConfiguration;
import org.apache.jmeter.report.core.ControllerSamplePredicate;
import org.apache.jmeter.report.core.ConvertException;
import org.apache.jmeter.report.core.Converters;
import org.apache.jmeter.report.core.SampleException;
import org.apache.jmeter.report.core.StringConverter;
import org.apache.jmeter.report.processor.AbstractSampleConsumer;
import org.apache.jmeter.report.processor.AggregateConsumer;
import org.apache.jmeter.report.processor.ApdexSummaryConsumer;
import org.apache.jmeter.report.processor.ApdexThresholdsInfo;
import org.apache.jmeter.report.processor.CsvFileSampleSource;
import org.apache.jmeter.report.processor.ErrorsSummaryConsumer;
import org.apache.jmeter.report.processor.FilterConsumer;
import org.apache.jmeter.report.processor.MaxAggregator;
import org.apache.jmeter.report.processor.MinAggregator;
import org.apache.jmeter.report.processor.NormalizerSampleConsumer;
import org.apache.jmeter.report.processor.RequestsSummaryConsumer;
import org.apache.jmeter.report.processor.SampleConsumer;
import org.apache.jmeter.report.processor.SampleContext;
import org.apache.jmeter.report.processor.SampleSource;
import org.apache.jmeter.report.processor.StatisticsSummaryConsumer;
import org.apache.jmeter.report.processor.Top5ErrorsBySamplerConsumer;
import org.apache.jmeter.report.processor.graph.AbstractGraphConsumer;
import org.apache.jmeter.reporters.ResultCollector;
import org.apache.jmeter.samplers.SampleSaveConfiguration;
import org.apache.jmeter.util.JMeterUtils;
import org.apache.oro.text.regex.PatternMatcher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The class ReportGenerator provides a way to generate all the templated files
* of the plugin.
*
* @since 3.0
*/
public class ReportGenerator {
private static final String REPORTGENERATOR_PROPERTIES = "reportgenerator.properties";
private static final Logger log = LoggerFactory.getLogger(ReportGenerator.class);
private static final boolean CSV_OUTPUT_FORMAT = "csv"
.equalsIgnoreCase(JMeterUtils.getPropDefault(
"jmeter.save.saveservice.output_format", "csv"));
private static final char CSV_DEFAULT_SEPARATOR =
// We cannot use JMeterUtils#getPropDefault as it applies a trim on value
JMeterUtils.getDelimiter(
JMeterUtils.getJMeterProperties().getProperty(SampleSaveConfiguration.DEFAULT_DELIMITER_PROP, SampleSaveConfiguration.DEFAULT_DELIMITER)).charAt(0);
private static final String INVALID_CLASS_FMT = "Class name \"%s\" is not valid.";
private static final String INVALID_EXPORT_FMT = "Data exporter \"%s\" is unable to export data.";
private static final String NOT_SUPPORTED_CONVERSION_FMT = "Not supported conversion to \"%s\"";
public static final String NORMALIZER_CONSUMER_NAME = "normalizer";
public static final String BEGIN_DATE_CONSUMER_NAME = "beginDate";
public static final String END_DATE_CONSUMER_NAME = "endDate";
public static final String NAME_FILTER_CONSUMER_NAME = "nameFilter";
public static final String DATE_RANGE_FILTER_CONSUMER_NAME = "dateRangeFilter";
public static final String APDEX_SUMMARY_CONSUMER_NAME = "apdexSummary";
public static final String ERRORS_SUMMARY_CONSUMER_NAME = "errorsSummary";
public static final String REQUESTS_SUMMARY_CONSUMER_NAME = "requestsSummary";
public static final String STATISTICS_SUMMARY_CONSUMER_NAME = "statisticsSummary";
public static final String TOP5_ERRORS_BY_SAMPLER_CONSUMER_NAME = "top5ErrorsBySampler";
public static final String START_INTERVAL_CONTROLLER_FILTER_CONSUMER_NAME = "startIntervalControlerFilter";
private static final Pattern POTENTIAL_CAMEL_CASE_PATTERN = Pattern.compile("_(.)");
private final File testFile;
private final ReportGeneratorConfiguration configuration;
/**
* ResultCollector used
*/
private final ResultCollector resultCollector;
/**
* Instantiates a new report generator.
*
* @param resultsFile
* the test results file
* @param resultCollector
* Can be null, used if generation occurs at end of test
* @throws ConfigurationException when loading configuration from file fails
*/
public ReportGenerator(String resultsFile, ResultCollector resultCollector)
throws ConfigurationException {
if (!CSV_OUTPUT_FORMAT) {
throw new IllegalArgumentException(
"Report generation requires csv output format, check 'jmeter.save.saveservice.output_format' property");
}
log.info("ReportGenerator will use for Parsing the separator: '{}'", CSV_DEFAULT_SEPARATOR);
File file = new File(resultsFile);
if (resultCollector == null) {
if (!(file.isFile() && file.canRead())) {
throw new IllegalArgumentException(String.format(
"Cannot read test results file : %s", file));
}
log.info("Will only generate report from results file: {}", resultsFile);
} else {
if (file.exists() && file.length() > 0) {
throw new IllegalArgumentException("Results file:"
+ resultsFile + " is not empty");
}
log.info("Will generate report at end of test from results file: {}", resultsFile);
}
this.resultCollector = resultCollector;
this.testFile = file;
final Properties merged = new Properties();
File rgp = new File(JMeterUtils.getJMeterBinDir(), REPORTGENERATOR_PROPERTIES);
if(log.isInfoEnabled()) {
log.info("Reading report generator properties from: {}", rgp.getAbsolutePath());
}
merged.putAll(loadProps(rgp));
log.info("Merging with JMeter properties");
merged.putAll(JMeterUtils.getJMeterProperties());
configuration = ReportGeneratorConfiguration.loadFromProperties(merged);
}
private static Properties loadProps(File file) {
final Properties props = new Properties();
try (FileInputStream inStream = new FileInputStream(file)) {
props.load(inStream);
} catch (IOException e) {
log.error("Problem loading properties from file.", e);
System.err.println("Problem loading properties. " + e); // NOSONAR
}
return props;
}
/**
* <p>
* Gets the name of property setter from the specified key.
* </p>
* <p>
* E.g : with key set_granularity, returns setGranularity (camel case)
* </p>
*
* @param propertyKey
* the property key
* @return the name of the property setter
*/
private static String getSetterName(String propertyKey) {
Matcher matcher = POTENTIAL_CAMEL_CASE_PATTERN.matcher(propertyKey);
StringBuffer buffer = new StringBuffer(); // NOSONAR Unfortunately Matcher does not support StringBuilder
while (matcher.find()) {
matcher.appendReplacement(buffer, matcher.group(1).toUpperCase());
}
matcher.appendTail(buffer);
return buffer.toString();
}
/**
* Generate dashboard reports using the data from the specified CSV File.
*
* @throws GenerationException
* when the generation failed
*/
public void generate() throws GenerationException {
if (resultCollector != null) {
log.info("Flushing result collector before report Generation");
resultCollector.flushFile();
}
log.debug("Start report generation");
File tmpDir = configuration.getTempDirectory();
boolean tmpDirCreated = createTempDir(tmpDir);
// Build consumers chain
SampleContext sampleContext = new SampleContext();
sampleContext.setWorkingDirectory(tmpDir);
SampleSource source = new CsvFileSampleSource(testFile, CSV_DEFAULT_SEPARATOR);
source.setSampleContext(sampleContext);
NormalizerSampleConsumer normalizer = new NormalizerSampleConsumer();
normalizer.setName(NORMALIZER_CONSUMER_NAME);
FilterConsumer dateRangeConsumer = createFilterByDateRange();
dateRangeConsumer.addSampleConsumer(createBeginDateConsumer());
dateRangeConsumer.addSampleConsumer(createEndDateConsumer());
FilterConsumer nameFilter = createNameFilter();
FilterConsumer excludeControllerFilter = createExcludeControllerFilter();
nameFilter.addSampleConsumer(excludeControllerFilter);
dateRangeConsumer.addSampleConsumer(nameFilter);
normalizer.addSampleConsumer(dateRangeConsumer);
source.addSampleConsumer(normalizer);
// Get graph configurations
Map<String, GraphConfiguration> graphConfigurations = configuration
.getGraphConfigurations();
// Process configuration to build graph consumers
for (Map.Entry<String, GraphConfiguration> entryGraphCfg : graphConfigurations
.entrySet()) {
addGraphConsumer(nameFilter, excludeControllerFilter,
entryGraphCfg);
}
// Generate data
log.debug("Start samples processing");
try {
source.run(); // NOSONAR
} catch (SampleException ex) {
throw new GenerationException("Error while processing samples:"+ex.getMessage(), ex);
}
log.debug("End of samples processing");
log.debug("Start data exporting");
// Process configuration to build data exporters
String key;
ExporterConfiguration value;
for (Map.Entry<String, ExporterConfiguration> entry : configuration.getExportConfigurations().entrySet()) {
key = entry.getKey();
value = entry.getValue();
if (log.isInfoEnabled()) {
log.info("Exporting data using exporter:'{}' of className:'{}'", key, value.getClassName());
}
exportData(sampleContext, key, value);
}
log.debug("End of data exporting");
removeTempDir(tmpDir, tmpDirCreated);
log.debug("End of report generation");
}
/**
* @return {@link FilterConsumer} that filter data based on date range
*/
private FilterConsumer createFilterByDateRange() {
FilterConsumer dateRangeFilter = new FilterConsumer();
dateRangeFilter.setName(DATE_RANGE_FILTER_CONSUMER_NAME);
dateRangeFilter.setSamplePredicate(sample -> {
long sampleStartTime = sample.getStartTime();
if(configuration.getStartDate() != null) {
if(sampleStartTime >= configuration.getStartDate().getTime()) {
if(configuration.getEndDate() != null) {
return sampleStartTime <= configuration.getEndDate().getTime();
} else {
return true;
}
}
return false;
} else {
if(configuration.getEndDate() != null) {
return sampleStartTime <= configuration.getEndDate().getTime();
} else {
return true;
}
}
});
return dateRangeFilter;
}
private void removeTempDir(File tmpDir, boolean tmpDirCreated) {
if (tmpDirCreated) {
try {
FileUtils.deleteDirectory(tmpDir);
} catch (IOException ex) {
log.warn("Cannot delete created temporary directory, '{}'.", tmpDir, ex);
}
}
}
private boolean createTempDir(File tmpDir) throws GenerationException {
boolean tmpDirCreated = false;
if (!tmpDir.exists()) {
tmpDirCreated = tmpDir.mkdir();
if (!tmpDirCreated) {
String message = String.format(
"Cannot create temporary directory \"%s\".", tmpDir);
log.error(message);
throw new GenerationException(message);
}
}
return tmpDirCreated;
}
private void addGraphConsumer(FilterConsumer nameFilter,
FilterConsumer excludeControllerFilter,
Map.Entry<String, GraphConfiguration> entryGraphCfg)
throws GenerationException {
String graphName = entryGraphCfg.getKey();
GraphConfiguration graphConfiguration = entryGraphCfg.getValue();
// Instantiate the class from the classname
String className = graphConfiguration.getClassName();
try {
Class<?> clazz = Class.forName(className);
Object obj = clazz.newInstance();
AbstractGraphConsumer graph = (AbstractGraphConsumer) obj;
graph.setName(graphName);
// Set the graph title
graph.setTitle(graphConfiguration.getTitle());
// Set graph properties using reflection
Method[] methods = clazz.getMethods();
for (Map.Entry<String, String> entryProperty : graphConfiguration
.getProperties().entrySet()) {
String propertyName = entryProperty.getKey();
String propertyValue = entryProperty.getValue();
String setterName = getSetterName(propertyName);
setProperty(className, obj, methods, propertyName,
propertyValue, setterName);
}
// Choose which entry point to use to plug the graph
AbstractSampleConsumer entryPoint = graphConfiguration
.excludesControllers() ? excludeControllerFilter
: nameFilter;
entryPoint.addSampleConsumer(graph);
} catch (ClassNotFoundException | IllegalAccessException
| InstantiationException | ClassCastException ex) {
String error = String.format(INVALID_CLASS_FMT, className);
log.error(error, ex);
throw new GenerationException(error, ex);
}
}
private void exportData(SampleContext sampleContext, String exporterName,
ExporterConfiguration exporterConfiguration)
throws GenerationException {
// Instantiate the class from the classname
String className = exporterConfiguration.getClassName();
try {
Class<?> clazz = Class.forName(className);
Object obj = clazz.newInstance();
DataExporter exporter = (DataExporter) obj;
exporter.setName(exporterName);
// Export data
exporter.export(sampleContext, testFile, configuration);
} catch (ClassNotFoundException | IllegalAccessException
| InstantiationException | ClassCastException ex) {
String error = String.format(INVALID_CLASS_FMT, className);
log.error(error, ex);
throw new GenerationException(error, ex);
} catch (ExportException ex) {
String error = String.format(INVALID_EXPORT_FMT, exporterName);
log.error(error, ex);
throw new GenerationException(error, ex);
}
}
private ErrorsSummaryConsumer createErrorsSummaryConsumer() {
ErrorsSummaryConsumer errorsSummaryConsumer = new ErrorsSummaryConsumer();
errorsSummaryConsumer.setName(ERRORS_SUMMARY_CONSUMER_NAME);
return errorsSummaryConsumer;
}
private FilterConsumer createExcludeControllerFilter() {
FilterConsumer excludeControllerFilter = new FilterConsumer();
excludeControllerFilter
.setName(START_INTERVAL_CONTROLLER_FILTER_CONSUMER_NAME);
excludeControllerFilter
.setSamplePredicate(new ControllerSamplePredicate());
excludeControllerFilter.setReverseFilter(true);
excludeControllerFilter.addSampleConsumer(createErrorsSummaryConsumer());
return excludeControllerFilter;
}
private SampleConsumer createTop5ErrorsConsumer(ReportGeneratorConfiguration configuration) {
Top5ErrorsBySamplerConsumer top5ErrorsBySamplerConsumer = new Top5ErrorsBySamplerConsumer();
top5ErrorsBySamplerConsumer.setName(TOP5_ERRORS_BY_SAMPLER_CONSUMER_NAME);
top5ErrorsBySamplerConsumer.setHasOverallResult(true);
top5ErrorsBySamplerConsumer.setIgnoreTransactionController(configuration.isIgnoreTCFromTop5ErrorsBySampler());
return top5ErrorsBySamplerConsumer;
}
private StatisticsSummaryConsumer createStatisticsSummaryConsumer() {
StatisticsSummaryConsumer statisticsSummaryConsumer = new StatisticsSummaryConsumer();
statisticsSummaryConsumer.setName(STATISTICS_SUMMARY_CONSUMER_NAME);
statisticsSummaryConsumer.setHasOverallResult(true);
return statisticsSummaryConsumer;
}
private RequestsSummaryConsumer createRequestsSummaryConsumer() {
RequestsSummaryConsumer requestsSummaryConsumer = new RequestsSummaryConsumer();
requestsSummaryConsumer.setName(REQUESTS_SUMMARY_CONSUMER_NAME);
return requestsSummaryConsumer;
}
private ApdexSummaryConsumer createApdexSummaryConsumer() {
ApdexSummaryConsumer apdexSummaryConsumer = new ApdexSummaryConsumer();
apdexSummaryConsumer.setName(APDEX_SUMMARY_CONSUMER_NAME);
apdexSummaryConsumer.setHasOverallResult(true);
apdexSummaryConsumer.setThresholdSelector(sampleName -> {
ApdexThresholdsInfo info = new ApdexThresholdsInfo();
// set default values anyway for safety
info.setSatisfiedThreshold(configuration
.getApdexSatisfiedThreshold());
info.setToleratedThreshold(configuration
.getApdexToleratedThreshold());
// see if the sample name is in the special cases targeted
// by property jmeter.reportgenerator.apdex_per_transaction
// key in entry below can be a hardcoded name or a regex
for (Map.Entry<String, Long[]> entry : configuration.getApdexPerTransaction().entrySet()) {
org.apache.oro.text.regex.Pattern regex = JMeterUtils.getPatternCache().getPattern(entry.getKey());
PatternMatcher matcher = JMeterUtils.getMatcher();
if (matcher.matches(sampleName, regex)) {
Long satisfied = entry.getValue()[0];
Long tolerated = entry.getValue()[1];
if(log.isDebugEnabled()) {
log.debug("Found match for sampleName:{}, Regex:{}, satisfied value:{}, tolerated value:{}",
entry.getKey(), satisfied, tolerated);
}
info.setSatisfiedThreshold(satisfied);
info.setToleratedThreshold(tolerated);
break;
}
}
return info;
});
return apdexSummaryConsumer;
}
/**
* @return a {@link FilterConsumer} that filters samplers based on their name
*/
private FilterConsumer createNameFilter() {
FilterConsumer nameFilter = new FilterConsumer();
nameFilter.setName(NAME_FILTER_CONSUMER_NAME);
nameFilter.setSamplePredicate(sample -> {
// Get filtered samples from configuration
Pattern filteredSamplesPattern = configuration
.getFilteredSamplesPattern();
// Sample is kept if no filter is set
// or if its name matches the filter pattern
return filteredSamplesPattern == null
|| filteredSamplesPattern.matcher(sample.getName()).matches();
});
nameFilter.addSampleConsumer(createApdexSummaryConsumer());
nameFilter.addSampleConsumer(createRequestsSummaryConsumer());
nameFilter.addSampleConsumer(createStatisticsSummaryConsumer());
nameFilter.addSampleConsumer(createTop5ErrorsConsumer(configuration));
return nameFilter;
}
/**
* @return Consumer that compute the end date of the test
*/
private AggregateConsumer createEndDateConsumer() {
AggregateConsumer endDateConsumer = new AggregateConsumer(
new MaxAggregator(), sample -> Double.valueOf(sample.getEndTime()));
endDateConsumer.setName(END_DATE_CONSUMER_NAME);
return endDateConsumer;
}
/**
* @return Consumer that compute the begining date of the test
*/
private AggregateConsumer createBeginDateConsumer() {
AggregateConsumer beginDateConsumer = new AggregateConsumer(
new MinAggregator(), sample -> Double.valueOf(sample.getStartTime()));
beginDateConsumer.setName(BEGIN_DATE_CONSUMER_NAME);
return beginDateConsumer;
}
/**
* Try to set a property on an object by reflection.
*
* @param className
* name of the objects class
* @param obj
* the object on which the property should be set
* @param methods
* methods of the object which will be search for the property
* setter
* @param propertyName
* name of the property to be set
* @param propertyValue
* value to be set
* @param setterName
* name of the property setter that should be used to set the
* property
* @throws IllegalAccessException
* if reflection throws an IllegalAccessException
* @throws GenerationException
* if conversion of the property value fails or reflection
* throws an InvocationTargetException
*/
private void setProperty(String className, Object obj, Method[] methods,
String propertyName, String propertyValue, String setterName)
throws IllegalAccessException, GenerationException {
try {
int i = 0;
while (i < methods.length) {
Method method = methods[i];
if (method.getName().equals(setterName)) {
Class<?>[] parameterTypes = method
.getParameterTypes();
if (parameterTypes.length == 1) {
Class<?> parameterType = parameterTypes[0];
if (parameterType
.isAssignableFrom(String.class)) {
method.invoke(obj, propertyValue);
} else {
StringConverter<?> converter = Converters
.getConverter(parameterType);
if (converter == null) {
throw new GenerationException(
String.format(
NOT_SUPPORTED_CONVERSION_FMT,
parameterType
.getName()));
}
method.invoke(obj, converter
.convert(propertyValue));
}
return;
}
}
i++;
}
log.warn("'{}' is not a valid property for class '{}', skip it", propertyName, className);
} catch (InvocationTargetException | ConvertException ex) {
String message = String
.format("Cannot assign \"%s\" to property \"%s\" (mapped as \"%s\"), skip it",
propertyValue, propertyName, setterName);
log.error(message, ex);
throw new GenerationException(message, ex);
}
}
}