Skip to content

Commit d6b59cd

Browse files
authored
Merge pull request #231 from faststats-dev/feat/snapshot-errors
Snapshot errors to avoid corruption
2 parents 9da1cca + a6f0b6f commit d6b59cd

5 files changed

Lines changed: 127 additions & 55 deletions

File tree

‎core/src/main/java/dev/faststats/ErrorHelper.java‎

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -36,25 +36,25 @@ private static JsonObject compileAll(final TrackedError trackedError, @Nullable
3636
@Nullable final Attributes defaultAttributes) {
3737
final var error = trackedError.error();
3838
final var report = new JsonObject();
39-
final var message = anonymize(error.getMessage(), customPatterns);
39+
final var message = anonymize(error.message(), customPatterns);
4040

4141
final var stacktrace = new JsonArray();
4242
final var header = message != null
43-
? error.getClass().getName() + ": " + message
44-
: error.getClass().getName();
43+
? error.type().getName() + ": " + message
44+
: error.type().getName();
4545
stacktrace.add(header);
4646

47-
final var elements = error.getStackTrace();
47+
final var elements = error.stackTraces();
4848
final var stack = collapseStackTrace(elements);
4949
final var list = new ArrayList<>(stack);
5050
if (suppress != null) list.removeAll(suppress);
5151
final var traces = Math.min(list.size(), MAX_STACK_SIZE);
5252

5353
populateTraces(traces, list, elements, stacktrace);
54-
appendCauseChain(error.getCause(), stack, suppress, stacktrace, customPatterns);
54+
appendCauseChain(error.cause(), stack, suppress, stacktrace, customPatterns);
5555

56-
report.addProperty("error", error.getClass().getName());
57-
final var first = anonymize(findFirstMessage(error, null), customPatterns);
56+
report.addProperty("error", error.type().getName());
57+
final var first = anonymize(findFirstMessage(error), customPatterns);
5858
if (first != null) report.addProperty("message", first);
5959

6060
report.add("stack", stacktrace);
@@ -68,28 +68,26 @@ private static JsonObject compileAll(final TrackedError trackedError, @Nullable
6868
return report;
6969
}
7070

71-
// fixme: unmaintainable mess, i already forgot what it does
72-
private static void appendCauseChain(@Nullable Throwable cause, final List<String> parentStack,
71+
private static void appendCauseChain(TrackedError.@Nullable ThrowableSnapshot cause, final List<String> parentStack,
7372
@Nullable final List<String> suppress, final JsonArray stacktrace,
7473
final List<Map.Entry<Pattern, String>> customPatterns) {
7574
final var toSuppress = new ArrayList<>(parentStack);
7675
if (suppress != null) toSuppress.addAll(suppress);
77-
final var visited = Collections.<Throwable>newSetFromMap(new IdentityHashMap<>());
78-
while (cause != null && visited.add(cause)) {
79-
final var causeMessage = anonymize(cause.getMessage(), customPatterns);
76+
while (cause != null) {
77+
final var causeMessage = anonymize(cause.message(), customPatterns);
8078
final var header = causeMessage != null
81-
? "Caused by: " + cause.getClass().getName() + ": " + causeMessage
82-
: "Caused by: " + cause.getClass().getName();
79+
? "Caused by: " + cause.type().getName() + ": " + causeMessage
80+
: "Caused by: " + cause.type().getName();
8381
stacktrace.add(header);
8482

85-
final var causeElements = cause.getStackTrace();
83+
final var causeElements = cause.stackTraces();
8684
final var causeStack = collapseStackTrace(causeElements);
8785
final var causeList = new ArrayList<>(causeStack);
8886
causeList.removeAll(toSuppress);
8987
final var causeTraces = Math.min(causeList.size(), MAX_STACK_SIZE);
9088
populateTraces(causeTraces, causeList, causeElements, stacktrace);
9189

92-
cause = cause.getCause();
90+
cause = cause.cause();
9391
}
9492
}
9593

@@ -214,13 +212,11 @@ private static boolean isSameClassLoader(final ClassLoader classLoader, final Cl
214212
return loader == current;
215213
}
216214

217-
private static @Nullable String findFirstMessage(@Nullable final Throwable error, @Nullable Set<Throwable> visited) {
215+
private static @Nullable String findFirstMessage(final TrackedError.@Nullable ThrowableSnapshot error) {
218216
if (error == null) return null;
219-
final var message = error.getMessage();
217+
final var message = error.message();
220218
if (message != null) return message;
221-
if (visited == null) visited = Collections.newSetFromMap(new IdentityHashMap<>());
222-
if (!visited.add(error)) return null;
223-
return findFirstMessage(error.getCause(), visited);
219+
return findFirstMessage(error.cause());
224220
}
225221

226222
private static @Nullable String anonymize(@Nullable final String message, final List<Map.Entry<Pattern, String>> customPatterns) {
Lines changed: 54 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package dev.faststats;
22

3+
import org.jetbrains.annotations.Contract;
34
import org.jspecify.annotations.Nullable;
45

56
import java.util.Arrays;
@@ -11,14 +12,55 @@
1112
final class SimpleTrackedError implements TrackedError {
1213
private volatile Attributes attributes = Attributes.empty();
1314
private volatile boolean handled = true;
14-
private final Throwable error;
15+
private final ThrowableSnapshot error;
1516

1617
SimpleTrackedError(final Throwable error) {
17-
this.error = error;
18+
this.error = snapshot(error, null);
19+
}
20+
21+
@Contract("_, null -> !null")
22+
private static @Nullable ThrowableSnapshot snapshot(final Throwable error, @Nullable Set<Throwable> visited) {
23+
final var message = error.getMessage();
24+
final var stackTrace = error.getStackTrace();
25+
if (error.getCause() != null && visited == null)
26+
visited = Collections.newSetFromMap(new IdentityHashMap<>());
27+
if (visited != null && !visited.add(error)) return null;
28+
final var cause = error.getCause() != null
29+
? snapshot(error.getCause(), visited)
30+
: null;
31+
final var trace = stackTrace.length == 0 ? new Throwable().getStackTrace() : stackTrace;
32+
return new SimpleThrowableSnapshot(error.getClass(), message, cause, trace);
33+
}
34+
35+
record SimpleThrowableSnapshot(
36+
Class<?> type,
37+
@Nullable String message,
38+
@Nullable ThrowableSnapshot cause,
39+
StackTraceElement... stackTraces
40+
) implements ThrowableSnapshot {
41+
@Override
42+
public StackTraceElement[] stackTraces() {
43+
return stackTraces.clone();
44+
}
45+
46+
@Override
47+
public boolean equals(@Nullable final Object o) {
48+
if (o == null || getClass() != o.getClass()) return false;
49+
final SimpleThrowableSnapshot that = (SimpleThrowableSnapshot) o;
50+
return Objects.equals(type, that.type)
51+
&& Objects.equals(message, that.message)
52+
&& Objects.equals(cause, that.cause)
53+
&& Objects.deepEquals(stackTraces, that.stackTraces);
54+
}
55+
56+
@Override
57+
public int hashCode() {
58+
return Objects.hash(type, message, cause, Arrays.hashCode(stackTraces));
59+
}
1860
}
1961

2062
@Override
21-
public Throwable error() {
63+
public ThrowableSnapshot error() {
2264
return error;
2365
}
2466

@@ -50,36 +92,20 @@ public boolean equals(@Nullable final Object o) {
5092
final SimpleTrackedError that = (SimpleTrackedError) o;
5193
return handled == that.handled
5294
&& Objects.equals(attributes, that.attributes)
53-
&& deepEquals(error, that.error, Collections.newSetFromMap(new IdentityHashMap<>()));
95+
&& Objects.equals(error, that.error);
5496
}
5597

5698
@Override
5799
public int hashCode() {
58-
return Objects.hash(attributes, handled, hash(error, Collections.newSetFromMap(new IdentityHashMap<>())));
59-
}
60-
61-
// fixme: hacky shit; it only has to compile and pass tests for now
62-
private static boolean deepEquals(
63-
@Nullable final Throwable first,
64-
@Nullable final Throwable second,
65-
final Set<Throwable> visited
66-
) {
67-
if (first == second) return true;
68-
if (first == null || second == null) return false;
69-
if (first.getClass() != second.getClass()) return false;
70-
if (!Objects.equals(first.getMessage(), second.getMessage())) return false;
71-
if (!Arrays.equals(first.getStackTrace(), second.getStackTrace())) return false;
72-
if (!visited.add(first)) return true;
73-
return deepEquals(first.getCause(), second.getCause(), visited);
100+
return Objects.hash(attributes, handled, error);
74101
}
75102

76-
private static int hash(@Nullable final Throwable error, final Set<Throwable> visited) {
77-
if (error == null || !visited.add(error)) return 0;
78-
return Objects.hash(
79-
error.getClass(),
80-
error.getMessage(),
81-
Arrays.hashCode(error.getStackTrace()),
82-
hash(error.getCause(), visited)
83-
);
103+
@Override
104+
public String toString() {
105+
return "SimpleTrackedError{" +
106+
"attributes=" + attributes +
107+
", handled=" + handled +
108+
", error=" + error +
109+
'}';
84110
}
85111
}

‎core/src/main/java/dev/faststats/TrackedError.java‎

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package dev.faststats;
22

33
import org.jetbrains.annotations.Contract;
4+
import org.jspecify.annotations.Nullable;
45

56
/**
67
* An error report with tracking metadata.
@@ -9,13 +10,51 @@
910
*/
1011
public sealed interface TrackedError permits SimpleTrackedError {
1112
/**
12-
* Returns the tracked error.
13+
* Returns a snapshot of the tracked error.
1314
*
14-
* @return the tracked error
15-
* @since 0.24.0
15+
* @return a snapshot of the tracked error
16+
* @since 0.30.0
1617
*/
1718
@Contract(pure = true)
18-
Throwable error();
19+
ThrowableSnapshot error();
20+
21+
/**
22+
* A snapshot of a {@link Throwable} captured when an error is tracked.
23+
* <p>
24+
* The snapshot preserves the throwable's type, message, cause chain, and stack trace
25+
* without retaining the original throwable.
26+
*
27+
* @since 0.30.0
28+
*/
29+
sealed interface ThrowableSnapshot permits SimpleTrackedError.SimpleThrowableSnapshot {
30+
/**
31+
* Returns the throwable class.
32+
*
33+
* @return the throwable class
34+
*/
35+
Class<?> type();
36+
37+
/**
38+
* Returns the throwable message.
39+
*
40+
* @return the throwable message, or {@code null} if none was provided
41+
*/
42+
@Nullable String message();
43+
44+
/**
45+
* Returns a snapshot of the throwable's cause.
46+
*
47+
* @return a snapshot of the throwable's cause, or {@code null} if it has no cause
48+
*/
49+
@Nullable ThrowableSnapshot cause();
50+
51+
/**
52+
* Returns a copy of the throwable's stack trace elements.
53+
*
54+
* @return a copy of the throwable's stack trace elements
55+
*/
56+
StackTraceElement[] stackTraces();
57+
}
1958

2059
/**
2160
* Returns whether the error was handled.

‎core/src/test/java/dev/faststats/ErrorTrackerTest.java‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,14 +224,25 @@ public void cyclicCauseChainStopsAfterFirstVisit() {
224224
var secondCauseCount = 0;
225225
for (final var element : stack) {
226226
final var line = element.getAsString();
227-
if (line.equals("Caused by: java.lang.RuntimeException: first")) firstCauseCount++;
227+
if (line.equals("java.lang.RuntimeException: first")) firstCauseCount++;
228228
if (line.equals("Caused by: java.lang.IllegalStateException: second")) secondCauseCount++;
229229
}
230230

231231
assertEquals(1, firstCauseCount);
232232
assertEquals(1, secondCauseCount);
233233
}
234234

235+
@Test
236+
public void noEmptyTraces() {
237+
final var oom = new OutOfMemoryError();
238+
oom.setStackTrace(new StackTraceElement[0]);
239+
240+
tracker.trackError(oom);
241+
242+
final var stack = tracker.getFullData().get(0).getAsJsonObject().getAsJsonArray("stack");
243+
assertFalse(stack.isEmpty(), "Tracked error must always have a stacktrace");
244+
}
245+
235246
@Test
236247
public void duplicateErrorsAreAggregatedWithCount() {
237248
final var first = createStableError();

‎gradle.properties‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
version=0.29.4
1+
version=0.30.0
22
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m

0 commit comments

Comments
 (0)