From 11480d2e6f7d596e148c38bb1abec214d919a74a Mon Sep 17 00:00:00 2001 From: Georgios Andrianakis Date: Mon, 13 Nov 2023 11:15:18 +0200 Subject: [PATCH 1/2] Decorate stacktraces in dev-mode error page Closes: #37002 --- .../http/runtime/DecoratedAssertionError.java | 61 +++++ .../vertx/http/runtime/FileCollector.java | 49 ++++ .../http/runtime/QuarkusErrorHandler.java | 1 + .../http/runtime/StackTraceDecorator.java | 235 ++++++++++++++++++ 4 files changed, 346 insertions(+) create mode 100644 extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/DecoratedAssertionError.java create mode 100644 extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/FileCollector.java create mode 100644 extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/StackTraceDecorator.java diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/DecoratedAssertionError.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/DecoratedAssertionError.java new file mode 100644 index 0000000000000..85848a9949aba --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/DecoratedAssertionError.java @@ -0,0 +1,61 @@ +package io.quarkus.vertx.http.runtime; + +import java.io.PrintStream; +import java.io.PrintWriter; + +/** + * Wraps an exception and prints stack trace with code snippets. + * + *

+ * To obtain the original exception, use {@link #getOriginal()}. + * + *

+ * NOTE: Copied from laech/java-stacksrc + */ +final class DecoratedAssertionError extends AssertionError { + + private final Throwable original; + private final String decoratedStackTrace; + + DecoratedAssertionError(Throwable original) { + this(original, null); + } + + /** + * @param pruneStackTraceKeepFromClass if not null, will prune the stack traces, keeping only + * elements that are called directly or indirectly by this class + */ + DecoratedAssertionError( + Throwable original, Class pruneStackTraceKeepFromClass) { + this.original = original; + this.decoratedStackTrace = StackTraceDecorator.get().decorate(original, pruneStackTraceKeepFromClass); + setStackTrace(new StackTraceElement[0]); + } + + @Override + public String getMessage() { + // Override this instead of calling the super(message) constructor, as super(null) will create + // the "null" string instead of actually being null + return getOriginal().getMessage(); + } + + /** Gets the original throwable being wrapped. */ + public Throwable getOriginal() { + return original; + } + + @Override + public void printStackTrace(PrintWriter out) { + out.println(this); + } + + @Override + public void printStackTrace(PrintStream out) { + out.println(this); + } + + @Override + public String toString() { + return decoratedStackTrace; + } +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/FileCollector.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/FileCollector.java new file mode 100644 index 0000000000000..8fb5e5bd6dbce --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/FileCollector.java @@ -0,0 +1,49 @@ +package io.quarkus.vertx.http.runtime; + +import java.io.IOException; +import java.nio.file.FileVisitOption; +import java.nio.file.FileVisitResult; +import java.nio.file.FileVisitor; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * NOTE: Copied from laech/java-stacksrc + */ +final class FileCollector implements FileVisitor { + + private final Map> result = new HashMap<>(); + + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + result.computeIfAbsent(file.getFileName().toString(), __ -> new ArrayList<>()).add(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException exc) { + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) { + return FileVisitResult.CONTINUE; + } + + static Map> collect(Path dir) throws IOException { + var collector = new FileCollector(); + Files.walkFileTree(dir, EnumSet.of(FileVisitOption.FOLLOW_LINKS), Integer.MAX_VALUE, collector); + return collector.result; + } +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandler.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandler.java index 6d2a61a2851a4..939578d216584 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandler.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandler.java @@ -129,6 +129,7 @@ public void accept(Throwable throwable) { exception.addSuppressed(e); } if (showStack && exception != null) { + exception = new DecoratedAssertionError(exception); details = generateHeaderMessage(exception, uuid); stack = generateStackTrace(exception); } else { diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/StackTraceDecorator.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/StackTraceDecorator.java new file mode 100644 index 0000000000000..2f0a1a4b32c37 --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/StackTraceDecorator.java @@ -0,0 +1,235 @@ +package io.quarkus.vertx.http.runtime; + +import static java.lang.String.format; +import static java.lang.System.lineSeparator; +import static java.util.Objects.requireNonNull; +import static java.util.stream.Collectors.joining; +import static java.util.stream.Collectors.toList; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.NavigableMap; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; +import java.util.stream.IntStream; + +/** + *

+ * NOTE: Copied from laech/java-stacksrc + */ +final class StackTraceDecorator { + private StackTraceDecorator() { + } + + private static final StackTraceDecorator instance = new StackTraceDecorator(); + + public static StackTraceDecorator get() { + return instance; + } + + private static final int CONTEXT_LINE_COUNT = 2; + + private volatile Map> cachedFiles; + + private Map> cachedFiles() throws IOException { + if (cachedFiles == null) { + cachedFiles = FileCollector.collect(Paths.get("")); + } + return cachedFiles; + } + + public String decorate(Throwable e) { + return decorate(e, null); + } + + public String decorate(Throwable e, Class keepFromClass) { + if (keepFromClass != null) { + pruneStackTrace(e, keepFromClass, new HashSet<>()); + } + + var stackTrace = getStackTraceAsString(e); + try { + + var alreadySeenElements = new HashSet(); + var alreadySeenSnippets = new HashSet>(); + stackTrace = decorate(e, stackTrace, 1, alreadySeenElements, alreadySeenSnippets); + + var cause = e.getCause(); + if (cause != null) { + stackTrace = decorate(cause, stackTrace, 1, alreadySeenElements, alreadySeenSnippets); + } + + for (var suppressed : e.getSuppressed()) { + stackTrace = decorate(suppressed, stackTrace, 2, alreadySeenElements, alreadySeenSnippets); + } + + } catch (Exception sup) { + e.addSuppressed(sup); + } + return stackTrace; + } + + private String decorate( + Throwable e, + String stackTrace, + int indentLevel, + Set alreadySeenElements, + Set> alreadySeenSnippets) + throws IOException { + + for (var element : e.getStackTrace()) { + if (!alreadySeenElements.add(element)) { + continue; + } + + var snippet = decorate(element); + if (snippet.isEmpty() || !alreadySeenSnippets.add(snippet.get())) { + // Don't print the same snippet multiple times, + // multiple lambda on one line can create this situation + continue; + } + + var line = element.toString(); + var indent = "\t".repeat(indentLevel); + var replacement = String.format( + "%s%n%n%s%n%n", + line, snippet.get().stream().collect(joining(lineSeparator() + indent, indent, ""))); + stackTrace = stackTrace.replace(line, replacement); + } + return stackTrace; + } + + private Optional> decorate(StackTraceElement element) throws IOException { + var file = findFile(element); + if (file.isEmpty()) { + return Optional.empty(); + } + + var lines = readContextLines(element, file.get()); + if (lines.isEmpty()) { + return Optional.empty(); + } + + removeBlankLinesFromStart(lines); + removeBlankLinesFromEnd(lines); + return Optional.of(buildSnippet(lines, element)); + } + + private Optional findFile(StackTraceElement element) throws IOException { + if (element.getLineNumber() < 1 + || element.getFileName() == null + || element.getMethodName().startsWith("access$")) { // Ignore class entry lines + return Optional.empty(); + } + + var tail = withPackagePath(element); + var paths = cachedFiles().getOrDefault(element.getFileName(), List.of()); + var exact = paths.stream().filter(it -> it.endsWith(tail)).findAny(); + if (exact.isPresent() || element.getFileName().endsWith(".java")) { + return exact; + } + return Optional.ofNullable(paths.size() == 1 ? paths.get(0) : null); + } + + private Path withPackagePath(StackTraceElement element) { + var fileName = requireNonNull(element.getFileName()); + var className = element.getClassName(); + var i = className.lastIndexOf("."); + var parent = i < 0 ? "" : className.substring(0, i).replace('.', '/'); + return Paths.get(parent).resolve(fileName); + } + + private NavigableMap readContextLines(StackTraceElement elem, Path path) + throws IOException { + + var startLineNum = Math.max(1, elem.getLineNumber() - CONTEXT_LINE_COUNT); + try (var stream = Files.lines(path)) { + + var lines = stream + .limit(elem.getLineNumber() + CONTEXT_LINE_COUNT) + .skip(startLineNum - 1) + .collect(toList()); + + return IntStream.range(0, lines.size()) + .boxed() + .reduce( + new TreeMap<>(), + (acc, i) -> { + acc.put(i + startLineNum, lines.get(i)); + return acc; + }, + (a, b) -> b); + } + } + + @SuppressWarnings("NullAway") + private void removeBlankLinesFromStart(NavigableMap lines) { + IntStream.rangeClosed(lines.firstKey(), lines.lastKey()) + .takeWhile(i -> lines.get(i).isBlank()) + .forEach(lines::remove); + } + + @SuppressWarnings("NullAway") + private void removeBlankLinesFromEnd(NavigableMap lines) { + IntStream.iterate(lines.lastKey(), i -> i >= lines.firstKey(), i -> i - 1) + .takeWhile(i -> lines.get(i).isBlank()) + .forEach(lines::remove); + } + + private static List buildSnippet( + NavigableMap lines, StackTraceElement elem) { + var maxLineNumWidth = String.valueOf(lines.lastKey()).length(); + return lines.entrySet().stream() + .map( + entry -> { + var lineNum = entry.getKey(); + var isTarget = lineNum == elem.getLineNumber(); + var line = entry.getValue(); + var lineNumStr = format("%" + maxLineNumWidth + "d", lineNum); + return format( + "%s %s%s", isTarget ? "->" : " ", lineNumStr, line.isEmpty() ? "" : " " + line); + }) + .collect(toList()); + } + + private static String getStackTraceAsString(Throwable e) { + var stringWriter = new StringWriter(); + var printWriter = new PrintWriter(stringWriter); + e.printStackTrace(printWriter); + printWriter.flush(); + return stringWriter.toString(); + } + + private static void pruneStackTrace( + Throwable throwable, Class keepFromClass, Set alreadySeen) { + if (!alreadySeen.add(throwable)) { + return; + } + + var stackTrace = throwable.getStackTrace(); + for (var i = stackTrace.length - 1; i >= 0; i--) { + if (stackTrace[i].getClassName().equals(keepFromClass.getName())) { + throwable.setStackTrace(Arrays.copyOfRange(stackTrace, 0, i + 1)); + break; + } + } + + var cause = throwable.getCause(); + if (cause != null) { + pruneStackTrace(cause, keepFromClass, alreadySeen); + } + + for (var suppressed : throwable.getSuppressed()) { + pruneStackTrace(suppressed, keepFromClass, alreadySeen); + } + } +} From 5348b71544307c8746c7e8282262f878da9effaf Mon Sep 17 00:00:00 2001 From: Phillip Kruger Date: Sun, 21 Jul 2024 20:59:48 +1000 Subject: [PATCH 2/2] Only decorate in Dev Mode Signed-off-by: Phillip Kruger --- .../vertx/http/runtime/HttpConfiguration.java | 6 ++++++ .../vertx/http/runtime/QuarkusErrorHandler.java | 9 +++++++-- .../vertx/http/runtime/VertxHttpRecorder.java | 10 ++++++++-- .../http/runtime/QuarkusErrorHandlerTest.java | 16 ++++++++++------ 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpConfiguration.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpConfiguration.java index 8a5465f671650..2e581bcb613d9 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpConfiguration.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/HttpConfiguration.java @@ -289,6 +289,12 @@ public class HttpConfiguration { @ConfigItem public Map filter; + /** + * This will decorate the stacktrace in dev mode to show the line in the code that cause the exception + */ + @ConfigItem(defaultValue = "true") + public Boolean decorateStacktraces; + public ProxyConfig proxy; public int determinePort(LaunchMode launchMode) { diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandler.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandler.java index 939578d216584..20c10be2eb202 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandler.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandler.java @@ -41,10 +41,13 @@ public class QuarkusErrorHandler implements Handler { private static final AtomicLong ERROR_COUNT = new AtomicLong(); private final boolean showStack; + private final boolean decorateStack; private final Optional contentTypeDefault; - public QuarkusErrorHandler(boolean showStack, Optional contentTypeDefault) { + public QuarkusErrorHandler(boolean showStack, boolean decorateStack, + Optional contentTypeDefault) { this.showStack = showStack; + this.decorateStack = decorateStack; this.contentTypeDefault = contentTypeDefault; } @@ -129,7 +132,9 @@ public void accept(Throwable throwable) { exception.addSuppressed(e); } if (showStack && exception != null) { - exception = new DecoratedAssertionError(exception); + if (decorateStack) { + exception = new DecoratedAssertionError(exception); + } details = generateHeaderMessage(exception, uuid); stack = generateStackTrace(exception); } else { diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/VertxHttpRecorder.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/VertxHttpRecorder.java index 0b91740c6100b..0a221237122a8 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/VertxHttpRecorder.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/VertxHttpRecorder.java @@ -414,7 +414,8 @@ public void finalizeRouter(BeanContainer container, Consumer defaultRoute applyCompression(httpBuildTimeConfig.enableCompression, httpRouteRouter); httpRouteRouter.route().last().failureHandler( - new QuarkusErrorHandler(launchMode.isDevOrTest(), httpConfiguration.unhandledErrorContentTypeDefault)); + new QuarkusErrorHandler(launchMode.isDevOrTest(), decorateStacktrace(launchMode, httpConfiguration), + httpConfiguration.unhandledErrorContentTypeDefault)); for (BooleanSupplier requireBodyHandlerCondition : requireBodyHandlerConditions) { if (requireBodyHandlerCondition.getAsBoolean()) { @@ -533,7 +534,8 @@ public void handle(RoutingContext event) { addHotReplacementHandlerIfNeeded(mr); mr.route().last().failureHandler( - new QuarkusErrorHandler(launchMode.isDevOrTest(), httpConfiguration.unhandledErrorContentTypeDefault)); + new QuarkusErrorHandler(launchMode.isDevOrTest(), decorateStacktrace(launchMode, httpConfiguration), + httpConfiguration.unhandledErrorContentTypeDefault)); mr.route().order(RouteConstants.ROUTE_ORDER_BODY_HANDLER_MANAGEMENT) .handler(createBodyHandlerForManagementInterface()); @@ -574,6 +576,10 @@ public void handle(HttpServerRequest event) { } } + private boolean decorateStacktrace(LaunchMode launchMode, HttpConfiguration httpConfiguration) { + return httpConfiguration.decorateStacktraces && launchMode.equals(LaunchMode.DEVELOPMENT); + } + private void addHotReplacementHandlerIfNeeded(Router router) { if (hotReplacementHandler != null) { //recorders are always executed in the current CL diff --git a/extensions/vertx-http/runtime/src/test/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandlerTest.java b/extensions/vertx-http/runtime/src/test/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandlerTest.java index f1634fee29f5f..53de4ac0c9723 100644 --- a/extensions/vertx-http/runtime/src/test/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandlerTest.java +++ b/extensions/vertx-http/runtime/src/test/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandlerTest.java @@ -51,7 +51,8 @@ public void string_with_quotes_should_be_correctly_escaped() { @Test public void json_content_type_hint_should_be_respected_if_not_accepted() { - QuarkusErrorHandler errorHandler = new QuarkusErrorHandler(false, Optional.of(HttpConfiguration.PayloadHint.JSON)); + QuarkusErrorHandler errorHandler = new QuarkusErrorHandler(false, false, + Optional.of(HttpConfiguration.PayloadHint.JSON)); Mockito.when(routingContext.failure()).thenReturn(testError); Mockito.when(routingContext.parsedHeaders().findBestUserAcceptedIn(any(), any())) .thenReturn(new ParsableMIMEValue("application/foo+json").forceParse()); @@ -62,7 +63,8 @@ public void json_content_type_hint_should_be_respected_if_not_accepted() { @Test public void json_content_type_hint_should_be_ignored_if_accepted() { - QuarkusErrorHandler errorHandler = new QuarkusErrorHandler(false, Optional.of(HttpConfiguration.PayloadHint.JSON)); + QuarkusErrorHandler errorHandler = new QuarkusErrorHandler(false, false, + Optional.of(HttpConfiguration.PayloadHint.JSON)); Mockito.when(routingContext.failure()).thenReturn(testError); Mockito.when(routingContext.parsedHeaders().findBestUserAcceptedIn(any(), any())) .thenReturn(new ParsableMIMEValue("text/html").forceParse()); @@ -73,7 +75,7 @@ public void json_content_type_hint_should_be_ignored_if_accepted() { @Test public void content_type_should_default_to_json_if_accepted() { - QuarkusErrorHandler errorHandler = new QuarkusErrorHandler(false, Optional.empty()); + QuarkusErrorHandler errorHandler = new QuarkusErrorHandler(false, false, Optional.empty()); Mockito.when(routingContext.failure()).thenReturn(testError); Mockito.when(routingContext.parsedHeaders().findBestUserAcceptedIn(any(), any())) .thenReturn(new ParsableMIMEValue("application/json").forceParse()); @@ -84,7 +86,8 @@ public void content_type_should_default_to_json_if_accepted() { @Test public void html_content_type_hint_should_be_respected_if_not_accepted() { - QuarkusErrorHandler errorHandler = new QuarkusErrorHandler(false, Optional.of(HttpConfiguration.PayloadHint.HTML)); + QuarkusErrorHandler errorHandler = new QuarkusErrorHandler(false, false, + Optional.of(HttpConfiguration.PayloadHint.HTML)); Mockito.when(routingContext.failure()).thenReturn(testError); Mockito.when(routingContext.parsedHeaders().findBestUserAcceptedIn(any(), any())) .thenReturn(new ParsableMIMEValue("application/foo+json").forceParse()); @@ -94,7 +97,8 @@ public void html_content_type_hint_should_be_respected_if_not_accepted() { @Test public void html_content_type_hint_should_be_ignored_if_accepted() { - QuarkusErrorHandler errorHandler = new QuarkusErrorHandler(false, Optional.of(HttpConfiguration.PayloadHint.HTML)); + QuarkusErrorHandler errorHandler = new QuarkusErrorHandler(false, false, + Optional.of(HttpConfiguration.PayloadHint.HTML)); Mockito.when(routingContext.failure()).thenReturn(testError); Mockito.when(routingContext.parsedHeaders().findBestUserAcceptedIn(any(), any())) .thenReturn(new ParsableMIMEValue("application/json").forceParse()); @@ -105,7 +109,7 @@ public void html_content_type_hint_should_be_ignored_if_accepted() { @Test public void content_type_should_default_to_html_if_accepted() { - QuarkusErrorHandler errorHandler = new QuarkusErrorHandler(false, Optional.empty()); + QuarkusErrorHandler errorHandler = new QuarkusErrorHandler(false, false, Optional.empty()); Mockito.when(routingContext.failure()).thenReturn(testError); Mockito.when(routingContext.parsedHeaders().findBestUserAcceptedIn(any(), any())) .thenReturn(new ParsableMIMEValue("text/html").forceParse());