diff --git a/core/src/main/java/dev/faststats/ErrorHelper.java b/core/src/main/java/dev/faststats/ErrorHelper.java index 8277e67a..4e4e0109 100644 --- a/core/src/main/java/dev/faststats/ErrorHelper.java +++ b/core/src/main/java/dev/faststats/ErrorHelper.java @@ -154,29 +154,45 @@ private static List collapseConsecutiveDuplicates(final List lin return result; } - public static boolean isSameLoader(final ClassLoader loader, final Throwable error) { - return isSameLoader(loader, error, Collections.newSetFromMap(new IdentityHashMap<>())); + private static final StackWalker STACK_WALKER = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE); + + public static @Nullable ClassLoader callerClassLoader() { + return STACK_WALKER.walk(frames -> frames + .map(StackWalker.StackFrame::getDeclaringClass) + .filter(type -> type != ErrorHelper.class && type != ErrorTracker.class) + .findFirst()) + .orElse(ErrorTracker.class) + .getClassLoader(); } - private static boolean isSameLoader(final ClassLoader loader, @Nullable final Throwable error, final Set visited) { + public static boolean isSameLoader(@Nullable final Thread thread, final ClassLoader loader, final Throwable error) { + return isSameLoader(thread, loader, error, Collections.newSetFromMap(new IdentityHashMap<>())); + } + + private static boolean isSameLoader(@Nullable final Thread thread, final ClassLoader loader, @Nullable final Throwable error, final Set visited) { if (error == null || !visited.add(error)) return false; final var stackTrace = error.getStackTrace(); if (stackTrace == null || stackTrace.length == 0) - return isSameLoader(loader, error.getCause(), visited); + return isSameLoader(thread, loader, error.getCause(), visited); final var firstNonLibraryIndex = findFirstNonLibraryFrameIndex(stackTrace); - if (firstNonLibraryIndex == -1) return isSameLoader(loader, error.getCause(), visited); + if (firstNonLibraryIndex == -1) return isSameLoader(thread, loader, error.getCause(), visited); final var framesToCheck = Math.min(5, stackTrace.length - firstNonLibraryIndex); for (var i = 0; i < framesToCheck; i++) { final var frame = stackTrace[firstNonLibraryIndex + i]; if (isLibraryFrame(frame.getClassName())) continue; - if (!isFromLoader(frame, loader)) return isSameLoader(loader, error.getCause(), visited); + if (!isFromLoader(frame, loader)) return isSameLoader(thread, loader, error.getCause(), visited); } - return true; + return thread == null || isRelatedContextLoader(thread.getContextClassLoader(), loader); + } + + private static boolean isRelatedContextLoader(@Nullable final ClassLoader contextLoader, final ClassLoader loader) { + if (contextLoader == null) return true; + return isSameClassLoader(contextLoader, loader) || isSameClassLoader(loader, contextLoader); } private static int findFirstNonLibraryFrameIndex(final StackTraceElement[] stackTrace) { diff --git a/core/src/main/java/dev/faststats/ErrorTracker.java b/core/src/main/java/dev/faststats/ErrorTracker.java index 687b1c7b..0110a81a 100644 --- a/core/src/main/java/dev/faststats/ErrorTracker.java +++ b/core/src/main/java/dev/faststats/ErrorTracker.java @@ -6,6 +6,7 @@ import java.util.Optional; import java.util.function.BiConsumer; +import java.util.function.BiPredicate; import java.util.regex.Pattern; /** @@ -15,14 +16,15 @@ */ public sealed interface ErrorTracker permits SimpleErrorTracker { /** - * Creates a context-aware error tracker policy. + * Creates a context-aware error tracker policy for the class loader of the calling class. * * @return the error tracker policy + * @see #contextAware(ClassLoader) * @since 0.24.0 */ - @Contract(value = " -> new", pure = true) + @Contract(value = " -> new") static ErrorTracker contextAware() { - return contextAware(ErrorTracker.class.getClassLoader()); + return contextAware(ErrorHelper.callerClassLoader()); } /** @@ -37,7 +39,7 @@ static ErrorTracker contextAware() { * @see #attachErrorContext(ClassLoader) * @since 0.24.0 */ - @Contract(value = "_ -> new", pure = true) + @Contract(value = "_ -> new") static ErrorTracker contextAware(@Nullable final ClassLoader classLoader) { final var tracker = new SimpleErrorTracker(); tracker.attachErrorContext(classLoader); @@ -164,6 +166,33 @@ default ErrorTracker ignoreError(final Class type, @RegExp return ignoreError(type, Pattern.compile(pattern)); } + /** + * Adds a rule that excludes automatically tracked errors from being reported to FastStats. + *

+ * Rules are only applied to errors captured through an attached error context, + * after the error has been attributed to the tracker's class loader. + *

+ * If any rule returns {@code true} for the thread and error, the error is not tracked. + *

{@code
+     * // Exclude all errors without a package frame from "your.package.name"
+     * tracker.addExclusionRule((thread, error) -> {
+     *   for (final var element : throwable.getStackTrace()) {
+     *     if (element.getClassName().startsWith("your.package.name")) {
+     *       return false; // track this error
+     *     }
+     *   }
+     *   return true; // do not track this error
+     * });
+     * }
+ * + * @param rule the rule deciding whether an error on the given thread should be excluded + * @return the error tracker + * @see #attachErrorContext(ClassLoader) + * @since 0.31.0 + */ + @Contract(value = "_ -> this", mutates = "this") + ErrorTracker addExclusionRule(BiPredicate rule); + /** * Adds an anonymization pattern that replaces matched text in error messages. *
{@code
@@ -224,6 +253,7 @@ default ErrorTracker anonymize(@RegExp final String pattern, final String replac
      * @return whether an error context is attached
      * @since 0.23.0
      */
+    @Contract(pure = true)
     boolean isContextAttached();
 
     /**
@@ -256,6 +286,25 @@ default ErrorTracker anonymize(@RegExp final String pattern, final String replac
      */
     @Contract(pure = true)
     static boolean isSameLoader(final ClassLoader loader, final Throwable error) {
-        return ErrorHelper.isSameLoader(loader, error);
+        return ErrorHelper.isSameLoader(null, loader, error);
+    }
+
+    /**
+     * Checks if the error occurred in the same class loader as the provided loader,
+     * taking the context class loader of the given thread into account.
+     * 

+ * In addition to the stack frame check of {@link #isSameLoader(ClassLoader, Throwable)}, the + * thread's context class loader must be either {@code null}, or be the given loader, one of + * its parents or one of its children. + * + * @param thread the thread the error occurred on + * @param loader the class loader + * @param error the error + * @return whether the error occurred in the same class loader + * @since 0.31.0 + */ + @Contract(pure = true) + static boolean isSameLoader(final Thread thread, final ClassLoader loader, final Throwable error) { + return ErrorHelper.isSameLoader(thread, loader, error); } -} +} diff --git a/core/src/main/java/dev/faststats/SimpleErrorTracker.java b/core/src/main/java/dev/faststats/SimpleErrorTracker.java index 4872366a..47cf1d31 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTracker.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTracker.java @@ -14,6 +14,7 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArraySet; import java.util.function.BiConsumer; +import java.util.function.BiPredicate; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -25,6 +26,7 @@ final class SimpleErrorTracker implements ErrorTracker { private final Set> ignoredTypes = new CopyOnWriteArraySet<>(); private final Set ignoredPatterns = new CopyOnWriteArraySet<>(); private final List> anonymizationEntries = new CopyOnWriteArrayList<>(); + private final List> contextExclusionRules = new CopyOnWriteArrayList<>(); private volatile @Nullable BiConsumer<@Nullable ClassLoader, Throwable> errorEvent; private volatile @Nullable ClassLoader attachedLoader; @@ -86,6 +88,12 @@ public ErrorTracker ignoreError(final Class type, final Pat return this; } + @Override + public ErrorTracker addExclusionRule(final BiPredicate rule) { + contextExclusionRules.add(rule); + return this; + } + @Override public ErrorTracker anonymize(final Pattern pattern, final String replacement) { anonymizationEntries.add(Map.entry(pattern, replacement)); @@ -146,4 +154,11 @@ public synchronized void setContextErrorHandler(@Nullable final BiConsumer<@Null @Nullable ClassLoader attachedLoader() { return attachedLoader; } + + boolean isContextErrorIgnored(final Thread thread, final Throwable error) { + for (final var rule : contextExclusionRules) { + if (rule.test(thread, error)) return true; + } + return false; + } } diff --git a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java index 250ecfce..2ad89d8e 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java @@ -58,7 +58,8 @@ private static void handleUncaughtException(final Thread thread, final Throwable for (final var tracker : DISPATCHER_TRACKERS) { try { final var loader = tracker.attachedLoader(); - if (loader != null && !ErrorHelper.isSameLoader(loader, error)) continue; + if (loader != null && !ErrorHelper.isSameLoader(thread, loader, error)) continue; + if (tracker.isContextErrorIgnored(thread, error)) continue; tracker.trackError(error).handled(false); tracker.getContextErrorHandler().ifPresent(handler -> handler.accept(loader, error)); } catch (final Throwable t) { diff --git a/core/src/test/java/dev/faststats/ErrorTrackerTest.java b/core/src/test/java/dev/faststats/ErrorTrackerTest.java index 1b841aa7..dead5ef2 100644 --- a/core/src/test/java/dev/faststats/ErrorTrackerTest.java +++ b/core/src/test/java/dev/faststats/ErrorTrackerTest.java @@ -1,11 +1,15 @@ package dev.faststats; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; +import java.io.IOException; import java.net.URL; import java.net.URLClassLoader; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -345,6 +349,114 @@ public void attachedContextTracksUnhandledThreadError() throws InterruptedExcept } } + @Test + public void threadContextLoaderOnSameChainMatches() { + final var loader = getClass().getClassLoader(); + final var childLoader = new URLClassLoader(new URL[0], loader); + final var error = new RuntimeException("test"); + final var thread = new Thread(() -> { + }); + + thread.setContextClassLoader(loader); + assertTrue(ErrorTracker.isSameLoader(thread, loader, error)); + + thread.setContextClassLoader(loader.getParent()); + assertTrue(ErrorTracker.isSameLoader(thread, loader, error)); + + thread.setContextClassLoader(childLoader); + assertTrue(ErrorTracker.isSameLoader(thread, loader, error)); + + thread.setContextClassLoader(null); + assertTrue(ErrorTracker.isSameLoader(thread, loader, error)); + } + + @Test + public void unrelatedThreadContextLoaderDoesNotMatch() { + final var loader = getClass().getClassLoader(); + final var error = new RuntimeException("test"); + final var thread = new Thread(() -> { + }); + + thread.setContextClassLoader(new URLClassLoader(new URL[0], null)); + assertFalse(ErrorTracker.isSameLoader(thread, loader, error)); + assertTrue(ErrorTracker.isSameLoader(loader, error)); + } + + @Test + public void contextExclusionRulesExcludeErrorsFromTrackedLoader() throws InterruptedException { + final var loader = getClass().getClassLoader(); + final var tracked = createErrorWithStackFrom(ErrorTrackerTest.class.getName()); + final var excluded = createErrorWithStackFrom(MockContext.class.getName()); + final var foreign = createErrorWithStackFrom("example.Foreign"); + + final var excludedThread = new AtomicReference<@Nullable Thread>(); + final var tracker = (SimpleErrorTracker) ErrorTracker.contextAware(loader) + .addExclusionRule((thread, error) -> error.getStackTrace()[0].getClassName().equals(MockContext.class.getName())) + .addExclusionRule((thread, error) -> thread == excludedThread.get()); + try { + for (final var error : new RuntimeException[]{tracked, excluded, foreign}) { + final var thread = new Thread(() -> { + throw error; + }); + thread.start(); + thread.join(1000); + } + + final var trackedOnExcludedThread = createErrorWithStackFrom(ErrorTrackerTest.class.getName()); + final var thread = new Thread(() -> { + throw trackedOnExcludedThread; + }); + excludedThread.set(thread); + thread.start(); + thread.join(1000); + + final var reports = tracker.getFullData(); + assertEquals(1, reports.size()); + final var stack = reports.get(0).getAsJsonObject().getAsJsonArray("stack"); + assertEquals(" at " + ErrorTrackerTest.class.getName() + ".test(Test.java:1)", stack.get(1).getAsString()); + } finally { + tracker.detachErrorContext(); + } + } + + @Test + public void contextAwareResolvesCallerClassLoader() throws Exception { + final var callerName = ContextAwareCaller.class.getName(); + final var parent = getClass().getClassLoader(); + final var callerLoader = new ClassLoader(parent) { + @Override + protected Class loadClass(final String name, final boolean resolve) throws ClassNotFoundException { + if (!name.equals(callerName)) return super.loadClass(name, resolve); + synchronized (getClassLoadingLock(name)) { + final var loaded = findLoadedClass(name); + if (loaded != null) return loaded; + try (final var stream = parent.getResourceAsStream(name.replace('.', '/') + ".class")) { + final var bytes = stream.readAllBytes(); + return defineClass(name, bytes, 0, bytes.length); + } catch (final IOException e) { + throw new ClassNotFoundException(name, e); + } + } + } + }; + + @SuppressWarnings("unchecked") final var caller = (Supplier) callerLoader + .loadClass(callerName).getDeclaredConstructor().newInstance(); + final var tracker = (SimpleErrorTracker) caller.get(); + try { + assertEquals(callerLoader, tracker.attachedLoader()); + } finally { + tracker.detachErrorContext(); + } + } + + public static final class ContextAwareCaller implements Supplier { + @Override + public ErrorTracker get() { + return ErrorTracker.contextAware(); + } + } + @Test public void trackedErrorSerializesProperties() { final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware();