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/2] 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/2] 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;