|
| 1 | +package dev.faststats.core; |
| 2 | + |
| 3 | +import com.google.gson.JsonArray; |
| 4 | +import com.google.gson.JsonObject; |
| 5 | +import org.jspecify.annotations.Nullable; |
| 6 | + |
| 7 | +import java.util.ArrayList; |
| 8 | +import java.util.Arrays; |
| 9 | +import java.util.List; |
| 10 | + |
| 11 | +final class ErrorHelper { |
| 12 | + private static final int MESSAGE_LENGTH = Math.min(1000, Integer.getInteger("faststats.message-length", 500)); |
| 13 | + private static final int STACK_TRACE_LENGTH = Math.min(500, Integer.getInteger("faststats.stack-trace-length", 300)); |
| 14 | + private static final int STACK_TRACE_LIMIT = Math.min(50, Integer.getInteger("faststats.stack-trace-limit", 15)); |
| 15 | + |
| 16 | + public static JsonObject compile(final Throwable error, @Nullable final List<String> suppress) { |
| 17 | + final var report = new JsonObject(); |
| 18 | + final var message = getAnonymizedMessage(error); |
| 19 | + |
| 20 | + report.addProperty("error", error.getClass().getName()); |
| 21 | + if (message != null) report.addProperty("message", message); |
| 22 | + |
| 23 | + final var elements = error.getStackTrace(); |
| 24 | + final var stack = collapseStackTrace(elements); |
| 25 | + final var list = new ArrayList<>(stack); |
| 26 | + if (suppress != null) list.removeAll(suppress); |
| 27 | + final var traces = Math.min(list.size(), STACK_TRACE_LIMIT); |
| 28 | + |
| 29 | + final var stacktrace = populateTraces(traces, list, elements); |
| 30 | + if (!stacktrace.isEmpty()) report.add("stack", stacktrace); |
| 31 | + |
| 32 | + if (error.getCause() != null) { |
| 33 | + final var toSuppress = new ArrayList<>(stack); |
| 34 | + if (suppress != null) toSuppress.addAll(suppress); |
| 35 | + report.add("cause", compile(error.getCause(), toSuppress)); |
| 36 | + } |
| 37 | + |
| 38 | + return report; |
| 39 | + } |
| 40 | + |
| 41 | + private static JsonArray populateTraces(final int traces, final ArrayList<String> list, final StackTraceElement[] elements) { |
| 42 | + final var stacktrace = new JsonArray(traces); |
| 43 | + |
| 44 | + for (var i = 0; i < traces; i++) { |
| 45 | + final var string = list.get(i); |
| 46 | + if (string.length() <= STACK_TRACE_LENGTH) stacktrace.add(string); |
| 47 | + else stacktrace.add(string.substring(0, STACK_TRACE_LENGTH) + "..."); |
| 48 | + } |
| 49 | + if (traces > 0 && traces < list.size()) { |
| 50 | + stacktrace.add("and " + (list.size() - traces) + " more..."); |
| 51 | + } else { |
| 52 | + final var i = elements.length - list.size(); |
| 53 | + if (i > 0) stacktrace.add("Omitted " + i + " duplicate stack frame" + (i == 1 ? "" : "s")); |
| 54 | + } |
| 55 | + return stacktrace; |
| 56 | + } |
| 57 | + |
| 58 | + private static List<String> collapseStackTrace(final StackTraceElement[] trace) { |
| 59 | + final var lines = Arrays.stream(trace) |
| 60 | + .map(StackTraceElement::toString) |
| 61 | + .toList(); |
| 62 | + |
| 63 | + return collapseRepeatingPattern(lines); |
| 64 | + } |
| 65 | + |
| 66 | + private static List<String> collapseRepeatingPattern(final List<String> lines) { |
| 67 | + final var deduplicated = collapseConsecutiveDuplicates(lines); |
| 68 | + |
| 69 | + final var n = deduplicated.size(); |
| 70 | + |
| 71 | + for (var cycleLen = 1; cycleLen <= n / 2; cycleLen++) { |
| 72 | + var isPattern = true; |
| 73 | + var repetitions = 0; |
| 74 | + |
| 75 | + for (var i = 0; i < n; i++) { |
| 76 | + if (!deduplicated.get(i).equals(deduplicated.get(i % cycleLen))) { |
| 77 | + isPattern = false; |
| 78 | + break; |
| 79 | + } |
| 80 | + if (i > 0 && i % cycleLen == 0) repetitions++; |
| 81 | + } |
| 82 | + |
| 83 | + if (isPattern && repetitions >= 2) { |
| 84 | + return deduplicated.subList(0, cycleLen); |
| 85 | + } |
| 86 | + } |
| 87 | + |
| 88 | + return deduplicated; |
| 89 | + } |
| 90 | + |
| 91 | + private static List<String> collapseConsecutiveDuplicates(final List<String> lines) { |
| 92 | + if (lines.isEmpty()) return lines; |
| 93 | + |
| 94 | + final var result = new ArrayList<String>(); |
| 95 | + String previous = null; |
| 96 | + |
| 97 | + for (final var line : lines) { |
| 98 | + if (line.equals(previous)) continue; |
| 99 | + result.add(line); |
| 100 | + previous = line; |
| 101 | + } |
| 102 | + |
| 103 | + return result; |
| 104 | + } |
| 105 | + |
| 106 | + public static boolean isSameLoader(final ClassLoader loader, final Throwable error) { |
| 107 | + final var stackTrace = error.getStackTrace(); |
| 108 | + if (stackTrace == null || stackTrace.length == 0) return false; |
| 109 | + |
| 110 | + final var firstNonLibraryIndex = findFirstNonLibraryFrameIndex(stackTrace); |
| 111 | + if (firstNonLibraryIndex == -1) return false; |
| 112 | + |
| 113 | + final var framesToCheck = Math.min(5, stackTrace.length - firstNonLibraryIndex); |
| 114 | + |
| 115 | + for (var i = 0; i < framesToCheck; i++) { |
| 116 | + final var frame = stackTrace[firstNonLibraryIndex + i]; |
| 117 | + if (isLibraryClass(frame.getClassName())) continue; |
| 118 | + if (!isFromLoader(frame, loader)) return false; |
| 119 | + } |
| 120 | + |
| 121 | + return true; |
| 122 | + } |
| 123 | + |
| 124 | + private static int findFirstNonLibraryFrameIndex(final StackTraceElement[] stackTrace) { |
| 125 | + for (var i = 0; i < stackTrace.length; i++) { |
| 126 | + if (!isLibraryClass(stackTrace[i].getClassName())) return i; |
| 127 | + } |
| 128 | + return -1; |
| 129 | + } |
| 130 | + |
| 131 | + private static boolean isLibraryClass(final String className) { |
| 132 | + return className.startsWith("java.") |
| 133 | + || className.startsWith("javax.") |
| 134 | + || className.startsWith("sun.") |
| 135 | + || className.startsWith("com.sun.") |
| 136 | + || className.startsWith("jdk."); |
| 137 | + } |
| 138 | + |
| 139 | + private static boolean isFromLoader(final StackTraceElement frame, final ClassLoader loader) { |
| 140 | + try { |
| 141 | + final var clazz = Class.forName(frame.getClassName(), false, loader); |
| 142 | + return isSameClassLoader(clazz.getClassLoader(), loader); |
| 143 | + } catch (final Throwable t) { |
| 144 | + return false; |
| 145 | + } |
| 146 | + } |
| 147 | + |
| 148 | + private static boolean isSameClassLoader(final ClassLoader classLoader, final ClassLoader loader) { |
| 149 | + if (classLoader == loader) return true; |
| 150 | + var current = classLoader; |
| 151 | + while (current != null && current != loader) { |
| 152 | + current = current.getParent(); |
| 153 | + } |
| 154 | + return loader == current; |
| 155 | + } |
| 156 | + |
| 157 | + private static final String IPV4_PATTERN = |
| 158 | + "\\b(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\b"; |
| 159 | + private static final String IPV6_PATTERN = |
| 160 | + "(?i)\\b([0-9a-f]{1,4}:){7}[0-9a-f]{1,4}\\b|" + // Full form |
| 161 | + "(?i)\\b([0-9a-f]{1,4}:){1,7}:\\b|" + // Trailing :: |
| 162 | + "(?i)\\b([0-9a-f]{1,4}:){1,6}:[0-9a-f]{1,4}\\b|" + // :: in middle (1 group after) |
| 163 | + "(?i)\\b([0-9a-f]{1,4}:){1,5}(:[0-9a-f]{1,4}){1,2}\\b|" + // :: in middle (2 groups after) |
| 164 | + "(?i)\\b([0-9a-f]{1,4}:){1,4}(:[0-9a-f]{1,4}){1,3}\\b|" + // :: in middle (3 groups after) |
| 165 | + "(?i)\\b([0-9a-f]{1,4}:){1,3}(:[0-9a-f]{1,4}){1,4}\\b|" + // :: in middle (4 groups after) |
| 166 | + "(?i)\\b([0-9a-f]{1,4}:){1,2}(:[0-9a-f]{1,4}){1,5}\\b|" + // :: in middle (5 groups after) |
| 167 | + "(?i)\\b[0-9a-f]{1,4}:(:[0-9a-f]{1,4}){1,6}\\b|" + // :: in middle (6 groups after) |
| 168 | + "(?i)\\b:(:[0-9a-f]{1,4}){1,7}\\b|" + // Leading :: |
| 169 | + "(?i)\\b::([0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4}\\b|" + // :: at start |
| 170 | + "(?i)\\b::\\b"; // Just :: |
| 171 | + private static final String USER_HOME_PATH_PATTERN = |
| 172 | + "(/home/)[^/\\s]+" + // Linux: /home/username |
| 173 | + "|(/Users/)[^/\\s]+" + // macOS: /Users/username |
| 174 | + "|((?i)[A-Z]:\\\\Users\\\\)[^\\\\\\s]+"; // Windows: A-Z:\\Users\\username |
| 175 | + |
| 176 | + private static String anonymize(String message) { |
| 177 | + message = message.replaceAll(IPV4_PATTERN, "[IP hidden]"); |
| 178 | + message = message.replaceAll(IPV6_PATTERN, "[IP hidden]"); |
| 179 | + message = message.replaceAll(USER_HOME_PATH_PATTERN, "$1$2$3[username hidden]"); |
| 180 | + final var username = System.getProperty("user.name"); |
| 181 | + if (username != null) message = message.replace(username, "[username hidden]"); |
| 182 | + return message; |
| 183 | + } |
| 184 | + |
| 185 | + private static @Nullable String getAnonymizedMessage(final Throwable error) { |
| 186 | + final var message = error.getMessage(); |
| 187 | + if (message == null) return null; |
| 188 | + final var truncated = message.length() > MESSAGE_LENGTH |
| 189 | + ? message.substring(0, MESSAGE_LENGTH) + "..." |
| 190 | + : message; |
| 191 | + return anonymize(truncated); |
| 192 | + } |
| 193 | +} |
0 commit comments