Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions core/src/main/java/dev/faststats/ErrorHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -154,29 +154,45 @@ private static List<String> collapseConsecutiveDuplicates(final List<String> 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<Throwable> 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<Throwable> 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) {
Expand Down
61 changes: 55 additions & 6 deletions core/src/main/java/dev/faststats/ErrorTracker.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import java.util.Optional;
import java.util.function.BiConsumer;
import java.util.function.BiPredicate;
import java.util.regex.Pattern;

/**
Expand All @@ -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());
}

/**
Expand All @@ -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);
Expand Down Expand Up @@ -164,6 +166,33 @@ default ErrorTracker ignoreError(final Class<? extends Throwable> type, @RegExp
return ignoreError(type, Pattern.compile(pattern));
}

/**
* Adds a rule that excludes automatically tracked errors from being reported to FastStats.
* <p>
* Rules are only applied to errors captured through an attached error context,
* after the error has been attributed to the tracker's class loader.
* <p>
* If any rule returns {@code true} for the thread and error, the error is not tracked.
* <pre>{@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
* });
* }</pre>
*
* @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<Thread, Throwable> rule);

/**
* Adds an anonymization pattern that replaces matched text in error messages.
* <pre>{@code
Expand Down Expand Up @@ -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();

/**
Expand Down Expand Up @@ -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.
* <p>
* 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);
}
}
}
15 changes: 15 additions & 0 deletions core/src/main/java/dev/faststats/SimpleErrorTracker.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -25,6 +26,7 @@ final class SimpleErrorTracker implements ErrorTracker {
private final Set<Class<? extends Throwable>> ignoredTypes = new CopyOnWriteArraySet<>();
private final Set<Pattern> ignoredPatterns = new CopyOnWriteArraySet<>();
private final List<Map.Entry<Pattern, String>> anonymizationEntries = new CopyOnWriteArrayList<>();
private final List<BiPredicate<Thread, Throwable>> contextExclusionRules = new CopyOnWriteArrayList<>();

private volatile @Nullable BiConsumer<@Nullable ClassLoader, Throwable> errorEvent;
private volatile @Nullable ClassLoader attachedLoader;
Expand Down Expand Up @@ -86,6 +88,12 @@ public ErrorTracker ignoreError(final Class<? extends Throwable> type, final Pat
return this;
}

@Override
public ErrorTracker addExclusionRule(final BiPredicate<Thread, Throwable> rule) {
contextExclusionRules.add(rule);
return this;
}

@Override
public ErrorTracker anonymize(final Pattern pattern, final String replacement) {
anonymizationEntries.add(Map.entry(pattern, replacement));
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
112 changes: 112 additions & 0 deletions core/src/test/java/dev/faststats/ErrorTrackerTest.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<ErrorTracker>) 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<ErrorTracker> {
@Override
public ErrorTracker get() {
return ErrorTracker.contextAware();
}
}

@Test
public void trackedErrorSerializesProperties() {
final var tracker = (SimpleErrorTracker) ErrorTracker.contextUnaware();
Expand Down
Loading