/*
* Copyright 2012 NGDATA nv
*
* Licensed 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.lilyproject.clientmetrics.postproc;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.PrintStream;
import java.text.Collator;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.cli.CommandLine;
import org.apache.commons.cli.Option;
import org.apache.commons.cli.OptionBuilder;
import org.apache.commons.io.FileUtils;
import org.joda.time.format.DateTimeFormat;
import org.joda.time.format.DateTimeFormatter;
import org.lilyproject.cli.BaseCliTool;
import org.lilyproject.util.ConsoleUtil;
import org.lilyproject.util.Version;
/**
* Makes a nice report with graphs based on a plain metrics output file.
*/
public class MetricsReportTool extends BaseCliTool {
private Option metricsFileOption;
private Option outputDirOption;
private Option forceOption;
private static final char SEP = ' ';
private static final String STRING_QUOTE = "\"";
private static final int COLS_PER_METRIC = 5;
private static final int HEADER_COLUMNS = 2;
private static final int COL_CNT = 1;
private static final int COL_AVG = 2;
private static final int COL_MED = 3;
private static final int COL_MIN = 4;
private static final int COL_MAX = 5;
// http://www.uni-hamburg.de/Wiss/FB/15/Sustainability/schneider/gnuplot/colors.htm
private static final String[] COLORS = new String[] {
"#D2691E", /* chocolate */
"#DC143C", /* crimson */
"#00008B", /* darkblue */
"#006400", /* darkgreen */
"#FF8C00", /* darkorange */
"#FF1493", /* deeppink */
"#FFD700", /* gold */
"#808000", /* olive */
"#FF0000", /* red */
"#708090", /* slategray */
"#00008B" /* darkblue */
};
private NumberFormat doubleFormat = new DecimalFormat("0.00");
private DateTimeFormatter timeFormat = DateTimeFormat.forPattern("yyyyMMddHHmmss");
@Override
protected String getCmdName() {
return "lily-metrics-report";
}
@Override
protected String getVersion() {
return Version.readVersion("org.lilyproject", "lily-clientmetrics");
}
public static void main(String[] args) {
new MetricsReportTool().start(args);
}
@Override
@SuppressWarnings("static-access")
public List<Option> getOptions() {
List<Option> options = super.getOptions();
metricsFileOption = OptionBuilder
.withArgName("filename")
.hasArg()
.withDescription("Name of the input metrics file")
.withLongOpt("metrics-file")
.create("m");
options.add(metricsFileOption);
outputDirOption = OptionBuilder
.withArgName("dirname")
.hasArg()
.withDescription("Name of the output dir")
.withLongOpt("output-dir")
.create("o");
options.add(outputDirOption);
forceOption = OptionBuilder.withDescription("Force using the output directory even if it already exists")
.withLongOpt("force").create("f");
options.add(forceOption);
return options;
}
@Override
public int run(CommandLine cmd) throws Exception {
int result = super.run(cmd);
if (result != 0) {
return result;
}
String metricFileName = cmd.getOptionValue(metricsFileOption.getOpt());
if (metricFileName == null) {
System.out.println("Specify metrics file name with -" + metricsFileOption.getOpt());
return 1;
}
File metricFile = new File(metricFileName);
if (!metricFile.exists()) {
System.err.println("Specified metrics file does not exist: " + metricFile.getAbsolutePath());
return 1;
}
String outputDirName = cmd.getOptionValue(outputDirOption.getOpt());
if (outputDirName == null) {
System.out.println("Specify output directory with -" + outputDirOption.getOpt());
return 1;
}
File outputDir = new File(outputDirName);
if (outputDir.exists()) {
if (!cmd.hasOption(forceOption.getOpt())) {
System.err.println("Specified output directory already exists: " + outputDir.getAbsolutePath());
boolean proceed = ConsoleUtil.promptYesNo("Continue anyway? [y/N]", false);
if (!proceed) {
return 1;
}
}
}
MetricsParser parser = new MetricsParser();
Tests tests;
try {
tests = parser.parse(metricFile);
} catch (Exception e) {
System.err.println("Error occurred reading metrics file, current line: " + parser.getCurrentLine());
System.err.println();
e.printStackTrace();
return 1;
}
if (tests.entries.size() == 0) {
System.err.println("No test data found in specified metrics file");
return 1;
}
for (Test test : tests.entries) {
File testDir = new File(outputDir, test.name);
FileUtils.forceMkdir(testDir);
writeMetrics(test, testDir);
}
writeTestsInfo(tests, outputDir);
// Include a copy of the original input metrics file in the output dir, except if the output is
// produced in the same directory as where the metrics file is located
if (!outputDir.getAbsoluteFile().getCanonicalFile().equals(metricFile.getAbsoluteFile().getParentFile().getCanonicalFile())) {
System.out.println("Copy original metrics file in output directory");
FileUtils.copyFile(metricFile, new File(outputDir, metricFile.getName()));
}
return 0;
}
private GroupMap writeMetrics(Test test, File outputDir) throws IOException, InterruptedException {
// Determine how the metrics will be grouped into files (plots)
// This map contains as key a group name, and as value the list of metrics that fall into that group
GroupMap groups = new GroupMap();
for (String metricName : test.metricNames.keySet()) {
int colonPos = metricName.indexOf(':');
int atPos = metricName.indexOf('@');
if (colonPos > 0) { // grouped by type
String group = metricName.substring(0, colonPos);
groups.getByString(group).add(metricName);
} else if (atPos > 0) { // custom grouping when not using types (e.g. used by the system metrics)
String group = metricName.substring(0, atPos);
groups.getByString(group).add(metricName);
} else {
// all the rest: each in its own group
groups.getByString(metricName).add(metricName);
}
}
for (List<String> list : groups.values()) {
Collections.sort(list);
}
for (Map.Entry<GroupName, List<String>> entry : groups.entrySet()) {
writeDataFile(entry.getKey(), entry.getValue(), test, outputDir);
}
System.out.println();
for (Map.Entry<GroupName, List<String>> entry : groups.entrySet()) {
writePlotScript(entry.getKey(), entry.getValue(), test, outputDir);
}
System.out.println();
for (Map.Entry<GroupName, List<String>> entry : groups.entrySet()) {
executePlot(entry.getKey(), outputDir);
}
System.out.println();
writeHtmlReport(groups.keySet(), test, outputDir);
System.out.println();
return groups;
}
private void writeDataFile(GroupName groupName, List<String> metricNames, Test test, File outputDir) throws IOException {
File file = new File(outputDir, groupName.fileName + ".txt");
System.out.println("Writing data file " + file);
PrintStream ps = new PrintStream(new BufferedOutputStream(new FileOutputStream(file)));
StringBuilder titleLine = new StringBuilder();
titleLine.append(STRING_QUOTE).append("time").append(STRING_QUOTE);
titleLine.append(SEP);
titleLine.append(STRING_QUOTE).append("seq").append(STRING_QUOTE);
for (String metricName : metricNames) {
titleLine.append(SEP);
titleLine.append(STRING_QUOTE).append(removeGroupingPrefix(metricName)).append(" count").append(STRING_QUOTE);
titleLine.append(SEP);
titleLine.append(STRING_QUOTE).append(removeGroupingPrefix(metricName)).append(" avg").append(STRING_QUOTE);
titleLine.append(SEP);
titleLine.append(STRING_QUOTE).append(removeGroupingPrefix(metricName)).append(" med").append(STRING_QUOTE);
titleLine.append(SEP);
titleLine.append(STRING_QUOTE).append(removeGroupingPrefix(metricName)).append(" min").append(STRING_QUOTE);
titleLine.append(SEP);
titleLine.append(STRING_QUOTE).append(removeGroupingPrefix(metricName)).append(" max").append(STRING_QUOTE);
}
ps.println(titleLine.toString());
int i = 0;
for (Interval interval : test.intervals) {
ps.print(timeFormat.print(interval.begin));
ps.print(SEP);
ps.print(i++);
for (String metricName : metricNames) {
int index = test.getIndex(metricName);
MetricData data = safeGet(interval, index);
ps.print(SEP);
ps.print(formatLong(data.count));
ps.print(SEP);
ps.print(formatDouble(data.average));
ps.print(SEP);
ps.print(formatDouble(data.median));
ps.print(SEP);
ps.print(formatDouble(data.min));
ps.print(SEP);
ps.print(formatDouble(data.max));
}
ps.println();
}
ps.close();
}
private String removeGroupingPrefix(String metricName) {
int colonPos = metricName.indexOf(':');
int atPos = metricName.indexOf('@');
if (colonPos > 0) {
return metricName.substring(colonPos + 1);
} else if (atPos > 0) {
return metricName.substring(atPos + 1);
} else {
return metricName;
}
}
private String formatDouble(double value) {
if (value < 0) {
return "NaN";
} else {
return doubleFormat.format(value);
}
}
private String formatLong(long value) {
if (value < 0) {
return "NaN";
} else {
return String.valueOf(value);
}
}
private MetricData safeGet(Interval interval, int index) {
if (index >= interval.datas.length) {
return new MetricData();
} else {
return interval.datas[index];
}
}
private void writePlotScript(GroupName groupName, List<String> metricNames, Test test, File outputDir) throws IOException {
File file = new File(outputDir, groupName.fileName + ".plot.txt");
System.out.println("Writing plot script " + file);
PrintStream ps = new PrintStream(new BufferedOutputStream(new FileOutputStream(file)));
ps.println("set terminal pngcairo enhanced rounded linewidth 2 size 1300, 500");
ps.println("set output \"" + groupName.fileName + ".png\"");
ps.println("set autoscale");
ps.println("set title '" + groupName.title + "'");
ps.println("set key autotitle columnheader");
ps.println("set datafile missing 'NaN'");
ps.println("set ylabel \"unit depends on metric, times usually in ms\"");
ps.println("set xlabel \"time\"");
ps.println("set grid");
// if the name starts with a dash, it means the values for avg/med/min/max are (intended to be) the same
boolean isAvgOnly = groupName.name.startsWith("-");
final int firstPlotValue = COL_AVG;
int lastPlotValue = COL_MIN;
if (isAvgOnly) {
lastPlotValue = COL_AVG;
}
if (test.intervals.size() > 1) {
// Calculate trendlines: on median except for avg-only metrics
// The trendline is calculated against col 2, that is the column containing the seq numbers, since it
// does not work with the date values (I think because they are too big integers?)
for (int i = 0; i < metricNames.size(); i++) {
ps.println("f" + i + "(x)=m" + i + "*x+c" + i);
int dataCol = (COLS_PER_METRIC * i) + HEADER_COLUMNS + (isAvgOnly ? COL_AVG : COL_MED);
ps.println("fit f" + i + "(x) \"" + groupName.fileName + ".txt\" using 2:" + dataCol + " via m" + i + ",c" + i);
}
}
// Change xdata to time only after calculating trendlines
ps.println("set xdata time");
ps.println("set timefmt \"%Y%m%d%H%M%S\"");
int numberOfValues = lastPlotValue - firstPlotValue + 1;
StringBuilder plot = new StringBuilder();
plot.append("plot ");
for (int i = 0; i < metricNames.size(); i++) {
int colorStart = i * numberOfValues;
for (int c = firstPlotValue; c <= lastPlotValue; c++) {
if (i > 0 || c > firstPlotValue) {
plot.append(", ");
}
int dataCol = (COLS_PER_METRIC * i) + HEADER_COLUMNS + c;
int color = colorStart + c - firstPlotValue;
plot.append("'").append(groupName.fileName).append(".txt' using 1:").append(dataCol).
append(" with steps linecolor rgb '").append(COLORS[color % COLORS.length]).append("'");
}
if (test.intervals.size() > 1) {
// add trendline
// same color as data line
int color = colorStart + (isAvgOnly ? COL_AVG : COL_MED) - firstPlotValue;
plot.append(", '").append(groupName.fileName).append(".txt' using 1:(f").append(i).append("($2))").
append(" with lines linewidth 1 linecolor rgb '").append(COLORS[color % COLORS.length]).append("' title '")
.append(removeGroupingPrefix(metricNames.get(i))).append(isAvgOnly ? " avg" : " med").append(" trend'");
}
}
ps.println(plot.toString());
ps.close();
}
private void executePlot(GroupName groupName, File outputDir) throws IOException, InterruptedException {
System.out.println("Calling gnuplot for " + groupName);
ProcessBuilder pb = new ProcessBuilder("gnuplot", groupName.fileName + ".plot.txt");
pb.directory(outputDir);
Process p = pb.start();
int exitValue = p.waitFor();
if (exitValue != 0) {
System.err.println("Warning: gnuplot returned exit code: " + exitValue);
}
}
private void writeHtmlReport(Set<GroupName> groupNames, Test test, File outputDir) throws IOException {
File file = new File(outputDir, "report.html");
System.out.println("Writing HTML report " + file);
PrintStream ps = new PrintStream(new BufferedOutputStream(new FileOutputStream(file)));
ps.println("<html><body>");
ps.println("<a href='../info.html'>General tests info</a>");
ps.println("<h1>" + test.name + ": " + (test.description != null ? test.description : "(no title)") + "</h1>");
List<GroupName> orderedGroupNames = new ArrayList<GroupName>(groupNames);
Collections.sort(orderedGroupNames);
for (GroupName group : orderedGroupNames) {
ps.println("<img src='" + group.fileName + ".png'/><br/>");
}
ps.println("</body></html>");
ps.close();
}
private void writeTestsInfo(Tests tests, File outputDir) throws IOException {
File file = new File(outputDir, "info.html");
System.out.println("Writing HTML report " + file);
PrintStream ps = new PrintStream(new BufferedOutputStream(new FileOutputStream(file)));
ps.println("<html><body>");
ps.println("<h1>Tests</h1>");
ps.println("<ul>");
for (Test test : tests.entries) {
ps.println("<li><a href='" + test.name + "/report.html'>" + test.name + "</a>");
}
ps.println("</ul>");
if (tests.header.size() > 0) {
ps.println("<h1>Pre-tests information</h1>");
ps.println("<p>System information before execution of the tests.");
ps.print("<pre>");
for (String line : tests.header) {
ps.println(line);
}
ps.println("</pre>");
}
if (tests.footer.size() > 0) {
ps.println("<h1>Post-tests information</h1>");
ps.println("<p>System information after execution of the tests.");
ps.print("<pre>");
for (String line : tests.footer) {
ps.println(line);
}
ps.println("</pre>");
}
ps.println("</body></html>");
ps.close();
}
private static class GroupName implements Comparable<GroupName> {
String name;
String title;
String fileName;
private Collator collator = Collator.getInstance(Locale.US);
GroupName(String name) {
this.name = name;
this.title = groupNameToTitle(name);
this.fileName = groupNameToFileName(name);
}
private String groupNameToFileName(String groupName) {
groupName = groupName.replaceAll(Pattern.quote("/"), Matcher.quoteReplacement("_"));
groupName = groupName.replaceAll(Pattern.quote(" "), Matcher.quoteReplacement("_"));
if (groupName.startsWith("-")) {
groupName = groupName.substring(1);
}
return groupName;
}
private String groupNameToTitle(String groupName) {
if (groupName.startsWith("-")) {
return groupName.substring(1);
} else {
return groupName;
}
}
@Override
public int compareTo(GroupName o) {
return collator.compare(title, o.title);
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
GroupName other = (GroupName) obj;
return name.equals(other.name);
}
@Override
public int hashCode() {
return name.hashCode();
}
@Override
public String toString() {
return name;
}
}
private static class GroupMap extends HashMap<GroupName, List<String>> {
public List<String> getByString(String key) {
GroupName groupName = new GroupName(key);
List<String> list = super.get(groupName);
if (list == null) {
list = new ArrayList<String>();
put(groupName, list);
}
return list;
}
}
}