From 175fdb013c09756ffbd2934efc406439cca565ee Mon Sep 17 00:00:00 2001 From: hengyunabc Date: Tue, 3 Feb 2026 15:51:28 +0800 Subject: [PATCH] feat: add Markdown report generation for profiler output --- .../core/command/model/ProfilerModel.java | 12 + .../command/monitor200/ProfilerCommand.java | 114 ++++- .../command/monitor200/ProfilerMarkdown.java | 448 ++++++++++++++++++ .../core/command/view/ProfilerView.java | 9 +- .../function/monitor200/ProfilerTool.java | 3 +- .../monitor200/ProfilerMarkdownTest.java | 49 ++ site/docs/doc/profiler.md | 32 +- site/docs/en/doc/profiler.md | 32 +- 8 files changed, 688 insertions(+), 11 deletions(-) create mode 100644 core/src/main/java/com/taobao/arthas/core/command/monitor200/ProfilerMarkdown.java create mode 100644 core/src/test/java/com/taobao/arthas/core/command/monitor200/ProfilerMarkdownTest.java diff --git a/core/src/main/java/com/taobao/arthas/core/command/model/ProfilerModel.java b/core/src/main/java/com/taobao/arthas/core/command/model/ProfilerModel.java index 1847d3595..32a4ddac2 100644 --- a/core/src/main/java/com/taobao/arthas/core/command/model/ProfilerModel.java +++ b/core/src/main/java/com/taobao/arthas/core/command/model/ProfilerModel.java @@ -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 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 getSupportedActions() { return supportedActions; } diff --git a/core/src/main/java/com/taobao/arthas/core/command/monitor200/ProfilerCommand.java b/core/src/main/java/com/taobao/arthas/core/command/monitor200/ProfilerCommand.java index 5deeb7869..2ab533d88 100644 --- a/core/src/main/java/com/taobao/arthas/core/command/monitor200/ProfilerCommand.java +++ b/core/src/main/java/com/taobao/arthas/core/command/monitor200/ProfilerCommand.java @@ -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()); } -} \ No newline at end of file +} diff --git a/core/src/main/java/com/taobao/arthas/core/command/monitor200/ProfilerMarkdown.java b/core/src/main/java/com/taobao/arthas/core/command/monitor200/ProfilerMarkdown.java new file mode 100644 index 000000000..2875173ab --- /dev/null +++ b/core/src/main/java/com/taobao/arthas/core/command/monitor200/ProfilerMarkdown.java @@ -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 报告。 + *

+ * 设计目标: + * 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 hotspots = topHotspots(profile.selfSamples, options == null ? 10 : options.topN); + List 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 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 selfSamples = new LinkedHashMap<>(); + private final List stacks = new ArrayList<>(); + private final Map> 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 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 + 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> roots = sortChildren(root); + for (int i = 0; i < roots.size() && i < maxRoots; i++) { + Map.Entry 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> children = sortChildren(node); + for (int i = 0; i < children.size() && i < maxChildren; i++) { + Map.Entry child = children.get(i); + renderCallTreeNode(sb, child.getKey(), child.getValue(), totalSamples, depth + 1, maxDepth, maxChildren); + } + } + + private static List> sortChildren(Node node) { + if (node == null || node.children.isEmpty()) { + return Collections.emptyList(); + } + List> list = new ArrayList<>(node.children.entrySet()); + list.sort(new Comparator>() { + @Override + public int compare(Map.Entry a, Map.Entry b) { + return Long.compare(b.getValue().samples, a.getValue().samples); + } + }); + return list; + } + + private static void addTopStacksByLeaf(Map> topStacksByLeaf, + String leaf, String stack, long samples, int limit) { + if (topStacksByLeaf == null || leaf == null || leaf.isEmpty() || stack == null || stack.isEmpty()) { + return; + } + List list = topStacksByLeaf.get(leaf); + if (list == null) { + list = new ArrayList<>(); + topStacksByLeaf.put(leaf, list); + } + list.add(new StackSample(stack, samples)); + list.sort(new Comparator() { + @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 topHotspots(Map selfSamples, int topN) { + if (selfSamples == null || selfSamples.isEmpty()) { + return Collections.emptyList(); + } + List list = new ArrayList<>(selfSamples.size()); + for (Map.Entry e : selfSamples.entrySet()) { + if (e.getKey() != null && e.getValue() != null) { + list.add(new Entry(e.getKey(), e.getValue())); + } + } + list.sort(new Comparator() { + @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 topStacks(List stacks, int topN) { + if (stacks == null || stacks.isEmpty()) { + return Collections.emptyList(); + } + List list = new ArrayList<>(stacks); + list.sort(new Comparator() { + @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)); + } +} diff --git a/core/src/main/java/com/taobao/arthas/core/command/view/ProfilerView.java b/core/src/main/java/com/taobao/arthas/core/command/view/ProfilerView.java index e67d03170..70d05652b 100644 --- a/core/src/main/java/com/taobao/arthas/core/command/view/ProfilerView.java +++ b/core/src/main/java/com/taobao/arthas/core/command/view/ProfilerView.java @@ -26,7 +26,10 @@ public class ProfilerView extends ResultView { 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 { } } } + + private boolean isMarkdown(String format) { + return format != null && format.toLowerCase().startsWith("md"); + } } diff --git a/core/src/main/java/com/taobao/arthas/core/mcp/tool/function/monitor200/ProfilerTool.java b/core/src/main/java/com/taobao/arthas/core/mcp/tool/function/monitor200/ProfilerTool.java index 28fb76335..13dde1dfc 100644 --- a/core/src/main/java/com/taobao/arthas/core/mcp/tool/function/monitor200/ProfilerTool.java +++ b/core/src/main/java/com/taobao/arthas/core/mcp/tool/function/monitor200/ProfilerTool.java @@ -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 { } } } - diff --git a/core/src/test/java/com/taobao/arthas/core/command/monitor200/ProfilerMarkdownTest.java b/core/src/test/java/com/taobao/arthas/core/command/monitor200/ProfilerMarkdownTest.java new file mode 100644 index 000000000..62181fcde --- /dev/null +++ b/core/src/test/java/com/taobao/arthas/core/command/monitor200/ProfilerMarkdownTest.java @@ -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: stackcount + 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"); + } +} + diff --git a/site/docs/doc/profiler.md b/site/docs/doc/profiler.md index 7cc37523f..67f04b908 100644 --- a/site/docs/doc/profiler.md +++ b/site/docs/doc/profiler.md @@ -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 diff --git a/site/docs/en/doc/profiler.md b/site/docs/en/doc/profiler.md index b8b4ddaea..f651157f1 100644 --- a/site/docs/en/doc/profiler.md +++ b/site/docs/en/doc/profiler.md @@ -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