diff --git a/grails-core/src/main/groovy/grails/util/GrailsUtil.java b/grails-core/src/main/groovy/grails/util/GrailsUtil.java index 891f1681469..cf508527a0b 100644 --- a/grails-core/src/main/groovy/grails/util/GrailsUtil.java +++ b/grails-core/src/main/groovy/grails/util/GrailsUtil.java @@ -23,6 +23,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,11 +41,51 @@ 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(); + + /** + * 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 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. * @@ -157,4 +202,37 @@ public static Throwable deepSanitize(Throwable t) { return stackFilterer.filter(t, true); } + private static StackTraceFilterer createConfiguredFilterer(GrailsApplication application) { + Class filtererClass = DefaultStackTraceFilterer.class; + boolean logOnFilter = 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; + } + } + 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..254b1f82aa1 --- /dev/null +++ b/grails-core/src/test/groovy/grails/util/GrailsUtilStackFiltererSpec.groovy @@ -0,0 +1,172 @@ +/* + * 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 + +import java.lang.reflect.Field + +/** + * 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 { + + StackTraceFilterer previous + + def setup() { + previous = currentFilterer() + setFilterer(fallbackFilterer()) + } + + def cleanup() { + setFilterer(previous) + } + + def 'deepSanitize uses the fallback filterer before initializeStackFilterer is called'() { + when: + 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 '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 + + when: + GrailsUtil.initializeStackFilterer(application) + GrailsUtil.deepSanitize(new RuntimeException('boom')) + + then: + currentFilterer() instanceof RecordingStackTraceFilterer + RecordingStackTraceFilterer.lastInstance.recursiveCalls == 1 + } + + 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 + + and: 'captured StackTrace logger output' + def originalErr = System.err + def baos = new ByteArrayOutputStream() + System.setErr(new PrintStream(baos, true)) + + when: + GrailsUtil.initializeStackFilterer(application) + GrailsUtil.deepSanitize(new RuntimeException('boom')) + + then: + System.err.flush() + !baos.toString().contains('ERROR StackTrace') + + cleanup: + System.setErr(originalErr) + } + + 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.get(null) as StackTraceFilterer + } + + private static Field filtererField() { + Field field = GrailsUtil.getDeclaredField('stackFilterer') + field.accessible = true + field + } + + static class RecordingStackTraceFilterer implements StackTraceFilterer { + static RecordingStackTraceFilterer lastInstance + int singleCalls = 0 + int recursiveCalls = 0 + + RecordingStackTraceFilterer() { + lastInstance = this + } + + 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 37c421b498f..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,6 +97,13 @@ 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` 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 b9d2603d0da..77259e9ad9c 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading71x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading71x.adoc @@ -849,3 +849,14 @@ 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 — `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()); }