From a39b3a5033a1b737cce6e76b8d93720716e2ebdf Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sat, 16 May 2026 15:49:23 -0700 Subject: [PATCH 1/3] GrailsUtil: honor stackTraceFiltererClass and logFullStackTraceOnFilter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GrailsUtil held a hardcoded `private static final DefaultStackTraceFilterer` that ignored grails.logging.stackTraceFiltererClass and the new grails.exceptionresolver.logFullStackTraceOnFilter flag. Non-resolver callers of the filterer — most visibly GroovyPageView.handleException via GrailsUtil.deepSanitize on GSP view-render exceptions, plus scheduled jobs and custom code calling sanitizeRootCause/deepSanitize directly — produced StackTrace logger emissions that no config key could suppress. The only workaround was silencing the StackTrace logger in logback, which is too blunt and is called out in the user guide as a fallback rather than the intended control surface. Resolve the filterer lazily from Holders.findApplication().getConfig() on first use. Cache the resolved instance once an application is discoverable; pre-context callers (early init, plain main, tests) get a fresh uncached default so a later call after the context boots can still populate the cache. Propagate the on-filter flag to DefaultStackTraceFilterer instances the same way GrailsExceptionResolver.applyLogFullStackTraceOnFilter does, leaving custom StackTraceFilterer implementations responsible for their own logging policy. Default behavior is unchanged — unset config yields a DefaultStackTraceFilterer with logFullStackTraceOnFilter=true, matching the previous static field. All exception paths and instantiation failures fall back to the default with a logged warning so a bad config value can't break GrailsUtil callers. Adds GrailsUtilStackFiltererSpec covering the three branches (no app, custom class, on-filter propagation). Existing GrailsUtilTests and StackTraceFiltererSpec unchanged and passing. Documents the change in the Logging Full Stack Traces guide and the 7.1.x upgrade notes. --- .../main/groovy/grails/util/GrailsUtil.java | 97 ++++++++++++- .../util/GrailsUtilStackFiltererSpec.groovy | 134 ++++++++++++++++++ .../logging/loggingFullStackTraces.adoc | 6 + .../src/en/guide/upgrading/upgrading71x.adoc | 8 ++ 4 files changed, 241 insertions(+), 4 deletions(-) create mode 100644 grails-core/src/test/groovy/grails/util/GrailsUtilStackFiltererSpec.groovy diff --git a/grails-core/src/main/groovy/grails/util/GrailsUtil.java b/grails-core/src/main/groovy/grails/util/GrailsUtil.java index 891f1681469..923f19b096e 100644 --- a/grails-core/src/main/groovy/grails/util/GrailsUtil.java +++ b/grails-core/src/main/groovy/grails/util/GrailsUtil.java @@ -22,7 +22,11 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.beans.BeanUtils; +import grails.config.Config; +import grails.config.Settings; +import grails.core.GrailsApplication; import org.grails.exceptions.reporting.DefaultStackTraceFilterer; import org.grails.exceptions.reporting.StackTraceFilterer; @@ -36,7 +40,15 @@ public class GrailsUtil { private static final Log LOG = LogFactory.getLog(GrailsUtil.class); private static final boolean LOG_DEPRECATED = Boolean.valueOf(System.getProperty("grails.log.deprecated", String.valueOf(Environment.isDevelopmentMode()))); - private static final StackTraceFilterer stackFilterer = new DefaultStackTraceFilterer(); + + /** + * Lazily-resolved filterer used by {@link #printSanitizedStackTrace}, {@link #sanitizeRootCause} + * and {@link #deepSanitize}. Cached once a {@link GrailsApplication} is discoverable via + * {@link Holders#findApplication()} so the config-driven class and emission flag are read + * exactly once. Volatile to publish the cached value safely; double-checked init in + * {@link #resolveStackFilterer()}. + */ + private static volatile StackTraceFilterer stackFilterer; private GrailsUtil() { } @@ -106,7 +118,7 @@ public static void warn(String message) { } public static void printSanitizedStackTrace(Throwable t, PrintWriter p) { - printSanitizedStackTrace(t, p, stackFilterer); + printSanitizedStackTrace(t, p, resolveStackFilterer()); } public static void printSanitizedStackTrace(Throwable t, PrintWriter p, StackTraceFilterer stackTraceFilterer) { @@ -144,7 +156,7 @@ public static Throwable extractRootCause(Throwable t) { * @return The root cause exception instance, with its stace trace modified to filter out grails runtime classes */ public static Throwable sanitizeRootCause(Throwable t) { - return stackFilterer.filter(extractRootCause(t)); + return resolveStackFilterer().filter(extractRootCause(t)); } /** @@ -154,7 +166,84 @@ public static Throwable sanitizeRootCause(Throwable t) { * @return The root cause exception instances, with stack trace modified to filter out grails runtime classes */ public static Throwable deepSanitize(Throwable t) { - return stackFilterer.filter(t, true); + return resolveStackFilterer().filter(t, true); + } + + /** + * Returns the {@link StackTraceFilterer} used by this class, lazily initialised from the + * Grails application config when one is discoverable. Honours + * {@link Settings#SETTING_LOGGING_STACKTRACE_FILTER_CLASS} (the filterer class — same key + * the exception resolver consults) and propagates + * {@link Settings#SETTING_LOG_FULL_STACKTRACE_ON_FILTER} to instances of + * {@link DefaultStackTraceFilterer}. + * + *

While no {@link GrailsApplication} is available (early-init paths, plain {@code main} + * usage, tests that don't wire one up) a fresh {@link DefaultStackTraceFilterer} is returned + * and not cached — so once the application context boots, the next call resolves + * the configured filterer for real. After that the value is cached for the lifetime of the + * JVM, matching the historical behaviour of the previous {@code static final} field. + */ + private static StackTraceFilterer resolveStackFilterer() { + StackTraceFilterer cached = stackFilterer; + if (cached != null) { + return cached; + } + GrailsApplication application = findApplicationQuietly(); + if (application == null) { + // No application discoverable yet — return an uncached default. A later call, + // once the context is up, will run through the configured-resolution branch + // and populate the cache. + return new DefaultStackTraceFilterer(); + } + synchronized (GrailsUtil.class) { + cached = stackFilterer; + if (cached != null) { + return cached; + } + stackFilterer = createConfiguredFilterer(application); + return stackFilterer; + } + } + + private static GrailsApplication findApplicationQuietly() { + try { + return Holders.findApplication(); + } + catch (Throwable ignored) { + return null; + } + } + + private static StackTraceFilterer createConfiguredFilterer(GrailsApplication application) { + Class filtererClass = DefaultStackTraceFilterer.class; + boolean logOnFilter = true; + try { + Config config = application.getConfig(); + if (config != null) { + filtererClass = config.getProperty( + Settings.SETTING_LOGGING_STACKTRACE_FILTER_CLASS, + Class.class, DefaultStackTraceFilterer.class); + logOnFilter = config.getProperty( + Settings.SETTING_LOG_FULL_STACKTRACE_ON_FILTER, + Boolean.class, true); + } + } + catch (Throwable t) { + LOG.warn("Unable to resolve StackTraceFilterer config; using default: " + t.getMessage()); + } + StackTraceFilterer instance; + try { + instance = BeanUtils.instantiateClass(filtererClass, StackTraceFilterer.class); + } + catch (Throwable t) { + LOG.warn("Problem instantiating configured StackTraceFilterer [" + filtererClass.getName() + + "], falling back to default: " + t.getMessage()); + instance = new DefaultStackTraceFilterer(); + } + if (instance instanceof DefaultStackTraceFilterer) { + ((DefaultStackTraceFilterer) instance).setLogFullStackTraceOnFilter(logOnFilter); + } + return instance; } } diff --git a/grails-core/src/test/groovy/grails/util/GrailsUtilStackFiltererSpec.groovy b/grails-core/src/test/groovy/grails/util/GrailsUtilStackFiltererSpec.groovy new file mode 100644 index 00000000000..181cbc96d67 --- /dev/null +++ b/grails-core/src/test/groovy/grails/util/GrailsUtilStackFiltererSpec.groovy @@ -0,0 +1,134 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package grails.util + +import grails.config.Config +import grails.core.GrailsApplication +import org.grails.exceptions.reporting.DefaultStackTraceFilterer +import org.grails.exceptions.reporting.StackTraceFilterer +import spock.lang.Specification + +/** + * Verifies that {@link GrailsUtil#deepSanitize}, {@link GrailsUtil#sanitizeRootCause} and + * {@link GrailsUtil#printSanitizedStackTrace} honour the same config keys as + * {@code GrailsExceptionResolver} — {@code grails.logging.stackTraceFiltererClass} and + * {@code grails.exceptionresolver.logFullStackTraceOnFilter}. + * + * The cached filterer is reset between scenarios via reflection so each test sees a + * fresh lookup against its own {@link GrailsApplication}. + */ +class GrailsUtilStackFiltererSpec extends Specification { + + GrailsApplication previousApplication + + def setup() { + previousApplication = Holders.findApplication() + resetCachedFilterer() + } + + def cleanup() { + Holders.setGrailsApplication(previousApplication) + resetCachedFilterer() + } + + def 'falls back to a DefaultStackTraceFilterer when no GrailsApplication is discoverable'() { + given: + Holders.setGrailsApplication(null) + + when: + def ex = new RuntimeException('boom') + GrailsUtil.deepSanitize(ex) + + then: + noExceptionThrown() + } + + def 'honours grails.logging.stackTraceFiltererClass'() { + given: + def application = Mock(GrailsApplication) + def config = Mock(Config) + config.getProperty('grails.logging.stackTraceFiltererClass', Class, DefaultStackTraceFilterer) >> RecordingStackTraceFilterer + config.getProperty('grails.exceptionresolver.logFullStackTraceOnFilter', Boolean, true) >> true + application.getConfig() >> config + Holders.setGrailsApplication(application) + + when: + def ex = new RuntimeException('boom') + GrailsUtil.deepSanitize(ex) + + then: + RecordingStackTraceFilterer.lastInstance != null + RecordingStackTraceFilterer.lastInstance.recursiveCalls == 1 + } + + def 'propagates logFullStackTraceOnFilter to DefaultStackTraceFilterer instances'() { + given: + def application = Mock(GrailsApplication) + def config = Mock(Config) + config.getProperty('grails.logging.stackTraceFiltererClass', Class, DefaultStackTraceFilterer) >> DefaultStackTraceFilterer + config.getProperty('grails.exceptionresolver.logFullStackTraceOnFilter', Boolean, true) >> false + application.getConfig() >> config + Holders.setGrailsApplication(application) + + and: 'captured StackTrace logger output' + def originalErr = System.err + def baos = new ByteArrayOutputStream() + System.setErr(new PrintStream(baos, true)) + + when: + GrailsUtil.deepSanitize(new RuntimeException('boom')) + + then: + System.err.flush() + !baos.toString().contains('ERROR StackTrace') + + cleanup: + System.setErr(originalErr) + } + + private static void resetCachedFilterer() { + def field = GrailsUtil.getDeclaredField('stackFilterer') + field.accessible = true + field.set(null, null) + } + + static class RecordingStackTraceFilterer implements StackTraceFilterer { + static RecordingStackTraceFilterer lastInstance + int singleCalls = 0 + int recursiveCalls = 0 + + RecordingStackTraceFilterer() { + lastInstance = this + } + + Throwable filter(Throwable source) { + singleCalls++ + return source + } + + Throwable filter(Throwable source, boolean recursive) { + recursiveCalls++ + return source + } + + void addInternalPackage(String name) {} + void setCutOffPackage(String cutOffPackage) {} + void setShouldFilter(boolean shouldFilter) {} + } +} diff --git a/grails-doc/src/en/guide/conf/config/logging/loggingFullStackTraces.adoc b/grails-doc/src/en/guide/conf/config/logging/loggingFullStackTraces.adoc index 37c421b498f..1639360139d 100644 --- a/grails-doc/src/en/guide/conf/config/logging/loggingFullStackTraces.adoc +++ b/grails-doc/src/en/guide/conf/config/logging/loggingFullStackTraces.adoc @@ -97,6 +97,12 @@ log record. It means non-resolver code paths (for example, a scheduled job that `GrailsUtil.sanitizeRootCause(ex)` before logging via its own logger) continue to populate the `StackTrace` appender without an explicit emission call. +NOTE: `GrailsUtil` resolves its filterer lazily from the same config keys as the exception resolver +(`grails.logging.stackTraceFiltererClass` and `grails.exceptionresolver.logFullStackTraceOnFilter`), +so this property controls both resolver-driven _and_ `GrailsUtil`-driven emission (including the GSP +view-render path through `GroovyPageView.handleException`). Custom `StackTraceFilterer` implementations +that don't extend `DefaultStackTraceFilterer` are responsible for their own logging policy. + The behaviour is enabled by default. To disable the side-effect emission and rely solely on `logFullStackTrace` for resolver-driven output, set: diff --git a/grails-doc/src/en/guide/upgrading/upgrading71x.adoc b/grails-doc/src/en/guide/upgrading/upgrading71x.adoc index b9d2603d0da..f8c8063c53d 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading71x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading71x.adoc @@ -849,3 +849,11 @@ Set to `false` to disable the side-effect emission and rely solely on `logFullSt output. The two flags interact — if both are enabled, a request exception with N causes produces N+1 `StackTrace` records (one resolver-driven plus one per throwable visited by the recursive filter walk). The Logging Full Stack Traces section of the user guide includes a matrix of behaviours for the four flag combinations. + +`GrailsUtil` honours both `grails.logging.stackTraceFiltererClass` and +`grails.exceptionresolver.logFullStackTraceOnFilter` as well — the filterer is resolved lazily on first use +from the application config, so non-resolver paths (including GSP view-render exceptions routed through +`GroovyPageView.handleException` → `GrailsUtil.deepSanitize`) participate in the same emission policy as +the resolver. Pre-7.1, `GrailsUtil` held a hardcoded `DefaultStackTraceFilterer` static field that ignored +both keys; applications that previously had to silence the `StackTrace` logger in logback purely to suppress +GSP-render-time noise can now set `logFullStackTraceOnFilter: false` and reach every caller of the filterer. From cc41875e05fcdf7d02ebf3d2fd47a46bc754af05 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sat, 16 May 2026 18:29:06 -0700 Subject: [PATCH 2/3] fix checkstyle ImportOrder: separate spring.beans import group --- grails-core/src/main/groovy/grails/util/GrailsUtil.java | 1 + 1 file changed, 1 insertion(+) diff --git a/grails-core/src/main/groovy/grails/util/GrailsUtil.java b/grails-core/src/main/groovy/grails/util/GrailsUtil.java index 923f19b096e..3cfe645a34d 100644 --- a/grails-core/src/main/groovy/grails/util/GrailsUtil.java +++ b/grails-core/src/main/groovy/grails/util/GrailsUtil.java @@ -22,6 +22,7 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; + import org.springframework.beans.BeanUtils; import grails.config.Config; From cdffb8f381c2095dfb16af81337b79677737cbf0 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Fri, 29 May 2026 01:50:54 -0700 Subject: [PATCH 3/3] GrailsUtil: bootstrap-hook init via GrailsExceptionResolver Address review feedback on #15666: - Replace the lazy resolveStackFilterer/findApplicationQuietly pattern with a deterministic bootstrap-hook: GrailsExceptionResolver.setGrailsApplication calls the new public GrailsUtil.initializeStackFilterer(application) during Spring bean wiring. Hot path collapses to a single volatile read. - Restore the single-instance invariant of the prior static final field via a static final FALLBACK_FILTERER sentinel. The volatile stackFilterer is initialised to FALLBACK_FILTERER so CLI, tests that don't boot a context, and plain main() usage continue to work with one shared instance. - Drop findApplicationQuietly and the surrounding try/catch. Holders no longer needs to be consulted from GrailsUtil at runtime; the resolver pushes the application in during its own wiring. GrailsUtilStackFiltererSpec rewritten to cover the new shape: fallback before init, null-application no-op, configured-class wiring, logFullStackTraceOnFilter propagation, and last-write-wins on repeat init. Existing GrailsExceptionResolverSpec, StackTraceFiltererSpec and GrailsUtilTests continue to pass; checkstyle clean. Docs in upgrading71x.adoc and loggingFullStackTraces.adoc updated to describe the bootstrap-hook wiring instead of the prior lazy resolution. --- .../main/groovy/grails/util/GrailsUtil.java | 120 ++++++++---------- .../util/GrailsUtilStackFiltererSpec.groovy | 112 ++++++++++------ .../logging/loggingFullStackTraces.adoc | 11 +- .../src/en/guide/upgrading/upgrading71x.adoc | 15 ++- .../web/errors/GrailsExceptionResolver.java | 2 + 5 files changed, 146 insertions(+), 114 deletions(-) diff --git a/grails-core/src/main/groovy/grails/util/GrailsUtil.java b/grails-core/src/main/groovy/grails/util/GrailsUtil.java index 3cfe645a34d..cf508527a0b 100644 --- a/grails-core/src/main/groovy/grails/util/GrailsUtil.java +++ b/grails-core/src/main/groovy/grails/util/GrailsUtil.java @@ -43,17 +43,49 @@ public class GrailsUtil { private static final boolean LOG_DEPRECATED = Boolean.valueOf(System.getProperty("grails.log.deprecated", String.valueOf(Environment.isDevelopmentMode()))); /** - * Lazily-resolved filterer used by {@link #printSanitizedStackTrace}, {@link #sanitizeRootCause} - * and {@link #deepSanitize}. Cached once a {@link GrailsApplication} is discoverable via - * {@link Holders#findApplication()} so the config-driven class and emission flag are read - * exactly once. Volatile to publish the cached value safely; double-checked init in - * {@link #resolveStackFilterer()}. + * Default filterer used before {@link #initializeStackFilterer(GrailsApplication)} runs (CLI, + * tests that don't boot a context, plain {@code main()} usage). Preserves the pre-PR behaviour + * of a single hardcoded {@link DefaultStackTraceFilterer} instance for the JVM lifetime when no + * application is wired. */ - private static volatile StackTraceFilterer stackFilterer; + private static final StackTraceFilterer FALLBACK_FILTERER = new DefaultStackTraceFilterer(); + + /** + * Active filterer for {@link #printSanitizedStackTrace}, {@link #sanitizeRootCause} and + * {@link #deepSanitize}. Starts as {@link #FALLBACK_FILTERER} and is replaced with a + * config-driven instance when {@link #initializeStackFilterer(GrailsApplication)} runs during + * Grails bootstrap. Volatile so the bootstrap-time write publishes safely to the request + * threads that read it later. + */ + private static volatile StackTraceFilterer stackFilterer = FALLBACK_FILTERER; private GrailsUtil() { } + /** + * Installs a {@link StackTraceFilterer} resolved from the given application's config, replacing + * the default fallback. Reads {@link Settings#SETTING_LOGGING_STACKTRACE_FILTER_CLASS} for the + * filterer class and propagates {@link Settings#SETTING_LOG_FULL_STACKTRACE_ON_FILTER} to + * instances of {@link DefaultStackTraceFilterer}. Called by {@code GrailsExceptionResolver} + * during Spring bean wiring (which is the same point the resolver consults these keys for its + * own filterer), so request-time callers of the static {@code sanitize}/{@code deepSanitize} + * methods see the configured instance. + * + *

No-ops when {@code application} is null. Safe to call more than once — the last successful + * invocation wins. + * + * @since 7.1.2 + */ + public static void initializeStackFilterer(GrailsApplication application) { + if (application == null) { + return; + } + StackTraceFilterer instance = createConfiguredFilterer(application); + if (instance != null) { + stackFilterer = instance; + } + } + /** * Retrieves whether the current execution environment is the development one. * @@ -119,7 +151,7 @@ public static void warn(String message) { } public static void printSanitizedStackTrace(Throwable t, PrintWriter p) { - printSanitizedStackTrace(t, p, resolveStackFilterer()); + printSanitizedStackTrace(t, p, stackFilterer); } public static void printSanitizedStackTrace(Throwable t, PrintWriter p, StackTraceFilterer stackTraceFilterer) { @@ -157,7 +189,7 @@ public static Throwable extractRootCause(Throwable t) { * @return The root cause exception instance, with its stace trace modified to filter out grails runtime classes */ public static Throwable sanitizeRootCause(Throwable t) { - return resolveStackFilterer().filter(extractRootCause(t)); + return stackFilterer.filter(extractRootCause(t)); } /** @@ -167,70 +199,26 @@ public static Throwable sanitizeRootCause(Throwable t) { * @return The root cause exception instances, with stack trace modified to filter out grails runtime classes */ public static Throwable deepSanitize(Throwable t) { - return resolveStackFilterer().filter(t, true); - } - - /** - * Returns the {@link StackTraceFilterer} used by this class, lazily initialised from the - * Grails application config when one is discoverable. Honours - * {@link Settings#SETTING_LOGGING_STACKTRACE_FILTER_CLASS} (the filterer class — same key - * the exception resolver consults) and propagates - * {@link Settings#SETTING_LOG_FULL_STACKTRACE_ON_FILTER} to instances of - * {@link DefaultStackTraceFilterer}. - * - *

While no {@link GrailsApplication} is available (early-init paths, plain {@code main} - * usage, tests that don't wire one up) a fresh {@link DefaultStackTraceFilterer} is returned - * and not cached — so once the application context boots, the next call resolves - * the configured filterer for real. After that the value is cached for the lifetime of the - * JVM, matching the historical behaviour of the previous {@code static final} field. - */ - private static StackTraceFilterer resolveStackFilterer() { - StackTraceFilterer cached = stackFilterer; - if (cached != null) { - return cached; - } - GrailsApplication application = findApplicationQuietly(); - if (application == null) { - // No application discoverable yet — return an uncached default. A later call, - // once the context is up, will run through the configured-resolution branch - // and populate the cache. - return new DefaultStackTraceFilterer(); - } - synchronized (GrailsUtil.class) { - cached = stackFilterer; - if (cached != null) { - return cached; - } - stackFilterer = createConfiguredFilterer(application); - return stackFilterer; - } - } - - private static GrailsApplication findApplicationQuietly() { - try { - return Holders.findApplication(); - } - catch (Throwable ignored) { - return null; - } + return stackFilterer.filter(t, true); } private static StackTraceFilterer createConfiguredFilterer(GrailsApplication application) { Class filtererClass = DefaultStackTraceFilterer.class; boolean logOnFilter = true; - try { - Config config = application.getConfig(); - if (config != null) { - filtererClass = config.getProperty( - Settings.SETTING_LOGGING_STACKTRACE_FILTER_CLASS, - Class.class, DefaultStackTraceFilterer.class); - logOnFilter = config.getProperty( - Settings.SETTING_LOG_FULL_STACKTRACE_ON_FILTER, - Boolean.class, true); + Config config = application.getConfig(); + if (config != null) { + Class configured = config.getProperty( + Settings.SETTING_LOGGING_STACKTRACE_FILTER_CLASS, + Class.class, DefaultStackTraceFilterer.class); + if (configured != null) { + filtererClass = configured; + } + Boolean configuredLogOnFilter = config.getProperty( + Settings.SETTING_LOG_FULL_STACKTRACE_ON_FILTER, + Boolean.class, Boolean.TRUE); + if (configuredLogOnFilter != null) { + logOnFilter = configuredLogOnFilter; } - } - catch (Throwable t) { - LOG.warn("Unable to resolve StackTraceFilterer config; using default: " + t.getMessage()); } StackTraceFilterer instance; try { diff --git a/grails-core/src/test/groovy/grails/util/GrailsUtilStackFiltererSpec.groovy b/grails-core/src/test/groovy/grails/util/GrailsUtilStackFiltererSpec.groovy index 181cbc96d67..254b1f82aa1 100644 --- a/grails-core/src/test/groovy/grails/util/GrailsUtilStackFiltererSpec.groovy +++ b/grails-core/src/test/groovy/grails/util/GrailsUtilStackFiltererSpec.groovy @@ -24,67 +24,68 @@ import org.grails.exceptions.reporting.DefaultStackTraceFilterer import org.grails.exceptions.reporting.StackTraceFilterer import spock.lang.Specification +import java.lang.reflect.Field + /** - * Verifies that {@link GrailsUtil#deepSanitize}, {@link GrailsUtil#sanitizeRootCause} and - * {@link GrailsUtil#printSanitizedStackTrace} honour the same config keys as - * {@code GrailsExceptionResolver} — {@code grails.logging.stackTraceFiltererClass} and - * {@code grails.exceptionresolver.logFullStackTraceOnFilter}. - * - * The cached filterer is reset between scenarios via reflection so each test sees a - * fresh lookup against its own {@link GrailsApplication}. + * Verifies that {@link GrailsUtil#initializeStackFilterer} resolves the configured filterer class + * from the application's config and propagates {@code grails.exceptionresolver.logFullStackTraceOnFilter} + * to {@link DefaultStackTraceFilterer} instances. Before initialization the FALLBACK_FILTERER + * (a {@link DefaultStackTraceFilterer} singleton) is used so CLI/test/main paths work unchanged. */ class GrailsUtilStackFiltererSpec extends Specification { - GrailsApplication previousApplication + StackTraceFilterer previous def setup() { - previousApplication = Holders.findApplication() - resetCachedFilterer() + previous = currentFilterer() + setFilterer(fallbackFilterer()) } def cleanup() { - Holders.setGrailsApplication(previousApplication) - resetCachedFilterer() + setFilterer(previous) } - def 'falls back to a DefaultStackTraceFilterer when no GrailsApplication is discoverable'() { - given: - Holders.setGrailsApplication(null) - + def 'deepSanitize uses the fallback filterer before initializeStackFilterer is called'() { when: - def ex = new RuntimeException('boom') - GrailsUtil.deepSanitize(ex) + GrailsUtil.deepSanitize(new RuntimeException('boom')) then: noExceptionThrown() + currentFilterer().is(fallbackFilterer()) + } + + def 'initializeStackFilterer is a no-op when application is null'() { + when: + GrailsUtil.initializeStackFilterer(null) + + then: + currentFilterer().is(fallbackFilterer()) } - def 'honours grails.logging.stackTraceFiltererClass'() { + def 'initializeStackFilterer wires the class declared by grails.logging.stackTraceFiltererClass'() { given: def application = Mock(GrailsApplication) def config = Mock(Config) config.getProperty('grails.logging.stackTraceFiltererClass', Class, DefaultStackTraceFilterer) >> RecordingStackTraceFilterer config.getProperty('grails.exceptionresolver.logFullStackTraceOnFilter', Boolean, true) >> true application.getConfig() >> config - Holders.setGrailsApplication(application) when: - def ex = new RuntimeException('boom') - GrailsUtil.deepSanitize(ex) + GrailsUtil.initializeStackFilterer(application) + GrailsUtil.deepSanitize(new RuntimeException('boom')) then: - RecordingStackTraceFilterer.lastInstance != null + currentFilterer() instanceof RecordingStackTraceFilterer RecordingStackTraceFilterer.lastInstance.recursiveCalls == 1 } - def 'propagates logFullStackTraceOnFilter to DefaultStackTraceFilterer instances'() { + def 'initializeStackFilterer propagates logFullStackTraceOnFilter to DefaultStackTraceFilterer instances'() { given: def application = Mock(GrailsApplication) def config = Mock(Config) config.getProperty('grails.logging.stackTraceFiltererClass', Class, DefaultStackTraceFilterer) >> DefaultStackTraceFilterer config.getProperty('grails.exceptionresolver.logFullStackTraceOnFilter', Boolean, true) >> false application.getConfig() >> config - Holders.setGrailsApplication(application) and: 'captured StackTrace logger output' def originalErr = System.err @@ -92,6 +93,7 @@ class GrailsUtilStackFiltererSpec extends Specification { System.setErr(new PrintStream(baos, true)) when: + GrailsUtil.initializeStackFilterer(application) GrailsUtil.deepSanitize(new RuntimeException('boom')) then: @@ -102,10 +104,46 @@ class GrailsUtilStackFiltererSpec extends Specification { System.setErr(originalErr) } - private static void resetCachedFilterer() { - def field = GrailsUtil.getDeclaredField('stackFilterer') + def 'last initializeStackFilterer call wins when invoked more than once'() { + given: + def first = mockApplicationFor(RecordingStackTraceFilterer) + def second = mockApplicationFor(SecondRecordingStackTraceFilterer) + + when: + GrailsUtil.initializeStackFilterer(first) + GrailsUtil.initializeStackFilterer(second) + + then: + currentFilterer() instanceof SecondRecordingStackTraceFilterer + } + + private GrailsApplication mockApplicationFor(Class filtererClass) { + def application = Mock(GrailsApplication) + def config = Mock(Config) + config.getProperty('grails.logging.stackTraceFiltererClass', Class, DefaultStackTraceFilterer) >> filtererClass + config.getProperty('grails.exceptionresolver.logFullStackTraceOnFilter', Boolean, true) >> true + application.getConfig() >> config + application + } + + private static StackTraceFilterer currentFilterer() { + filtererField().get(null) as StackTraceFilterer + } + + private static void setFilterer(StackTraceFilterer filterer) { + filtererField().set(null, filterer) + } + + private static StackTraceFilterer fallbackFilterer() { + Field field = GrailsUtil.getDeclaredField('FALLBACK_FILTERER') field.accessible = true - field.set(null, null) + field.get(null) as StackTraceFilterer + } + + private static Field filtererField() { + Field field = GrailsUtil.getDeclaredField('stackFilterer') + field.accessible = true + field } static class RecordingStackTraceFilterer implements StackTraceFilterer { @@ -117,16 +155,16 @@ class GrailsUtilStackFiltererSpec extends Specification { lastInstance = this } - Throwable filter(Throwable source) { - singleCalls++ - return source - } - - Throwable filter(Throwable source, boolean recursive) { - recursiveCalls++ - return source - } + Throwable filter(Throwable source) { singleCalls++; source } + Throwable filter(Throwable source, boolean recursive) { recursiveCalls++; source } + void addInternalPackage(String name) {} + void setCutOffPackage(String cutOffPackage) {} + void setShouldFilter(boolean shouldFilter) {} + } + static class SecondRecordingStackTraceFilterer implements StackTraceFilterer { + Throwable filter(Throwable source) { source } + Throwable filter(Throwable source, boolean recursive) { source } void addInternalPackage(String name) {} void setCutOffPackage(String cutOffPackage) {} void setShouldFilter(boolean shouldFilter) {} diff --git a/grails-doc/src/en/guide/conf/config/logging/loggingFullStackTraces.adoc b/grails-doc/src/en/guide/conf/config/logging/loggingFullStackTraces.adoc index 1639360139d..2c9a52e97d0 100644 --- a/grails-doc/src/en/guide/conf/config/logging/loggingFullStackTraces.adoc +++ b/grails-doc/src/en/guide/conf/config/logging/loggingFullStackTraces.adoc @@ -97,11 +97,12 @@ log record. It means non-resolver code paths (for example, a scheduled job that `GrailsUtil.sanitizeRootCause(ex)` before logging via its own logger) continue to populate the `StackTrace` appender without an explicit emission call. -NOTE: `GrailsUtil` resolves its filterer lazily from the same config keys as the exception resolver -(`grails.logging.stackTraceFiltererClass` and `grails.exceptionresolver.logFullStackTraceOnFilter`), -so this property controls both resolver-driven _and_ `GrailsUtil`-driven emission (including the GSP -view-render path through `GroovyPageView.handleException`). Custom `StackTraceFilterer` implementations -that don't extend `DefaultStackTraceFilterer` are responsible for their own logging policy. +NOTE: `GrailsUtil` honours the same config keys as the exception resolver +(`grails.logging.stackTraceFiltererClass` and `grails.exceptionresolver.logFullStackTraceOnFilter`); +`GrailsExceptionResolver.setGrailsApplication` calls `GrailsUtil.initializeStackFilterer(application)` during +Spring bean wiring, so this property controls both resolver-driven _and_ `GrailsUtil`-driven emission +(including the GSP view-render path through `GroovyPageView.handleException`). Custom `StackTraceFilterer` +implementations that don't extend `DefaultStackTraceFilterer` are responsible for their own logging policy. The behaviour is enabled by default. To disable the side-effect emission and rely solely on `logFullStackTrace` for resolver-driven output, set: diff --git a/grails-doc/src/en/guide/upgrading/upgrading71x.adoc b/grails-doc/src/en/guide/upgrading/upgrading71x.adoc index f8c8063c53d..77259e9ad9c 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading71x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading71x.adoc @@ -851,9 +851,12 @@ records (one resolver-driven plus one per throwable visited by the recursive fil Stack Traces section of the user guide includes a matrix of behaviours for the four flag combinations. `GrailsUtil` honours both `grails.logging.stackTraceFiltererClass` and -`grails.exceptionresolver.logFullStackTraceOnFilter` as well — the filterer is resolved lazily on first use -from the application config, so non-resolver paths (including GSP view-render exceptions routed through -`GroovyPageView.handleException` → `GrailsUtil.deepSanitize`) participate in the same emission policy as -the resolver. Pre-7.1, `GrailsUtil` held a hardcoded `DefaultStackTraceFilterer` static field that ignored -both keys; applications that previously had to silence the `StackTrace` logger in logback purely to suppress -GSP-render-time noise can now set `logFullStackTraceOnFilter: false` and reach every caller of the filterer. +`grails.exceptionresolver.logFullStackTraceOnFilter` as well — `GrailsExceptionResolver.setGrailsApplication` +calls the new `GrailsUtil.initializeStackFilterer(GrailsApplication)` during Spring bean wiring, so non-resolver +paths (including GSP view-render exceptions routed through `GroovyPageView.handleException` → +`GrailsUtil.deepSanitize`) participate in the same emission policy as the resolver. Before initialization +runs (CLI, tests that don't boot a context, plain `main()`), `GrailsUtil` uses a single hardcoded +`DefaultStackTraceFilterer` fallback, matching the pre-PR behaviour. Pre-7.1, `GrailsUtil` held a +hardcoded `DefaultStackTraceFilterer` static field that ignored both keys; applications that previously had +to silence the `StackTrace` logger in logback purely to suppress GSP-render-time noise can now set +`logFullStackTraceOnFilter: false` and reach every caller of the filterer. diff --git a/grails-web-mvc/src/main/groovy/org/grails/web/errors/GrailsExceptionResolver.java b/grails-web-mvc/src/main/groovy/org/grails/web/errors/GrailsExceptionResolver.java index feb7dc6fb24..cf2bc4bfd1b 100644 --- a/grails-web-mvc/src/main/groovy/org/grails/web/errors/GrailsExceptionResolver.java +++ b/grails-web-mvc/src/main/groovy/org/grails/web/errors/GrailsExceptionResolver.java @@ -49,6 +49,7 @@ import grails.core.GrailsApplication; import grails.core.support.GrailsApplicationAware; import grails.util.Environment; +import grails.util.GrailsUtil; import grails.web.mapping.UrlMappingInfo; import grails.web.mapping.UrlMappingsHolder; import grails.web.mapping.exceptions.UrlMappingException; @@ -131,6 +132,7 @@ public void setServletContext(ServletContext servletContext) { public void setGrailsApplication(GrailsApplication grailsApplication) { this.grailsApplication = grailsApplication; createStackFilterer(); + GrailsUtil.initializeStackFilterer(grailsApplication); this.auditorAwareLookup = new AuditorAwareLookup(grailsApplication.getMainContext()); }