feat: add Markdown report generation for profiler output

This commit is contained in:
hengyunabc
2026-02-03 15:51:28 +08:00
parent c6efb82fef
commit 175fdb013c
8 changed files with 688 additions and 11 deletions
@@ -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");
}
}
@@ -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 {
}
}
}
@@ -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");
}
}
+31 -1
View File
@@ -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
+31 -1
View File
@@ -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