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
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@
*/
public class DefaultStackTraceFilterer implements StackTraceFilterer {
public static final String STACK_LOG_NAME = "StackTrace";
/**
* Dedicated logger for exception stack traces. The filterer itself no longer writes
* to it — exception logging is driven by {@code GrailsExceptionResolver}, which emits
* to this logger in addition to its own request-context log entry when
* {@code grails.exceptionresolver.logFullStackTrace} is enabled. Exposed as a public
* constant so that subclasses and logback configurations can reference the logger
* name symbolically.
*/
public static final Log STACK_LOG = LogFactory.getLog(STACK_LOG_NAME);

private static final String[] DEFAULT_INTERNAL_PACKAGES = new String[] {
Expand Down Expand Up @@ -81,32 +89,32 @@ public Throwable filter(Throwable source, boolean recursive) {
if (recursive) {
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.

I noticed that filter(source) is called again after the cause chain loop. Since the loop already filters the source throwable (when current == source), is the second call on line 89 necessary?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@sanjana2505006 that's true about the old code, but after this PR, the early return makes the two calls mutually exclusive now.

Throwable current = source;
while (current != null) {
current = filter(current);
filter(current);
current = current.getCause();
}
return source;
}
return filter(source);
}

public Throwable filter(Throwable source) {
if (shouldFilter) {
StackTraceElement[] trace = source.getStackTrace();
List<StackTraceElement> newTrace = filterTraceWithCutOff(trace, cutOffPackage);
if (!shouldFilter) {
return source;
}
StackTraceElement[] trace = source.getStackTrace();
List<StackTraceElement> newTrace = filterTraceWithCutOff(trace, cutOffPackage);

if (newTrace.isEmpty()) {
// filter with no cut-off so at least there is some trace
newTrace = filterTraceWithCutOff(trace, null);
}
if (newTrace.isEmpty()) {
// filter with no cut-off so at least there is some trace
newTrace = filterTraceWithCutOff(trace, null);
}

// Only trim the trace if there was some application trace on the stack
// if not we will just skip sanitizing and leave it as is
if (!newTrace.isEmpty()) {
// We don't want to lose anything, so log it
STACK_LOG.error(FULL_STACK_TRACE_MESSAGE, source);
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.

We discussed this and we think this should stay with a configuration setting.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Consulted AI and it thinks it is a really bad idea:

can you just introduce another config value and default it to false?

It would work, but the mess comes back for opt-in users. Let me trace it.

Setup

  • Keep logFullStackTrace where it is (resolver emits once per request exception when on).
  • Add logFullStackTraceOnFilter (default false). When true, filter(Throwable) emits to STACK_LOG as it did pre-PR.

Output matrix for a 3-cause request exception

logFullStackTrace logFullStackTraceOnFilter STACK_LOG entries Resolver entries Total
false (default) false (default) 0 1 1
true false 1 1 2
false true 3 (one per cause via filter loop) 1 4
true true 4 (1 from resolver + 3 from filter loop) 1 5

The bottom two rows are exactly the per-cause duplication the PR fixes. The user who opts into logFullStackTraceOnFilter for
non-resolver coverage pays for it with 3+ STACK_LOG entries every time a request exception happens.

Non-resolver sanitize path

logFullStackTraceOnFilter STACK_LOG entries from one sanitizeRootCause(ex) call
false 0
true 1

This is the only clean win. But it only kicks in when the flag is on, at which point the user is also paying the 4-entry penalty on
every request exception.

Plus the layering problem

DefaultStackTraceFilterer lives in grails-bootstrap, which doesn't depend on grails-core where Config lives. Options:

  1. Pull Config into bootstrap — module cycle.
  2. Setter on the filterer — resolver reads the flag and calls filterer.setLogFullStackTraceOnFilter(flag) at construction.
    Every other caller (new DefaultStackTraceFilterer() in TestStacktraceSanitizer, user subclasses, plugin code) defaults to
    false because nobody wires the flag for them.
  3. System property (Boolean.getBoolean("grails.full.stacktrace.onFilter")) — matches the existing
    SYS_PROP_DISPLAY_FULL_STACKTRACE pattern in this file. Avoids the module coupling. Costs YAML-config discoverability.

Option 3 is the cleanest plumbing-wise but means users opt in via JVM flag rather than application.yml. A bit inconsistent with
the other grails.exceptionresolver.* settings, all of which live in YAML.

Cleaner alternative

Document one line in the upgrade guide for teams relying on non-resolver sanitize coverage:

// before: relied on side-effect log
Throwable cleaned = GrailsUtil.sanitizeRootCause(ex);                                                                               
log.error("job failed", cleaned);
                                                                                                                                    
// after: explicit                                                          
Throwable cleaned = GrailsUtil.sanitizeRootCause(ex);
DefaultStackTraceFilterer.STACK_LOG.error(StackTraceFilterer.FULL_STACK_TRACE_MESSAGE, cleaned);                                    
log.error("job failed", cleaned);                                                                                                   

No new flag. No layering workaround. No duplicate-per-cause regression for users who happen to need non-resolver coverage.

My call

If you want a concrete non-resolver path you want covered, better the two-line migration. If the project wants blanket pre-PR compatibility, the second flag works but I'd go the system-property route (option 3) to avoid the layering mess, and I'd document clearly that turning it on without also turning logFullStackTrace off produces N+1 entries per exception.

Honestly though: the second flag is solving the problem by re-exposing the old mechanism. The cleaner fix is the two-line code change at the actual call sites that want the behavior. That's explicit, discoverable at the code site, and doesn't involve tuning two interacting flags.

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.

a config setting preserves both and we agreed in the weekly that this was the way forward, I am -1 on this without this

StackTraceElement[] clean = new StackTraceElement[newTrace.size()];
newTrace.toArray(clean);
source.setStackTrace(clean);
}
// Only trim the trace if there was some application trace on the stack
// if not we will just skip sanitizing and leave it as is
if (!newTrace.isEmpty()) {
StackTraceElement[] clean = new StackTraceElement[newTrace.size()];
newTrace.toArray(clean);
source.setStackTrace(clean);
}
return source;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@
*/
public interface StackTraceFilterer {

/**
* Message used as the header for full stack trace log entries emitted on the
* dedicated {@code StackTrace} logger. The filterer itself does not log;
* {@code GrailsExceptionResolver} writes the entry when
* {@code grails.exceptionresolver.logFullStackTrace} is enabled.
*/
String FULL_STACK_TRACE_MESSAGE = "Full Stack Trace:";
String SYS_PROP_DISPLAY_FULL_STACKTRACE = "grails.full.stacktrace";

Expand Down
24 changes: 24 additions & 0 deletions grails-core/src/main/groovy/grails/config/Settings.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,30 @@ interface Settings {
* The parameters to exclude from logging
*/
String SETTING_EXCEPTION_RESOLVER_PARAM_EXCLUDES = 'grails.exceptionresolver.params.exclude'
/**
* Whether the exception resolver should also emit the exception on the separate
* {@code StackTrace} logger in addition to its own request-context log entry.
* Defaults to {@code false}; set to {@code true} to restore the historical two-logger
* behaviour, which allows routing the trace to a separate appender via logback config.
*/
String SETTING_LOG_FULL_STACKTRACE = 'grails.exceptionresolver.logFullStackTrace'
/**
* Whether the exception resolver should append the current auditor (resolved via a
* registered {@code org.grails.datastore.gorm.timestamp.AuditorAware} bean) to the
* exception log line. Defaults to {@code true}; set to {@code false} to keep user
* identifiers out of exception logs. When no {@code AuditorAware} bean is registered
* the log line is unchanged regardless of this setting.
*/
String SETTING_LOG_AUDITOR = 'grails.exceptionresolver.logAuditor'
/**
* Whether the exception resolver should append the remote client address
* ({@link jakarta.servlet.http.HttpServletRequest#getRemoteAddr()}) to the exception
* log line. Defaults to {@code false}; set to {@code true} to include client addresses
* in exception logs. Behind a reverse proxy, configure the servlet container
* (for example Spring Boot's {@code server.forward-headers-strategy}) so that
* {@code remoteAddr} reflects the real client IP.
*/
String SETTING_LOG_REMOTE_ADDR = 'grails.exceptionresolver.logRemoteAddr'
/**
* The class to use for stacktrace filtering. Should be an instanceof {@link org.grails.exceptions.reporting.StackTraceFilterer}
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,102 +21,202 @@ package org.grails.exception.reporting
import org.grails.exceptions.reporting.DefaultStackTraceFilterer
import spock.lang.Specification

import org.grails.exceptions.reporting.StackTraceFilterer

class StackTraceFiltererSpec extends Specification {

private filterer = new DefaultStackTraceFilterer()
private gcl = new GroovyClassLoader()
StackTraceFilterer filterer = new DefaultStackTraceFilterer()
ClassLoader gcl = new GroovyClassLoader()

void "Test basic filter"() {
given: "A controller that should throw a MissingPropertyException"
def cls = gcl.parseClass('''
package test
def 'retains application frames when filtering a stack trace'() {
given: 'a controller action that raises a missing property exception'
def controller = gcl.parseClass('''
package test

class FooController {
def show = {
display()
}
class FooController {
def show = {
display()
}

void display() {
notHere
}
}
''')
void display() {
notHere
}
}
''').getDeclaredConstructor().newInstance()

when: "The stack trace is filtered with custom packages"
filterer.setCutOffPackage("org.spockframework.util")
Throwable exception
try {
cls.getDeclaredConstructor().newInstance().show()
} catch (e) {
filterer.filter(e)
exception = e
}

then: "Only valid stack elements are retained"
exception != null
when: 'the exception stack trace is filtered'
Throwable exception = null
try {
controller['show']()
} catch (e) {
filterer.filter(e)
exception = e
}

when:
StackTraceElement[] stackTraces = exception.stackTrace
then: 'the exception is available for inspection'
exception != null

then:
stackTraces.find { it.className == 'test.FooController' && it.lineNumber == 10 }
stackTraces.find { it.className.startsWith('test.FooController') && it.lineNumber == 6 }
and: 'the controller action and helper method frames remain in the filtered stack trace'
with(exception.stackTrace) {
it.find { it.className == 'test.FooController' && it.lineNumber == 10 }
it.find { it.className.startsWith('test.FooController') && it.lineNumber == 6 }
}
}

void "Test deep filter"() {
given: "A controller that calls a service and rethrows an exception"
def cls = gcl.parseClass('''
package test
def 'filter does not emit a StackTrace log entry for a single throwable'() {
given: 'captured System.err'
def originalErr = System.err
def baos = new ByteArrayOutputStream()
System.setErr(new PrintStream(baos, true))

class FooController {
def fooService = new FooService()
def show = {
display()
}
and: 'an exception whose stack trace mixes application and internal frames'
def exception = new RuntimeException('boom')
exception.stackTrace = [
new StackTraceElement('test.FooController', 'show', 'FooController.groovy', 6),
new StackTraceElement('java.lang.reflect.Method', 'invoke', 'Method.java', 580)
] as StackTraceElement[]

void display() {
try {
fooService.notThere()
}
catch(e) {
throw new RuntimeException("Bad things happened", e)
}
when: 'the exception is filtered'
filterer.filter(exception)

then: "no 'Full Stack Trace:' entry is emitted by the filterer"
System.err.flush()
!baos.toString().contains('Full Stack Trace:')

cleanup:
System.setErr(originalErr)
}
}
class FooService {
void doStuff() {
notThere()
}
}
''')

when: "The stack trace is filtered with custom packages"
filterer.setCutOffPackage("org.spockframework.util")
Throwable exception
def 'retains controller frames across wrapped exceptions during recursive filtering'() {
given: 'a controller action that wraps a failure triggered during service interaction'
def controller = gcl.parseClass('''
package test

class FooController {
def fooService = new FooService()
def show = {
display()
}

void display() {
try {
fooService.notThere()
}
catch(e) {
throw new RuntimeException("Bad things happened", e)
}

}
}
class FooService {
void doStuff() {
notThere()
}
}
''').getDeclaredConstructor().newInstance()

when: 'recursive filtering is applied to the exception'
Throwable exception = null
try {
cls.getDeclaredConstructor().newInstance().show()
controller['show']()
} catch (e) {
filterer.filter(e, true)
println getExceptionContents(e)
exception = e
}

then: "Only valid stack elements are retained"
then: 'the wrapped exception is available for inspection'
exception != null

when:
StackTraceElement[] stackTraces = exception.stackTrace
and: 'the filtered stack trace retains the controller frames for the wrapper and action'
with(exception.stackTrace) {
it.find { it.className == 'test.FooController' && it.lineNumber == 15 }
it.find { it.className.startsWith('test.FooController') && it.lineNumber == 7 }
}
}

def 'filter does not emit a StackTrace log entry when walking the cause chain'() {
given: 'captured System.err'
def originalErr = System.err
def baos = new ByteArrayOutputStream()
System.setErr(new PrintStream(baos, true))

and: 'a wrapped exception whose wrapper and cause mix application and internal frames'
def rootCause = new IllegalStateException('root cause')
rootCause.stackTrace = [
new StackTraceElement('test.FooService', 'doStuff', 'FooService.groovy', 3),
new StackTraceElement('java.lang.reflect.Method', 'invoke', 'Method.java', 580)
] as StackTraceElement[]

def exception = new RuntimeException('boom', rootCause)
exception.stackTrace = [
new StackTraceElement('test.FooController', 'show', 'FooController.groovy', 6),
new StackTraceElement('java.lang.reflect.Method', 'invoke', 'Method.java', 580)
] as StackTraceElement[]

when: 'recursive filtering is applied to the top-level exception'
filterer.filter(exception, true)

then: "no 'Full Stack Trace:' entry is emitted for any throwable in the chain"
System.err.flush()
!baos.toString().contains('Full Stack Trace:')

cleanup:
System.setErr(originalErr)
}

then:
stackTraces.find { it.className == 'test.FooController' && it.lineNumber == 15 }
stackTraces.find { it.className.startsWith('test.FooController') && it.lineNumber == 7 }
def 'recursive filtering visits every throwable in the cause chain and sanitizes each'() {
given: 'a cause chain with both application and internal stack frames'
def filterer = new CountingStackTraceFilterer()
def rootCause = new IllegalStateException('root cause')
rootCause.stackTrace = [
new StackTraceElement('test.FooService', 'doStuff', 'FooService.groovy', 3),
new StackTraceElement('org.codehaus.groovy.runtime.InvokerHelper', 'invokeMethod', 'InvokerHelper.java', 12)
] as StackTraceElement[]

def wrappedCause = new RuntimeException('wrapped cause', rootCause)
wrappedCause.stackTrace = [
new StackTraceElement('test.FooController', 'display', 'FooController.groovy', 11),
new StackTraceElement('org.codehaus.groovy.runtime.callsite.CallSiteArray', 'defaultCall', 'CallSiteArray.java', 15)
] as StackTraceElement[]

def exception = new RuntimeException('top level', wrappedCause)
exception.stackTrace = [
new StackTraceElement('test.FooController', 'show', 'FooController.groovy', 7),
new StackTraceElement('org.codehaus.groovy.runtime.ScriptBytecodeAdapter', 'unwrap', 'ScriptBytecodeAdapter.java', 20)
] as StackTraceElement[]

when: 'recursive filtering is applied to the top-level exception'
filterer.filter(exception, true)

then: 'filter is invoked once per throwable in the cause chain, in cause-chain order'
filterer.singleExceptionFilterInvocations == 3
filterer.filteredSources == [exception, wrappedCause, rootCause]

and: 'application stack frames are retained across the full cause chain'
with(exception) {
stackTrace*.className == ['test.FooController']
stackTrace*.lineNumber == [7]
cause.stackTrace*.className == ['test.FooController']
cause.stackTrace*.lineNumber == [11]
cause.cause.stackTrace*.className == ['test.FooService']
cause.cause.stackTrace*.lineNumber == [3]
}
}

private String getExceptionContents(Throwable e) {
final sw = new StringWriter()
def pw = new PrintWriter(sw)
e.printStackTrace pw
return sw.toString()
private static class CountingStackTraceFilterer extends DefaultStackTraceFilterer {

int singleExceptionFilterInvocations
List<Throwable> filteredSources = []

CountingStackTraceFilterer() {
super(true)
}

@Override
Throwable filter(Throwable source) {
singleExceptionFilterInvocations++
filteredSources << source
super.filter(source)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,4 @@ class GrailsVersionSpec extends Specification {
thrown(IllegalArgumentException)
}
}
}
Loading
Loading