Skip to content

GrailsUtil: honor stackTraceFiltererClass and logFullStackTraceOnFilter#15666

Open
codeconsole wants to merge 2 commits into
apache:7.1.xfrom
codeconsole:7.1.x-grailsutil-honor-filterer-config
Open

GrailsUtil: honor stackTraceFiltererClass and logFullStackTraceOnFilter#15666
codeconsole wants to merge 2 commits into
apache:7.1.xfrom
codeconsole:7.1.x-grailsutil-honor-filterer-config

Conversation

@codeconsole
Copy link
Copy Markdown
Contributor

@codeconsole codeconsole commented May 16, 2026

grails.logging.stackTraceFiltererClass config value is not honored by GrailsUtil.deepSanitize

Let's say you customize the stackTraceFiltererClass via yml or groovy
application.groovy

grails.logging.stackTraceFiltererClass=com.pixoto.grails.NonLoggingStackTraceFilterer.class

GroovyPageView.java:103:
GrailsUtil.deepSanitize dumps a massive exception to your console and there is no way around it even if you defined a custom stackTraceFiltererClass. Any gap error results in massive spam.

  protected void handleException(Exception exception, GroovyPagesTemplateEngine engine) {
      GrailsUtil.deepSanitize(exception);   // ← uses the hardcoded static filterer
      ...
      if (exception instanceof GroovyPagesException) {
          throw (GroovyPagesException) exception;
      }
      ...
  }

This PR uses Holders to route to the appropriate stackTraceFiltererClass

Summary

Follow-up to #15564. GrailsUtil held a hardcoded private static final DefaultStackTraceFilterer that ignored both grails.logging.stackTraceFiltererClass (the class-swap key that GrailsExceptionResolver honors) and the new grails.exceptionresolver.logFullStackTraceOnFilter flag introduced by #15564.

Non-resolver callers of the filterer — most visibly GroovyPageView.handleExceptionGrailsUtil.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 the user guide already calls out as a fallback rather than the intended control surface.

This PR closes that gap: GrailsUtil now resolves its filterer lazily on first use from Holders.findApplication().getConfig(), honoring the same two keys the resolver honors and propagating logFullStackTraceOnFilter to DefaultStackTraceFilterer instances exactly the way GrailsExceptionResolver.applyLogFullStackTraceOnFilter() does.

Why this matters

Pre-7.1, applications could set grails.logging.stackTraceFiltererClass to a custom filterer and reach every framework caller — including GrailsUtil. The transition through #15564 and the years prior left GrailsUtil as the one path the key never reached, which only becomes visible when GSP rendering throws (because that's the path that calls GrailsUtil.deepSanitize directly, bypassing the resolver). Apps hitting this today see 2 unfilterable StackTrace records per render-time exception in 7.1.x (3 in 7.0.x, before #15564 dropped the redundant trailing filter(source)), with no Grails-level knob.

After this PR, setting grails.exceptionresolver.logFullStackTraceOnFilter: false silences every caller of the filterer — resolver and GrailsUtil alike — without logback intervention.

Design

  • Lazy + cached. Pre-context callers (early init, plain main, tests that don't wire an application) get a fresh uncached DefaultStackTraceFilterer so a later call after the context boots can still populate the cache. Post-context callers resolve from config and cache for JVM lifetime, matching the historical semantics of the previous static final field.
  • Backwards compatible. Unset config yields DefaultStackTraceFilterer with logFullStackTraceOnFilter=true — identical to the previous hardcoded value. No behavior change for apps that don't set the new keys.
  • Defensive. Every config-read and instantiation path swallows Throwable and falls back to DefaultStackTraceFilterer with a logged warning. A bad config value can't break GrailsUtil callers.
  • Respects custom filterers. setLogFullStackTraceOnFilter only applied when the resolved instance is a DefaultStackTraceFilterer (or subclass) — matching GrailsExceptionResolver.applyLogFullStackTraceOnFilter() exactly. Custom StackTraceFilterer implementations remain responsible for their own logging policy.

Tests

New GrailsUtilStackFiltererSpec covers three branches:

  • Falls back to a DefaultStackTraceFilterer when no GrailsApplication is discoverable
  • Honors grails.logging.stackTraceFiltererClass (verifies a custom filterer is instantiated and invoked)
  • Propagates logFullStackTraceOnFilter=false to DefaultStackTraceFilterer instances (verifies no StackTrace emission)

Existing GrailsUtilTests and StackTraceFiltererSpec are unchanged and continue to pass. Full :grails-core:test and :grails-web-mvc:test suites green locally.

Docs

  • loggingFullStackTraces.adoc: NOTE block clarifying that GrailsUtil now participates in the same emission policy as the resolver, so the matrix applies to GrailsUtil-driven paths too (including the GSP render path).
  • upgrading71x.adoc §2.13: short paragraph noting GrailsUtil now honors both keys and that apps previously silencing the StackTrace logger in logback purely to suppress GSP-render noise can now use logFullStackTraceOnFilter: false instead.

Test plan

  • CI passes (grails-core + grails-web-mvc test suites)
  • Existing apps with no new-key config: identical log output to pre-PR (verified via default-path test)
  • App with grails.exceptionresolver.logFullStackTraceOnFilter: false: GSP-render exceptions produce zero StackTrace records (manually verified against a repro app on 7.2.0-SNAPSHOT after applying the patch)
  • App with custom grails.logging.stackTraceFiltererClass: GrailsUtil.deepSanitize routes through the custom class (verified via custom-class test)

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.
@testlens-app
Copy link
Copy Markdown

testlens-app Bot commented May 17, 2026

🚨 TestLens detected 2 failed tests 🚨

Here is what you can do:

  1. Inspect the test failures carefully.
  2. If you are convinced that some of the tests are flaky, you can mute them below.
  3. Finally, trigger a rerun by checking the rerun checkbox.

Test Summary

Check Project/Task Test Runs
CI - Groovy Joint Validation Build / build_grails :grails-data-hibernate5-dbmigration:test GroovyChangeLogSpec > outputs a warning message by calling the warn method
CI - Groovy Joint Validation Build / build_grails :grails-data-hibernate5-dbmigration:test GroovyChangeLogSpec > updates a database with Groovy Change

🏷️ Commit: cc41875
▶️ Tests: 40996 executed
🟡 Checks: 36/37 completed

Test Failures

GroovyChangeLogSpec > outputs a warning message by calling the warn method (:grails-data-hibernate5-dbmigration:test in CI - Groovy Joint Validation Build / build_grails)
Condition not satisfied:

output.toString().contains('warn message')
|      |          |
|      |          false
|      Running Changeset: changelog.groovy::2::John Smith
|       
|      UPDATE SUMMARY
|      Run:                          1
|      Previously run:               0
|      Filtered out:                 0
|      -------------------------------
|      Total change sets:            1
|       
|      Liquibase: Update has been successful. Rows affected: 1
Running Changeset: changelog.groovy::2::John Smith
 
UPDATE SUMMARY
Run:                          1
Previously run:               0
Filtered out:                 0
-------------------------------
Total change sets:            1
 
Liquibase: Update has been successful. Rows affected: 1

	at org.grails.plugins.databasemigration.liquibase.GroovyChangeLogSpec.outputs a warning message by calling the warn method(GroovyChangeLogSpec.groovy:87)
GroovyChangeLogSpec > updates a database with Groovy Change (:grails-data-hibernate5-dbmigration:test in CI - Groovy Joint Validation Build / build_grails)
Condition not satisfied:

output.toString().contains('confirmation message')
|      |          |
|      |          false
|      Running Changeset: changelog.groovy::1::John Smith
|       
|      UPDATE SUMMARY
|      Run:                          1
|      Previously run:               0
|      Filtered out:                 0
|      -------------------------------
|      Total change sets:            1
|       
|      Liquibase: Update has been successful. Rows affected: 1
Running Changeset: changelog.groovy::1::John Smith
 
UPDATE SUMMARY
Run:                          1
Previously run:               0
Filtered out:                 0
-------------------------------
Total change sets:            1
 
Liquibase: Update has been successful. Rows affected: 1

	at org.grails.plugins.databasemigration.liquibase.GroovyChangeLogSpec.updates a database with Groovy Change(GroovyChangeLogSpec.groovy:61)

Muted Tests

Note

Checks are currently running using the configuration below.

Select tests to mute in this pull request:

🔲 GroovyChangeLogSpec > outputs a warning message by calling the warn method
🔲 GroovyChangeLogSpec > updates a database with Groovy Change

Reuse successful test results:

🔲 ♻️ Only rerun the tests that failed or were muted before

Click the checkbox to trigger a rerun:

🔲 Rerun jobs


Learn more about TestLens at testlens.app.


private static GrailsApplication findApplicationQuietly() {
try {
return Holders.findApplication();
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.

If this throws it's likely trying to fetch this prior to the application being initialized. It seems like this would be a major issue because before this functioned regardless of the application being initialized.

@bito-code-review
Copy link
Copy Markdown

The CSV file contains only 1 row of actual data (row 2), which is empty. There are no review comments available to analyze.

* 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 {
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.

Can you point me to an integration test that exercises this logic?

if (cached != null) {
return cached;
}
GrailsApplication application = findApplicationQuietly();
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.

Before it always used one instance, but now it's using multiple potentially. This seems backwards.

// 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();
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.

What scenario is this occuring? Now we're initializing this every access until the app is online. Why aren't we just setting this value earlier in the bootstrap context and then referencing outside of the bean scope?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants