diff --git a/core/src/main/java/dev/faststats/ErrorHelper.java b/core/src/main/java/dev/faststats/ErrorHelper.java index 34f9f56a..d00ca25d 100644 --- a/core/src/main/java/dev/faststats/ErrorHelper.java +++ b/core/src/main/java/dev/faststats/ErrorHelper.java @@ -36,25 +36,25 @@ private static JsonObject compileAll(final TrackedError trackedError, @Nullable @Nullable final Attributes defaultAttributes) { final var error = trackedError.error(); final var report = new JsonObject(); - final var message = anonymize(error.getMessage(), customPatterns); + final var message = anonymize(error.message(), customPatterns); final var stacktrace = new JsonArray(); final var header = message != null - ? error.getClass().getName() + ": " + message - : error.getClass().getName(); + ? error.type().getName() + ": " + message + : error.type().getName(); stacktrace.add(header); - final var elements = error.getStackTrace(); + final var elements = error.stackTraces(); final var stack = collapseStackTrace(elements); final var list = new ArrayList<>(stack); if (suppress != null) list.removeAll(suppress); final var traces = Math.min(list.size(), MAX_STACK_SIZE); populateTraces(traces, list, elements, stacktrace); - appendCauseChain(error.getCause(), stack, suppress, stacktrace, customPatterns); + appendCauseChain(error.cause(), stack, suppress, stacktrace, customPatterns); - report.addProperty("error", error.getClass().getName()); - final var first = anonymize(findFirstMessage(error, null), customPatterns); + report.addProperty("error", error.type().getName()); + final var first = anonymize(findFirstMessage(error), customPatterns); if (first != null) report.addProperty("message", first); report.add("stack", stacktrace); @@ -68,28 +68,26 @@ private static JsonObject compileAll(final TrackedError trackedError, @Nullable return report; } - // fixme: unmaintainable mess, i already forgot what it does - private static void appendCauseChain(@Nullable Throwable cause, final List parentStack, + private static void appendCauseChain(TrackedError.@Nullable ThrowableSnapshot cause, final List parentStack, @Nullable final List suppress, final JsonArray stacktrace, final List> customPatterns) { final var toSuppress = new ArrayList<>(parentStack); if (suppress != null) toSuppress.addAll(suppress); - final var visited = Collections.newSetFromMap(new IdentityHashMap<>()); - while (cause != null && visited.add(cause)) { - final var causeMessage = anonymize(cause.getMessage(), customPatterns); + while (cause != null) { + final var causeMessage = anonymize(cause.message(), customPatterns); final var header = causeMessage != null - ? "Caused by: " + cause.getClass().getName() + ": " + causeMessage - : "Caused by: " + cause.getClass().getName(); + ? "Caused by: " + cause.type().getName() + ": " + causeMessage + : "Caused by: " + cause.type().getName(); stacktrace.add(header); - final var causeElements = cause.getStackTrace(); + final var causeElements = cause.stackTraces(); final var causeStack = collapseStackTrace(causeElements); final var causeList = new ArrayList<>(causeStack); causeList.removeAll(toSuppress); final var causeTraces = Math.min(causeList.size(), MAX_STACK_SIZE); populateTraces(causeTraces, causeList, causeElements, stacktrace); - cause = cause.getCause(); + cause = cause.cause(); } } @@ -214,13 +212,11 @@ private static boolean isSameClassLoader(final ClassLoader classLoader, final Cl return loader == current; } - private static @Nullable String findFirstMessage(@Nullable final Throwable error, @Nullable Set visited) { + private static @Nullable String findFirstMessage(final TrackedError.@Nullable ThrowableSnapshot error) { if (error == null) return null; - final var message = error.getMessage(); + final var message = error.message(); if (message != null) return message; - if (visited == null) visited = Collections.newSetFromMap(new IdentityHashMap<>()); - if (!visited.add(error)) return null; - return findFirstMessage(error.getCause(), visited); + return findFirstMessage(error.cause()); } private static @Nullable String anonymize(@Nullable final String message, final List> customPatterns) { diff --git a/core/src/main/java/dev/faststats/SimpleTrackedError.java b/core/src/main/java/dev/faststats/SimpleTrackedError.java index 27e01d12..d3dbc69b 100644 --- a/core/src/main/java/dev/faststats/SimpleTrackedError.java +++ b/core/src/main/java/dev/faststats/SimpleTrackedError.java @@ -1,5 +1,6 @@ package dev.faststats; +import org.jetbrains.annotations.Contract; import org.jspecify.annotations.Nullable; import java.util.Arrays; @@ -11,14 +12,55 @@ final class SimpleTrackedError implements TrackedError { private volatile Attributes attributes = Attributes.empty(); private volatile boolean handled = true; - private final Throwable error; + private final ThrowableSnapshot error; SimpleTrackedError(final Throwable error) { - this.error = error; + this.error = snapshot(error, null); + } + + @Contract("_, null -> !null") + private static @Nullable ThrowableSnapshot snapshot(final Throwable error, @Nullable Set visited) { + final var message = error.getMessage(); + final var stackTrace = error.getStackTrace(); + if (error.getCause() != null && visited == null) + visited = Collections.newSetFromMap(new IdentityHashMap<>()); + if (visited != null && !visited.add(error)) return null; + final var cause = error.getCause() != null + ? snapshot(error.getCause(), visited) + : null; + final var trace = stackTrace.length == 0 ? new Throwable().getStackTrace() : stackTrace; + return new SimpleThrowableSnapshot(error.getClass(), message, cause, trace); + } + + record SimpleThrowableSnapshot( + Class type, + @Nullable String message, + @Nullable ThrowableSnapshot cause, + StackTraceElement... stackTraces + ) implements ThrowableSnapshot { + @Override + public StackTraceElement[] stackTraces() { + return stackTraces.clone(); + } + + @Override + public boolean equals(@Nullable final Object o) { + if (o == null || getClass() != o.getClass()) return false; + final SimpleThrowableSnapshot that = (SimpleThrowableSnapshot) o; + return Objects.equals(type, that.type) + && Objects.equals(message, that.message) + && Objects.equals(cause, that.cause) + && Objects.deepEquals(stackTraces, that.stackTraces); + } + + @Override + public int hashCode() { + return Objects.hash(type, message, cause, Arrays.hashCode(stackTraces)); + } } @Override - public Throwable error() { + public ThrowableSnapshot error() { return error; } @@ -50,36 +92,20 @@ public boolean equals(@Nullable final Object o) { final SimpleTrackedError that = (SimpleTrackedError) o; return handled == that.handled && Objects.equals(attributes, that.attributes) - && deepEquals(error, that.error, Collections.newSetFromMap(new IdentityHashMap<>())); + && Objects.equals(error, that.error); } @Override public int hashCode() { - return Objects.hash(attributes, handled, hash(error, Collections.newSetFromMap(new IdentityHashMap<>()))); - } - - // fixme: hacky shit; it only has to compile and pass tests for now - private static boolean deepEquals( - @Nullable final Throwable first, - @Nullable final Throwable second, - final Set visited - ) { - if (first == second) return true; - if (first == null || second == null) return false; - if (first.getClass() != second.getClass()) return false; - if (!Objects.equals(first.getMessage(), second.getMessage())) return false; - if (!Arrays.equals(first.getStackTrace(), second.getStackTrace())) return false; - if (!visited.add(first)) return true; - return deepEquals(first.getCause(), second.getCause(), visited); + return Objects.hash(attributes, handled, error); } - private static int hash(@Nullable final Throwable error, final Set visited) { - if (error == null || !visited.add(error)) return 0; - return Objects.hash( - error.getClass(), - error.getMessage(), - Arrays.hashCode(error.getStackTrace()), - hash(error.getCause(), visited) - ); + @Override + public String toString() { + return "SimpleTrackedError{" + + "attributes=" + attributes + + ", handled=" + handled + + ", error=" + error + + '}'; } } diff --git a/core/src/main/java/dev/faststats/TrackedError.java b/core/src/main/java/dev/faststats/TrackedError.java index 02e282cb..0860b2a2 100644 --- a/core/src/main/java/dev/faststats/TrackedError.java +++ b/core/src/main/java/dev/faststats/TrackedError.java @@ -1,6 +1,7 @@ package dev.faststats; import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.Nullable; /** * An error report with tracking metadata. @@ -9,13 +10,51 @@ */ public sealed interface TrackedError permits SimpleTrackedError { /** - * Returns the tracked error. + * Returns a snapshot of the tracked error. * - * @return the tracked error - * @since 0.24.0 + * @return a snapshot of the tracked error + * @since 0.30.0 */ @Contract(pure = true) - Throwable error(); + ThrowableSnapshot error(); + + /** + * A snapshot of a {@link Throwable} captured when an error is tracked. + *

+ * The snapshot preserves the throwable's type, message, cause chain, and stack trace + * without retaining the original throwable. + * + * @since 0.30.0 + */ + sealed interface ThrowableSnapshot permits SimpleTrackedError.SimpleThrowableSnapshot { + /** + * Returns the throwable class. + * + * @return the throwable class + */ + Class type(); + + /** + * Returns the throwable message. + * + * @return the throwable message, or {@code null} if none was provided + */ + @Nullable String message(); + + /** + * Returns a snapshot of the throwable's cause. + * + * @return a snapshot of the throwable's cause, or {@code null} if it has no cause + */ + @Nullable ThrowableSnapshot cause(); + + /** + * Returns a copy of the throwable's stack trace elements. + * + * @return a copy of the throwable's stack trace elements + */ + StackTraceElement[] stackTraces(); + } /** * Returns whether the error was handled. diff --git a/core/src/test/java/dev/faststats/ErrorTrackerTest.java b/core/src/test/java/dev/faststats/ErrorTrackerTest.java index aa5ad741..1b841aa7 100644 --- a/core/src/test/java/dev/faststats/ErrorTrackerTest.java +++ b/core/src/test/java/dev/faststats/ErrorTrackerTest.java @@ -224,7 +224,7 @@ public void cyclicCauseChainStopsAfterFirstVisit() { var secondCauseCount = 0; for (final var element : stack) { final var line = element.getAsString(); - if (line.equals("Caused by: java.lang.RuntimeException: first")) firstCauseCount++; + if (line.equals("java.lang.RuntimeException: first")) firstCauseCount++; if (line.equals("Caused by: java.lang.IllegalStateException: second")) secondCauseCount++; } @@ -232,6 +232,17 @@ public void cyclicCauseChainStopsAfterFirstVisit() { assertEquals(1, secondCauseCount); } + @Test + public void noEmptyTraces() { + final var oom = new OutOfMemoryError(); + oom.setStackTrace(new StackTraceElement[0]); + + tracker.trackError(oom); + + final var stack = tracker.getFullData().get(0).getAsJsonObject().getAsJsonArray("stack"); + assertFalse(stack.isEmpty(), "Tracked error must always have a stacktrace"); + } + @Test public void duplicateErrorsAreAggregatedWithCount() { final var first = createStableError(); diff --git a/gradle.properties b/gradle.properties index 0cd5247f..ffd71de6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,2 @@ -version=0.29.4 +version=0.30.0 org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m