diff --git a/argus-cli/src/main/java/io/argus/cli/command/GcLogCommand.java b/argus-cli/src/main/java/io/argus/cli/command/GcLogCommand.java index 629a849..f1cdd75 100644 --- a/argus-cli/src/main/java/io/argus/cli/command/GcLogCommand.java +++ b/argus-cli/src/main/java/io/argus/cli/command/GcLogCommand.java @@ -54,11 +54,18 @@ public void execute(String[] args, CliConfig config, ProviderRegistry registry, boolean json = "json".equals(config.format()); boolean useColor = config.color(); boolean flagsOnly = false; + boolean follow = false; String exportHtml = null; for (int i = 1; i < args.length; i++) { if (args[i].equals("--format=json")) json = true; if (args[i].equals("--suggest-flags")) flagsOnly = true; if (args[i].startsWith("--export=")) exportHtml = args[i].substring(9); + if (args[i].equals("--follow") || args[i].equals("-f")) follow = true; + } + + if (follow) { + runFollowMode(logFile, useColor); + return; } List events; @@ -275,6 +282,98 @@ private void printRich(GcLogAnalysis a, List events, Path file, boolean a.pauseEvents() + " pauses, " + String.format("%.1f%%", a.throughputPercent()) + " throughput", WIDTH)); } + private void runFollowMode(Path logFile, boolean c) { + GcLogFollower follower = new GcLogFollower(logFile); + RollingGcAnalysis rolling = new RollingGcAnalysis(); + + try { + List initial = follower.readAll(); + rolling.addEvents(initial); + } catch (IOException e) { + System.err.println("Failed to read GC log: " + e.getMessage()); + return; + } + + System.out.println("Following " + logFile.getFileName() + "... (Ctrl+C to stop)"); + + // Set terminal to non-canonical mode if possible + try { + new ProcessBuilder("stty", "-icanon", "min", "0", "-echo") + .inheritIO().start().waitFor(); + } catch (Exception ignored) {} + + try { + long lastUpdate = System.currentTimeMillis(); + while (true) { + // Poll for new events + List newEvents = follower.pollNewEvents(); + if (!newEvents.isEmpty()) { + rolling.addEvents(newEvents); + lastUpdate = System.currentTimeMillis(); + } + + // Refresh display every 2 seconds + long elapsed = System.currentTimeMillis() - lastUpdate; + RollingGcAnalysis.Snapshot snap = rolling.snapshot(); + + // Clear screen and render + System.out.print("\033[H\033[2J"); + System.out.flush(); + + long uptime = (System.currentTimeMillis() - lastUpdate) / 1000; + String uptimeStr = snap.totalEventsEver() > 0 + ? String.format("last update %ds ago", elapsed / 1000) : "waiting for events..."; + + System.out.println(RichRenderer.boxHeader(c, "GC Log Monitor", WIDTH, + logFile.getFileName().toString(), "following", uptimeStr)); + System.out.println(RichRenderer.emptyLine(WIDTH)); + + kv(c, "Events", snap.totalEventsEver() + " total, " + rolling.windowSize() + " in window"); + kv(c, "Throughput", String.format("%.1f%%", snap.throughputPercent())); + System.out.println(RichRenderer.emptyLine(WIDTH)); + + System.out.println(RichRenderer.boxLine(String.format( + " p50: %dms p95: %dms p99: %dms max: %dms avg: %dms", + snap.p50PauseMs(), snap.p95PauseMs(), snap.p99PauseMs(), + snap.maxPauseMs(), snap.avgPauseMs()), WIDTH)); + + if (snap.fullGcCount() > 0) { + System.out.println(RichRenderer.emptyLine(WIDTH)); + kv(c, "Full GC", AnsiStyle.style(c, AnsiStyle.RED) + snap.fullGcCount() + + AnsiStyle.style(c, AnsiStyle.RESET) + + (snap.secsSinceLastFullGc() >= 0 + ? String.format(" (last %.0fs ago)", snap.secsSinceLastFullGc()) : "")); + } + + if (snap.peakHeapKB() > 0) { + kv(c, "Peak Heap", RichRenderer.formatKB(snap.peakHeapKB())); + } + + System.out.println(RichRenderer.emptyLine(WIDTH)); + System.out.println(RichRenderer.boxFooter(c, + "[Ctrl+C] quit", WIDTH)); + + // Check for key input (non-blocking) + if (System.in.available() > 0) { + int key = System.in.read(); + if (key == 'q' || key == 'Q') break; + } + + Thread.sleep(2000); + } + } catch (InterruptedException ignored) { + // Normal exit on Ctrl+C + } catch (IOException e) { + System.err.println("Follow error: " + e.getMessage()); + } finally { + // Restore terminal + try { + new ProcessBuilder("stty", "icanon", "echo") + .inheritIO().start().waitFor(); + } catch (Exception ignored) {} + } + } + private void section(boolean c, String title) { System.out.println(RichRenderer.emptyLine(WIDTH)); System.out.println(RichRenderer.boxSeparator(WIDTH)); diff --git a/argus-cli/src/main/java/io/argus/cli/gclog/GcLogFollower.java b/argus-cli/src/main/java/io/argus/cli/gclog/GcLogFollower.java new file mode 100644 index 0000000..debb72d --- /dev/null +++ b/argus-cli/src/main/java/io/argus/cli/gclog/GcLogFollower.java @@ -0,0 +1,203 @@ +package io.argus.cli.gclog; + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Tails a growing GC log file, polling for new lines and parsing them. + * Thread-safe for the poll loop pattern (single consumer). + */ +public final class GcLogFollower { + + private final Path logFile; + private final int maxEvents; + + private long filePosition = 0; + private boolean formatDetected = false; + private boolean unified = false; + private final StringBuilder lineBuffer = new StringBuilder(); + + public GcLogFollower(Path logFile, int maxEvents) { + this.logFile = logFile; + this.maxEvents = maxEvents; + } + + public GcLogFollower(Path logFile) { + this(logFile, 1000); + } + + /** + * Reads the entire file from the beginning, returning all parsed events. + * Resets the follower position to end of file. + */ + public synchronized List readAll() throws IOException { + filePosition = 0; + lineBuffer.setLength(0); + formatDetected = false; + unified = false; + return pollNewEvents(); + } + + /** + * Reads new bytes since last position, parses any complete lines, + * and returns newly parsed GcEvents. Returns empty list if no new data. + */ + public synchronized List pollNewEvents() throws IOException { + try (RandomAccessFile raf = new RandomAccessFile(logFile.toFile(), "r")) { + long fileLength = raf.length(); + if (fileLength <= filePosition) { + return Collections.emptyList(); + } + + raf.seek(filePosition); + long bytesToRead = fileLength - filePosition; + byte[] buf = new byte[(int) Math.min(bytesToRead, 4 * 1024 * 1024)]; // 4MB cap + int bytesRead = raf.read(buf); + if (bytesRead <= 0) { + return Collections.emptyList(); + } + + filePosition += bytesRead; + + String chunk = new String(buf, 0, bytesRead, StandardCharsets.UTF_8); + lineBuffer.append(chunk); + + List events = new ArrayList<>(); + int start = 0; + int len = lineBuffer.length(); + for (int i = 0; i < len; i++) { + if (lineBuffer.charAt(i) == '\n') { + String line = lineBuffer.substring(start, i).stripTrailing(); + start = i + 1; + if (line.isEmpty()) continue; + + // Auto-detect format from first non-empty line + if (!formatDetected) { + unified = line.contains("[gc") || line.contains("[info]") + || (line.startsWith("[") && (line.contains("s]") || line.contains("T"))); + formatDetected = true; + } + + GcEvent event = parseLine(line, unified); + if (event != null) events.add(event); + } + } + // Keep any incomplete line in buffer + lineBuffer.delete(0, start); + + return events; + } + } + + /** + * Parses a single GC log line using the detected format. + * Package-private to allow reuse from tests. + */ + static GcEvent parseLine(String line, boolean unified) { + return unified ? parseUnifiedLine(line) : parseLegacyLine(line); + } + + // ── Unified (JDK 9+) ──────────────────────────────────────────────────── + + private static GcEvent parseUnifiedLine(String line) { + double timestamp = extractTimestamp(line); + if (timestamp < 0) return null; + + java.util.regex.Matcher m = GcLogPatterns.UNIFIED_PAUSE.matcher(line); + if (m.find()) { + String type = m.group(1).trim(); + long heapBefore = toKB(Long.parseLong(m.group(2)), m.group(3)); + long heapAfter = toKB(Long.parseLong(m.group(4)), m.group(5)); + long heapTotal = toKB(Long.parseLong(m.group(6)), m.group(7)); + double pauseMs = Double.parseDouble(m.group(8)); + + String cause = ""; + java.util.regex.Matcher cm = GcLogPatterns.UNIFIED_CAUSE.matcher(line); + if (cm.find()) cause = cm.group(1).trim(); + + return new GcEvent(timestamp, type, cause, pauseMs, heapBefore, heapAfter, heapTotal); + } + + java.util.regex.Matcher zm = GcLogPatterns.ZGC_PAUSE.matcher(line); + if (zm.find() && line.contains("ZGC")) { + return new GcEvent(timestamp, "ZGC Pause " + zm.group(1), "ZGC", + Double.parseDouble(zm.group(2)), 0, 0, 0); + } + + java.util.regex.Matcher zcm = GcLogPatterns.ZGC_CYCLE.matcher(line); + if (zcm.find()) { + long heapBefore = toKB(Long.parseLong(zcm.group(2)), zcm.group(3)); + long heapAfter = toKB(Long.parseLong(zcm.group(4)), zcm.group(5)); + return new GcEvent(timestamp, "ZGC Cycle", zcm.group(1), 0, heapBefore, heapAfter, 0); + } + + java.util.regex.Matcher sm = GcLogPatterns.SHENANDOAH_PAUSE.matcher(line); + if (sm.find()) { + return new GcEvent(timestamp, "Shenandoah " + sm.group(1), "Shenandoah", + Double.parseDouble(sm.group(2)), 0, 0, 0); + } + + java.util.regex.Matcher cm2 = GcLogPatterns.UNIFIED_CONCURRENT.matcher(line); + if (cm2.find()) { + return new GcEvent(timestamp, "Concurrent " + cm2.group(1), "Concurrent", + Double.parseDouble(cm2.group(2)), 0, 0, 0); + } + + return null; + } + + private static GcEvent parseLegacyLine(String line) { + java.util.regex.Matcher m = GcLogPatterns.LEGACY_GC.matcher(line); + if (!m.find()) return null; + + double timestamp = Double.parseDouble(m.group(1)); + boolean full = m.group(2) != null; + String cause = m.group(3).trim(); + long heapBefore = Long.parseLong(m.group(4)); + long heapAfter = Long.parseLong(m.group(5)); + long heapTotal = Long.parseLong(m.group(6)); + double pauseMs = Double.parseDouble(m.group(7)) * 1000; + + return new GcEvent(timestamp, full ? "Full" : "Young", cause, + pauseMs, heapBefore, heapAfter, heapTotal); + } + + private static double extractTimestamp(String line) { + java.util.regex.Matcher m = GcLogPatterns.TIMESTAMP_UPTIME.matcher(line); + if (m.find()) return Double.parseDouble(m.group(1)); + + java.util.regex.Matcher im = GcLogPatterns.TIMESTAMP_ISO.matcher(line); + if (im.find()) { + try { + String iso = im.group(1); + int tIdx = iso.indexOf('T'); + if (tIdx > 0) { + String timePart = iso.substring(tIdx + 1); + String[] parts = timePart.split("[:+]"); + if (parts.length >= 3) { + return Double.parseDouble(parts[0]) * 3600 + + Double.parseDouble(parts[1]) * 60 + + Double.parseDouble(parts[2]); + } + } + } catch (Exception ignored) {} + } + return -1; + } + + private static long toKB(long value, String unit) { + return switch (unit) { + case "K" -> value; + case "M" -> value * 1024; + case "G" -> value * 1024 * 1024; + default -> value; + }; + } + + public int maxEvents() { return maxEvents; } +} diff --git a/argus-cli/src/main/java/io/argus/cli/gclog/GcLogPatterns.java b/argus-cli/src/main/java/io/argus/cli/gclog/GcLogPatterns.java new file mode 100644 index 0000000..4bacbd7 --- /dev/null +++ b/argus-cli/src/main/java/io/argus/cli/gclog/GcLogPatterns.java @@ -0,0 +1,34 @@ +package io.argus.cli.gclog; + +import java.util.regex.Pattern; + +/** + * Shared GC log regex patterns used by GcLogParser and GcLogFollower. + */ +final class GcLogPatterns { + + static final Pattern TIMESTAMP_UPTIME = Pattern.compile("\\[(\\d+\\.\\d+)s\\]"); + static final Pattern TIMESTAMP_ISO = Pattern.compile("\\[(\\d{4}-\\d{2}-\\d{2}T[\\d:.+]+)\\]"); + + static final Pattern UNIFIED_PAUSE = Pattern.compile( + "GC\\(\\d+\\)\\s+Pause\\s+(\\S+(?:\\s+\\([^)]*\\))*)\\s+(\\d+)([MKG])->(\\d+)([MKG])\\((\\d+)([MKG])\\)\\s+(\\d+\\.?\\d*)ms"); + + static final Pattern UNIFIED_CAUSE = Pattern.compile("\\(([^)]+)\\)\\s+\\d+[MKG]->"); + + static final Pattern ZGC_PAUSE = Pattern.compile( + "GC\\(\\d+\\)\\s+Pause\\s+(\\w+)\\s+(\\d+\\.?\\d*)ms"); + + static final Pattern ZGC_CYCLE = Pattern.compile( + "GC\\(\\d+\\)\\s+Garbage Collection\\s+\\(([^)]+)\\)\\s+(\\d+)([MKG])\\([^)]*\\)->(\\d+)([MKG])"); + + static final Pattern SHENANDOAH_PAUSE = Pattern.compile( + "GC\\(\\d+\\)\\s+Pause\\s+(Init Mark|Final Mark|Init Update|Final Update|Full).*?(\\d+\\.?\\d*)ms"); + + static final Pattern LEGACY_GC = Pattern.compile( + "(\\d+\\.\\d+):\\s+\\[(Full )?GC\\s*\\(([^)]+)\\).*?(\\d+)K->(\\d+)K\\((\\d+)K\\),?\\s+(\\d+\\.\\d+)\\s+secs"); + + static final Pattern UNIFIED_CONCURRENT = Pattern.compile( + "GC\\(\\d+\\)\\s+Concurrent\\s+(\\S+)\\s+(\\d+\\.?\\d*)ms"); + + private GcLogPatterns() {} +} diff --git a/argus-cli/src/main/java/io/argus/cli/gclog/RollingGcAnalysis.java b/argus-cli/src/main/java/io/argus/cli/gclog/RollingGcAnalysis.java new file mode 100644 index 0000000..a701427 --- /dev/null +++ b/argus-cli/src/main/java/io/argus/cli/gclog/RollingGcAnalysis.java @@ -0,0 +1,138 @@ +package io.argus.cli.gclog; + +import java.time.Instant; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Deque; +import java.util.List; + +/** + * Maintains a rolling window of the last N GcEvents and computes live metrics. + * Exposes the same key statistics as GcLogAnalysis but over the rolling window. + * Also tracks events/sec rate and time-since-last-full-GC. + */ +public final class RollingGcAnalysis { + + private final int maxEvents; + private final Deque window; + + // Event rate tracking + private int totalEventsEver = 0; + private final Instant startTime = Instant.now(); + + // Last full GC tracking + private Instant lastFullGcTime = null; + + public RollingGcAnalysis(int maxEvents) { + this.maxEvents = maxEvents; + this.window = new ArrayDeque<>(maxEvents + 1); + } + + public RollingGcAnalysis() { + this(1000); + } + + /** + * Adds new events to the rolling window, evicting oldest when capacity exceeded. + */ + public synchronized void addEvents(List newEvents) { + for (GcEvent e : newEvents) { + window.addLast(e); + totalEventsEver++; + if (e.isFullGc()) { + lastFullGcTime = Instant.now(); + } + if (window.size() > maxEvents) { + window.pollFirst(); + } + } + } + + /** Total events ever seen (not just in window). */ + public synchronized int totalEventsEver() { return totalEventsEver; } + + /** Events currently in rolling window. */ + public synchronized int windowSize() { return window.size(); } + + /** Events per second since analysis started. */ + public synchronized double eventsPerSec() { + double elapsedSec = (Instant.now().toEpochMilli() - startTime.toEpochMilli()) / 1000.0; + return elapsedSec > 0 ? totalEventsEver / elapsedSec : 0; + } + + /** Seconds since the last Full GC, or -1 if none observed. */ + public synchronized double secsSinceLastFullGc() { + if (lastFullGcTime == null) return -1; + return (Instant.now().toEpochMilli() - lastFullGcTime.toEpochMilli()) / 1000.0; + } + + /** Snapshot of current computed metrics over the rolling window. */ + public synchronized Snapshot snapshot() { + List events = new ArrayList<>(window); + if (events.isEmpty()) { + return new Snapshot(0, 0, 0, 0, 0, 0, 100.0, 0, 0, + eventsPerSec(), secsSinceLastFullGc(), totalEventsEver); + } + + List pauses = new ArrayList<>(); + int fullGcCount = 0; + for (GcEvent e : events) { + if (!e.isConcurrent()) { + pauses.add(e); + if (e.isFullGc()) fullGcCount++; + } + } + + if (pauses.isEmpty()) { + return new Snapshot(0, 0, 0, 0, 0, 0, 100.0, fullGcCount, 0, + eventsPerSec(), secsSinceLastFullGc(), totalEventsEver); + } + + double[] sorted = pauses.stream().mapToDouble(GcEvent::pauseMs).sorted().toArray(); + long totalPauseMs = (long) Arrays.stream(sorted).sum(); + + double firstTs = events.getFirst().timestampSec(); + double lastTs = events.getLast().timestampSec(); + double durationSec = Math.max(lastTs - firstTs, 0.001); + double throughput = Math.max(0, Math.min(100, + (1.0 - totalPauseMs / (durationSec * 1000)) * 100)); + + long peakHeapKB = pauses.stream().mapToLong(GcEvent::heapBeforeKB).max().orElse(0); + + return new Snapshot( + (long) percentile(sorted, 50), + (long) percentile(sorted, 95), + (long) percentile(sorted, 99), + (long) sorted[sorted.length - 1], + (long) (totalPauseMs / pauses.size()), + totalPauseMs, + throughput, + fullGcCount, + peakHeapKB, + eventsPerSec(), + secsSinceLastFullGc(), + totalEventsEver + ); + } + + private static double percentile(double[] sorted, int pct) { + int idx = (int) Math.ceil(pct / 100.0 * sorted.length) - 1; + return sorted[Math.max(0, Math.min(idx, sorted.length - 1))]; + } + + public record Snapshot( + long p50PauseMs, + long p95PauseMs, + long p99PauseMs, + long maxPauseMs, + long avgPauseMs, + long totalPauseMs, + double throughputPercent, + int fullGcCount, + long peakHeapKB, + double eventsPerSec, + double secsSinceLastFullGc, + int totalEventsEver + ) {} +} diff --git a/argus-cli/src/test/java/io/argus/cli/gclog/GcLogFollowerTest.java b/argus-cli/src/test/java/io/argus/cli/gclog/GcLogFollowerTest.java new file mode 100644 index 0000000..a225827 --- /dev/null +++ b/argus-cli/src/test/java/io/argus/cli/gclog/GcLogFollowerTest.java @@ -0,0 +1,153 @@ +package io.argus.cli.gclog; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class GcLogFollowerTest { + + private static final String G1_LINE_0 = + "[0.234s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 24M->8M(256M) 3.456ms\n"; + private static final String G1_LINE_1 = + "[0.567s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 32M->12M(256M) 5.123ms\n"; + private static final String G1_LINE_2 = + "[1.234s][info][gc] GC(2) Pause Full (Ergonomics) 64M->32M(256M) 120.000ms\n"; + private static final String G1_LINE_3 = + "[2.000s][info][gc] GC(3) Pause Young (Normal) (G1 Evacuation Pause) 40M->15M(256M) 4.000ms\n"; + + @TempDir Path tempDir; + + @Test + void readAll_parsesEntireFile() throws IOException { + Path log = tempDir.resolve("gc.log"); + Files.writeString(log, G1_LINE_0 + G1_LINE_1); + + GcLogFollower follower = new GcLogFollower(log); + List events = follower.readAll(); + + assertEquals(2, events.size()); + assertEquals(0.234, events.get(0).timestampSec(), 0.001); + assertEquals(0.567, events.get(1).timestampSec(), 0.001); + } + + @Test + void pollNewEvents_returnsEmptyWhenNoChange() throws IOException { + Path log = tempDir.resolve("gc.log"); + Files.writeString(log, G1_LINE_0); + + GcLogFollower follower = new GcLogFollower(log); + follower.readAll(); // consume everything + + List polled = follower.pollNewEvents(); + assertTrue(polled.isEmpty()); + } + + @Test + void pollNewEvents_returnsNewLinesAfterAppend() throws IOException { + Path log = tempDir.resolve("gc.log"); + Files.writeString(log, G1_LINE_0); + + GcLogFollower follower = new GcLogFollower(log); + List initial = follower.readAll(); + assertEquals(1, initial.size()); + + // Simulate log file growing + Files.writeString(log, G1_LINE_1, StandardOpenOption.APPEND); + + List polled = follower.pollNewEvents(); + assertEquals(1, polled.size()); + assertEquals(0.567, polled.get(0).timestampSec(), 0.001); + } + + @Test + void pollNewEvents_handlesMultipleAppends() throws IOException { + Path log = tempDir.resolve("gc.log"); + Files.writeString(log, G1_LINE_0); + + GcLogFollower follower = new GcLogFollower(log); + follower.readAll(); + + Files.writeString(log, G1_LINE_1, StandardOpenOption.APPEND); + List first = follower.pollNewEvents(); + assertEquals(1, first.size()); + + Files.writeString(log, G1_LINE_2 + G1_LINE_3, StandardOpenOption.APPEND); + List second = follower.pollNewEvents(); + assertEquals(2, second.size()); + assertTrue(second.get(0).isFullGc()); + } + + @Test + void rollingWindow_evictsOldestWhenFull() throws IOException { + Path log = tempDir.resolve("gc.log"); + Files.writeString(log, G1_LINE_0 + G1_LINE_1 + G1_LINE_2 + G1_LINE_3); + + // maxEvents = 2 — only the last 2 should be in window + GcLogFollower follower = new GcLogFollower(log, 2); + RollingGcAnalysis rolling = new RollingGcAnalysis(2); + + List all = follower.readAll(); + assertEquals(4, all.size()); + + rolling.addEvents(all); + + assertEquals(2, rolling.windowSize()); + assertEquals(4, rolling.totalEventsEver()); + } + + @Test + void rollingAnalysis_snapshotReflectsWindow() throws IOException { + Path log = tempDir.resolve("gc.log"); + Files.writeString(log, G1_LINE_0 + G1_LINE_1); + + GcLogFollower follower = new GcLogFollower(log); + RollingGcAnalysis rolling = new RollingGcAnalysis(); + rolling.addEvents(follower.readAll()); + + RollingGcAnalysis.Snapshot snap = rolling.snapshot(); + assertEquals(2, snap.totalEventsEver()); + assertTrue(snap.throughputPercent() > 0); + assertTrue(snap.p50PauseMs() > 0); + } + + @Test + void rollingAnalysis_tracksFullGcTime() throws IOException { + Path log = tempDir.resolve("gc.log"); + Files.writeString(log, G1_LINE_0 + G1_LINE_2); // G1_LINE_2 is Full GC + + GcLogFollower follower = new GcLogFollower(log); + RollingGcAnalysis rolling = new RollingGcAnalysis(); + rolling.addEvents(follower.readAll()); + + RollingGcAnalysis.Snapshot snap = rolling.snapshot(); + assertEquals(1, snap.fullGcCount()); + assertTrue(snap.secsSinceLastFullGc() >= 0); + } + + @Test + void parseLine_unifiedFormat() { + GcEvent e = GcLogFollower.parseLine( + "[0.234s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 24M->8M(256M) 3.456ms", + true); + assertNotNull(e); + assertEquals(0.234, e.timestampSec(), 0.001); + assertFalse(e.isFullGc()); + } + + @Test + void parseLine_legacyFormat() { + GcEvent e = GcLogFollower.parseLine( + "1.234: [GC (Allocation Failure) [PSYoungGen: 65536K->8192K(76288K)] 65536K->8200K(251392K), 0.0123456 secs]", + false); + assertNotNull(e); + assertEquals(1.234, e.timestampSec(), 0.001); + assertEquals("Allocation Failure", e.cause()); + } +}