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/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 6d2a61a2851a4..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,6 +132,9 @@ public void accept(Throwable throwable) {
exception.addSuppressed(e);
}
if (showStack && exception != null) {
+ 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/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);
+ }
+ }
+}
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());