Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ import spock.lang.Shared
@TrackScopeContinuations
abstract class InstrumentationSpecification extends DDSpecification implements AgentBuilder.Listener, ScopeDiagnosticsSpockSupport {
private static final long TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(20)
private LoggedStackOverflowCheck loggedStackOverflow

protected static final Instrumentation INSTRUMENTATION = ByteBuddyAgent.getInstrumentation()

Expand Down Expand Up @@ -468,6 +469,8 @@ abstract class InstrumentationSpecification extends DDSpecification implements A

configureLoggingLevels()

loggedStackOverflow = new LoggedStackOverflowCheck()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Start capture before suite fixtures run

Because the capture is created only in per-iteration setup() and closed after every cleanup, setupSpec() and cleanupSpec() execute with no logging hook. Many instrumentation specifications start and stop asynchronous servers, clients, or containers in those suite fixtures; if a framework catches and logs a StackOverflowError from that work without propagating it, the entire suite can still pass. Keep a suite-level capture active across the fixture windows and report any retained error as a suite failure.

Useful? React with 👍 / 👎.


assertThreadsEachCleanup = false

closePrevious(true) // in case the previous test left a lingering iteration span
Expand Down Expand Up @@ -552,6 +555,7 @@ abstract class InstrumentationSpecification extends DDSpecification implements A

// check for instrumentation issues while running each test
assert InstrumentationErrors.noErrors(): InstrumentationErrors.describeErrors()
loggedStackOverflow?.check()
} catch (Throwable cleanupFailure) {
if (scopeDiagnosticsFailure != null) {
cleanupFailure.addSuppressed(scopeDiagnosticsFailure)
Expand All @@ -562,6 +566,7 @@ abstract class InstrumentationSpecification extends DDSpecification implements A
throw scopeDiagnosticsFailure
}
} finally {
loggedStackOverflow?.close()
if (scopeDiagnosticsSuiteEnabled()) {
ScopeDiagnostics.startRecording()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ public abstract class AbstractInstrumentationTest {
protected static ListWriter writer;
private static ClassFileTransformer activeTransformer;
private static ClassFileTransformerListener transformerListener;
private LoggedStackOverflowCheck loggedStackOverflow;

@BeforeAll
static void initAll() {
Expand Down Expand Up @@ -116,17 +117,25 @@ static void initAll() {

@BeforeEach
public void init() {
loggedStackOverflow = new LoggedStackOverflowCheck();
InstrumentationErrors.resetErrors(); // reset for each test
tracer.flush();
writer.start();
}

@AfterEach
public void tearDown() {
tracer.flush();

// check for instrumentation issues while running each test
assertTrue(InstrumentationErrors.noErrors(), InstrumentationErrors::describeErrors);
try {
tracer.flush();

// check for instrumentation issues while running each test
assertTrue(InstrumentationErrors.noErrors(), InstrumentationErrors::describeErrors);
loggedStackOverflow.check();
} finally {
if (loggedStackOverflow != null) {
loggedStackOverflow.close();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Guarantee logging-hook cleanup when subclasses override tearDown

Existing R2DBC subclasses override tearDown() without calling super, so JUnit skips this base cleanup while inherited init() still installs the logging hooks. Those tests never check captured stack overflows and retain another handler/filter after each invocation. Put hook checking and detachment in dedicated lifecycle callbacks that cannot be bypassed by these overrides, or update all affected subclasses.

React: 👍 useful · 👎 wrong · 😕 unclear · 👀 right, but not acting on it

What each reaction means
  • 👍 Useful: correct, and you're fixing it or will.
  • 👎 Wrong: the finding is incorrect, for example the bug can't happen or the code was misread.
  • 😕 Unclear: you can't tell what it means or why it matters.
  • 👀 Right, not acting: correct, but intended, out of scope for this PR, a known risk, or too minor.

🤖 Bits Code Review · Open Bits AI session

}
}
}

@AfterAll
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package datadog.trace.agent.test;

import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Handler;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
import org.slf4j.LoggerFactory;

/** Captures logged stack overflows so framework exception handling cannot silently pass a test. */
public final class LoggedStackOverflowCheck implements AutoCloseable {
private final AtomicReference<StackOverflowError> firstError = new AtomicReference<>();
private final Logger julRoot = Logger.getLogger("");
private final Handler julHandler =
new Handler() {
@Override
public void publish(LogRecord record) {
capture(record.getThrown());
}

@Override
public void flush() {}

@Override
public void close() {}
};
private final Runnable detachLogback;

public LoggedStackOverflowCheck() {
Object factory = LoggerFactory.getILoggerFactory();
// Some instrumentation suites deliberately exclude Logback from their runtime classpath.
detachLogback =
factory.getClass().getName().equals("ch.qos.logback.classic.LoggerContext")
? LogbackCapture.install(factory, this)
: () -> {};
julRoot.addHandler(julHandler);
}

private void capture(Throwable error) {
// Do not format a stack trace or log from the thread whose stack is exhausted. Bound traversal
// because frameworks may wrap the error, and Throwable cause chains can contain cycles.
for (int depth = 0; error != null && depth < 16; depth++) {
if (error instanceof StackOverflowError) {
firstError.compareAndSet(null, (StackOverflowError) error);
return;
}
error = error.getCause();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Inspect suppressed exceptions for stack overflows

When a framework aggregates failures by attaching the original StackOverflowError as a suppressed exception, the logged wrapper renders that stack overflow but this traversal follows only getCause(), so cleanup does not fail. This is common for multi-failure and resource-cleanup wrappers; traverse a bounded, cycle-safe throwable graph that includes getSuppressed() rather than only the cause chain.

Useful? React with 👍 / 👎.

}
}

public void check() {
StackOverflowError error = firstError.get();
if (error != null) {
throw new AssertionError("A StackOverflowError was logged during the test", error);
}
}

@Override
public void close() {
julRoot.removeHandler(julHandler);
detachLogback.run();
}

private static final class LogbackCapture extends ch.qos.logback.classic.turbo.TurboFilter {
private final LoggedStackOverflowCheck check;

private LogbackCapture(LoggedStackOverflowCheck check) {
this.check = check;
}

static Runnable install(Object factory, LoggedStackOverflowCheck check) {
ch.qos.logback.classic.LoggerContext context = (ch.qos.logback.classic.LoggerContext) factory;
LogbackCapture filter = new LogbackCapture(check);
filter.start();
context.addTurboFilter(filter);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Preserve Logback capture across context resets

SpringBootApplicationTest starts Spring applications inside its feature methods, after the harness installs this filter. Spring Boot logging initialization resets LoggerContext, which clears its turbo filters. Subsequent Logback stack overflows therefore escape detection and can still pass cleanup. Add reset-resistant listener handling that reinstalls and restarts the filter, and remove both listener and filter during close.

React: 👍 useful · 👎 wrong · 😕 unclear · 👀 right, but not acting on it

What each reaction means
  • 👍 Useful: correct, and you're fixing it or will.
  • 👎 Wrong: the finding is incorrect, for example the bug can't happen or the code was misread.
  • 😕 Unclear: you can't tell what it means or why it matters.
  • 👀 Right, not acting: correct, but intended, out of scope for this PR, a known risk, or too minor.

🤖 Bits Code Review · Open Bits AI session

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve capture across Logback context resets

When a test initializes or reconfigures Logback after the base setup() has run, LoggerContext.reset() clears the turbo-filter list, so the filter installed here silently disappears for the remainder of the test. This occurs in instrumentation suites that start an application from inside a feature—for example, SpringBootApplicationTest calls SpringApplication.run(...) after InstrumentationSpecification.setup()—and a subsequently logged StackOverflowError can still pass unnoticed. Make the capture reset-resistant or reinstall it when the context resets.

Useful? React with 👍 / 👎.

return () -> {
context.getTurboFilterList().remove(filter);
filter.stop();
};
}

@Override
public ch.qos.logback.core.spi.FilterReply decide(
org.slf4j.Marker marker,
ch.qos.logback.classic.Logger logger,
ch.qos.logback.classic.Level level,
String format,
Object[] params,
Throwable throwable) {
check.capture(throwable);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore Logback events disabled by the effective level

When code passes a StackOverflowError to a disabled Logback call, such as logger.debug("caught", error) while the logger is configured at WARN, this filter still records it and fails the test even though no event is logged. Logback invokes turbo filters before applying the logger's effective-level check, and this filter captures unconditionally before returning NEUTRAL; only capture events that would remain enabled after the neutral decision.

Useful? React with 👍 / 👎.

// Logback can extract the trailing throwable from SLF4J arguments after this callback.
if (params != null && params.length > 0) {
Object last = params[params.length - 1];
if (last instanceof Throwable) {
check.capture((Throwable) last);
}
}
return ch.qos.logback.core.spi.FilterReply.NEUTRAL;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package datadog.trace.agent.test;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.util.logging.Level;
import java.util.logging.Logger;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;

class LoggedStackOverflowCheckTest {
@Test
void julBackgroundErrorFailsOnTheTestThread() throws Exception {
StackOverflowError overflow = new StackOverflowError("background JUL error");
try (LoggedStackOverflowCheck check = new LoggedStackOverflowCheck()) {
Thread background =
new Thread(
() -> Logger.getLogger(getClass().getName()).log(Level.SEVERE, "caught", overflow));
background.start();
background.join();

assertSame(overflow, assertThrows(AssertionError.class, check::check).getCause());
}
}

@Test
void logbackBackgroundErrorFailsOnTheTestThread() throws Exception {
StackOverflowError overflow = new StackOverflowError("background Logback error");
try (LoggedStackOverflowCheck check = new LoggedStackOverflowCheck()) {
Thread background =
new Thread(() -> LoggerFactory.getLogger(getClass()).error("caught", overflow));
background.start();
background.join();

assertSame(overflow, assertThrows(AssertionError.class, check::check).getCause());
}
}

@Test
void capturesWrappedAndParameterizedErrorsAndRetainsTheFirst() {
StackOverflowError first = new StackOverflowError("first");
try (LoggedStackOverflowCheck check = new LoggedStackOverflowCheck()) {
LoggerFactory.getLogger(getClass())
.error("caught in {}", "handler", new RuntimeException(first));
Logger.getLogger(getClass().getName())
.log(Level.SEVERE, "caught", new StackOverflowError("second"));

assertSame(first, assertThrows(AssertionError.class, check::check).getCause());
}
}

@Test
void ignoresOrdinaryErrorsAndMessagesMentioningStackOverflow() {
RuntimeException first = new RuntimeException();
RuntimeException second = new RuntimeException(first);
first.initCause(second);
try (LoggedStackOverflowCheck check = new LoggedStackOverflowCheck()) {
Logger.getLogger(getClass().getName()).log(Level.SEVERE, "StackOverflowError", first);
LoggerFactory.getLogger(getClass()).error("java.lang.StackOverflowError");

assertDoesNotThrow(check::check);
}
}

@Test
void closeDetachesBothLoggingHooksAndNextTestStartsClean() {
LoggedStackOverflowCheck closed = new LoggedStackOverflowCheck();
closed.close();
StackOverflowError overflow = new StackOverflowError("after close");
Logger.getLogger(getClass().getName()).log(Level.SEVERE, "caught", overflow);
LoggerFactory.getLogger(getClass()).error("caught", overflow);
assertDoesNotThrow(closed::check);

try (LoggedStackOverflowCheck next = new LoggedStackOverflowCheck()) {
assertDoesNotThrow(next::check);
}
}
}
7 changes: 7 additions & 0 deletions docs/how_to_test.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ The project leverages different types of tests:
> [!TIP]
> Most of the instrumented tests and integration tests are instrumentation tests.

### Logged stack overflows

Instrumentation tests fail when JUL or Logback logs a `StackOverflowError` during a test,
including one caught by a framework on a background thread. The harness retains the error and
reports it during cleanup even if the response and trace assertions passed. This check does not
cover errors that are never logged, other logging backends, or a JVM that hangs before cleanup.

### Forked Tests

Independent of the type of test, test can be run in another (forked) JVM than the one running Gradle.
Expand Down
Loading