mirror of
https://github.com/alibaba/arthas.git
synced 2026-10-20 08:18:03 +00:00
feat: add Markdown report generation for profiler output
This commit is contained in:
@@ -10,6 +10,10 @@ public class ProfilerModel extends ResultModel {
|
||||
|
||||
private String action;
|
||||
private String actionArg;
|
||||
/**
|
||||
* profiler stop/dump 输出格式(对应命令行 --format/-o)
|
||||
*/
|
||||
private String format;
|
||||
private String executeResult;
|
||||
private Collection<String> supportedActions;
|
||||
private String outputFile;
|
||||
@@ -43,6 +47,14 @@ public class ProfilerModel extends ResultModel {
|
||||
this.actionArg = actionArg;
|
||||
}
|
||||
|
||||
public String getFormat() {
|
||||
return format;
|
||||
}
|
||||
|
||||
public void setFormat(String format) {
|
||||
this.format = format;
|
||||
}
|
||||
|
||||
public Collection<String> getSupportedActions() {
|
||||
return supportedActions;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.CodeSource;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
@@ -26,6 +27,7 @@ import com.taobao.arthas.core.shell.cli.Completion;
|
||||
import com.taobao.arthas.core.shell.cli.CompletionUtils;
|
||||
import com.taobao.arthas.core.shell.command.AnnotatedCommand;
|
||||
import com.taobao.arthas.core.shell.command.CommandProcess;
|
||||
import com.taobao.arthas.core.util.FileUtils;
|
||||
import com.taobao.middleware.cli.annotations.Argument;
|
||||
import com.taobao.middleware.cli.annotations.DefaultValue;
|
||||
import com.taobao.middleware.cli.annotations.Description;
|
||||
@@ -55,6 +57,7 @@ import one.profiler.Counter;
|
||||
+ " profiler start --loop 300s -f /tmp/result-%t.html"
|
||||
+ " profiler start --duration 300"
|
||||
+ " profiler stop --format html # output file format, support flat[=N]|traces[=N]|collapsed|flamegraph|tree|jfr\n"
|
||||
+ " profiler stop --format md # output Markdown report (LLM friendly), support md[=N]\n"
|
||||
+ " profiler stop --file /tmp/result.html\n"
|
||||
+ " profiler stop --threads \n"
|
||||
+ " profiler stop --include 'java/*' --include 'com/demo/*' --exclude '*Unsafe.park*'\n"
|
||||
@@ -326,7 +329,7 @@ public class ProfilerCommand extends AnnotatedCommand {
|
||||
}
|
||||
|
||||
@Option(shortName = "o", longName = "format")
|
||||
@Description("dump output content format(flat[=N]|traces[=N]|collapsed|flamegraph|tree|jfr)")
|
||||
@Description("dump output content format(flat[=N]|traces[=N]|collapsed|flamegraph|tree|jfr|md[=N])")
|
||||
public void setFormat(String format) {
|
||||
// only for backward compatibility
|
||||
if ("html".equals(format)) {
|
||||
@@ -335,6 +338,10 @@ public class ProfilerCommand extends AnnotatedCommand {
|
||||
this.format = format;
|
||||
}
|
||||
|
||||
private boolean isMarkdownFormat() {
|
||||
return this.format != null && this.format.toLowerCase().startsWith("md");
|
||||
}
|
||||
|
||||
@Option(shortName = "e", longName = "event")
|
||||
@Description("which event to trace (cpu, alloc, lock, cache-misses etc.), default value is cpu")
|
||||
@DefaultValue("cpu")
|
||||
@@ -619,10 +626,16 @@ public class ProfilerCommand extends AnnotatedCommand {
|
||||
this.format = "jfr";
|
||||
sb.append("jfrsync=").append(this.jfrsync).append(COMMA);
|
||||
}
|
||||
if (this.file != null) {
|
||||
boolean markdown = isMarkdownFormat();
|
||||
// md 是 Arthas 侧的后处理格式,不应传递给 async-profiler(避免识别失败/输出到文件导致数据丢失等问题)
|
||||
boolean passFile = this.file != null;
|
||||
if (markdown && (action == ProfilerAction.start || action == ProfilerAction.resume || action == ProfilerAction.check)) {
|
||||
passFile = false;
|
||||
}
|
||||
if (passFile) {
|
||||
sb.append("file=").append(this.file).append(COMMA);
|
||||
}
|
||||
if (this.format != null) {
|
||||
if (this.format != null && !markdown) {
|
||||
sb.append(this.format).append(COMMA);
|
||||
}
|
||||
if (this.interval != null) {
|
||||
@@ -865,6 +878,15 @@ public class ProfilerCommand extends AnnotatedCommand {
|
||||
}
|
||||
|
||||
private ProfilerModel processStop(AsyncProfiler asyncProfiler, ProfilerAction profilerAction) throws IOException {
|
||||
// profiler stop --file xxx.md:自动推断为 Markdown 输出
|
||||
if (this.format == null && this.file != null && this.file.toLowerCase().endsWith(".md")) {
|
||||
this.format = "md";
|
||||
}
|
||||
|
||||
if (isMarkdownFormat() && (profilerAction == ProfilerAction.stop || profilerAction == ProfilerAction.dump)) {
|
||||
return processStopMarkdown(asyncProfiler, profilerAction);
|
||||
}
|
||||
|
||||
String outputFile = null;
|
||||
|
||||
// If we're stopping and a file was specified during start, don't generate a new
|
||||
@@ -889,6 +911,79 @@ public class ProfilerCommand extends AnnotatedCommand {
|
||||
return profilerModel;
|
||||
}
|
||||
|
||||
private ProfilerModel processStopMarkdown(AsyncProfiler asyncProfiler, ProfilerAction profilerAction) throws IOException {
|
||||
// Markdown 输出:先让 async-profiler 输出 collapsed 文本,再在 Arthas 侧做结构化汇总。
|
||||
String userFormat = this.format;
|
||||
String userFile = this.file;
|
||||
int topN = mdTopN(userFormat);
|
||||
|
||||
// stop 时如果 start 阶段指定过 file,需要清理掉,避免影响后续 stop 行为
|
||||
if (profilerAction == ProfilerAction.stop) {
|
||||
fileSpecifiedAtStart = null;
|
||||
}
|
||||
|
||||
File collapsedFile = File.createTempFile("arthas-profiler-collapsed", ".txt");
|
||||
String collapsed;
|
||||
try {
|
||||
// 为避免 async-profiler 由于历史 file 配置导致返回 OK 而非 collapsed 文本,这里强制输出到临时文件后再读取。
|
||||
this.file = collapsedFile.getAbsolutePath();
|
||||
this.format = "collapsed";
|
||||
|
||||
String executeArgs = executeArgs(profilerAction);
|
||||
execute(asyncProfiler, executeArgs);
|
||||
|
||||
collapsed = FileUtils.readFileToString(collapsedFile, StandardCharsets.UTF_8);
|
||||
} finally {
|
||||
// best-effort cleanup
|
||||
try {
|
||||
collapsedFile.delete();
|
||||
} catch (Throwable ignore) {
|
||||
// ignore
|
||||
}
|
||||
this.format = userFormat;
|
||||
this.file = userFile;
|
||||
}
|
||||
|
||||
String markdown = ProfilerMarkdown.toMarkdown(new ProfilerMarkdown.Options()
|
||||
.action(profilerAction.name())
|
||||
.event(this.event)
|
||||
.threads(this.threads)
|
||||
.topN(topN)
|
||||
.collapsed(collapsed));
|
||||
|
||||
String outputFile = null;
|
||||
if (userFile != null && !userFile.trim().isEmpty()) {
|
||||
outputFile = userFile;
|
||||
FileUtils.writeByteArrayToFile(new File(outputFile), markdown.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
ProfilerModel profilerModel = createProfilerModel(markdown);
|
||||
profilerModel.setFormat(userFormat);
|
||||
profilerModel.setOutputFile(outputFile);
|
||||
return profilerModel;
|
||||
}
|
||||
|
||||
private int mdTopN(String format) {
|
||||
final int defaultTopN = 10;
|
||||
if (format == null) {
|
||||
return defaultTopN;
|
||||
}
|
||||
String f = format.trim().toLowerCase();
|
||||
if (!f.startsWith("md")) {
|
||||
return defaultTopN;
|
||||
}
|
||||
int idx = f.indexOf('=');
|
||||
if (idx < 0 || idx == f.length() - 1) {
|
||||
return defaultTopN;
|
||||
}
|
||||
try {
|
||||
int n = Integer.parseInt(f.substring(idx + 1).trim());
|
||||
return n > 0 ? n : defaultTopN;
|
||||
} catch (Throwable e) {
|
||||
return defaultTopN;
|
||||
}
|
||||
}
|
||||
|
||||
private String outputFile() throws IOException {
|
||||
if (this.file == null) {
|
||||
String fileExt = outputFileExt();
|
||||
@@ -911,6 +1006,8 @@ public class ProfilerCommand extends AnnotatedCommand {
|
||||
String fileExt = "";
|
||||
if (this.format == null) {
|
||||
fileExt = "html";
|
||||
} else if (this.format.toLowerCase().startsWith("md")) {
|
||||
fileExt = "md";
|
||||
} else if (this.format.startsWith("flat") || this.format.startsWith("traces")
|
||||
|| this.format.equals("collapsed")) {
|
||||
fileExt = "txt";
|
||||
@@ -934,6 +1031,7 @@ public class ProfilerCommand extends AnnotatedCommand {
|
||||
ProfilerModel profilerModel = new ProfilerModel();
|
||||
profilerModel.setAction(action);
|
||||
profilerModel.setActionArg(actionArg);
|
||||
profilerModel.setFormat(format);
|
||||
profilerModel.setExecuteResult(result);
|
||||
return profilerModel;
|
||||
}
|
||||
@@ -989,8 +1087,12 @@ public class ProfilerCommand extends AnnotatedCommand {
|
||||
if (token_2.equals("-e") || token_2.equals("--event")) {
|
||||
CompletionUtils.complete(completion, events());
|
||||
return;
|
||||
} else if (token_2.equals("-f") || token_2.equals("--format")) {
|
||||
CompletionUtils.complete(completion, Arrays.asList("html", "jfr"));
|
||||
} else if (token_2.equals("-o") || token_2.equals("--format")) {
|
||||
CompletionUtils.complete(completion, Arrays.asList(
|
||||
"flamegraph", "tree", "jfr",
|
||||
"flat", "traces", "collapsed",
|
||||
"md", "md=10"
|
||||
));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1004,4 +1106,4 @@ public class ProfilerCommand extends AnnotatedCommand {
|
||||
CompletionUtils.complete(completion, actions());
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
package com.taobao.arthas.core.command.monitor200;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 将 async-profiler 的 collapsed stacktraces 文本,转换为更适合 LLM 读取与检索的 Markdown 报告。
|
||||
* <p>
|
||||
* 设计目标:
|
||||
* 1) 终端可直接输出,便于复制粘贴给 LLM
|
||||
* 2) Token 效率高:关键信息结构化、去噪、可 grep
|
||||
* 3) 不引入额外依赖,保持实现简单可维护
|
||||
*/
|
||||
final class ProfilerMarkdown {
|
||||
|
||||
private ProfilerMarkdown() {
|
||||
}
|
||||
|
||||
static class Options {
|
||||
private String action;
|
||||
private String event;
|
||||
private boolean threads;
|
||||
private int topN = 10;
|
||||
private String collapsed;
|
||||
|
||||
Options action(String action) {
|
||||
this.action = action;
|
||||
return this;
|
||||
}
|
||||
|
||||
Options event(String event) {
|
||||
this.event = event;
|
||||
return this;
|
||||
}
|
||||
|
||||
Options threads(boolean threads) {
|
||||
this.threads = threads;
|
||||
return this;
|
||||
}
|
||||
|
||||
Options topN(int topN) {
|
||||
if (topN > 0) {
|
||||
this.topN = topN;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
Options collapsed(String collapsed) {
|
||||
this.collapsed = collapsed;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
static String toMarkdown(Options options) {
|
||||
String collapsed = options == null ? null : options.collapsed;
|
||||
if (collapsed == null) {
|
||||
collapsed = "";
|
||||
}
|
||||
|
||||
CollapsedProfile profile = parseCollapsed(collapsed, options != null && options.threads);
|
||||
List<Entry> hotspots = topHotspots(profile.selfSamples, options == null ? 10 : options.topN);
|
||||
List<StackSample> topStacks = topStacks(profile.stacks, options == null ? 10 : options.topN);
|
||||
|
||||
StringBuilder sb = new StringBuilder(8 * 1024);
|
||||
sb.append("# Arthas profiler report (Markdown)\n\n");
|
||||
sb.append("- action: ").append(nullToDash(options == null ? null : options.action)).append("\n");
|
||||
sb.append("- event: ").append(nullToDash(options == null ? null : options.event)).append("\n");
|
||||
sb.append("- threads: ").append(options != null && options.threads).append("\n");
|
||||
sb.append("- total samples: ").append(profile.totalSamples).append("\n\n");
|
||||
|
||||
sb.append("## Top ").append(options == null ? 10 : options.topN).append(" hotspots (self)\n\n");
|
||||
sb.append("| rank | function | self_samples | self_percent |\n");
|
||||
sb.append("| ---: | --- | ---: | ---: |\n");
|
||||
int rank = 1;
|
||||
for (Entry e : hotspots) {
|
||||
sb.append("| ").append(rank++).append(" | ")
|
||||
.append(escapePipes(e.key)).append(" | ")
|
||||
.append(e.value).append(" | ")
|
||||
.append(formatPercent(e.value, profile.totalSamples)).append(" |\n");
|
||||
}
|
||||
if (hotspots.isEmpty()) {
|
||||
sb.append("| - | - | 0 | 0.00% |\n");
|
||||
}
|
||||
sb.append("\n");
|
||||
|
||||
sb.append("## Top ").append(options == null ? 10 : options.topN).append(" stacks\n\n");
|
||||
sb.append("| rank | samples | percent | stack |\n");
|
||||
sb.append("| ---: | ---: | ---: | --- |\n");
|
||||
rank = 1;
|
||||
for (StackSample s : topStacks) {
|
||||
sb.append("| ").append(rank++).append(" | ")
|
||||
.append(s.samples).append(" | ")
|
||||
.append(formatPercent(s.samples, profile.totalSamples)).append(" | ")
|
||||
.append(escapePipes(s.stack)).append(" |\n");
|
||||
}
|
||||
if (topStacks.isEmpty()) {
|
||||
sb.append("| - | 0 | 0.00% | - |\n");
|
||||
}
|
||||
sb.append("\n");
|
||||
|
||||
sb.append("## Call tree (top)\n\n");
|
||||
sb.append("```text\n");
|
||||
sb.append(renderCallTree(profile.callTreeRoot, profile.totalSamples, options == null ? 10 : options.topN));
|
||||
sb.append("\n```\n\n");
|
||||
|
||||
sb.append("## Function details\n\n");
|
||||
int detailsFunctions = Math.min(hotspots.size(), options == null ? 10 : options.topN);
|
||||
for (int i = 0; i < detailsFunctions; i++) {
|
||||
Entry e = hotspots.get(i);
|
||||
sb.append("### ").append(e.key).append("\n\n");
|
||||
sb.append("- self: ").append(e.value)
|
||||
.append(" (").append(formatPercent(e.value, profile.totalSamples)).append(")\n");
|
||||
|
||||
List<StackSample> stacksForFunction = profile.topStacksByLeaf.get(e.key);
|
||||
if (stacksForFunction != null && !stacksForFunction.isEmpty()) {
|
||||
sb.append("- top stacks:\n");
|
||||
for (StackSample s : stacksForFunction) {
|
||||
sb.append(" - ").append(s.samples)
|
||||
.append(" (").append(formatPercent(s.samples, profile.totalSamples)).append(") ")
|
||||
.append(s.stack).append("\n");
|
||||
}
|
||||
}
|
||||
sb.append("\n");
|
||||
}
|
||||
|
||||
sb.append("## Notes\n\n");
|
||||
sb.append("- 该报告由 Arthas 基于 async-profiler 的 `collapsed` 输出生成,适合直接复制给 LLM 分析。\n");
|
||||
sb.append("- `hotspots (self)` 以栈顶帧为准(近似 self time),有助于快速定位热点函数。\n");
|
||||
sb.append("- `stacks` 用于观察最常出现的调用路径;如需火焰图可继续使用 `--format flamegraph`.\n");
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String nullToDash(String s) {
|
||||
if (s == null || s.trim().isEmpty()) {
|
||||
return "-";
|
||||
}
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
private static String formatPercent(long part, long total) {
|
||||
if (total <= 0) {
|
||||
return "0.00%";
|
||||
}
|
||||
double p = (double) part * 100.0d / (double) total;
|
||||
return String.format(java.util.Locale.ROOT, "%.2f%%", p);
|
||||
}
|
||||
|
||||
private static String escapePipes(String s) {
|
||||
if (s == null) {
|
||||
return "-";
|
||||
}
|
||||
// Markdown 表格中需要转义 '|'
|
||||
return s.replace("|", "\\|");
|
||||
}
|
||||
|
||||
private static class CollapsedProfile {
|
||||
private long totalSamples;
|
||||
private final Map<String, Long> selfSamples = new LinkedHashMap<>();
|
||||
private final List<StackSample> stacks = new ArrayList<>();
|
||||
private final Map<String, List<StackSample>> topStacksByLeaf = new LinkedHashMap<>();
|
||||
private final Node callTreeRoot = new Node();
|
||||
}
|
||||
|
||||
private static class StackSample {
|
||||
private final String stack;
|
||||
private final long samples;
|
||||
|
||||
private StackSample(String stack, long samples) {
|
||||
this.stack = stack;
|
||||
this.samples = samples;
|
||||
}
|
||||
}
|
||||
|
||||
private static class Entry {
|
||||
private final String key;
|
||||
private final long value;
|
||||
|
||||
private Entry(String key, long value) {
|
||||
this.key = key;
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
private static class Node {
|
||||
private long samples;
|
||||
private final Map<String, Node> children = new LinkedHashMap<>();
|
||||
}
|
||||
|
||||
private static CollapsedProfile parseCollapsed(String collapsed, boolean threadsEnabled) {
|
||||
CollapsedProfile profile = new CollapsedProfile();
|
||||
|
||||
String[] lines = collapsed.split("\\r?\\n");
|
||||
for (String line : lines) {
|
||||
if (line == null) {
|
||||
continue;
|
||||
}
|
||||
line = line.trim();
|
||||
if (line.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// collapsed 格式:frame1;frame2;frame3 <samples>
|
||||
int spaceIdx = lastSpaceIndex(line);
|
||||
if (spaceIdx < 0) {
|
||||
continue;
|
||||
}
|
||||
String stack = line.substring(0, spaceIdx).trim();
|
||||
String countStr = line.substring(spaceIdx + 1).trim();
|
||||
if (stack.isEmpty() || countStr.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
long samples;
|
||||
try {
|
||||
samples = Long.parseLong(countStr);
|
||||
} catch (Throwable ignore) {
|
||||
continue;
|
||||
}
|
||||
if (samples <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// threads 模式下,栈最后可能会附带线程名帧;为了减少噪音,这里尽量去掉它。
|
||||
String normalizedStack = stripThreadFrame(stack, threadsEnabled);
|
||||
profile.totalSamples += samples;
|
||||
profile.stacks.add(new StackSample(normalizedStack, samples));
|
||||
|
||||
addToCallTree(profile.callTreeRoot, normalizedStack, samples);
|
||||
|
||||
String topFrame = topFrame(normalizedStack, threadsEnabled);
|
||||
if (topFrame == null || topFrame.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
Long old = profile.selfSamples.get(topFrame);
|
||||
profile.selfSamples.put(topFrame, old == null ? samples : old + samples);
|
||||
|
||||
addTopStacksByLeaf(profile.topStacksByLeaf, topFrame, normalizedStack, samples, 3);
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
private static int lastSpaceIndex(String s) {
|
||||
// 兼容 stack 中包含空格(线程名)时,取最后一个空格作为 count 分隔
|
||||
for (int i = s.length() - 1; i >= 0; i--) {
|
||||
if (s.charAt(i) == ' ') {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static String topFrame(String stack, boolean threadsEnabled) {
|
||||
if (stack == null || stack.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
int lastSep = stack.lastIndexOf(';');
|
||||
String top = lastSep >= 0 ? stack.substring(lastSep + 1) : stack;
|
||||
top = top.trim();
|
||||
|
||||
// threads 模式下,async-profiler 会以“线程帧”结束;这类帧对定位热点帮助有限,尽量跳过:
|
||||
// 规则:如果 top 看起来是 [thread] 或包含 "(thread)" 等,这里仅做轻量过滤,避免误删正常 frame。
|
||||
if (threadsEnabled && looksLikeThreadFrame(top)) {
|
||||
// 回退到倒数第二帧
|
||||
int prevSep = lastSep >= 0 ? stack.lastIndexOf(';', lastSep - 1) : -1;
|
||||
String prev = prevSep >= 0 ? stack.substring(prevSep + 1, lastSep) : "";
|
||||
return prev.trim();
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
private static String stripThreadFrame(String stack, boolean threadsEnabled) {
|
||||
if (!threadsEnabled || stack == null || stack.isEmpty()) {
|
||||
return stack;
|
||||
}
|
||||
int lastSep = stack.lastIndexOf(';');
|
||||
if (lastSep < 0) {
|
||||
return stack;
|
||||
}
|
||||
String last = stack.substring(lastSep + 1).trim();
|
||||
// async-profiler threads 模式下常见线程帧形如:[tid=1234] "thread-name"
|
||||
if (last.startsWith("[") && last.contains("tid=")) {
|
||||
return stack.substring(0, lastSep);
|
||||
}
|
||||
return stack;
|
||||
}
|
||||
|
||||
private static void addToCallTree(Node root, String stack, long samples) {
|
||||
if (root == null || stack == null || stack.isEmpty() || samples <= 0) {
|
||||
return;
|
||||
}
|
||||
root.samples += samples;
|
||||
String[] frames = stack.split(";");
|
||||
Node current = root;
|
||||
for (String frame : frames) {
|
||||
String f = frame == null ? "" : frame.trim();
|
||||
if (f.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
Node child = current.children.get(f);
|
||||
if (child == null) {
|
||||
child = new Node();
|
||||
current.children.put(f, child);
|
||||
}
|
||||
child.samples += samples;
|
||||
current = child;
|
||||
}
|
||||
}
|
||||
|
||||
private static String renderCallTree(Node root, long totalSamples, int topN) {
|
||||
if (root == null || root.children.isEmpty() || totalSamples <= 0) {
|
||||
return "-";
|
||||
}
|
||||
int maxDepth = 8;
|
||||
int maxChildren = 5;
|
||||
int maxRoots = Math.min(topN, 10);
|
||||
StringBuilder sb = new StringBuilder(4096);
|
||||
List<Map.Entry<String, Node>> roots = sortChildren(root);
|
||||
for (int i = 0; i < roots.size() && i < maxRoots; i++) {
|
||||
Map.Entry<String, Node> e = roots.get(i);
|
||||
renderCallTreeNode(sb, e.getKey(), e.getValue(), totalSamples, 0, maxDepth, maxChildren);
|
||||
}
|
||||
return sb.toString().trim();
|
||||
}
|
||||
|
||||
private static void renderCallTreeNode(StringBuilder sb, String label, Node node, long totalSamples,
|
||||
int depth, int maxDepth, int maxChildren) {
|
||||
if (node == null || label == null) {
|
||||
return;
|
||||
}
|
||||
if (depth >= maxDepth) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < depth; i++) {
|
||||
sb.append(" ");
|
||||
}
|
||||
sb.append("(").append(formatPercent(node.samples, totalSamples)).append(") ")
|
||||
.append(node.samples).append(" ")
|
||||
.append(label).append("\n");
|
||||
|
||||
if (node.children.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<Map.Entry<String, Node>> children = sortChildren(node);
|
||||
for (int i = 0; i < children.size() && i < maxChildren; i++) {
|
||||
Map.Entry<String, Node> child = children.get(i);
|
||||
renderCallTreeNode(sb, child.getKey(), child.getValue(), totalSamples, depth + 1, maxDepth, maxChildren);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Map.Entry<String, Node>> sortChildren(Node node) {
|
||||
if (node == null || node.children.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<Map.Entry<String, Node>> list = new ArrayList<>(node.children.entrySet());
|
||||
list.sort(new Comparator<Map.Entry<String, Node>>() {
|
||||
@Override
|
||||
public int compare(Map.Entry<String, Node> a, Map.Entry<String, Node> b) {
|
||||
return Long.compare(b.getValue().samples, a.getValue().samples);
|
||||
}
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
private static void addTopStacksByLeaf(Map<String, List<StackSample>> topStacksByLeaf,
|
||||
String leaf, String stack, long samples, int limit) {
|
||||
if (topStacksByLeaf == null || leaf == null || leaf.isEmpty() || stack == null || stack.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<StackSample> list = topStacksByLeaf.get(leaf);
|
||||
if (list == null) {
|
||||
list = new ArrayList<>();
|
||||
topStacksByLeaf.put(leaf, list);
|
||||
}
|
||||
list.add(new StackSample(stack, samples));
|
||||
list.sort(new Comparator<StackSample>() {
|
||||
@Override
|
||||
public int compare(StackSample a, StackSample b) {
|
||||
return Long.compare(b.samples, a.samples);
|
||||
}
|
||||
});
|
||||
if (list.size() > limit) {
|
||||
list.subList(limit, list.size()).clear();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean looksLikeThreadFrame(String frame) {
|
||||
if (frame == null) {
|
||||
return false;
|
||||
}
|
||||
String f = frame.trim();
|
||||
if (f.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
// async-profiler 线程帧通常形如:[tid=1234] "thread-name"
|
||||
if (f.startsWith("[") && f.contains("tid=")) {
|
||||
return true;
|
||||
}
|
||||
if (f.startsWith("java.lang.Thread.run") || f.startsWith("java.base/java.lang.Thread.run")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<Entry> topHotspots(Map<String, Long> selfSamples, int topN) {
|
||||
if (selfSamples == null || selfSamples.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<Entry> list = new ArrayList<>(selfSamples.size());
|
||||
for (Map.Entry<String, Long> e : selfSamples.entrySet()) {
|
||||
if (e.getKey() != null && e.getValue() != null) {
|
||||
list.add(new Entry(e.getKey(), e.getValue()));
|
||||
}
|
||||
}
|
||||
list.sort(new Comparator<Entry>() {
|
||||
@Override
|
||||
public int compare(Entry a, Entry b) {
|
||||
return Long.compare(b.value, a.value);
|
||||
}
|
||||
});
|
||||
if (topN <= 0 || topN >= list.size()) {
|
||||
return list;
|
||||
}
|
||||
return new ArrayList<>(list.subList(0, topN));
|
||||
}
|
||||
|
||||
private static List<StackSample> topStacks(List<StackSample> stacks, int topN) {
|
||||
if (stacks == null || stacks.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<StackSample> list = new ArrayList<>(stacks);
|
||||
list.sort(new Comparator<StackSample>() {
|
||||
@Override
|
||||
public int compare(StackSample a, StackSample b) {
|
||||
return Long.compare(b.samples, a.samples);
|
||||
}
|
||||
});
|
||||
if (topN <= 0 || topN >= list.size()) {
|
||||
return list;
|
||||
}
|
||||
return new ArrayList<>(list.subList(0, topN));
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,10 @@ public class ProfilerView extends ResultView<ProfilerModel> {
|
||||
process.write("profiler output file will be: " + model.getOutputFile() + "\n");
|
||||
}
|
||||
} else if (ProfilerAction.stop.name().equals(model.getAction())) {
|
||||
process.write("profiler output file: " + model.getOutputFile() + "\n");
|
||||
// markdown 输出时,额外的提示行会影响复制粘贴给 LLM 的效果
|
||||
if (model.getOutputFile() != null && !isMarkdown(model.getFormat())) {
|
||||
process.write("profiler output file: " + model.getOutputFile() + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -39,4 +42,8 @@ public class ProfilerView extends ResultView<ProfilerModel> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isMarkdown(String format) {
|
||||
return format != null && format.toLowerCase().startsWith("md");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -59,7 +59,7 @@ public class ProfilerTool extends AbstractArthasTool {
|
||||
@ToolParam(description = "输出文件路径 (--file)。如果以 .html/.jfr 结尾可推断 format;也可包含 %t 等占位符(如 /tmp/result-%t.html)", required = false)
|
||||
String file,
|
||||
|
||||
@ToolParam(description = "输出格式 (--format),支持 flat[=N]|traces[=N]|collapsed|flamegraph|tree|jfr(兼容传入 html)", required = false)
|
||||
@ToolParam(description = "输出格式 (--format),支持 flat[=N]|traces[=N]|collapsed|flamegraph|tree|jfr|md[=N](兼容传入 html)", required = false)
|
||||
String format,
|
||||
|
||||
@ToolParam(description = "alloc 事件采样间隔字节数 (--alloc),如 1m/512k/1000 等", required = false)
|
||||
@@ -276,4 +276,3 @@ public class ProfilerTool extends AbstractArthasTool {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.taobao.arthas.core.command.monitor200;
|
||||
|
||||
import org.assertj.core.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ProfilerMarkdownTest {
|
||||
|
||||
@Test
|
||||
void shouldGenerateMarkdownWithHotspotsAndStacks() {
|
||||
// collapsed stacktraces: stack<space>count
|
||||
String collapsed = ""
|
||||
+ "a.A.foo;java.lang.Thread.run 5\n"
|
||||
+ "a.A.foo;java.lang.Thread.run 3\n"
|
||||
+ "b.B.bar 2\n";
|
||||
|
||||
String md = ProfilerMarkdown.toMarkdown(new ProfilerMarkdown.Options()
|
||||
.action("stop")
|
||||
.event("cpu")
|
||||
.threads(false)
|
||||
.topN(10)
|
||||
.collapsed(collapsed));
|
||||
|
||||
Assertions.assertThat(md).contains("# Arthas profiler report (Markdown)");
|
||||
Assertions.assertThat(md).contains("total samples: 10");
|
||||
// top frame 统计:两条栈顶都是 java.lang.Thread.run (5+3),另一条是 b.B.bar(2)
|
||||
Assertions.assertThat(md).contains("java.lang.Thread.run");
|
||||
Assertions.assertThat(md).contains("b.B.bar");
|
||||
Assertions.assertThat(md).contains("## Top 10 stacks");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSkipThreadFrameWhenThreadsEnabled() {
|
||||
String collapsed = ""
|
||||
+ "a.A.foo;java.lang.Thread.run 5\n"
|
||||
+ "b.B.bar;[tid=1234] \"http-nio-8080-exec-1\" 3\n";
|
||||
|
||||
String md = ProfilerMarkdown.toMarkdown(new ProfilerMarkdown.Options()
|
||||
.action("stop")
|
||||
.event("cpu")
|
||||
.threads(true)
|
||||
.topN(10)
|
||||
.collapsed(collapsed));
|
||||
|
||||
// threads 模式下,栈顶线程帧应尽量被过滤,热点回退到上一帧
|
||||
Assertions.assertThat(md).contains("a.A.foo");
|
||||
Assertions.assertThat(md).contains("b.B.bar");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,37 @@ Call trace storage: 10244 KB
|
||||
|
||||
### 生成火焰图格式结果
|
||||
|
||||
默认情况下,结果是 [Flame Graph](https://github.com/BrendanGregg/FlameGraph) 格式的 `html` 文件,也可以用 `-o` 或 `--format` 参数指定其他内容格式,包括 flat、traces、collapsed、flamegraph、tree、jfr。
|
||||
默认情况下,结果是 [Flame Graph](https://github.com/BrendanGregg/FlameGraph) 格式的 `html` 文件,也可以用 `-o` 或 `--format` 参数指定其他内容格式,包括 flat、traces、collapsed、flamegraph、tree、jfr、md。
|
||||
|
||||
### 生成 LLM 友好的 Markdown 报告
|
||||
|
||||
当 `--format md`(或 `md=N`)时,Arthas 会基于 async-profiler 的 `collapsed` 输出生成结构化的 Markdown,便于直接复制到 LLM 或在终端里 grep。报告包含:
|
||||
|
||||
- Top N Hotspots(self)
|
||||
- Call Tree(调用树,按采样占比排序截断)
|
||||
- Function Details(每个热点函数的 Top stacks)
|
||||
|
||||
```bash
|
||||
$ profiler stop --format md
|
||||
```
|
||||
|
||||
指定输出文件(可选):
|
||||
|
||||
```bash
|
||||
$ profiler stop --format md --file /tmp/profile.md
|
||||
```
|
||||
|
||||
如果 `--file` 以 `.md` 结尾且未指定 `--format`,将自动推断为 Markdown 输出:
|
||||
|
||||
```bash
|
||||
$ profiler stop --file /tmp/profile.md
|
||||
```
|
||||
|
||||
指定 TopN(可选,默认 10):
|
||||
|
||||
```bash
|
||||
$ profiler stop --format md=20
|
||||
```
|
||||
|
||||
```bash
|
||||
$ profiler stop --format flamegraph
|
||||
|
||||
@@ -65,7 +65,37 @@ Call trace storage: 10244 KB
|
||||
|
||||
### Generating flame graph results
|
||||
|
||||
By default, the result file is `html` file in [Flame Graph](https://github.com/BrendanGregg/FlameGraph) format. You can also specify other format with the `-o` or `--format` parameter, including flat, traces, collapsed, flamegraph, tree, jfr:
|
||||
By default, the result file is `html` file in [Flame Graph](https://github.com/BrendanGregg/FlameGraph) format. You can also specify other format with the `-o` or `--format` parameter, including flat, traces, collapsed, flamegraph, tree, jfr, md:
|
||||
|
||||
### Generate LLM-friendly Markdown report
|
||||
|
||||
When `--format md` (or `md=N`) is specified, Arthas generates a structured Markdown report based on async-profiler `collapsed` output, which is easy to copy/paste to an LLM or grep in terminal. The report includes:
|
||||
|
||||
- Top N Hotspots (self)
|
||||
- Call Tree (truncated by samples)
|
||||
- Function Details (top stacks per hotspot)
|
||||
|
||||
```bash
|
||||
$ profiler stop --format md
|
||||
```
|
||||
|
||||
Write to file (optional):
|
||||
|
||||
```bash
|
||||
$ profiler stop --format md --file /tmp/profile.md
|
||||
```
|
||||
|
||||
If `--file` ends with `.md` and `--format` is not specified, Arthas will infer Markdown output automatically:
|
||||
|
||||
```bash
|
||||
$ profiler stop --file /tmp/profile.md
|
||||
```
|
||||
|
||||
Set TopN (optional, default 10):
|
||||
|
||||
```bash
|
||||
$ profiler stop --format md=20
|
||||
```
|
||||
|
||||
```bash
|
||||
$ profiler stop --format flamegraph
|
||||
|
||||
Reference in New Issue
Block a user