diff --git a/argus-cli/src/main/java/io/argus/cli/ArgusCli.java b/argus-cli/src/main/java/io/argus/cli/ArgusCli.java
index 4a46d51..c052d2c 100644
--- a/argus-cli/src/main/java/io/argus/cli/ArgusCli.java
+++ b/argus-cli/src/main/java/io/argus/cli/ArgusCli.java
@@ -56,6 +56,7 @@
import io.argus.cli.command.ThreadDumpCommand;
import io.argus.cli.command.ThreadsCommand;
import io.argus.cli.command.TopCommand;
+import io.argus.cli.command.TraceCommand;
import io.argus.cli.command.TuiCommand;
import io.argus.cli.command.VmFlagCommand;
import io.argus.cli.command.VmLogCommand;
@@ -210,6 +211,7 @@ public static void main(String[] args) {
register(commands, new PerfCounterCommand());
register(commands, new MBeanCommand());
register(commands, new TopCommand());
+ register(commands, new TraceCommand());
register(commands, new WatchCommand());
register(commands, new ExplainCommand());
register(commands, new TuiCommand(commands));
diff --git a/argus-cli/src/main/java/io/argus/cli/command/TraceCommand.java b/argus-cli/src/main/java/io/argus/cli/command/TraceCommand.java
new file mode 100644
index 0000000..8d47ca9
--- /dev/null
+++ b/argus-cli/src/main/java/io/argus/cli/command/TraceCommand.java
@@ -0,0 +1,409 @@
+package io.argus.cli.command;
+
+import io.argus.cli.config.CliConfig;
+import io.argus.cli.config.Messages;
+import io.argus.cli.provider.ProviderRegistry;
+import io.argus.cli.render.AnsiStyle;
+import io.argus.cli.render.RichRenderer;
+import io.argus.core.command.CommandGroup;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Traces method execution in a running JVM by collecting rapid thread dumps
+ * and aggregating stack frames that match the target class.method.
+ *
+ *
Uses {@code jcmd Thread.print} at high frequency (10 samples/sec)
+ * for the specified duration, then builds a call tree from matching traces.
+ */
+public final class TraceCommand implements Command {
+
+ private static final int WIDTH = RichRenderer.DEFAULT_WIDTH;
+ private static final int SAMPLE_INTERVAL_MS = 100; // 10 samples/sec
+ private static final int DEFAULT_DURATION_SEC = 10;
+
+ @Override
+ public String name() {
+ return "trace";
+ }
+
+ @Override
+ public CommandGroup group() { return CommandGroup.PROFILING; }
+
+ @Override
+ public CommandMode mode() { return CommandMode.WRITE; }
+
+ @Override
+ public boolean supportsTui() { return false; }
+
+ @Override
+ public String description(Messages messages) {
+ return messages.get("cmd.trace.desc");
+ }
+
+ @Override
+ public void execute(String[] args, CliConfig config, ProviderRegistry registry, Messages messages) {
+ if (args.length < 2) {
+ printHelp(config.color(), messages);
+ return;
+ }
+
+ long pid;
+ try {
+ pid = Long.parseLong(args[0]);
+ } catch (NumberFormatException e) {
+ System.err.println(messages.get("error.pid.invalid", args[0]));
+ return;
+ }
+
+ String target = args[1]; // e.g. "com.example.OrderService.createOrder"
+ int durationSec = DEFAULT_DURATION_SEC;
+
+ for (int i = 2; i < args.length; i++) {
+ String arg = args[i];
+ if (arg.startsWith("--duration=")) {
+ try { durationSec = Integer.parseInt(arg.substring(11)); } catch (NumberFormatException ignored) {}
+ } else if (arg.equals("--duration") && i + 1 < args.length) {
+ try { durationSec = Integer.parseInt(args[++i]); } catch (NumberFormatException ignored) {}
+ }
+ }
+
+ // Validate target format (must contain at least one dot for class.method)
+ if (!target.contains(".")) {
+ System.err.println(messages.get("error.trace.invalid.target", target));
+ return;
+ }
+
+ boolean useColor = config.color();
+ int totalSamples = durationSec * (1000 / SAMPLE_INTERVAL_MS);
+
+ System.out.println(messages.get("status.trace.sampling", pid, durationSec, totalSamples));
+
+ List> matchedStacks = collectSamples(pid, target, durationSec, totalSamples);
+
+ if (matchedStacks.isEmpty()) {
+ System.out.println(messages.get("status.trace.no.match", target, totalSamples));
+ return;
+ }
+
+ // Build call tree from matched stacks
+ CallNode root = buildCallTree(matchedStacks);
+
+ printResult(root, pid, target, durationSec, totalSamples, matchedStacks.size(), useColor, messages);
+ }
+
+ // -------------------------------------------------------------------------
+ // Sampling
+ // -------------------------------------------------------------------------
+
+ /**
+ * Collects thread dump samples at SAMPLE_INTERVAL_MS intervals for durationSec seconds.
+ * Returns stacks (as frame lists, outermost first) that contain the target method.
+ */
+ static List> collectSamples(long pid, String target, int durationSec, int totalSamples) {
+ List> matched = new ArrayList<>();
+ // Derive the simple method-only part from the target for matching
+ // e.g. "com.example.OrderService.createOrder" -> class="com.example.OrderService", method="createOrder"
+ int lastDot = target.lastIndexOf('.');
+ String targetClass = target.substring(0, lastDot);
+ String targetMethod = target.substring(lastDot + 1);
+
+ for (int i = 0; i < totalSamples; i++) {
+ try {
+ String output = executeJcmdThreadPrint(pid);
+ List stack = extractMatchingStack(output, targetClass, targetMethod);
+ if (stack != null) {
+ matched.add(stack);
+ }
+ if (i < totalSamples - 1) {
+ Thread.sleep(SAMPLE_INTERVAL_MS);
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ } catch (Exception e) {
+ // jcmd failure: skip this sample, keep going
+ }
+ }
+ return matched;
+ }
+
+ /**
+ * Executes {@code jcmd Thread.print} and returns stdout.
+ * Factored out to allow unit-test overrides via subclass.
+ */
+ static String executeJcmdThreadPrint(long pid) {
+ try {
+ ProcessBuilder pb = new ProcessBuilder("jcmd", String.valueOf(pid), "Thread.print");
+ pb.redirectErrorStream(true);
+ Process process = pb.start();
+
+ StringBuilder sb = new StringBuilder();
+ Thread reader = new Thread(() -> {
+ try (java.io.BufferedReader br = new java.io.BufferedReader(
+ new java.io.InputStreamReader(process.getInputStream()))) {
+ String line;
+ while ((line = br.readLine()) != null) {
+ if (!sb.isEmpty()) sb.append('\n');
+ sb.append(line);
+ }
+ } catch (Exception ignored) {}
+ }, "trace-reader");
+ reader.setDaemon(true);
+ reader.start();
+
+ boolean finished = process.waitFor(5, java.util.concurrent.TimeUnit.SECONDS);
+ if (!finished) {
+ process.destroyForcibly();
+ }
+ reader.join(1000);
+ return sb.toString();
+ } catch (Exception e) {
+ return "";
+ }
+ }
+
+ /**
+ * Scans a thread dump output for any thread whose stack contains the target class+method.
+ * Returns the stack frames in order from the target frame outward (target first),
+ * or null if no match found.
+ */
+ static List extractMatchingStack(String dump, String targetClass, String targetMethod) {
+ if (dump == null || dump.isEmpty()) return null;
+
+ String[] lines = dump.split("\n");
+ List currentStack = new ArrayList<>();
+ boolean inThread = false;
+
+ for (String line : lines) {
+ String trimmed = line.trim();
+
+ // Thread header line (starts with ")
+ if (trimmed.startsWith("\"")) {
+ // Check previous thread's stack
+ List result = findTargetInStack(currentStack, targetClass, targetMethod);
+ if (result != null) return result;
+ currentStack = new ArrayList<>();
+ inThread = true;
+ continue;
+ }
+
+ if (!inThread) continue;
+
+ if (trimmed.startsWith("at ")) {
+ currentStack.add(trimmed.substring(3));
+ }
+ }
+
+ // Check last thread
+ return findTargetInStack(currentStack, targetClass, targetMethod);
+ }
+
+ /**
+ * Returns the sub-stack starting from the target frame going up to caller frames,
+ * or null if the target frame is not present.
+ */
+ private static List findTargetInStack(List stack, String targetClass, String targetMethod) {
+ if (stack.isEmpty()) return null;
+
+ // Stack is in order: innermost (bottom of call) first in jstack output
+ // Find the target frame
+ int targetIdx = -1;
+ for (int i = 0; i < stack.size(); i++) {
+ String frame = stack.get(i);
+ if (frame.startsWith(targetClass + "." + targetMethod)) {
+ targetIdx = i;
+ break;
+ }
+ }
+
+ if (targetIdx < 0) return null;
+
+ // Return from targetIdx backwards to 0 (target + all callees below it)
+ // In jstack output, index 0 = innermost frame (currently executing)
+ // So frames 0..targetIdx represent callees of the target, and targetIdx is the target
+ // We want: target + any callee frames (0 to targetIdx, reversed so target is first)
+ List result = new ArrayList<>();
+ for (int i = targetIdx; i >= 0; i--) {
+ result.add(stack.get(i));
+ }
+ return result;
+ }
+
+ // -------------------------------------------------------------------------
+ // Call tree
+ // -------------------------------------------------------------------------
+
+ static CallNode buildCallTree(List> stacks) {
+ CallNode root = new CallNode("__root__");
+ for (List stack : stacks) {
+ CallNode current = root;
+ for (String frame : stack) {
+ current = current.getOrCreate(frame);
+ current.hits++;
+ }
+ }
+ return root;
+ }
+
+ // -------------------------------------------------------------------------
+ // Rendering
+ // -------------------------------------------------------------------------
+
+ private static void printResult(CallNode root, long pid, String target, int durationSec,
+ int totalSamples, int matchedSamples,
+ boolean useColor, Messages messages) {
+ System.out.print(RichRenderer.brandedHeader(useColor, "trace",
+ messages.get("desc.trace")));
+
+ String header = RichRenderer.boxHeader(useColor, messages.get("header.trace"), WIDTH,
+ "pid:" + pid,
+ durationSec + "s",
+ totalSamples + " samples");
+ System.out.println(header);
+ System.out.println(RichRenderer.emptyLine(WIDTH));
+
+ // Print call tree (children of root are the target method entries)
+ for (CallNode child : root.children.values()) {
+ printNode(child, "", true, totalSamples, matchedSamples, useColor);
+ }
+
+ System.out.println(RichRenderer.emptyLine(WIDTH));
+
+ String summary = messages.get("trace.summary", totalSamples, matchedSamples, target);
+ System.out.println(RichRenderer.boxLine(
+ AnsiStyle.style(useColor, AnsiStyle.DIM) + " " + summary
+ + AnsiStyle.style(useColor, AnsiStyle.RESET), WIDTH));
+
+ System.out.println(RichRenderer.boxFooter(useColor, null, WIDTH));
+ }
+
+ private static void printNode(CallNode node, String prefix, boolean isRoot,
+ int totalSamples, int parentHits, boolean useColor) {
+ double pct = totalSamples > 0 ? (double) node.hits / totalSamples * 100.0 : 0.0;
+ double ms = node.hits * SAMPLE_INTERVAL_MS / 1000.0 * 1000.0; // hits * interval_ms = estimated ms
+
+ String methodShort = shortenFrame(node.method, WIDTH - prefix.length() - 24);
+ String timePart = String.format("%.1fms (%.0f%%)", ms, pct);
+
+ String line;
+ if (isRoot) {
+ line = " " + AnsiStyle.style(useColor, AnsiStyle.BOLD, AnsiStyle.CYAN)
+ + RichRenderer.padRight(methodShort, WIDTH - 26)
+ + AnsiStyle.style(useColor, AnsiStyle.RESET)
+ + AnsiStyle.style(useColor, AnsiStyle.YELLOW)
+ + RichRenderer.padLeft(timePart, 20)
+ + AnsiStyle.style(useColor, AnsiStyle.RESET);
+ } else {
+ line = " " + AnsiStyle.style(useColor, AnsiStyle.DIM) + prefix
+ + AnsiStyle.style(useColor, AnsiStyle.RESET)
+ + AnsiStyle.style(useColor, AnsiStyle.CYAN)
+ + RichRenderer.padRight(methodShort, WIDTH - 2 - prefix.length() - 20)
+ + AnsiStyle.style(useColor, AnsiStyle.RESET)
+ + AnsiStyle.style(useColor, AnsiStyle.DIM)
+ + RichRenderer.padLeft(timePart, 20)
+ + AnsiStyle.style(useColor, AnsiStyle.RESET);
+ }
+
+ System.out.println(RichRenderer.boxLine(line, WIDTH));
+
+ // Print children sorted by hits descending
+ List children = new ArrayList<>(node.children.values());
+ children.sort((a, b) -> Integer.compare(b.hits, a.hits));
+
+ for (int i = 0; i < children.size(); i++) {
+ boolean last = (i == children.size() - 1);
+ String childPrefix = prefix + (last ? "\u2514\u2500\u2500 " : "\u251c\u2500\u2500 ");
+ String grandchildPrefix = prefix + (last ? " " : "\u2502 ");
+ CallNode child = children.get(i);
+ printNodeWithPrefix(child, childPrefix, grandchildPrefix, totalSamples, node.hits, useColor);
+ }
+ }
+
+ private static void printNodeWithPrefix(CallNode node, String prefix, String childPrefix,
+ int totalSamples, int parentHits, boolean useColor) {
+ double pct = totalSamples > 0 ? (double) node.hits / totalSamples * 100.0 : 0.0;
+ double ms = node.hits * SAMPLE_INTERVAL_MS / 1000.0 * 1000.0;
+
+ int methodWidth = Math.max(10, WIDTH - 2 - prefix.length() - 20);
+ String methodShort = shortenFrame(node.method, methodWidth);
+ String timePart = String.format("%.1fms (%.0f%%)", ms, pct);
+
+ String line = " " + AnsiStyle.style(useColor, AnsiStyle.DIM) + prefix
+ + AnsiStyle.style(useColor, AnsiStyle.RESET)
+ + AnsiStyle.style(useColor, AnsiStyle.CYAN)
+ + RichRenderer.padRight(methodShort, methodWidth)
+ + AnsiStyle.style(useColor, AnsiStyle.RESET)
+ + AnsiStyle.style(useColor, AnsiStyle.DIM)
+ + RichRenderer.padLeft(timePart, 20)
+ + AnsiStyle.style(useColor, AnsiStyle.RESET);
+
+ System.out.println(RichRenderer.boxLine(line, WIDTH));
+
+ List children = new ArrayList<>(node.children.values());
+ children.sort((a, b) -> Integer.compare(b.hits, a.hits));
+
+ for (int i = 0; i < children.size(); i++) {
+ boolean last = (i == children.size() - 1);
+ String nextPrefix = childPrefix + (last ? "\u2514\u2500\u2500 " : "\u251c\u2500\u2500 ");
+ String nextChildPrefix = childPrefix + (last ? " " : "\u2502 ");
+ printNodeWithPrefix(children.get(i), nextPrefix, nextChildPrefix, totalSamples, node.hits, useColor);
+ }
+ }
+
+ private static void printHelp(boolean useColor, Messages messages) {
+ System.out.print(RichRenderer.brandedHeader(useColor, "trace",
+ messages.get("cmd.trace.desc")));
+ System.out.println(RichRenderer.boxHeader(useColor, "Usage", WIDTH));
+ System.out.println(RichRenderer.boxLine("argus trace [options]", WIDTH));
+ System.out.println(RichRenderer.emptyLine(WIDTH));
+ System.out.println(RichRenderer.boxLine(
+ AnsiStyle.style(useColor, AnsiStyle.BOLD) + "Options:"
+ + AnsiStyle.style(useColor, AnsiStyle.RESET), WIDTH));
+ System.out.println(RichRenderer.boxLine(
+ RichRenderer.padRight(" --duration=N", 36)
+ + "Duration in seconds (default: " + DEFAULT_DURATION_SEC + ")", WIDTH));
+ System.out.println(RichRenderer.emptyLine(WIDTH));
+ System.out.println(RichRenderer.boxLine(
+ AnsiStyle.style(useColor, AnsiStyle.BOLD) + "Example:"
+ + AnsiStyle.style(useColor, AnsiStyle.RESET), WIDTH));
+ System.out.println(RichRenderer.boxLine(
+ " argus trace 12345 com.example.OrderService.createOrder --duration=10", WIDTH));
+ System.out.println(RichRenderer.boxFooter(useColor, null, WIDTH));
+ }
+
+ /**
+ * Shortens a fully-qualified frame string to fit within maxLen characters.
+ * Trims package prefix if needed, keeping the class.method(...) part readable.
+ */
+ static String shortenFrame(String frame, int maxLen) {
+ if (frame.length() <= maxLen) return frame;
+ // Strip source file/line info: "com.Foo.bar(Foo.java:42)" -> "com.Foo.bar"
+ int parenIdx = frame.indexOf('(');
+ String sig = parenIdx > 0 ? frame.substring(0, parenIdx) : frame;
+ if (sig.length() <= maxLen) return sig;
+ // Truncate with ellipsis
+ return "\u2026" + sig.substring(sig.length() - (maxLen - 1));
+ }
+
+ // -------------------------------------------------------------------------
+ // Internal call tree node
+ // -------------------------------------------------------------------------
+
+ static final class CallNode {
+ final String method;
+ int hits;
+ final Map children = new LinkedHashMap<>();
+
+ CallNode(String method) {
+ this.method = method;
+ }
+
+ CallNode getOrCreate(String frame) {
+ return children.computeIfAbsent(frame, CallNode::new);
+ }
+ }
+}
diff --git a/argus-cli/src/main/resources/messages_en.properties b/argus-cli/src/main/resources/messages_en.properties
index f7dd571..6314603 100644
--- a/argus-cli/src/main/resources/messages_en.properties
+++ b/argus-cli/src/main/resources/messages_en.properties
@@ -354,3 +354,10 @@ error.profile.invalid.type=Invalid profiling type: %s. Use: cpu, alloc, lock, wa
gcnew.age.title=Object Age Distribution
gcnew.age.unavailable=Age data unavailable. Enable -Xlog:gc+age=debug for live data.
cmd.gclog.tenuring.desc=Analyze tenuring threshold changes from GC age log
+cmd.trace.desc=Method execution tracing via rapid thread sampling
+header.trace=Method Trace
+desc.trace=Traces method execution by sampling thread dumps at 10/sec. Builds a call tree with timing estimates.
+status.trace.sampling=Tracing PID %s for %ss (%s samples)...
+status.trace.no.match=No stack traces matched '%s' in %s samples
+error.trace.invalid.target=Invalid target format: '%s'. Use class.method (e.g. com.example.OrderService.createOrder)
+trace.summary=%s samples, %s matched target method '%s'
diff --git a/argus-cli/src/main/resources/messages_ja.properties b/argus-cli/src/main/resources/messages_ja.properties
index dbe5330..47dbc8f 100644
--- a/argus-cli/src/main/resources/messages_ja.properties
+++ b/argus-cli/src/main/resources/messages_ja.properties
@@ -338,3 +338,10 @@ error.profile.invalid.type=\u7121\u52B9\u306A\u30D7\u30ED\u30D5\u30A1\u30A4\u30E
gcnew.age.title=\u30AA\u30D6\u30B8\u30A7\u30AF\u30C8\u5E74\u9F62\u5206\u5E03
gcnew.age.unavailable=\u5E74\u9F62\u30C7\u30FC\u30BF\u306F\u5229\u7528\u4E0D\u53EF\u3002-Xlog:gc+age=debug\u3067\u6709\u52B9\u5316\u3057\u3066\u304F\u3060\u3055\u3044\u3002
cmd.gclog.tenuring.desc=GC\u30A8\u30FC\u30B8\u30ED\u30B0\u304B\u3089\u30C6\u30CB\u30E5\u30A2\u30EA\u30F3\u30B0\u5909\u5316\u3092\u5206\u6790
+cmd.trace.desc=高速スレッドサンプリングによるメソッド実行トレース
+header.trace=メソッドトレース
+desc.trace=秒間10回のスレッドダンプサンプリングでメソッド実行を追跡します。タイミング推定付きのコールツリーを生成します。
+status.trace.sampling=PID %sを%s秒間トレース中 (%sサンプル)...
+status.trace.no.match=%sサンプルで'%s'に一致するスタックトレースが見つかりません
+error.trace.invalid.target=無効なターゲット形式: '%s'。class.method形式を使用してください (例: com.example.OrderService.createOrder)
+trace.summary=%sサンプル、%s件がターゲットメソッド'%s'に一致
diff --git a/argus-cli/src/main/resources/messages_ko.properties b/argus-cli/src/main/resources/messages_ko.properties
index 551b68a..f5fdc89 100644
--- a/argus-cli/src/main/resources/messages_ko.properties
+++ b/argus-cli/src/main/resources/messages_ko.properties
@@ -338,3 +338,10 @@ error.profile.invalid.type=\uC798\uBABB\uB41C \uD504\uB85C\uD30C\uC77C\uB9C1 \uD
gcnew.age.title=\uAC1D\uCCB4 \uC5F0\uB839 \uBD84\uD3EC
gcnew.age.unavailable=\uC5F0\uB839 \uB370\uC774\uD130\uB97C \uC0AC\uC6A9\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. -Xlog:gc+age=debug\uB97C \uC0AC\uC6A9\uD558\uC138\uC694.
cmd.gclog.tenuring.desc=GC \uC5D0\uC774\uC9C0 \uB85C\uADF8\uC5D0\uC11C \uD14C\uB274\uC5B4\uB9C1 \uBCC0\uD654 \uBD84\uC11D
+cmd.trace.desc=고속 스레드 샘플링을 통한 메서드 실행 추적
+header.trace=메서드 추적
+desc.trace=초당 10회 스레드 덤프 샘플링으로 메서드 실행을 추적합니다. 타이밍 추정치와 함께 호출 트리를 생성합니다.
+status.trace.sampling=PID %s를 %s초 동안 추적 중 (%s개 샘플)...
+status.trace.no.match=%s개 샘플에서 '%s'와 일치하는 스택 트레이스가 없습니다
+error.trace.invalid.target=잘못된 대상 형식: '%s'. class.method 형식을 사용하세요 (예: com.example.OrderService.createOrder)
+trace.summary=%s개 샘플, %s개가 대상 메서드 '%s'와 일치
diff --git a/argus-cli/src/main/resources/messages_zh.properties b/argus-cli/src/main/resources/messages_zh.properties
index 8ef8952..87d6edb 100644
--- a/argus-cli/src/main/resources/messages_zh.properties
+++ b/argus-cli/src/main/resources/messages_zh.properties
@@ -335,3 +335,10 @@ error.profile.unsupported.platform=\u6B64\u5E73\u53F0\u4E0D\u652F\u6301async-pro
error.profile.download.failed=async-profiler\u4E0B\u8F7D\u5931\u8D25: %s
error.profile.asprof.failed=async-profiler\u5931\u8D25: %s
error.profile.invalid.type=\u65E0\u6548\u7684\u5206\u6790\u7C7B\u578B: %s\u3002\u8BF7\u4F7F\u7528: cpu, alloc, lock, wall
+cmd.trace.desc=通过高速线程采样进行方法执行追踪
+header.trace=方法追踪
+desc.trace=以每秒10次的频率采样线程转储来追踪方法执行,生成带时间估算的调用树。
+status.trace.sampling=正在追踪PID %s,持续%s秒(%s个样本)...
+status.trace.no.match=在%s个样本中未找到匹配'%s'的堆栈跟踪
+error.trace.invalid.target=无效的目标格式:'%s'。请使用class.method格式(例如:com.example.OrderService.createOrder)
+trace.summary=%s个样本,%s个匹配目标方法'%s'
diff --git a/argus-cli/src/test/java/io/argus/cli/command/TraceCommandTest.java b/argus-cli/src/test/java/io/argus/cli/command/TraceCommandTest.java
new file mode 100644
index 0000000..4a4f160
--- /dev/null
+++ b/argus-cli/src/test/java/io/argus/cli/command/TraceCommandTest.java
@@ -0,0 +1,138 @@
+package io.argus.cli.command;
+
+import io.argus.cli.command.TraceCommand.CallNode;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class TraceCommandTest {
+
+ // -------------------------------------------------------------------------
+ // extractMatchingStack
+ // -------------------------------------------------------------------------
+
+ private static final String DUMP_WITH_TARGET =
+ "\"main\" #1 prio=5 os_prio=0 tid=0x00007f nid=0x1234 runnable\n" +
+ " java.lang.Thread.State: RUNNABLE\n" +
+ " at org.hibernate.internal.SessionImpl.merge(SessionImpl.java:100)\n" +
+ " at com.example.OrderRepository.save(OrderRepository.java:45)\n" +
+ " at com.example.OrderService.createOrder(OrderService.java:22)\n" +
+ " at com.example.Controller.handle(Controller.java:10)\n" +
+ "\n" +
+ "\"GC task thread\" #2 daemon prio=9 os_prio=0 tid=0x00007f nid=0x5678 runnable\n" +
+ " java.lang.Thread.State: RUNNABLE\n" +
+ " at java.lang.Object.wait(Object.java:1)\n";
+
+ @Test
+ void extractMatchingStack_findsTargetFrame() {
+ List stack = TraceCommand.extractMatchingStack(
+ DUMP_WITH_TARGET, "com.example.OrderService", "createOrder");
+ assertNotNull(stack, "Should find matching stack");
+ assertFalse(stack.isEmpty());
+ // First element should be the target method
+ assertTrue(stack.get(0).startsWith("com.example.OrderService.createOrder"),
+ "First frame should be the target method");
+ }
+
+ @Test
+ void extractMatchingStack_includesCalleeFrames() {
+ List stack = TraceCommand.extractMatchingStack(
+ DUMP_WITH_TARGET, "com.example.OrderService", "createOrder");
+ assertNotNull(stack);
+ // Should include callee frames (innermost first)
+ assertTrue(stack.size() >= 3, "Should include callee frames");
+ assertTrue(stack.stream().anyMatch(f -> f.startsWith("com.example.OrderRepository.save")));
+ assertTrue(stack.stream().anyMatch(f -> f.startsWith("org.hibernate.internal.SessionImpl.merge")));
+ }
+
+ @Test
+ void extractMatchingStack_returnsNullWhenNoMatch() {
+ List stack = TraceCommand.extractMatchingStack(
+ DUMP_WITH_TARGET, "com.example.NonExistent", "missing");
+ assertNull(stack, "Should return null when target not found");
+ }
+
+ @Test
+ void extractMatchingStack_handlesEmptyDump() {
+ assertNull(TraceCommand.extractMatchingStack("", "com.example.Foo", "bar"));
+ assertNull(TraceCommand.extractMatchingStack(null, "com.example.Foo", "bar"));
+ }
+
+ @Test
+ void extractMatchingStack_matchesOnlyTargetThread() {
+ // GC thread does not contain the target — only main thread does
+ List stack = TraceCommand.extractMatchingStack(
+ DUMP_WITH_TARGET, "com.example.OrderService", "createOrder");
+ assertNotNull(stack);
+ // Should not contain Object.wait from GC thread
+ assertTrue(stack.stream().noneMatch(f -> f.startsWith("java.lang.Object.wait")));
+ }
+
+ // -------------------------------------------------------------------------
+ // buildCallTree
+ // -------------------------------------------------------------------------
+
+ @Test
+ void buildCallTree_singleStack() {
+ List> stacks = List.of(
+ List.of("com.example.OrderService.createOrder(OrderService.java:22)",
+ "com.example.OrderRepository.save(OrderRepository.java:45)")
+ );
+ CallNode root = TraceCommand.buildCallTree(stacks);
+ assertEquals(1, root.children.size());
+ CallNode orderServiceNode = root.children.values().iterator().next();
+ assertEquals(1, orderServiceNode.hits);
+ assertEquals(1, orderServiceNode.children.size());
+ }
+
+ @Test
+ void buildCallTree_aggregatesRepeatedStacks() {
+ List> stacks = List.of(
+ List.of("com.example.OrderService.createOrder(OrderService.java:22)",
+ "com.example.OrderRepository.save(OrderRepository.java:45)"),
+ List.of("com.example.OrderService.createOrder(OrderService.java:22)",
+ "com.example.OrderRepository.save(OrderRepository.java:45)"),
+ List.of("com.example.OrderService.createOrder(OrderService.java:22)",
+ "com.example.Validator.validate(Validator.java:10)")
+ );
+ CallNode root = TraceCommand.buildCallTree(stacks);
+ CallNode target = root.children.values().iterator().next();
+ assertEquals(3, target.hits, "Root target should have 3 hits");
+ // Should have 2 distinct children
+ assertEquals(2, target.children.size());
+ }
+
+ @Test
+ void buildCallTree_emptyStacks() {
+ CallNode root = TraceCommand.buildCallTree(List.of());
+ assertTrue(root.children.isEmpty());
+ }
+
+ // -------------------------------------------------------------------------
+ // shortenFrame
+ // -------------------------------------------------------------------------
+
+ @Test
+ void shortenFrame_shortFrameUnchanged() {
+ String frame = "com.example.Foo.bar(Foo.java:10)";
+ assertEquals(frame, TraceCommand.shortenFrame(frame, 100));
+ }
+
+ @Test
+ void shortenFrame_stripsSourceInfo() {
+ String frame = "com.example.OrderService.createOrder(OrderService.java:22)";
+ String shortened = TraceCommand.shortenFrame(frame, 40);
+ assertFalse(shortened.contains("(OrderService.java"), "Should strip source file info");
+ assertTrue(shortened.length() <= 40);
+ }
+
+ @Test
+ void shortenFrame_truncatesWithEllipsis() {
+ String frame = "com.example.very.long.package.name.OrderService.createOrder";
+ String shortened = TraceCommand.shortenFrame(frame, 20);
+ assertTrue(shortened.length() <= 20);
+ assertTrue(shortened.startsWith("\u2026"), "Should start with ellipsis when truncated");
+ }
+}
diff --git a/completions/argus.bash b/completions/argus.bash
index 11a0a4a..298781e 100644
--- a/completions/argus.bash
+++ b/completions/argus.bash
@@ -3,7 +3,7 @@ _argus_completions() {
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
- commands="alert init ps histo threads gc gcutil heap sysprops vmflag nmt classloader profile jfr jfranalyze diff report doctor gclog gclogdiff gcprofile flame suggest watch tui info heapdump heapanalyze deadlock threaddump buffers gcrun logger events compilerqueue sc env compiler finalizer stringtable pool gccause metaspace dynlibs vmset vmlog jmx classstat gcnew symboltable top perfcounter mbean ci compare slowlog explain"
+ commands="alert init ps histo threads gc gcutil heap sysprops vmflag nmt classloader profile jfr jfranalyze diff report doctor gclog gclogdiff gcprofile flame suggest watch tui info heapdump heapanalyze deadlock threaddump buffers gcrun logger events compilerqueue sc env compiler finalizer stringtable pool gccause metaspace dynlibs vmset vmlog jmx classstat gcnew symboltable top perfcounter mbean ci compare slowlog explain trace"
if [ "$COMP_CWORD" -eq 1 ]; then
COMPREPLY=($(compgen -W "$commands --help --version" -- "$cur"))
@@ -43,6 +43,7 @@ _argus_completions() {
diff) opts="$opts --top=" ;;
alert) opts="$opts --config= --gc-overhead= --leak --webhook= --interval=" ;;
top) opts="$opts --host= --port= --interval=" ;;
+ trace) opts="$opts --duration=" ;;
esac
COMPREPLY=($(compgen -W "$opts" -- "$cur"))
fi
diff --git a/completions/argus.zsh b/completions/argus.zsh
index 1636c3a..aeae946 100644
--- a/completions/argus.zsh
+++ b/completions/argus.zsh
@@ -59,6 +59,7 @@ _argus() {
'compare:Compare two JVM snapshots'
'slowlog:Real-time slow method detection'
'explain:Explain JVM metrics, GC causes, and flags in plain English'
+ 'trace:Method execution tracing via rapid thread sampling'
)
_arguments -C \
@@ -104,6 +105,9 @@ _argus() {
gcutil)
_arguments '--watch=[Refresh interval]'
;;
+ trace)
+ _arguments '--duration=[Duration in seconds]'
+ ;;
esac
;;
esac