/* * 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; } } }